接入大模型构建聊天系统时,最容易被低估的问题,往往不是模型“能不能返回内容”,而是这些内容能不能被稳定、准确、安全地展示给用户。
对于一个支持多模型接入、流式输出、深度思考、联网搜索、文档生成和对话分享的 AI 对话系统来说,模型返回的文本早已不再是普通字符串。它会直接影响页面布局、代码高亮、表格展示、分享页阅读体验,甚至影响后续的 Word、PDF 等文档生成结果。
在这个项目的 chat 模块中,围绕 Markdown 稳定输出设计了一套完整的治理链路。它不是简单地在前端接入一个 Markdown 解析器,而是从 Prompt 约束、SSE 流式分流、后端内容规整,到前端安全渲染,构建了多层防线。
这套设计背后的核心判断是:
不要把模型输出当作最终文本,而要把它视为一种需要解析、分类、修复和验证的半结构化协议。
一、问题本质:模型输出并不是稳定协议
在真实系统中,AI 回复的不稳定主要体现在三个方面。
1. 不同模型的 reasoning 输出方式不一致
不同模型对“思考过程”的表达方式差异很大。
有些模型会通过独立字段返回,例如:
reasoning_contentreasoningthinking
有些模型没有独立 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_content、reasoning 或 thinking 字段时,系统会优先通过 processNativeReasoning 将内容写入思考通道。
当 delta 只包含 content 时,则交由 processContent 判断它属于以下哪一种内容:
- 正常正文
<think>包裹的思考内容- 混在正文开头的隐式分析过程
这种处理方式的关键,不只是“识别字段”,而是要在流式、不完整的输入中持续维护上下文。
<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,将完整内容划分为两类区域:
- 代码块外的普通 Markdown 文本
- 必须原样保留的代码块内容
所有结构修复只作用于第一类区域,代码块内部不做任何改写。
这是 Markdown 修复过程中必须守住的底线。用户可以接受排版不够完美,但很难接受“系统为了优化排版,把可执行代码改坏了”。
五、第四层防线:前端按照不可信输入处理
即使后端已经完成了内容分类和 Markdown 规整,前端仍然不能直接把模型输出当作可信 HTML 插入页面。
项目中的 markdown-renderer.js 使用:
markdown-it解析 Markdownhighlight.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