流式 Markdown 的增量渲染:让大模型回答边生成边排版
流式 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 classgrowing${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 库把工程重心放在与虚拟滚动、交互反馈的协同上。这条路走通后对话界面的专业感会直接上几个台阶。