# 终端里实时渲染 markdown · 把 O(N²) 压成 O(N)

> 模型的回复是流式 markdown——一个字一个字来。每来一段就重渲染整篇是 O(N²)，会越来越卡。grok 用「检查点冻结」把它压到近似 O(N)，还让流式代码块的每一行只高亮一次、且和批量渲染逐字节一致。这一章拆这套流式 markdown 引擎。

- 作者：David（道雾轩）
- 专栏：Grok Build 源码解读（https://daiw.org/manual/grok-build.md）
- 最后更新：2026-09-15
- 原文：https://daiw.org/manual/grok-build/markdown-render
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 终端里实时渲染 markdown · 把 O(N²) 压成 O(N)

模型的回复是**流式 markdown**——token 一段段到。天真的做法是每来一段就把整篇重渲染一遍，但那是 **O(N²)**：回复越长，每次重渲越贵，越到后面越卡。grok 的 `xai-grok-markdown`(约 2 万行，不含测试约 1.2 万行)专门解这个问题。

## 两趟：解析 + 渲染

用 **`pulldown-cmark`** 解析，两趟：`parse()` → `ParsedMarkdown` → `render_ratatui()`。解析这趟往 `MarkdownBuffers` 里填**按字节范围索引**的记录：高亮范围、代码块替换、表格、链接——代码块在这一趟就已高亮好，表格和 mermaid 图也已排成行；渲染那趟再按字节位置把它们拼成最终的行。

解析选项和「只认 `~~` 为删除线」的规则，来自一个只依赖 pulldown-cmark 的小 crate `xai-grok-markdown-core`：它能在不拉起 syntect、ratatui 整套终端渲染栈的情况下做无头分析，又保证分析口径和实际渲染完全一致。

<Callout type="warn">
  一个安全细节：markdown 里的 HTML 实体解码时，**拒绝控制字符**(遇到就保留实体原文、不解码)——防止不可信的 markdown(比如工具输出、网页内容)夹带终端转义序列搞注入。「渲染别人给的文本」永远要防这一手。
</Callout>

## 流式核心：检查点冻结

关键手段是**检查点(checkpoint)冻结**。`StreamingMarkdownRenderer` 维护一个「**已冻结的前缀** + 会变的尾巴」：每次 push 新内容，只重渲染 `source[已冻结字节..]` 这段尾巴，把结果接到冻结前缀后面。

检查点只在**顶层块边界**(depth=0)产生——一个标题、一个段落、一个代码块结束了(后面跟了空行，或代码块已闭合)，它上面的内容就再也不会变，可以冻结；而列表/表格/引用**内部**不能冻结(因为容器可能还在续)。于是整体从 O(N²) 降到**近似 O(N)**——源码注释用的就是 approximately：一个列表或表格迟迟不闭合时，检查点推不动，尾巴会一直变长。

```mermaid
flowchart LR
  P1["已冻结前缀<br/>(不再重渲)"] --> T["尾巴<br/>(每次 push 重渲这段)"]
  NEW[新 token] --> T
  T -->|遇到顶层块边界| CP[推进冻结点]
  CP --> P1
```

## 流式代码块高亮：每行只高亮一次

代码块的语法高亮更棘手——一个还没闭合的代码块，每来一行都重高亮整块又是平方级(模块注释记着旧代价：千行代码块末尾，每次 push 约 35 ms)。`OpenCodeHighlighter` 的解法：**持久化 syntect 的可续解析状态**(`ParseState` + `HighlightState`)，只解析「上次提交换行之后」的新内容，尾部没换行的部分在**克隆**上高亮(保持已提交状态锚定)。于是**每个已提交行只高亮一次**，syntect 的总计算量是 O(N)；返回结果时仍要拷贝一遍已算好的样式片段，这点 O(行数) 的残差代码里专门标了 TODO，明确是「接受的残差」。而且它保证**和批量渲染逐字节一致**——流式和一次性渲染出来的东西不能有差别，测试会按多种分块方式推送并逐行比对。

困在尾巴里、却已经闭合的代码块(比如在一个没闭合的列表里)走另一条路：按 `(fence_info, body)` 记忆批量高亮的结果。注释里记着，没有这层缓存时曾出现过一次 4.5 秒的界面卡死。

语法集用 **syntect + two-face**(bat 那套 250+ 语言)，主题是 TextMate 的 `.tmTheme`。

## 和《从 LLM 到 Coding Agent》对照

教学实现里，markdown 渲染大概就是「攒齐了调个库渲染一次」。grok 面对的是**流式**：一边到一边渲，还要不卡、代码高亮实时、和批量一致、防注入。检查点冻结 + 可续 syntect 状态，把「实时渲染流式 markdown」这个看似简单、实则平方级陷阱的问题，压成了近似线性。下一章回到输入这一侧：按键、slash 命令与补全。

---

上一篇：[渲染管线与 scrollback](https://daiw.org/manual/grok-build/render-pipeline) · 下一篇：[输入、slash 与补全](https://daiw.org/manual/grok-build/input-and-completions)
