恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

AI流式Markdown渲染优化:全量重渲染的代价与增量方案

  • 首页
  • 资讯中心
  • /
  • AI流式Markdown渲染优化:全量重渲染的代价与增量方案

相关资讯

基于二自由度模型与传递函数的车辆横摆角速度响应分析 2026/9/16 4:37:09
SpringBoot+Vue宠物领养系统源码解析:企业级前后端分离架构实战 2026/9/16 4:37:09
大功率工业级Wi-Fi终端:AGV稳定通信的关键保障 2026/9/16 4:37:09

最新资讯

Python requests库:高效HTTP请求与接口调用实战
2025年AI大模型技术趋势与实战指南
MVI架构中UiEffect的设计原理与最佳实践
FreeRTOS事件组实战:CubeMX配置与多任务同步
两周掌握FreeRTOS事件组:从CubeMX源码切入的嵌入式同步机制实战
安全工程师必备的Linux底层权限与进程实战指南

今日推荐

IoT-For-Beginners 智能语音计时器:Wio Terminal 基于 DMAC 与 Flash 的音频采集实战
基于MATLAB的CRI显色指数计算:从SPD光谱到Ra的完整流程
JSP+Servlet+MySQL博客系统源码部署与优化全攻略

本周热门

AI SDK Harness 依赖更新指南:掌握 harness 包 SDK 依赖的升级、桥接同步与一致性校验
Refine v5 Ant Design NumberField 组件实战:基于 Intl 的本地化数字格式化
Flutter应用改名全指南:从Android到iOS的配置与工具实践

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

AI流式Markdown渲染优化:全量重渲染的代价与增量方案

