🤖
AI审核中

从Prompt到渲染的Markdown输出治理

  Java   20分钟   143浏览   0评论

接入大模型构建聊天系统时,最容易被低估的问题,往往不是模型“能不能返回内容”,而是这些内容能不能被稳定、准确、安全地展示给用户

对于一个支持多模型接入、流式输出、深度思考、联网搜索、文档生成和对话分享的 AI 对话系统来说,模型返回的文本早已不再是普通字符串。它会直接影响页面布局、代码高亮、表格展示、分享页阅读体验,甚至影响后续的 Word、PDF 等文档生成结果。

在这个项目的 chat 模块中,围绕 Markdown 稳定输出设计了一套完整的治理链路。它不是简单地在前端接入一个 Markdown 解析器,而是从 Prompt 约束、SSE 流式分流、后端内容规整,到前端安全渲染,构建了多层防线。

这套设计背后的核心判断是:

不要把模型输出当作最终文本,而要把它视为一种需要解析、分类、修复和验证的半结构化协议。

一、问题本质:模型输出并不是稳定协议

在真实系统中,AI 回复的不稳定主要体现在三个方面。

1. 不同模型的 reasoning 输出方式不一致

不同模型对“思考过程”的表达方式差异很大。

有些模型会通过独立字段返回,例如:

  • reasoning_content
  • reasoning
  • thinking

有些模型没有独立 reasoning 字段,而是直接把思考过程放进 content,再用:

<think>...</think>

进行包裹。

还有一些模型会在正文开始前先输出一段类似下面的内部分析:

用户希望了解……
我需要从以下几个方面回答……
首先分析……

随后才通过“最终回答”“结论”等标记进入真正面向用户的内容。

如果系统把这些输出统一当作正文处理,思考过程就可能直接暴露在聊天页面中,不仅影响阅读体验,也会破坏后续的 Markdown 渲染。

2. SSE 分片不等于完整语义单元

流式输出中的每一个 delta,只是传输层分片,并不一定对应完整的单词、标签或 Markdown 结构。

例如 <think> 可能被拆成:

<th

和:

ink>

Markdown 表格也可能按行、按列,甚至按字符分批返回。代码块的三个反引号可能还没有接收完整,前端就已经开始渲染。

如果系统收到一个 delta 就立即把它作为正文转发,页面上很容易短暂出现:

  • 半截 <think> 标签
  • 未闭合的代码块
  • 错位的 Markdown 表格
  • 抖动的标题和列表结构
  • 本应隐藏的 reasoning 内容

因此,SSE delta 不能直接等同于用户可见文本。

3. 模型经常压缩 Markdown 结构

为了减少 token 消耗,模型有时会主动压缩换行和空白字符。

常见情况包括:

正文内容 --- ### 下一节标题
1.第一项
2.第二项
说明文字 > 这是一段引用

以及原本应该多行展示的 GFM 表格被压缩到一行。

这些内容对人来说或许仍然可以理解,但对 Markdown 渲染器而言,已经不再是可靠输入。最终表现可能是标题失效、表格错乱、引用块无法识别,甚至部分文本被误判为 HTML。

二、第一层防线:用 Prompt 建立输出契约

项目首先通过 MarkdownPromptContract,统一定义模型应该遵守的 Markdown 输出规范,并在主对话生成和异步生成链路中追加到 system prompt。

它不是简单地要求模型“排版美观”,而是明确规定可解析的输出结构,例如:

  • 标题必须独占一行,并使用 ######
  • 分隔线 --- 必须独占一行
  • 列表项必须逐条换行
  • 表格必须使用标准 GFM 表格语法
  • 代码必须放在 fenced code block 中
  • 不得为了节省 token 删除 Markdown 所需的换行
  • 深度思考内容应进入 reasoning 字段或 <think> 通道
  • content 字段只输出最终答复

这一步的作用,是把格式稳定性前置到模型生成阶段。

Prompt 无法保证所有模型百分之百遵守规则,但它可以显著降低异常格式的出现概率,也能让不同厂商、不同能力的模型在进入业务系统前,先对齐到同一份输出契约。

从工程角度看,把规则集中在 MarkdownPromptContract 中,也比将同一套要求散落在多个提示词里更加可靠。

