目录 · 第 10 / 16 章
ActorPart IV · 流式 Markdown 解析器

10AI 把 Markdown 解析变成了流处理

批量吞吐 → 首字延迟的需求反转;已定稿前缀 + 暂定尾巴;你熟悉的 markdown-it 与 pulldown-cmark。

传统 Markdown 解析器的隐含假设是:输入是完整的。你手里有一整篇 README.md,调一次 parse(),拿到 AST 或 token 流,渲染,结束。markdown-it、pulldown-cmark、cmark 概莫能外——它们比拼的是批量吞吐:GB/s 级的解析速度、最少的内存分配。大语言模型把这个假设连根拔起。聊天界面收到的从来不是一整篇文档,而是逐 token 追加的字节流:先是 "# 三",然后是 "# 三种排",再是 "# 三种排序\n\n先看**冒"。每一次追加都可能停在语法构造写到一半的地方——**加粗只打了一半、代码围栏还没闭合。这不是边界情况,是常态

需求随之整体反转,不止「优化目标」一处:

维度批量时代AI 流式时代
优化目标吞吐——整篇解析多快首次可见延迟——第一个字多快上屏
输入完整性完整文档永远处于「写到一半」状态
未闭合构造语法错误或边界情况常态:**加粗写了一半、代码围栏未闭合
增量成本无此概念——只有一次 parse()每来一个 chunk 的追加成本决定体验
消费者速度无此概念——一次调用即释放慢客户端(SSE/弱网)不能拖垮服务端

前两行是表象,后三行才是架构级的麻烦:一旦「未闭合是常态」,解析器就必须在任意断点给出合理输出;一旦「增量成本」成为体验指标,就不能假装每次都是从零开始的新请求;一旦「消费者速度」进入考量,解析管线就必须知道怎么慢下来而不是崩掉或把内存撑爆。这三条,批量解析器的设计者从未需要考虑过,因为它们的输入永远是一整块、静止不动的字节。

朴素做法的 O(n²) 陷阱

最直接的应对是「攒一段、重解析一遍」:每来一个 chunk,把累计的全部文本重新丢进 parse()。这个做法能跑,但账算不过来。设文档最终由 N 个 chunk 拼成,每个 chunk 平均 c 字节,逐 chunk 重解析的总工作量是一个等差数列:第 1 次解析 c 字节,第 2 次解析 2c 字节……第 N 次解析 Nc 字节,求和约等于 c·N²/2——正比于 。换成只解析新增部分的增量做法,每个 chunk 只处理属于自己的 c 字节,总工作量正比于 N。文档越长,两者的差距不是线性拉开,而是平方拉开:对话轮数翻一倍,朴素重解析的总成本要翻四倍。语法高亮这类昂贵的渲染步骤还会被反复重做——第 500 个 chunk 到达时,前 499 个 chunk 早就高亮过的代码块要被原样再高亮一遍,而且是在每一次新增的 parse() 调用里都重高亮一次。Chrome 官方的 LLM 渲染指南把这种写法列为反面教材(自报案例,未独立核实),给出的结论与上面的算术完全一致:文档越长,每一次追加越贵,体验反而随对话变长而变差。

JS 生态里已经长出一批「补丁式」方案,思路值得借鉴——它们都没有改造解析器本体,而是在渲染层打补丁。Streamdown(Vercel 出品)定位是 react-markdown 的替代品,核心技巧是自动补全未闭合语法:检测到写了一半的 ** 或围栏,先合成一个闭合符再交给常规解析器渲染(文档,自报能力,未独立核实)。streaming-markdown(thetarnav)走 append-only 渲染,见到开分隔符就乐观提交样式,不等闭合符出现,同样是自报设计、未独立核实。phoenix_streamdown(Elixir)把文档切块,冻结已完成块的 DOM,只重渲染最后一个活动块,自报在 56 块文档上少做约 7 倍的解析工作。这三个方案的共同立场是:批量解析器本身不用动,在它前面(先补齐语法再喂给解析器)或旁边(乐观渲染、冻结 DOM)加一层壳就够了。

已定稿前缀 + 暂定尾巴

这三个方案指向同一个形状:已定稿前缀(stable prefix)+ 暂定尾巴(provisional tail)。已经闭合的块永远冻结,字节不再改动、DOM 不再重算;只有文档末尾那个还「开着」的块才需要反复重渲染,而它通常只有几百字节——不管文档本身涨到多长,重渲染的工作量都被摁在这个常数级的窗口里。这正是这个形状比朴素重解析优越的地方:重渲染成本从「正比于全文长度」降到「正比于最后一个未闭合块的长度」。

上面这台实验台就是这个形状本身:按下播放,它像 LLM 一样逐块吐字;已定稿前缀会在块闭合的瞬间冻结成灰,暂定尾巴始终高亮、跟着光标走。打开毒块开关还能看到某个 worker 崩溃时,两侧的定稿内容完好无损,只有那一个缺口先被占位符补上,随后自愈。

但 Streamdown 们都是在批量解析器外面包一层补丁——解析器本身对「输入未完整」这件事一无所知,补丁层要替它兜住全部意外。本书要做的是把这个形状直接做进解析器内部——而一旦这么做,你需要的东西列出来会非常眼熟:一个持有「解析进行到哪了」的有状态实体,串行处理追加输入;一段可以并行化的无状态计算;某个块崩溃时不连坐整篇文档的故障隔离;慢消费者出现时逐级反向传播的背压;断线重连时从任意进度重放的能力。

