news 2026/7/14 11:41:43

流式 Markdown 的增量渲染:让大模型回答边生成边排版

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
流式 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 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 库,把工程重心放在与虚拟滚动、交互反馈的协同上。这条路走通后,对话界面的专业感会直接上几个台阶。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/14 11:41:26

抖音批量下载工具完整指南:3分钟掌握无水印视频保存技巧

抖音批量下载工具完整指南&#xff1a;3分钟掌握无水印视频保存技巧 【免费下载链接】douyin-downloader A practical Douyin downloader for both single-item and profile batch downloads, with progress display, retries, SQLite deduplication, and browser fallback sup…

作者头像 李华
网站建设 2026/7/14 11:39:55

终极Steam创意工坊下载器WorkshopDL:无需客户端轻松获取模组

终极Steam创意工坊下载器WorkshopDL&#xff1a;无需客户端轻松获取模组 【免费下载链接】WorkshopDL WorkshopDL - The Best Steam Workshop Downloader 项目地址: https://gitcode.com/gh_mirrors/wo/WorkshopDL 你是否在GOG、Epic Games Store等平台购买了游戏&#…

作者头像 李华
网站建设 2026/7/14 11:39:24

《数据库系统》核心实战:从SQL查询到并发与恢复的工程化解析

1. SQL查询优化实战&#xff1a;从基础到高级技巧我刚入行做DBA时&#xff0c;遇到过这样一个场景&#xff1a;电商大促期间&#xff0c;商品列表页加载缓慢&#xff0c;查询耗时超过5秒。通过分析发现&#xff0c;问题出在一个简单的商品查询SQL上——开发人员写了全表扫描的查…

作者头像 李华
网站建设 2026/7/14 11:38:47

终极指南:如何用unrpyc轻松反编译Ren‘Py游戏脚本

终极指南&#xff1a;如何用unrpyc轻松反编译RenPy游戏脚本 【免费下载链接】unrpyc A renpy script decompiler 项目地址: https://gitcode.com/gh_mirrors/un/unrpyc 你是否曾经遇到过只有编译后的.rpyc文件却需要修改RenPy游戏脚本的困境&#xff1f;或者想要分析一个…

作者头像 李华
网站建设 2026/7/14 11:38:46

如何选择与使用文件哈希值(从MD5到SHA256)

1. 文件哈希值到底是什么&#xff1f;每次从网上下载大型安装包时&#xff0c;总会看到旁边附带的MD5或SHA256校验码。这串看似乱码的字符&#xff0c;其实是文件的"数字指纹"。就像每个人的指纹独一无二&#xff0c;理想状态下每个文件的哈希值也应该是唯一的。简单…

作者头像 李华
网站建设 2026/7/14 11:38:03

Windows C盘清理工具使用指南:从原理到实践

1. 先搞清楚这类工具到底能解决哪些C盘问题 C盘空间不足是很多Windows用户都会遇到的典型问题。系统运行一段时间后&#xff0c;各种临时文件、缓存、日志、更新残留会不断积累&#xff0c;占用大量空间。手动清理不仅效率低&#xff0c;还可能误删重要文件。 这类清理工具的核…

作者头像 李华