后续无论要增加表格规范、调整代码块要求,还是修改 reasoning 输出约定,都只需要维护一个地方,主生成链路和异步生成链路便可以同步生效。

不过,Prompt 只能降低问题概率,不能成为唯一防线。

三、第二层防线:在流式阶段拆分 reasoning 和正文

真正复杂的部分,出现在 SSE 流式输出阶段。

ChatMessageServiceImpl 会持续读取上游 AI API 返回的 data: 分片,并将每个 delta 交给 StreamContentProcessor 处理。

StreamContentProcessor 的核心不是字符串拼接,而是一个流式状态机。它会根据当前输出特征,在不同状态之间切换:

  • INITIAL:刚开始接收内容,尚未确定模型采用哪种输出方式
  • NATIVE_REASONING:模型使用原生 reasoning 字段
  • THINK_TAG:模型在 content 中使用 <think> 标签
  • IMPLICIT_THINKING:模型把内部分析混在 content 开头
  • CONTENT:已经进入用户可见正文

当 delta 中出现 reasoning_contentreasoningthinking 字段时,系统会优先通过 processNativeReasoning 将内容写入思考通道。

当 delta 只包含 content 时,则交由 processContent 判断它属于以下哪一种内容:

  1. 正常正文
  2. <think> 包裹的思考内容
  3. 混在正文开头的隐式分析过程

这种处理方式的关键,不只是“识别字段”,而是要在流式、不完整的输入中持续维护上下文。

<think> 标签必须支持跨 delta 识别

假设模型连续返回两个分片:

<th
ink>正在分析问题……

如果系统收到第一个分片后就立即将 <th 输出到正文,后续即使识别出了完整标签,也已经无法挽回页面上的错误展示。

因此,处理器会保留一小段尾部缓冲。当检测到内容可能是 <think></think> 的前缀时,不会立即输出,而是等待下一个分片到达后再做判断。

这相当于在字符流上实现了增量标签解析。

一个回答中可能出现多个 <think>

某些模型并不只在回答开头输出一次思考过程,而可能在正文中间再次进入 reasoning,例如:

<think>第一次分析</think>
正文第一部分
<think>补充分析</think>
正文第二部分

因此,系统不能在识别完第一个 </think> 后就永久进入 CONTENT 状态。

即使已经进入正文阶段,处理器仍然要继续检测后续出现的 <think> 标签,避免新的思考内容泄漏到用户可见正文中。

content-carried reasoning 必须保守判断

还有一类模型既没有原生 reasoning 字段,也不使用 <think> 标签,而是直接在 content 开头输出内部分析。

例如:

用户想了解 Markdown 输出治理。
我需要分别说明 Prompt、流式处理和前端渲染。
最终回答:
……

对于这种内容,处理器会在检测到足够明确的正文起点之前,暂时将其归入 reasoning 通道。

可能的正文起点包括:

  • “最终回答:”
  • “结论:”
  • “正式答复:”
  • 其他具有明确语义的回答标记

这里需要采取保守策略。

判断过于激进,可能把正常正文误判为思考过程;判断过于宽松,又可能把内部分析展示给用户。因此,这类识别规则应尽量建立在明确标记和足够上下文之上,而不是仅凭几个模糊关键词切换状态。

经过这一层处理后,前端可以同时获得两种内容:

  • 干净、可直接展示的最终正文
  • 单独保存的 reasoning 内容

reasoning 可以用于折叠展示、调试、审计或历史记录,而不会污染主要阅读区域。

四、第三层防线:生成完成后统一修复 Markdown

流式状态机解决的是“内容属于哪个通道”的问题,但并不能保证最终正文的 Markdown 结构一定正确。

因此,项目又增加了 MarkdownContentNormalizer,在以下关键节点统一规整正文:

  • AI 消息保存前
  • 继续生成完成后
  • 流式响应结束并回写消息时
  • 其他需要持久化最终内容的链路

它负责将模型压缩或破坏的 Markdown,恢复为更稳定的 GFM 结构。

典型处理场景包括:

  • 正文 --- ### 标题 拆分成段落、分隔线和标题
  • 1.标题 补上缺失的空格
  • 将黏在段落后的引用块移动到新行
  • 修复被压缩或拆散的 Markdown 表格
  • 识别“一、问题概述”等中文编号标题
  • 对强调语法中的 <> 做必要转义,避免被误判为 HTML 标签

