流式 Markdown 渲染方案踩坑实录:从解析失败到输出延迟的深度复盘

6 阅读10分钟

在最近构建 AI 对话产品的过程中,我需要一个能在 React 里实时流式渲染 Markdown 的组件。要求很简单:大模型逐 token 吐出内容,前端立刻解析并渲染,保留富文本格式(加粗、代码块、表格等),不能闪屏,也不能崩溃。于是我把社区里热门的方案挨个试了一遍,包括 markstream-reactstreaming-markdown-react@ant-design/x 以及 Streamdown,全部使用最新版,不做任何二次封装。结果却是踩坑不断,直到最后才找到了相对“能用”的平衡点。

特性markstream-react、streaming-markdown-react@ant-design/xStreamdownreact-markdown
流式渲染支持✅ 流式设计❌ 非原生流式,依赖组件重新整体渲染✅ 原生流式设计❌ 仅支持一次性完整渲染
解析机制轻量级正则 + 状态机全量解析 (底层用 react-markdown 等)基于 unified 生态的增量AST解析一次性 AST 解析 (remark/rehype)
未闭合标记处理⚠️ 不可靠,直接显示裸露标记(如 **),闭合后跳变❌ 可能因全量重建出现短暂白屏或闪烁✅ 完美处理,将未闭合标记暂存,等闭合后再渲染N/A (一次性输入,无未闭合问题)
渲染体感延迟🟢 很低,逐字输出🔴 随文本变长,重渲染开销增大,有卡顿感🟡 存在明显缓冲延迟,长段落会积攒后一次性输出N/A
稳健性/崩溃风险🔴 高,边界情况易出格式错乱🟡 中等,较少崩溃,但可能内容闪跳🟢 极高,格式永远合法,从不崩溃🟢 极高,但仅用于最终静态渲染
核心依赖自研简易解析器react-markdownremark-gfm 等unifiedmicromark 增量扩展unifiedremarkrehype
适用场景快速开发项目历史消息展示、AI回复完成后渲染生产级AI流式对话的核心渲染引擎静态文档、历史消息、非流式AI回复
维护活跃度 (截至2026年)较低,更新缓慢活跃,但重心在AI交互组件套件活跃,专注于解决流式解析痛点非常活跃,生态成熟

一、第一轮踩坑:未闭合的加粗与解析失败

最早引入的是 markstream-react 和 streaming-markdown-react,两者在文档里都声称支持真正的增量解析,可以处理半截 Markdown 语法。然而实际接上我们的 SSE 流以后,问题立刻就暴露了出来。

最常见的场景是 加粗不闭合。当服务端发来 **这里是一段很长的加粗文字 但还没来得及发送结尾的 ** 时,两个库的表现就完全不同:

  • markstream-react 会直接将 ** 原样显示出来,然后继续渲染后续文本,导致页面上出现裸露的星号,并且在闭合标签真正到来时出现一次明显的“跳变”——文本突然从普通样式变成加粗。
  • streaming-markdown-react 则在遇到未闭合的 ** 后直接抛出了解析异常,控制台刷满错误,整个组件崩掉,用户只能看到一片空白,直到下一次完整更新后才恢复。

不止是加粗,代码块(三个反引号)未闭合、嵌套列表、以及像 1. 这样的寡头列表标记,都极易触发解析失败。究其原因,二者虽然都声称“增量解析”,但底层很大程度依赖一次性正则匹配或全量 AST 重建。当流式内容是“不合法”的中间态时,正则匹配不到结束标记,解析器就进入错误分支,或者直接中断。这个问题在用户输入会话、模型输出速度不稳定时尤为严重,几乎无法用于生产。

接着我寄希望于 @ant-design/x 的 Markdown 渲染支持。Ant Design X 作为蚂蚁集团推出的 AI 组件库,自带的 Chat 与 Bubble 组件都内置了 Markdown 渲染功能。我天真地以为它肯定对 AI 场景做过适配,结果很快被打脸。

@ant-design/x 的 Markdown 渲染走的还是全量解析+整体替换的老路。每当流式内容增加一小段,整个消息字符串被重新解析,生成新的 AST,再重新渲染整个 React 子树。这种方式在短消息上没有太大问题,但当对话超过几百字,内容变化频率又很高时,就出现了三个致命缺陷:

  1. 同样承受未闭合语法导致的解析错误,只不过因为它使用了 react-markdown 之类较稳健的解析器,崩溃少了,但页面会出现短暂的白屏或内容闪烁。
  2. 每一次重新解析都会丢失 UI 的连续感,按钮、图片等非文本组件会经历卸载-重新挂载,产生不必要的副作用。
  3. 编辑器光标跟踪、滚动容器跟随等功能极易被打断。

显然,@ant-design/x 的设计重心不在逐 token 级别的流式解析上,而是更偏向于完成态消息的展示与控制。用它来做逐字渲染,就像用大炮打蚊子,不仅笨重,效果还差。

二、第三轮转折:Streamdown 解决了格式错乱,但带来了新问题

几乎到了准备自研解析器的时候,我发现了 Streamdown。它是一款专门为流式 Markdown 设计的库,核心利用 unified 生态的增量解析能力,可以真正“理解”不完整的 Markdown 树。测试下来的第一感觉是:终于没有格式爆炸了

