一个 Markdown 渲染器的性能进化史:从全量渲染到前缀缓存

0 阅读8分钟

做大模型应用之前,我们一直觉得 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 版本的实现:

  1. 使用 remark 解析 Markdown,生成 mdast
  2. 把 mdast 转换成 Vue 的 VNode
  3. 利用 Vue 的 diff 算法只更新变化的部分
  4. 避免全量 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

也就是说:

只要前面的段落没有变化,就完全没必要重新解析。

真正需要解析的,只有最后一个还没结束的段落。

于是我们做了一个非常简单但非常高效的优化策略:

  1. 把 Markdown 按段落拆分
  2. 已解析的段落直接缓存
  3. 只解析最后一个未完成的段落
  4. 最终把结果拼接起来

这一优化直接把复杂度从:

O(n) 降到了 O(1)

也就是说,无论 Markdown 有多长,解析时间几乎不会再增长。

在长上下文场景下,这一步优化的效果可以用“质变”来形容。

CPU 使用率大幅下降,页面几乎不再卡顿,流式输出也变得异常丝滑。

到这里,一个真正为大模型时代设计的 Markdown 渲染器,才算正式诞生。


未来规划:下一步优化,其实是代码块

虽然普通文本已经通过前缀缓存彻底解决了性能问题,但还有一个地方依然存在瓶颈:

代码块。

原因其实很简单:

普通 Markdown 可以按段落缓存,但代码块往往是一个非常长的连续结构。

比如:

  • 几百行的代码示例
  • 一整段 SQL
  • 长达上千行的配置文件
  • 完整的前端组件代码

在这些场景下,Markdown 的解析已经不是主要问题,真正的瓶颈变成了:

代码块本身的渲染。


下一步优化:代码块级别的前缀缓存

其实代码块完全可以用和文本一样的思路优化:

只是“分段逻辑”不再是 \n\n,而是代码行本身。

也就是说:

  • 已经输出的代码行可以直接缓存
  • 只解析新增的代码部分
  • 只更新新增的高亮内容
  • 避免整段代码反复重新渲染

如果这一步实现成功,长代码块的性能问题基本也可以彻底解决。


Mermaid / ECharts:优化的方向不是解析,而是展示

还有一种更特殊的场景:

自定义代码块,比如:

  • Mermaid
  • ECharts
  • 流程图
  • 复杂图表
  • 可视化组件代码块

这些内容有一个非常明显的特点:

中间状态没有意义。

比如 Mermaid:

模型可能输出一半的流程图语法,这时候你即使渲染出来,也是一张错误的图。

再比如 ECharts:

输出到一半时,图表基本是不可用的。

既然中间状态没有意义,那就完全没必要实时渲染。


更合理的策略:延迟渲染 + Loading 态

针对这类代码块,其实最优方案不是优化解析,而是优化展示策略:

  • 当检测到 Mermaid / ECharts 代码块开始时
  • 先展示一个 Loading 状态
  • 等代码块完整输出之后
  • 再一次性解析 + 渲染

这样做有几个好处:

  1. 避免反复创建图表实例
  2. 避免中间状态的错误渲染
  3. 性能压力大幅降低
  4. 用户体验反而更好

因为用户真正关心的不是“中间过程”,而是最终结果。


总结:这不是一次优化,而是一次思维转变

回头看整个过程,其实只有四个阶段:

全量渲染 → 增量渲染 → 节流解析 → 前缀缓存

每一步都不是简单的性能优化,而是一次思维升级:

  • 从“每次全部重算”,变成“只算变化部分”
  • 从“优化 DOM”,变成“优化计算”
  • 从“减少调用次数”,变成“改变时间复杂度”
  • 从“让它更快”,变成“让它几乎不用计算”

写在后面

本文由人类撰写大纲,GPT 生成全文。本来还应该贴点性能数据和图片啥的,现在这长文本实在看着无聊,再对比一下各类竞品,有点懒,先不弄了,只能说现在市面上的 Markdown 流式渲染器,有一个算一个,都是垃圾,不点名了;官网最简单的示例,打开浏览器的性能监视器面板,都能看到 CPU 使用率长期是大几十,丢人,欢迎打脸。好了,水水文章,涨涨经验,刹国。