例如,模型可能返回:

这是上一段内容 --- ### 下一部分 1.第一项 2.第二项

规整后可以变成:

这是上一段内容

---

### 下一部分

1. 第一项
2. 第二项

这一步并不是为了追求“更漂亮”的排版,而是为了把模型输出重新恢复成 Markdown 渲染器能够稳定理解的结构。

修复 Markdown,但绝不能破坏代码

MarkdownContentNormalizer 最重要的边界,是 fenced code block。

技术类回答中经常包含下面这样的代码:

```java
String value = "---";
System.out.println("1.这不是列表");
```

其中的 ---1.># 都可能与 Markdown 语法相似,但它们属于代码内容,不能被 normalizer 当作标题、列表、引用或分隔线处理。

因此,规整器会先识别 fenced code block,将完整内容划分为两类区域:

  1. 代码块外的普通 Markdown 文本
  2. 必须原样保留的代码块内容

所有结构修复只作用于第一类区域,代码块内部不做任何改写。

这是 Markdown 修复过程中必须守住的底线。用户可以接受排版不够完美,但很难接受“系统为了优化排版,把可执行代码改坏了”。

五、第四层防线:前端按照不可信输入处理

即使后端已经完成了内容分类和 Markdown 规整,前端仍然不能直接把模型输出当作可信 HTML 插入页面。

项目中的 markdown-renderer.js 使用:

  • markdown-it 解析 Markdown
  • highlight.js 实现代码高亮
  • DOMPurify 清洗生成后的 HTML

这三者分别解决结构解析、代码展示和安全隔离问题。

同时,项目还对代码块渲染进行了扩展,为代码块增加:

  • 编程语言标识
  • 复制按钮
  • 更稳定的容器结构
  • 便于样式控制的 class

对于 Markdown 链接,则自动补充:

target="_blank"
rel="noopener noreferrer"

这样既能避免当前聊天页面被直接覆盖,也能降低新页面通过 window.opener 影响原页面的风险。

这些看起来像前端体验细节,但本质上仍属于输出治理的一部分。

模型内容来自外部生成系统,理论上可能包含:

  • 非预期 HTML
  • 危险链接
  • 事件处理属性
  • 试图绕过 Markdown 解析器的标签
  • 影响页面结构的异常节点

因此,浏览器侧必须始终把 AI 输出视为不可信输入。

后端负责让 Markdown 更规范,前端负责将规范 Markdown 安全、稳定地转换成用户界面。两者不是重复建设,而是分别处理不同层面的风险。

六、测试策略:用“坏输入”验证系统稳定性

这套方案最值得借鉴的地方,不只是实现了若干处理类,而是为关键边界补充了针对性测试。

AI 输出治理不能只测试标准回答,因为标准回答通常不会暴露真正的问题。更有效的方式,是主动构造模型最容易生成的异常格式,再验证系统是否能够正确处理。

StreamContentProcessorTest

流式处理测试覆盖了以下场景:

  • 原生 reasoning 分片拼接时,单词之间的空格不能丢失
  • content-carried analysis 不能混入最终正文
  • <think> 内的内容必须进入 reasoning 通道
  • <think> 开始标签跨多个 delta 拆分时仍然能够识别
  • </think> 结束标签跨多个 delta 拆分时仍然能够识别
  • 一个回答中出现多个 <think> 块时都能正确处理
  • 流末尾只有普通文本 <t 时,完成阶段要将其正确刷回正文
  • 正文与 reasoning 的状态切换不能丢字符或重复字符

其中,“末尾普通文本 <t”是一个很典型的边界场景。

处理器为了识别 <think>,可能暂时缓存 <t。如果后续流已经结束,而系统没有在完成阶段主动刷新缓冲区,这两个字符就会永久丢失。

因此,流式处理器除了需要 process 方法,还必须具备明确的完成阶段,用于处理尚未确定语义的尾部内容。

MarkdownContentNormalizerTest

