终端里实时渲染 markdown · 把 O(N²) 压成 O(N)
模型的回复是流式 markdown——一个字一个字来。每来一段就重渲染整篇是 O(N²),会越来越卡。grok 用「检查点冻结」把它压到近似 O(N),还让流式代码块的每一行只高亮一次、且和批量渲染逐字节一致。这一章拆这套流式 markdown 引擎。
终端里实时渲染 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 整套终端渲染栈的情况下做无头分析,又保证分析口径和实际渲染完全一致。
一个安全细节:markdown 里的 HTML 实体解码时,拒绝控制字符(遇到就保留实体原文、不解码)——防止不可信的 markdown(比如工具输出、网页内容)夹带终端转义序列搞注入。「渲染别人给的文本」永远要防这一手。
流式核心:检查点冻结
关键手段是检查点(checkpoint)冻结。StreamingMarkdownRenderer 维护一个「已冻结的前缀 + 会变的尾巴」:每次 push 新内容,只重渲染 source[已冻结字节..] 这段尾巴,把结果接到冻结前缀后面。
检查点只在顶层块边界(depth=0)产生——一个标题、一个段落、一个代码块结束了(后面跟了空行,或代码块已闭合),它上面的内容就再也不会变,可以冻结;而列表/表格/引用内部不能冻结(因为容器可能还在续)。于是整体从 O(N²) 降到近似 O(N)——源码注释用的就是 approximately:一个列表或表格迟迟不闭合时,检查点推不动,尾巴会一直变长。
流式代码块高亮:每行只高亮一次
代码块的语法高亮更棘手——一个还没闭合的代码块,每来一行都重高亮整块又是平方级(模块注释记着旧代价:千行代码块末尾,每次 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 · 下一篇:输入、slash 与补全