流式 Markdown 的增量渲染:让大模型回答边生成边排版
一、模型吐到一半,前端整屏闪了三次
某团队去年上线"AI 编程助手"时就遇到过这种尴尬:模型回答的代码块特别长,结果每来一个 token,前端就把整段文档重渲染一次,已显示的代码高亮整段闪一下,光标跟着跳。录屏下来,用户看到的是 5 分钟内屏幕"啪啪啪"闪了上百次。这事我见过太多团队栽进去。
更早之前,他们试过"等整段输出再渲染",结果用户每次提问要等 8 秒才看到第一行字。次日留率掉了 18%。后来才转向流式,结果带来"半截语法"的副作用,绕了一圈才回到"边生成边排版"这条路。
流式 Markdown 的核心难题,是"增量"二字:如何只处理新增片段、复用已有渲染结果,同时保证语法块(尤其是跨 chunk 的代码围栏与表格)在闭合前不被错误解析。这正是大模型对话界面体验专业与否的分水岭。
二、增量解析的状态机:从整篇到分块
整篇 Markdown 解析器(如 marked、markdown-it)通常是无状态的:吃进完整字符串,吐出完整 HTML。要支持流式,必须把解析过程改造成一个有状态的"消费机",它记住上一次解析到的位置、未闭合的语法块状态,新数据到了就从上次断点续解析。
典型做法是维护一个"未闭合缓冲区"。当遇到一个开始的代码围栏而尚未见到结束围栏时,把后续内容暂存,不立即渲染;直到闭合标志出现,才一次性把整块交给高亮器。这样避免中间态出现半成品代码块。
flowchart LR A[新 chunk 到达] --> B{是否处于未闭合块?} B -- 否 --> C[尝试解析为行内/块级语法] B -- 是 --> D[追加到悬挂缓冲区] C --> E{命中开始围栏?} E -- 是 --> D D --> F{收到结束围栏?} F -- 否 --> G[暂不渲染, 继续累积] F -- 是 --> H[整块交给高亮与渲染] H --> I[输出稳定 DOM 片段] C --> I另一种更彻底的思路是"块级增量 + 行内全量"。把文档按块级边界(空行、标题、列表项)切分为独立块,已闭合的块只渲染一次并缓存;只有当前正在生长的"最后一个块"需要随新数据重渲染。由于行内语法(加粗、链接)只作用于单块内部,重渲染范围被限制在极小区域,性能与稳定性都更优。某团队做聊天界面时用这种思路,长代码块场景下闪烁从百次降到 3 次以内。
三、生产级流式渲染器:缓存、防抖与并发安全
下面给出一个基于"已闭合块缓存 + 当前块重渲染"思路的流式 Markdown 渲染器骨架。它刻意处理了三个生产隐患:代码块悬挂、高频重渲染节流、以及组件卸载后的写入竞态。
import { marked } from 'marked'; import hljs from 'highlight.js'; // 维护每个会话的解析状态,避免多会话串台 class StreamMarkdown { private closed = ''; // 已闭合且渲染过的稳定前缀(不再重算) private pending = ''; // 当前正在生长的悬挂内容 private timer: number | null = null; private dirty = false; // push:外部每收到一个 chunk 就调用,仅追加不重渲染 push(chunk: string) { this.pending += chunk; this.dirty = true; // 用微节流把多次 push 合并到下一帧,避免每个字符都触发解析 if (this.timer == null) { this.timer = requestAnimationFrame(() => this.flush()); } } private flush() { this.timer = null; if (!this.dirty) return; this.dirty = false; // 只取最后一个块级边界之后的内容作为"可变区",之前部分视为已稳定 const lastBoundary = this.pending.lastIndexOf('\n\n'); const stable = lastBoundary >= 0 ? this.pending.slice(0, lastBoundary + 2) : ''; const growing = lastBoundary >= 0 ? this.pending.slice(lastBoundary + 2) : this.pending; // 已稳定前缀只拼接,不重新解析;仅生长区参与增量渲染 const html = this.closed + marked.parse(stable.slice(this.closed.length)) + `<span class="growing">${marked.parse(growing)}</span>`; this.closed = stable; // 生长区一旦越过边界即固化 this.onRender(html); } // 代码块高亮需整块完成,故对悬挂中的围栏做特殊判断 private isCodeOpen(text: string): boolean { // 用正则统计围栏出现次数,奇数代表未闭合 const fences = (text.match(/\x60\x60\x60/g) || []).length; return fences % 2 === 1; } onRender: (html: string) => void = () => {}; destroy() { if (this.timer != null) cancelAnimationFrame(this.timer); this.timer = null; } } // 调用侧:绑定会话生命周期,卸载即销毁,杜绝竞态写入 function bindStream(sessionId: string, container: HTMLElement) { const sm = new StreamMarkdown(); sm.onRender = (html) => { container.innerHTML = html; }; return { feed: (chunk: string) => sm.push(chunk), dispose: () => sm.destroy(), // 组件卸载时务必调用,释放 rAF 与缓冲 }; }这段实现的三个关键是:第一,requestAnimationFrame把高频 chunk 合并为每帧一次解析,防止主线程被压垮;第二,closed区固化机制保证已生成内容不被反复重排,消除闪烁;第三,destroy在组件卸载时取消挂起的渲染帧,避免向已销毁节点写入导致内存泄漏。某工具产品上线初期就吃过竞态写入的亏,关闭会话后渲染器仍在写 DOM,半小时内累计泄漏 80MB 内存。
四、渲染质量的边界:增量方案的取舍
增量渲染并非银弹,需要在几处做权衡。第一是语法正确性。若按"最后一个块级边界"切割,有时生长区里正写一个跨多行的表格或嵌套列表,过早重渲染会产出临时非法结构。缓解办法是扩展边界判定,把表格首行、列表缩进层级也纳入"悬挂"条件,宁可晚渲一点,也不渲错。
第二是代码高亮时机。hljs 必须在代码块完整时才高亮,否则半截代码会先以纯文本出现再整体变样。正确做法是:检测围栏未闭合时只做转义不调用高亮,闭合后再高亮,代价是闭合瞬间有一次重渲染。
第三是内存。持续累积的closed字符串会越来越长,极端长对话要配合虚拟滚动,只保留视口附近的 DOM,历史 HTML 转存为文本。这又引出"流式渲染必须与列表虚拟化协同"的更深话题。
最后要承认:自研增量解析器维护成本高。若团队资源有限,优先选用社区成熟的流式方案(如支持streaming模式的 unified 插件),把精力放在"交互体验"而非"解析内核"上。某团队尝试自己手写解析器半年,最后还是回退到 unified 插件,自研的代码退役了 6000 行。
五、总结
流式 Markdown 渲染的目标,是让大模型回答在生成过程中就拥有正确排版,而非生成结束再整体美化。落地要点有三:第一,把解析改为有状态增量消费,用"已闭合块缓存 + 当前块重渲染"把重解析范围压到最小;第二,代码围栏等语法块必须检测闭合状态,未完成前只累积不高亮,避免半成品闪烁;第三,用帧节流合并高频 chunk、用生命周期销毁防止竞态写入。在资源受限时,应优先复用成熟的流式 Markdown 库,把工程重心放在与虚拟滚动、交互反馈的协同上。这条路走通后,对话界面的专业感会直接上几个台阶。