未闭合的 ** 不会显示为星号,而是被暂时“压住”,直到解析器确认内容已闭合才输出带样式的文本。代码块也一样,即便三个反引号之间只有一半的内容,页面也不会渲染出散落的反引号,而是安静地等待闭合。解析失败的红色错误警告消失得无影无踪。

然而好景不长,产品同学很快反馈:“为什么对话输出会卡一下,然后突然蹦出一大段话?”

这就是 Streamdown 最明显的代价——渲染延迟与缓冲区堆积。为了维护 Markdown 树结构的完整性,它的增量解析器会为每个词法单元维护一个缓冲区。以段落为例,除非遇到换行或明确的结束标记,否则当前段落的全部 token 都会暂存在内部队列中,直到确定这一“块”可以安全提交渲染。当模型生成一个长段落(比如 200 个字符)且其间没有任何换行时,这些字符会全部积压在 Streamdown 的缓冲里,直到段落闭合才一起吐出。这就造成了用户视角里的“长时间静默然后一大段突现”。

另外,Streamdown 内部的 React 渲染层为了减少 DOM 操作,也可能使用了一些积累策略(比如 debounce 渲染队列),这虽然提高了整体性能,但在流式这个对实时性要求极高的场景下,反而放大了体感延迟。结果就是:格式正确了,但流畅度却丢了

三、深挖原理:为什么稳稳的 Streamdown 反而变慢?

要明白背后的原因,得从“两种极端的 Markdown 解析思维”说起。

  • 极速流式思维(不完美但流畅) :每收到 1~3 个字符,就用轻量级正则或简单状态机快速判断当前是否可能构造出一个有效标记。对不确定的标记(比如只收到一个 *)选择暂时按纯文本渲染,等确认后再回退修改。优点是反应极快,缺点是会有短暂的标记裸露或样式闪烁。
  • 纯正增量思维(完美但不流畅) :将 Markdown 解析彻底纳入 AST 增量构建流程,只有当一个节点完全闭合、类型确定后,才提交渲染。优点是渲染结果永远合法,缺点是必须等待节点闭合,由此产生缓冲延迟。

markstream-reactstreaming-markdown-react 试图走中间路线,但状态机设计不够鲁棒,导致大量边界情况崩溃。@ant-design/x 则本质是“全量思维”,根本没走上流式这条路。Streamdown 选择的是完整的增量 AST 方案,因此天然带来了缓冲等待。延迟的根源不在于它写得不好,而恰恰在于它为了保证 Markdown 语义绝对正确所做的取舍。

四、可落地的优化方案

既要 Streamdown 的稳健,又不想忍受明显的延迟停顿,我们可以从三个方向着手:

1. 调整 Streamdown 的内置配置(如果库支持)

很多增量解析器允许配置 maxWait(最长等待时间)或 bufferSize。如果 Streamdown 暴露了相关参数,可以适当降低默认缓存阈值,强制在累积一定字符或时间后先输出当前内容,哪怕部分标记不闭合也先按纯文本渲染,后续再进行样式修正。这就从“绝对正确”向“准确实时”做了一点妥协。

2. 对送入解析器的流进行“句子级”分割

在实际接入 SSE 事件时,不要将逐 token 的直接塞给 Streamdown,而是在中间加一层轻量级预处理:

  • 按全角/半角句号、换行、逗号等定界符进行切分。
  • 每次向 Streamdown 送入相对完整的短句子或短语,而不是单字。
    这样一来,解析器的内部缓冲会更快被“填完并触发渲染”,又能保证句子级别的语义完整,延迟感大幅降低。

3. 混合渲染策略:Streamdown + 原始文本兜底

将 Streamdown 作为最终格式化输出层,但同时监听原始流式文本流直接渲染一份无格式的“预览层”。当 Streamdown 缓冲未释放时,用户可以看到不带样式的字符连续增长,保证打字机的流畅感;当 Streamdown 完成一块输出时,无缝替换为格式化后的内容。视觉上,用户几乎感觉不到中断。

五、总结与选型建议

经过真实项目里的反复试验,我的结论很直接:

  • 快速开发:可以用 markstream-react,在模型输出稳定、内容较短时还不错。
  • 组件生态@ant-design/x 的 Markdown 渲染完全够用,配合生态组件可以快速搭建美观界面。
  • 生产级流式 AI 对话:当前最靠谱的选择就是 Streamdown,它的解析正确性是目前其他库无法比拟的。延迟问题可以通过上述配置、分句和混合渲染等手段有效缓解,实现“既稳又快”的效果,而且在社区里是支持度比较高的。

对 Streamdown 的维护者来说,我也希望能看到未来版本加入更灵活的输出控制策略,比如“最长等待 80ms 强制渲染当前缓冲区中的内容”,这将直接从底层解决延迟顽疾,让它在流式 Markdown 领域真正做到一骑绝尘。


本篇分享全部基于真实项目中踩过的坑,如果你也在纠结流式 Markdown 渲染方案,希望这篇文章能帮你少走一些弯路。