发布时间:2026/9/16 4:37:09
AI流式Markdown渲染优化:全量重渲染的代价与增量方案 面试官把问题甩给我的时候我正卡在一个很真实的场景里AI 的消息通过 WebSocket 一个 chunk 一个 chunk 往下吐前端拿到的永远是“半截的 Markdown”—— 开了一个头、** 没闭合、[链接] 还差半边括号。然后他问了一句“那直接每次把完整 buffer 交给 marked 整段重新渲染不就不截断了吗行不行”这个问题当时差点把我问住。因为在最简单的情况下它确实可行但一旦你开始考虑滚动位置、输入焦点、代码高亮、长文档性能就会发现“直接全部重新渲染”这个方案背后藏着一堆没有说出口的代价。今天这篇博客就把这个“行不行”彻底拆开先分析全量 re-render 的三个坑再讲清 Markdown 标签截断的根因最后给出我实际在线上用的稳定前缀 尾部滑动窗口方案以及落地 marked 定制时的细节和实测数据。适合正在做 AI 对话前端、流式 Markdown 展示或者被 SSE 渲染性能问题卡住的同学参考。1. 全量重渲染的三个雷区它能在功能上过关却会在体验上翻车1.1 从最简单的实现看“直接全量渲染”为什么能消除截断先说结论如果你只问“能不能做到”答案是能。实现方式短到没法再短let buffer ; ws.addEventListener(message, (event) { const delta JSON.parse(event.data).choices?.[0]?.delta?.content ?? ; buffer delta; contentEl.innerHTML marked.parse(buffer); });每次消息到达我们都把完整累积文本交给 marked 解析。没有跨 chunk 的半截标签问题因为 marked 看到的永远是“截至目前最完整”的文本。上一秒**bo还是纯文本下一秒ld**到达后重新解析完整输出strongbold/strong。从终态正确性看这个方案无可挑剔。面试官问“行不行”如果回答“不行”反而容易被反驳正解是先承认“能但这是在用全量重算掩盖流式的矛盾”然后引出代价。这里的关键词其实是“直接”和“每次”——它把一个持续几秒、每秒 20 到 40 次事件的流式输入简化成了 N 次全量解析加 N 次全量 DOM 替换。下面的三个雷区全部来自这个简化。1.2 代价一滚动与选区状态被一遍遍清零AI 聊天页面里用户在流式输出的过程中大概率不是干等着。他可能在滚动回看前面某一轮的结论可能正在用鼠标选中一段代码准备复制也可能光标正停在输入框准备追问。这时你执行innerHTML marked.parse(buffer)浏览器会把整个内容区域的 DOM 推倒重建于是滚动位置直接弹回顶部或者重置到容器顶部用户选中的文本选区被清空输入框虽然通常不在渲染容器内但如果你的编辑区和预览区共用组件状态焦点也容易丢正在播放的video、audio或 iframe 会被重新加载。这些现象在本地测试时很难被发现因为测试者通常只在看“有没有截断”不会去模拟一个边滚动边等输出的用户。真上线之后用户第一反应就是“页面怎么老跳”尤其当 AI 输出几秒就结束的时候滚动位置几乎一直刚被重置又再次输出观感非常糟糕。1.3 代价二每次全量 parse 都拖着 O(n²) 的隐形债marked 的解析速度在纯文本场景下其实不差问题出在“每次全量”这四个字。流式输出下n 是单调递增的每来一批 chunk 就要对 n 大小的字符串解析一次。总开销近似为 n²/2而不是 n。我拿一台中端笔记本在 Chrome 里对完整文档做了一次快速 profile数字大概是这样单位毫秒量级供参考重点看趋势累计 Markdown 体积marked.parse 全量耗时innerHTML 替换 layout 耗时单次 tick 总耗时8 KB23532 KB71219128 KB3865103512 KB1603104701 MB3108901200流式场景下 chunk 到达频率一般是 30 到 80 毫秒一次。当单次 tick 超过 100 毫秒用户就会明显感到卡顿超过 300 毫秒页面基本是冻结状态点击、滚动、输入全部受阻塞。也就是说文档越长体验越差而且这个劣化不是线性的是加速的。1.4 代价三内容闪烁、图片重拉、高亮重做体验全面劣化除了滚动和性能还有一类问题叫“中间态抖动”。Markdown 渲染对半截语法的处理不稳定最典型的就是加粗**这个词还没闭合**到达前后渲染结果会在“纯文本”和“strong 标签”之间反复横跳。AI 输出很快时你看到的效果就是一行文字忽明忽暗或者是代码高亮闪一下又消失下一帧又出现。全量替换还会连累资源和后处理如果 Markdown 里有img srcblob:...每次全量重新设置 innerHTML即使 URL 没变浏览器也要重新走一遍图片解码如果有高亮库highlight.js、Shiki 等在渲染后遍历pre code做语法高亮那么每次全量更新都会把整篇代码高亮重做一遍。用户不关心解析器内部怎么工作他们只看到界面在闪、卡、跳。全量 re-render 的本质是“用一个较低的错误率换来更简单的实现”这在原型阶段完全合理但它没有回答流式解析真正的问题如何让每一帧都只需要处理真正变化的那一小块内容。2. 截断的根因不是字符串缺陷而是 Markdown 块级状态机在等待闭合2.1 marked 的 tokenizer 是这样的闭合字符到位普通文本才升级为 token要设计更好的方案先得明白“标签截断”为什么发生。很多人以为这是字符串拼接的问题——缺个右括号补上就行。实际上这是 Markdown 解析器内部状态机的问题。Markdown 不是正则掰一掰就能处理的格式marked 内部先由 lexer 把原始文本切成 token再由 parser 把 token 变成 HTML。拿加粗举例。当你只把**bo交给 marked 时它不会产出 strong token而是原样输出 text token// 输入: **bo // tokens: [{ type: text, raw: **bo, text: **bo }] // HTML: **bo // 输入: **bold** // tokens: [{ type: strong, raw: **bold**, text: bold }] // HTML: strongbold/strong同样一段字符在不同累积阶段有完全不同的语义。原因很简单tokenizer 是贪婪的它必须看到足够的闭合条件才能确定这是一个“完整的 strong token”。你只给它半个它就退化成普通文本。代码围栏更典型当缓冲区里有js 但没有结尾的时marked 不会把它解析成 code token。它会认为这是普通的换行文本或者段落。只有当你最终补齐三个反引号整个块才会被识别为代码块并且语言标记也会重新生效。2.2 高频截断点的状态矩阵不只有代码块和加粗实际 AI 输出中高频截断点远不只是**和 。我整理了一张表每次写流式渲染都要对照着看语法结构需要等待的闭合条件流式中间态残像不处理时的表现加粗 / 强调第二个**或***text/*text显示为普通文本围栏代码块结尾的 只有开始的 代码被当普通段落链接 / 图片右括号)[text](url链接无法点击表格分隔行 --- 完整出现列表嵌套后续行缩进是否保持- item后的连续行是否嵌套未知HTML 标签完整的闭合标签div没有/div标签可能被忽略每一类截断都在“等待未来数据”和“展示当前中间态”之间产生冲突。流式解析真正难的不是解析本身而是你不知道当前的半截 token 到底是一个 token 不完整的开始还是用户/模型本来就要输出一段普通文本。2.3 为什么“等到闭合再渲染”做不到零成本顺着这个思路你可能想到一个规避方案每次等某个标签彻底闭合了再渲染。这个思路有一层是对的但做起来成本很高因为流不知道“未来”。收到**bo时如果你停下来等待就得修改渲染时机如果 AI 输出停顿了几百毫秒用户会看到内容卡住不动像断网了一样如果不等就只能把半截语法当作普通文本先展示随后再纠正。这里就形成了一个基本权衡延迟渲染换准确率还是即时渲染换流畅度。全量 re-render 本质上是把延迟拉满到“拿到全部数据后再处理”它只适合一次性渲染而流式场景需要的是“每帧只付增量代价”的机制。这也是下一节增量渲染方案的出发点。3. 增量渲染的正解稳定前缀固化 尾部滑动窗口3.1 核心思路把“已确定”和“待定”分开而不是从头再来既然半截 token 只可能出现在当前缓冲区的尾部那么整个缓冲区就可以切成两段稳定前缀所有语法块都已经闭合无论后续来多少数据这段的解析结果不会再变。活动尾部可能包含未闭合的代码围栏、加粗、链接、表格需要随着每次新数据到达重新解析。稳定前缀一旦渲染成 HTML就可以像缓存一样保存下来后续每次更新只需要重新解析几 KB 的活动尾部再把两段结果拼起来。这样时间复杂度从 O(n²) 降到 O(tail²)tail 通常是几 KB解析响应时间基本是常数级。为什么这个思路可行因为 Markdown 的块级结构虽然有状态但它的“影响范围”通常有限。一个未闭合的 strong 最多往后延伸几行一个未闭合的代码围栏可能很长但它在当前缓冲区的最后不影响它之前的任何内容。只要把“最后可能出现半截语法的范围”单独拎出来反复解析前方内容完全不需要改动。3.2 基于 marked.lexer 的安全分界给 splitStableTail 一个实现具体怎么切分最简单的启发式是“按空行切”因为绝大多数块级语法以空行作为自然边界。但这个启发式不够稳处理列表、引用、代码围栏时容易切错。更可靠的思路是直接借用 marked 的 lexer先对完整 buffer 做一次 tokenize这是纯字符串处理耗时远低于完整 parse HTML 生成然后从后往前找第一个“确定完整的块”。import { marked } from marked; function isClosedToken(token) { if (token.type space) return true; if (token.raw token.raw.endsWith(\n)) return true; // 表格、列表、引用在 marked 里 raw 通常以换行结尾 return false; } function splitStableTail(source, maxTail 4096) { const tokens marked.lexer(source.replace(/\r\n/g, \n)); let lastClosed tokens.length; for (let i tokens.length - 1; i 0; i--) { if (!isClosedToken(tokens[i])) continue; lastClosed i 1; break; } let boundary 0; for (let i 0; i lastClosed; i) { boundary (tokens[i].raw ?? tokens[i].text ?? ).length; } // 强制保护tail 不能超过上限防止极端情况整个文档变成活动尾部 boundary Math.max(boundary, source.length - maxTail); return { stable: source.slice(0, boundary), tail: source.slice(boundary), }; }这里的核心判断是“token 的 raw 是否以换行结尾”。大多数块级 tokenheading、paragraph、list、blockquote、code 等在闭合后都会消费到行尾换行用这个条件判断“块已经完整”较为可靠。但要注意这只是一个安全近似不是形式化证明。缩进列表的嵌套可能跨多个空行所以我还留了maxTail兜底确保 tail 至少包含文档最后 4KB 内容。这样即使分界不准代价也只是多解析了一点。3.3 增量缓存与拼接把每次解析成本压到常数级有了分界函数渲染层就很简单了const cache { stableKey: , stableHTML: }; function renderIncremental(buffer) { const { stable, tail } splitStableTail(buffer); // 只有稳定前缀真正变化时才重新渲染 stable 部分 if (stable ! cache.stableKey) { cache.stableKey stable; cache.stableHTML marked.parse(stable); } const tailHTML marked.parse(tail); return cache.stableHTML tailHTML; }每次数据到达只有两种情况稳定前缀不变只解析 tail耗时几乎恒定稳定前缀变了说明上一次分界把某些半截内容误归到了 stable 里这时候更新一次缓存即可。因为 tail 通常在 2KB 到 4KBmarked 解析它的耗时几乎可以忽略不计。DOM 更新时也不要再用container.innerHTML fullHTML。更稳的做法是把页面分成两个容器一个专门放稳定前缀的 HTML另一个放尾部 HTML。每次只更新尾部容器稳定容器完全不动。这样滚动位置、选区、图片状态都不会被破坏。4. 落地 marked 流式渲染时真正值得抠的工程细节4.1 walkTokens 做一次性高亮别每次全量重刷代码块很多流式渲染方案里代码高亮是性能黑洞。最常见的错误写法是在每次拿到完整 HTML 后用document.querySelectorAll(pre code)重新遍历所有代码块做高亮。全量 re-render 方案下这等于每帧都把整个页面的代码高亮重做一遍。marked 提供了walkTokens钩子可以在 token 层面上做处理避免事后遍历 DOM。结合增量方案可以这样用marked.use({ walkTokens(token) { if (token.type code token.text) { // 这里只做标记不要直接同步调用 Shiki 的 highlight 同步版本 token.needHighlight true; } }, });不过这里有个关键细节walkTokens是随每次 lexer 调用执行的。在增量方案里stable 部分的 token 并不会被重新解析所以needHighlight标记只打在新 tail 的 token 上完整渲染完成后我们再单独处理一轮新出现的needHighlight的代码块。这样高亮永远只跑新增加的代码不重做已稳定的部分。如果你用 Shiki 等异步高亮库还要再套一层“空闲节流”流式输出期间先展示纯文本代码等 200ms 没有新 chunk 时再做高亮避免高亮和内容渲染互相抢主线程。4.2 透明补偿半截语法用预览态换取流畅观感增量方案解决的是性能问题但没有解决“中间态不准确”的问题**bo在某个瞬间看起来还是普通文本。你无法改变这种状态但可以让它在视觉上更接近终态。做法是“透明补偿”——解析前对 tail 做一次补齐让它临时“假装”闭合function makePreviewSafe(source) { const fences (source.match(//g) || []).length; if (fences % 2 1) source \n; const strong (source.match(/\*\*/g) || []).length; if (strong % 2 1) source **; const openLinks (source.match(/\[/g) || []).length; const closeLinks (source.match(/\]/g) || []).length; if (openLinks closeLinks) source ]; return source; }注意这个函数只用于预览态的 tail 解析不能污染原始 buffer。它的作用是让**bo临时变成**bo**Markdown 渲染出来就是加粗文本观感上更接近最终效果等后续 chunk 到达tail 重新解析时再换回真实内容。透明补偿不是解析正确性方案但它能把“闪烁感”压到很低。4.3 滚动保持、图片去重与框架层的更新策略增量方案解决了滚动问题的绝大部分但仍然要在 DOM 更新前记录滚动位置。尤其是尾部容器高度不断变化时用户阅读位置可能被顶下去。做法是在每次渲染前记录container.scrollTop和用户是否处于底部区域渲染后按需恢复const isStickyBottom container.scrollHeight - container.scrollTop - container.clientHeight 40; // 渲染后 if (isStickyBottom) { container.scrollTop container.scrollHeight; }图片是另一个容易被忽视的坑。AI 输出 markdown 图片时URL 可能是分多段拼出来的比如![img](https://先到下一个 chunk 才补全后半段。如果你在https://阶段就生成img srchttps://浏览器会发出一个无效请求。稳妥做法是给未完整闭合的链接加一个>全量方案单次 tick增量方案 tail 解析增量方案 DOM 增量更新8 KB5 ms1 ms1 ms32 KB19 ms3 ms2 ms256 KB180 ms4 ms3 ms1 MB1200 ms6 ms12 ms增量方案的 tail 固定为 2KB 到 4KB因此无论文档累积到多大单次解析耗时都维持在几毫秒的量级DOM 更新因为只碰尾部容器耗时也基本是常数。这个差距在长对话中非常明显用户连续追问十轮后buffer 可能已经几百 KB全量方案早就卡得没法看增量方案依然跟刚开始一样顺滑。5.2 什么时候全量重渲染反而是六十分的好方案说了这么多增量方案的好处但我也承认全量 re-render 有它的适用面。如果你遇到下面这些场景直接上全量重渲染反而更划算文档很小小于 4KB每次 parse 都在 3ms 内输出频率很低一秒都不一定有一次性更新页面不需要保持滚动位置、输入框焦点、文本选区没有长代码块和复杂高亮或者高亮可以延迟到流结束后一次性做你的核心诉求是“快上线原型”而不是“长时间稳定运行”。比如一个只显示 200 字摘要的流式结果页全量渲染的实现成本几乎为零增量方案反而显得过度设计。面试场景里能说出“80% 场景用增量20% 场景全量更优”的人比只会背方案的人更有说服力。5.3 实测过程中我踩到的三个坑直接讲结论第一个坑是换行符。SSE 或 WebSocket 拿到的文本里经常混着\r\n如果你直接拿 buffer 去splitStableTail\r会残留在 token 的 raw 里导致 boundary 计算和最终 HTML 都出现奇怪的空白或者字符。解决方式是入口统一buffer.replace(/\r\n/g, \n)再进渲染链路。第二个坑是 tail 阈值太小。最开始我把maxTail设为 512B结果频繁出现“stable 被迫重新渲染”的情况——因为半截代码块比 512B 长分界函数每次都要把越来越多内容划进 tail直到边界超过阈值后强制切分把一部分本该是 tail 的内容错误归入 stable导致 stableKey 频繁变化。后来我把阈值调到 2KB 到 4KB并根据实际场景里最长单块内容的长度适当增加问题就不再出现。第三个坑是高亮库的重复执行。我在一把梭接入 Shiki 时每次 tail 更新后都对整篇pre code跑高亮结果文档稍一长主线程直接卡死。后来改成“新 tail 只标记、空闲时统一高亮”的策略具体做法是维护一个待高亮代码块队列设置 200ms 的 debounce如果这期间又有新 chunk就继续让队列累积直到输出停顿后再执行 Shiki。这个改动让流式输出期间的主线程占用大幅下降。最后提一个我长期保留的习惯把流式分块测试挂进 CI。同一个 Markdown 文档按 2 字符、5 字符、随机行尾三种方式切分逐块喂给renderIncremental最后把逐块渲染结果和marked.parse(完整文档)对拍一旦不同就立刻失败。这个方法帮我抓出过很多边界问题比如表格分隔行在中间态被误判、列表项 raw 不以换行结尾导致边界计算偏移。跑过几轮之后你再改 marked 版本、调 tail 阈值心里都踏实很多。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号