🔑 设计钥匙

这正是 AI 时代对解析器提出的真实需求反转:优化目标从批量吞吐变成首次可见延迟,而满足它的架构恰好是——一个有状态的增量核心 + 可并行的无状态计算 + 全链路背压。这份清单不是巧合,它就是一个 Actor 运行时的功能列表。

你已熟悉的两个世界:markdown-it 与 pulldown-cmark

把这套新需求摆在你已经用惯的两个解析器旁边,反差会很清楚。以下结构性事实都能在两个项目各自的官方文档里找到出处。

markdown-it 的解析器由三条嵌套的规则链(ruler)组成——coreblockinline,每条链维护一个独立的 state 对象,链上的每条规则都可以在运行时单独开关。三条链分工明确:core 跑在最外层,负责跨整篇文档的前后处理(把 block 链的产物逐个交给 inline 链、插入 linkify 一类的后处理规则);block 链把整篇输入切成标题、段落、列表、引用块等容器块与叶子块;inline 链只在 block 产出的每一个「行内容器」token 内部运行,处理强调、链接、行内代码这些字符级语法。三条链依次跑完,产物是扁平 token 流——开/闭标签成对出现(heading_open……heading_close),而不是一棵嵌套的 AST 节点树。markdown-it 的架构文档把这个选择说得很直白:「不需要 AST,遵循 KISS」——扁平数组省掉一次指针追踪、序列化更便宜,渲染阶段也更容易按 tag 做分派。解析严格两段式:block 链必须整篇跑完,inline 链才开始工作——这意味着第一个行内 token 出现之前,至少要读到能让某个块闭合的地步,遑论整篇文档解析完。

pulldown-cmark 走了一条外观完全不同的路:对外是pull 式事件迭代器(Event::StartEvent::EndEvent::Text……),调用方每 next() 一次才拿到一个事件,解析器不会抢先把整棵树堆进内存等你来取。项目 README 把选择 pull 的理由说得很清楚——「pull 解析比构建整棵文档树省得多」,内存是第一诉求,不是流式。但翻开内部实现,故事和 markdown-it 如出一辙:专门的 first pass(源码里的 firstpass.rs)先把块级结构建完,行内解析消费的是已经构建完成的块结构,只是这棵结构不需要以公开 AST 的形式暴露给调用方——迭代器只是在这棵内部结构上做惰性遍历。也就是说,哪怕对外形态是「拉一个吐一个」,内部依然是先块后行内的两阶段,并不是对原始输入字节的单遍增量流。

📝 注意

「pull」说的是调用方与解析器之间的控制权归属——是解析器推事件给你,还是你主动拉事件出来;「流式」说的是能否在输入未完整之前产出正确的前缀结果。两者常被混为一谈,但 pulldown-cmark 恰好证明它们是正交的两个轴:它的 API 是 pull,内部实现却仍然要求完整输入才能产出第一个事件,因为 first pass 必须先看到块结构收尾。一个 pull 式 API 完全可以包着一个批量内核——这也是本书选择在解析器内部改造增量能力,而不只是改造对外 API 形状的原因。

两者的共同点比表面差异更重要:都是严格的「先块后行内」两阶段——这不是巧合,是 CommonMark 语法本身决定的,下一章会展开;都假设输入完整——token 流和事件流都要等对应的块结构定型才开始产出,行内事件更要等所在块闭合才会出现,这对流式场景是结构性的首字延迟;都是单线程的——不是实现偷懒,是它们的架构里根本没有一条可以安全并行的缝,这条也留给下一章细讲。

pulldown-cmark 的 README 还留了一句对我们特别重要的警告:push 式解析接口(事件回调)出了名地难用、易错,因为消费者要在一串回调里小心翼翼地维护状态——既要记住当前处于哪个块,又要提防回调因为异常输入而乱序或重入。Actor 消息本质上就是 push——发消息不等回复,到达顺序由 mailbox 决定。这句警告等于替我们的设计画了一条红线:push 的复杂度必须被关在系统内部,绝不能泄漏给使用者;调用方看到的必须是一个足够简单、足够「pull 化」的门面。

延伸阅读

已核实:

线索(自报数据,未独立核实):

小结

  • AI 把 Markdown 解析的优化目标从「批量吞吐」反转成「首次可见延迟」;需求反转还牵连输入完整性、增量成本、消费者速度三条架构级要求,朴素的「重解析一遍」是 O(n²) 陷阱,平方增长有算术可查。
  • 正确的形状是已定稿前缀 + 暂定尾巴;满足它需要的架构,恰好是有状态增量核心 + 并行无状态计算 + 背压——一个 Actor 运行时的功能清单。
  • markdown-it 与 pulldown-cmark 外观不同(token 流 vs pull 迭代器),内核却是同一套先块后行内的严格两阶段批量解析器,没有天然的流式故事;pulldown-cmark 自己的警告为我们的门面设计画了红线。
  • 下一章翻开 CommonMark 规范附录 A,看官方文档里早已画好的那道并行缝隙——markdown-it 与 pulldown-cmark 都没有用上它,因为单线程库的架构里用不上。
源码

正在读取完整文件…