Markdown 规整测试更关注最终展示效果,包括:

  • 将压缩在一行中的标题、分隔线和正文正确拆开
  • 中文编号标题能够单独分段
  • 编号列表缺少空格时自动补齐
  • 被拆得很碎的管道表格能够重新组织
  • 引用块不能黏在普通段落后面
  • 分隔线必须独占一行
  • 强调内容中的尖括号不会被错误解析为 HTML
  • fenced code block 内部内容保持完全不变

这些测试共同说明了一件事:

AI 输出治理不能只验证“模型正常时系统能不能工作”,而要验证“模型输出异常时系统会不会失控”。

只有半截标签、压缩表格、缺失换行、未闭合结构等边界情况都能稳定处理,用户才会真正感受到系统可靠。

七、这套设计带来的工程启发

1. Prompt 是契约,但不是唯一防线

Prompt 负责告诉模型“应该怎样输出”,却不能假设模型永远遵守。

不同模型、不同版本、不同上下文长度,甚至不同生成温度,都可能影响格式遵循程度。因此,Prompt 更适合承担“降低错误概率”的职责,而不是作为格式正确性的最终保证。

2. 流式内容必须先解析,再展示

SSE delta 是传输单元,不是语义单元。

一个分片可能只是半个标签、半个代码围栏,甚至只是一个 Markdown 表格单元格。直接把每个 delta 当正文转发,迟早会遇到标签泄漏、页面抖动、表格错乱和代码块闪烁。

流式输出系统真正需要处理的,不只是“逐字打印”,而是增量语义解析。

3. reasoning 和 content 必须分通道

深度思考能力可以改善回答质量,但产品层面必须明确:

  • 哪些内容应该展示给用户
  • 哪些内容只属于模型内部过程
  • 哪些内容可以折叠展示
  • 哪些内容需要保存但默认隐藏

如果 reasoning 和正文共用同一条字符串链路,后续无论做页面展示、消息持久化,还是文档导出,都会面临内容污染问题。

4. Markdown 修复必须有明确边界

规整器可以修复标题、列表、表格、引用和分隔线,但不能无限猜测模型意图。

尤其是代码块、行内代码、链接地址等高敏感区域,必须尽量避免改写。

Markdown normalizer 的目标应当是“恢复明显丢失的结构”,而不是“重新创作模型回答”。

5. 前端必须默认不信任 AI 输出

即使后端已经完成清洗和规整,浏览器侧仍然需要:

  • HTML 清洗
  • 链接安全属性
  • 代码块隔离
  • 可控的 Markdown 配置
  • 对原始 HTML 的谨慎处理

后端规整解决的是格式稳定性,前端清洗解决的是浏览器安全问题,二者不能互相替代。

6. 最终内容和实时内容应承担不同职责

实时流式内容强调低延迟和即时反馈,最终持久化内容强调完整性和结构正确性。

因此,一个成熟的系统可以允许流式阶段进行轻量缓冲和分流,在完成阶段再执行更完整的 Markdown normalization,并用规整后的结果回写消息。

不必强求每一个 delta 都是最终可持久化文本,但必须保证最终保存的内容是稳定的。

结语

许多 AI 应用在早期阶段,会把重点放在“把模型接进来”。

但进入产品化阶段后,真正困难的问题会逐渐变成:

如何把不稳定、不可预测的模型输出,转换成稳定、安全、可测试、可持续演进的用户体验?

这个项目中的 Markdown 输出治理,提供了一个具有代表性的工程化答案:

  • 用 Prompt 契约降低异常输出概率
  • 用流式状态机拆分 reasoning 和正文
  • 用后端 normalizer 修复 Markdown 结构
  • 用前端安全渲染完成最后一公里
  • 用异常输入测试验证系统边界

它解决的并不是某一个模型、某一种标签或某一个 Markdown 渲染器的问题,而是将“模型输出不可控”这件事,转化成系统内部一套可解析、可管理、可测试、可演进的处理流程。

对于任何需要把 AI 回复展示给真实用户的系统来说,输出治理都不会只是锦上添花的体验优化。

它最终会成为 AI 应用基础设施的一部分。

如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论
AI助手
召田最帅boy的小助手
🤖
我是召田最帅boy的小助手
我已经阅读了这篇文章,可以帮您:
理解文章内容 · 解答细节问题 · 分析核心观点