做大模型应用之前,我们一直觉得 Markdown 渲染是一件很普通的事情。
直到模型开始流式输出,我们才意识到:Markdown 渲染不是一个 UI 问题,而是一个实时计算问题。
你可以把整个过程理解成一个实时编译器:
模型每输出一点内容,前端就要重新解析一次 Markdown,再重新生成 DOM,再重新渲染页面。
当输出速度慢的时候,这个问题几乎感觉不到;但当模型 TPOT 越来越高,问题就开始被无限放大。
于是,这个 Markdown 渲染器经历了四个非常清晰的阶段:
全量渲染 → 增量渲染 → 节流解析 → 前缀缓存
这不是一次优化,而是一场进化。
第一阶段:全量渲染 —— 最简单,也是最慢的方案
最开始调研时,我们直接使用了 MateChat 的现成组件,底层是 markdown-it。
逻辑非常简单:
模型输出一段文本 → 把整段文本交给 markdown-it → 解析成 HTML → 整体替换 DOM。
刚开始看起来完全没问题,甚至觉得这个方案挺优雅:
实现简单、逻辑清晰、可维护性也不错。
但当模型开始流式输出时,一切都变了。
每当模型多输出一个字,整个 Markdown 都会被重新解析一次,然后整个 DOM 也会重新生成一次。
你会看到非常明显的现象:
- 图片会不停闪动
- 代码块会不断重新渲染
- 页面滚动开始掉帧
- CPU 使用率持续飙升
我们当时做过一次简单测试:
让模型输出一段带图片的 Markdown,然后观察浏览器的行为。
结果非常直观——图片会被重复加载。
原因很简单:组件每次都会重新创建整个 DOM。
也就是说,这种方案本质上是:
O(n) 解析 + O(n) 渲染 + 高频触发
这在普通页面中完全没问题,但在流式输出场景下,就是灾难。
于是,第一阶段宣告失败。
第二阶段:增量渲染 —— 让框架去解决 DOM 问题
第一次优化的核心目标其实很明确:
不要每次都重新渲染整个 DOM。
于是我们开始研究 React 社区的做法,很快发现一个非常成熟的思路:react-markdown。
它并不是直接生成 HTML,而是走了一条完全不同的路线:
Markdown → AST → VNode → 框架 diff → 局部更新
这一步的核心思想只有一句话:
把 Markdown 变成组件树,而不是字符串。
于是我们基于同样的原理,做了一个 Vue 版本的实现:
- 使用 remark 解析 Markdown,生成 mdast
- 把 mdast 转换成 Vue 的 VNode
- 利用 Vue 的 diff 算法只更新变化的部分
- 避免全量 DOM 重建
效果是立竿见影的。
图片不再闪动了,代码块也不会重新创建,滚动流畅度提升非常明显。
如果说第一阶段是“能用”,那第二阶段就是“好用”。
但问题还没有真正解决。
第三阶段:节流解析 —— 真正的瓶颈不是 DOM,而是 Markdown
随着模型能力不断提升,TPOT(每秒 token 输出量)越来越高。
也就是说:模型吐字越来越快。
这时候新的瓶颈开始出现了——不是 DOM 渲染,而是 Markdown 解析本身。
我们做了一次性能分析,结果非常意外:
Markdown 解析的 CPU 占用,已经超过了 DOM 更新。
也就是说,真正拖慢页面的不是浏览器,而是 JavaScript 本身。
问题的本质其实非常简单:
模型每输出一个 token,我们就解析一次 Markdown。
当输出频率变成每秒几十次时,解析就变成了一个高频 CPU 计算任务。
于是我们做了一次非常“朴素”的优化:
节流解析。
逻辑是这样的:
- 模型每输出一次,不立刻解析
- 而是等 50ms 或 100ms 再统一解析
- 如果这段时间模型又输出了,就合并到一次解析中
这一步优化的效果非常明显。
解析次数直接减少 60%~80%,CPU 使用率立刻下降了一大截。
而用户几乎感觉不到变化,因为人眼对几十毫秒的延迟是无感的。
到这里,我们以为问题已经解决了。
但现实告诉我们:还没有。
第四阶段:前缀缓存 —— 从“减少次数”到“改变复杂度”
节流虽然减少了解析次数,但并没有改变一个本质问题:
每次解析的 Markdown 都在变长。
也就是说:
之前是 100 次解析,每次解析 1KB 现在是 20 次解析,但每次解析 10KB
最终还是会卡。
尤其是在长上下文场景下,一段 Markdown 动不动就是几千行,甚至上万字符。
这时候每一次解析都像在运行一个小型编译器。
真正的突破,来自一次非常偶然的灵感——推理引擎的前缀缓存。
推理引擎不会重复计算已经生成的内容,它只计算新增的 token。
那 Markdown 能不能也这样?
于是我们开始思考一个问题:
Markdown 的最小结构单位是什么?
答案是:段落。
而段落的分隔方式,就是两个换行符:
\n\n
也就是说:
只要前面的段落没有变化,就完全没必要重新解析。
真正需要解析的,只有最后一个还没结束的段落。
于是我们做了一个非常简单但非常高效的优化策略:
- 把 Markdown 按段落拆分
- 已解析的段落直接缓存
- 只解析最后一个未完成的段落
- 最终把结果拼接起来
这一优化直接把复杂度从:
O(n) 降到了 O(1)
也就是说,无论 Markdown 有多长,解析时间几乎不会再增长。
在长上下文场景下,这一步优化的效果可以用“质变”来形容。
CPU 使用率大幅下降,页面几乎不再卡顿,流式输出也变得异常丝滑。
到这里,一个真正为大模型时代设计的 Markdown 渲染器,才算正式诞生。
未来规划:下一步优化,其实是代码块
虽然普通文本已经通过前缀缓存彻底解决了性能问题,但还有一个地方依然存在瓶颈:
代码块。
原因其实很简单:
普通 Markdown 可以按段落缓存,但代码块往往是一个非常长的连续结构。
比如:
- 几百行的代码示例
- 一整段 SQL
- 长达上千行的配置文件
- 完整的前端组件代码
在这些场景下,Markdown 的解析已经不是主要问题,真正的瓶颈变成了:
代码块本身的渲染。
下一步优化:代码块级别的前缀缓存
其实代码块完全可以用和文本一样的思路优化:
只是“分段逻辑”不再是 \n\n,而是代码行本身。
也就是说:
- 已经输出的代码行可以直接缓存
- 只解析新增的代码部分
- 只更新新增的高亮内容
- 避免整段代码反复重新渲染
如果这一步实现成功,长代码块的性能问题基本也可以彻底解决。
Mermaid / ECharts:优化的方向不是解析,而是展示
还有一种更特殊的场景:
自定义代码块,比如:
- Mermaid
- ECharts
- 流程图
- 复杂图表
- 可视化组件代码块
这些内容有一个非常明显的特点:
中间状态没有意义。
比如 Mermaid:
模型可能输出一半的流程图语法,这时候你即使渲染出来,也是一张错误的图。
再比如 ECharts:
输出到一半时,图表基本是不可用的。
既然中间状态没有意义,那就完全没必要实时渲染。
更合理的策略:延迟渲染 + Loading 态
针对这类代码块,其实最优方案不是优化解析,而是优化展示策略:
- 当检测到 Mermaid / ECharts 代码块开始时
- 先展示一个 Loading 状态
- 等代码块完整输出之后
- 再一次性解析 + 渲染
这样做有几个好处:
- 避免反复创建图表实例
- 避免中间状态的错误渲染
- 性能压力大幅降低
- 用户体验反而更好
因为用户真正关心的不是“中间过程”,而是最终结果。
总结:这不是一次优化,而是一次思维转变
回头看整个过程,其实只有四个阶段:
全量渲染 → 增量渲染 → 节流解析 → 前缀缓存
每一步都不是简单的性能优化,而是一次思维升级:
- 从“每次全部重算”,变成“只算变化部分”
- 从“优化 DOM”,变成“优化计算”
- 从“减少调用次数”,变成“改变时间复杂度”
- 从“让它更快”,变成“让它几乎不用计算”
写在后面
本文由人类撰写大纲,GPT 生成全文。本来还应该贴点性能数据和图片啥的,现在这长文本实在看着无聊,再对比一下各类竞品,有点懒,先不弄了,只能说现在市面上的 Markdown 流式渲染器,有一个算一个,都是垃圾,不点名了;官网最简单的示例,打开浏览器的性能监视器面板,都能看到 CPU 使用率长期是大几十,丢人,欢迎打脸。好了,水水文章,涨涨经验,刹国。