ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Markdown流式渲染标签截断:尾缓冲与Token增量方案实战

Markdown流式渲染标签截断:尾缓冲与Token增量方案实战 1. 先把问题说清楚流式输出里“标签截断”到底断在哪如果你做过 AI 聊天页面的前端大概率见过这种画面后端用 SSE 把 Markdown 文本一段段推过来前端每收到一个 chunk 就把累积文本交给marked.parse()渲染然后更新innerHTML。第一次跑通时你觉得很顺利但很快会发现问题——屏幕上偶尔会冒出光秃秃的**、[、(这类符号过一两帧又消失内容突然变成了加粗或超链接。这就是这道面试题要问的“标签截断”。它并不是指 HTML 标签被截断而是指 Markdown 语法元素在流式传输过程中处于“半开状态”语法开始标记已经到了闭合标记还没到。比如后端推了这是一段带 [超链接](https://example.com此时前端收到的完整文本里有一个未闭合的链接。marked按 CommonMark 规范会把[超链接](https://example.com当作普通字面量原样输出于是用户先看到一对中括号和一个左括号等下一个 chunk 把)补齐页面才突然出现真正的a超链接。视觉上就是一次闪烁字面量跳变到富文本。类似的情况还有行内代码code只收到了一个反引号还是两个加粗/斜体**加粗内容只收到左标记星号会裸奔在页面上。围栏代码块三个反引号被拆成两批到达第一批只来了。表格表头和分隔行的|---|---|还没到齐表格根本不会被识别。引用块 引用的到了但文本还没到或者反过来。这类问题有一个共同特点当前看到的中间态和最终文档的完整态不一致。解析结果本身没有错但用户的感知是错的——他看到一个闪烁的、不稳定的页面。如果你把问题再往深里想一层会发现还有“上下文断裂”这种变体。假设后端把一段有序列表拆成多个 chunk1. 第一项 2. 第二项如果你图省事不维护完整文本而是只把每个新 chunk 单独交给marked解析那2. 第二项单独渲染时会因为上下文缺失被识别成一个从2.开始的新列表甚至因为前面没有列表结构而渲染成普通段落。这就偏离了原文意图。所以标题里的“全部重新渲染”这类思路本质上是在说我用全量上下文来解析避免这种“上下文断裂”。但“全部重新渲染”真的能解决标签截断吗答案没那么简单。2. 面试官问的“全量重渲染”代价藏在三个角落2.1 第一笔账视觉跳变从裸星号到加粗的闪烁先说结论全量重渲染能保证最终态正确但不能消除中间态闪烁。假设后端分三批推送一段内容chunk1: 这是一段 chunk2: **加粗内容 chunk3: ** 对吧如果你在 chunk2 到达时把完整文本这是一段 **加粗内容交给marked因为它没有闭合的**会原样输出两个星号。用户在屏幕上看到的是这是一段 **加粗内容等 chunk3 到达全量文本变成这是一段 **加粗内容** 对吧再一次渲染才变成这是一段 加粗内容 对吧问题在于这不是从“普通文本”变成“普通文本加粗”的平滑过渡而是从“带星号的字面量”变成“带格式的文本”。用户会看到星号闪一下、消失文字突然变粗。这种跳变在多个未闭合语法叠加时会被放大整个页面像在疯狂抖动。所以全量重渲染对“标签截断”这个场景只是把“错误渲染”变成了“暂时显示字面量”。它并没有让截断消失只是让你把问题推迟到了补齐那一刻。面试官问“行不行”如果你只回答“行因为 marked 能解析完整文本”说明你还没进入到中间态体验的思考层。2.2 第二笔账长文本下 marked.parse 的线性代价marked的解析耗时和输入文本长度基本呈线性关系。几 KB 的文本在主流笔记本上单次解析大约 1~3ms看起来很低但流式场景的关键不是“单次”而是“频率”。AI 对话的 SSE 输出通常每 20~50ms 就推送一个 token 或一小段 chunk。如果你每次收到 chunk 都立刻全量marked.parse()一秒钟可能解析 20~40 次每次都是全文线性扫描。几 KB 时还能接受但文档到 50~100KB 时单次解析可能达到几十毫秒已经逼近一帧的预算。再加上浏览器 DOM 替换、reflow、React 或 Vue 的 diff掉帧几乎是必然的。我遇到过最糟糕的实现是把marked.parse()直接写在 SSEonmessage里页面跑到两三分钟时整个 tab 卡成幻灯片。文本越长每次追加带来的重解析成本越高这是全量方案最硬的墙。2.3 第三笔账光标、滚动和选区比性能更致命性能问题在文档足够大时才暴露但光标和滚动问题从第一秒就存在。如果你的渲染结果是直接替换某个容器的innerHTML那么每次 chunk 到达浏览器都会重建这段 DOM。用户正在往上滚动查看之前的输出新的 DOM 变化可能导致滚动位置跳动如果页面里还有可编辑区域比如 Markdown 编辑器的实时预览模式全量替换 DOM 会打断用户的选区选中的文字会被取消甚至导致输入光标跳到末尾。这个问题在纯展示型页面里不明显但你一旦把它做成“可交互的流式文档”就会立刻感知到。全量重渲染本质上是拿“最终准确”换“过程稳定”而过程稳定性恰恰是流式体验的命根子。2.4 什么情况下直接全量重渲染确实是够用的不要把全量渲染说得一无是处。它有几个不可替代的优点实现成本极低、逻辑绝对正确、不会出现上下文断裂也不需要维护额外的缓冲状态。所以如果你的场景符合以下条件直接用不用纠结文档量级小稳定在几 KB 以内更新频率低不是逐 token 推送而是几百毫秒推送一大段不需要保持滚动位置和输入光标对过程的闪烁不敏感比如内部后台面板、定时刷新文档预览。一个典型的适用例子是“静态页面里的 Markdown 预览增强”编辑器里粘贴了一段文本防抖 500ms 后全量渲染完全没问题。真正需要专项处理的是“AI 流式对话”“实时协同文档”这类高频率、长文本、强交互的场景。3. 方案一尾部缓冲Tail Buffer让“未闭合”先别上屏3.1 核心思路渲染慢半步体验快半拍既然问题在于“末尾的语法可能没闭合”那最直接的做法就是永远不渲染末尾那一小段“未确认区”把它作为纯文本灰显等后续 chunk 把它补完整后再让它进入正常渲染区。这个“未确认区”就是尾部缓冲Tail Buffer。完整文本fullText被切成两部分stableText前面已经确定安全的部分交给marked渲染成 HTMLpendingText末尾可能未闭合的部分不参与 Markdown 解析直接用textContent展示成半透明的“打字中”文本。这样用户永远不会看到裸的**或[参与渲染看到的只是末尾有一段灰色文字正在“输入”。等光标继续往后走灰色文字逐渐变为正式的富文本视觉上非常顺滑。你可能会问这不就是人为引入了延迟吗对但不是坏延迟。它只延迟了“末尾几百字符”的确认时间而且这些内容本来就在用户的视线焦点里用户看到的是打字机式推进而不是整页闪烁。3.2 不完整块检测启发式正则 round-trip 验证想让尾缓冲生效首先要解决“从哪里切”的问题。最笨的方法是数**的个数、找[是否闭合但 Markdown 的转义和行内代码会让计数变得极不可靠。比如\*\* 不是加粗 ** 是代码数星号的人会被这些 case 坑得很惨。我的建议不要试图精确定位“未闭合标记在哪一行”而是简单粗暴地把最后 N 个字符全部划入 pending。这里的 N 要能覆盖绝大多数语法块的最大长度。长链接、长行内代码、围栏代码块、表格都可能超过几十字符。我一般取 512~2048按你的产品定位选希望首屏更跟手就取小一点希望结构更稳定就取大一点。取一个简单的切割函数const TAIL_LIMIT 1024; function splitStreamText(fullText: string): { stable: string; pending: string } { // 从末尾往前数 TAIL_LIMIT 字符再向前找最近的换行让切割点落在行边界 let start Math.max(0, fullText.length - TAIL_LIMIT); const newlineIndex fullText.indexOf(\n, start); if (newlineIndex ! -1 newlineIndex fullText.length - 1) { start newlineIndex 1; } return { stable: fullText.slice(0, start), pending: fullText.slice(start), }; }切割点落在行边界很重要。Markdown 的块级结构几乎都靠换行分隔从换行后切开能显著降低“一个语法块的前半段在 stable、后半段在 pending”的概率。但“行边界”不能保证 100% 安全。比如一个超长段落里有一个未闭合链接换行切开的 stable 末尾仍可能残留[text](http。所以实际操作里我会叠加一道“round-trip 验证”把 stable 交给marked渲染一次检查输出的 HTML 末尾有没有未闭合链接或裸星号的痕迹。这个方法听起来玄学但在实战里非常好用。它利用了解析器自己的规则来判断“这段文本是否可能存在悬挂语法”比你自己写正则靠谱得多。更简单的做法是把 pending 当“永不参与解析的灰色区域”直到它被新 chunk 推出尾窗口为止。这样哪怕 stable 末尾有一点悬挂语法也只会影响末尾几个字符的渲染不会被用户明显注意到。3.3 代码骨架SSE 尾缓冲 marked下面是一个最小的原生实现方便你直接看懂结构div idpreview/div div idtail classstream-tail/divimport { marked } from marked; const TAIL_LIMIT 1024; const previewEl document.getElementById(preview); const tailEl document.getElementById(tail); let fullText ; let lastStableText ; function splitStreamText(fullText) { let start Math.max(0, fullText.length - TAIL_LIMIT); const newlineIndex fullText.indexOf(\n, start); if (newlineIndex ! -1 newlineIndex fullText.length - 1) { start newlineIndex 1; } return { stable: fullText.slice(0, start), pending: fullText.slice(start), }; } function renderStream() { const { stable, pending } splitStreamText(fullText); // stable 没变化时不要重新 marked.parse if (stable ! lastStableText) { const html marked.parse(stable, { async: false, breaks: true }); previewEl.innerHTML html; lastStableText stable; } // pending 只需更新纯文本不用过解析器 tailEl.textContent pending; } // SSE 回调示例 function onChunk(chunk) { fullText chunk; renderStream(); } function onDone() { // 流结束后把尾巴全部确认 fullText \n; renderStream(); }这段代码有几个细节值得注意stable没变化就不重新marked.parse这是长文本性能的关键。因为 chunk 到达时通常只是 pending 变长了stable 不变。tailEl用textContent而不是innerHTML永远不会被 Markdown 影响也天然防了 XSS。onDone()里给fullText补一个换行强制触发一次完整渲染确保最后一段灰色文本被正式确认。这一步经常被遗忘导致流结束页面末尾还留着半透明文字。stream-tail的样式可以做成半透明灰色让用户感知“这里还在生成”.stream-tail { opacity: 0.35; white-space: pre-wrap; }3.4 这个方案的小尾巴延迟、多行 block、链接跨 chunk尾缓冲有几个局限性需要心里有数。第一有感知延迟。如果 TAIL_LIMIT 取 1024意味着永远有最多 1024 字符的内容处于“灰色待确认”状态。对聊天场景这通常没问题因为用户的视线焦点就在末尾但如果是快速输出大片文本灰色区域会显得滞后。第二多行块级元素可能被“卡”在灰色区。一个表格包含多行如果表头被划入 stable、分隔行还在 pending表格会延迟到分隔行到达后才出现。视觉上的表现是表头文字先灰显再过一小会儿变正式。这是可接受的但如果你的产品要求“即时看到表格”需要把 pending 的“行级确认”做得更细甚至逐块判断。第三超长链接或超长段落跨过尾窗口。TAIL_LIMIT 是固定值遇到一个 2KB 的 URL切出来还是半截。所以实际做的时候要么把 TAIL_LIMIT 调大要么在切割后检测 stable 末尾有没有明显的未闭合[text](模式有就把切割点再往前推一行。后者更好因为能保持 pending 较小。我在生产里对 TAIL_LIMIT 的取值经验是聊天场景 512~1024文档协作场景 2048。取到 2048 之后未闭合的链接基本不会突破尾窗口代价是灰色区更明显一点。你可以观察实际流式输出速度再调。4. 方案二拿 Tokens 判断“闭合状态”只渲染安全部分4.1 为什么直接看 HTML 不如看 Tokens尾缓冲方案是“用空间换正确性”它不做精细判断只是把可能不完整的部分隐藏起来。还有一种更精确的思路让解析器先把全文解析成一棵 Token 树然后我们自己决定哪些 Token 可以渲染、哪些必须挂起。marked底层也有一套 Lexer/Tokenizer 流程但暴露给业务层的 API 更偏向“直接出 HTML”。相比之下markdown-it的md.parse()会返回完整的 Token 数组每个块级 Token 都有行号信息方便你判断“这一块在原文中覆盖到第几行为止”。看 HTML 的问题在于它已经把源码结构压平了你看不到“这个段落是不是半截”。而 Token 数组里一个未闭合的链接、一个还没成型的表格都会以不同形态出现。我们不需要完全解析它们只需要知道“哪些部分已经落袋为安哪些还在空中”。4.2 markdown-it 的 token.map 怎么帮我们切分markdown-it的块级 Token 会带一个map: [startLine, endLine]表示它从源码的第startLine行开始到第endLine行结束。这里的行号是对应原文的行索引。你可以扫描整个 Token 数组找到最后一个“已经闭合”的块级 Token然后用它的endLine作为切割点endLine之前的原文是安全部分之后的原文挂起为 pending。代码骨架大致是import MarkdownIt from markdown-it; const md new MarkdownIt({ html: false, linkify: true }); function splitByTokens(text) { const tokens md.parse(text, {}); let lastSafeLine 0; for (const token of tokens) { // 只考虑块级 token行内 token 的 map 可能为 null if (token.map) { if (token.nesting -1) { // 这是某个块的 close token说明该块已经闭合 lastSafeLine Math.max(lastSafeLine, token.map[1]); } } } const lines text.split(\n); const safeText lines.slice(0, lastSafeLine).join(\n); const pending lines.slice(lastSafeLine).join(\n); return { safeText, pending }; }但有坑markdown-it在文档结束时会自动给未闭合的 paragraph 补一个paragraph_close所以即使全文是一段没有闭合的**加粗内容Token 数组末尾也会有paragraph_closelastSafeLine会直接推到最后一行pending 变成空字符串。结果是未闭合行内标记依旧被渲染成字面量。所以纯 Token 判断解决不了行内截断它擅长解决块级截断。实战中要把尾缓冲和 Token 判断结合起来先用尾缓冲的“最后 N 字符”策略把可能未闭合的行内语法切到 pending再用 Token 的map信息精确确认 pending 的边界是否合理最后把安全的 stable 文本交给渲染器。4.3 结合尾缓冲的混合实现我最终在生产环境采用的形态是把两种方案按层级叠起来。下面给一个更工程化的示例import MarkdownIt from markdown-it; import DOMPurify from dompurify; const md new MarkdownIt({ html: false, linkify: true, breaks: true }); const TAIL_LIMIT 2048; function splitAtSafeBoundary(fullText) { // 第一层尾部窗口大致切割 let start Math.max(0, fullText.length - TAIL_LIMIT); const newlineIndex fullText.indexOf(\n, start); if (newlineIndex ! -1 newlineIndex fullText.length - 1) { start newlineIndex 1; } const candidateStable fullText.slice(0, start); const candidatePending fullText.slice(start); // 第二层用 token 信息确认 stable 是否结束在安全行 const tokens md.parse(candidateStable, {}); let lastSafeLine 0; for (const token of tokens) { if (token.map token.nesting -1) { lastSafeLine Math.max(lastSafeLine, token.map[1]); } } const lines candidateStable.split(\n); const safeStable lines.slice(0, lastSafeLine).join(\n); const extraPending lines.slice(lastSafeLine).join(\n); return { stable: safeStable, pending: candidatePending ? extraPending \n candidatePending : extraPending, }; }这个函数有三层含义TAIL_LIMIT负责兜底行内未闭合lastSafeLine负责确认 stable 是否结束在已闭合块上多余的extraPending会被并入 pending等待下一次 chunk 确认。这套逻辑在实际使用中很稳它既照顾了“行内截断”这种细粒度问题又照顾了“表格/列表块未结束”这种块级问题。4.4 增量渲染从整树替换到只追加拿到稳定的 Token 边界后还能进一步优化渲染效率不再每次重渲染整个 stable而是只把新增的部分追加到 DOM 里。具体做法是用一个lastRenderedLength记录上一次渲染到的文本长度。每次renderStream()时计算新的 stable如果新的 stable 是在旧的 stable 基础上追加的通常如此pending 被确认时 stable 只会变长不会回退只对fullText.slice(lastRenderedLength, newStable.length)这个增量片段做md.render()把结果追加到容器末尾。增量渲染能显著降低长文成本但要注意一个问题有时某个块在前一轮是被切割的“半个块”在新一轮 stable 变长后会完整闭合这时候前一轮可能渲染了多余的内容。比如前一轮 stable 末尾是一个未闭合的 list itemmarkdown-it会渲染出一个li下一轮补充了更多 item 行后markdown-it可能会重新组合列表导致结构和上一轮不同。这种“结构回溯”是增量渲染最大的敌人。我应对的经验是对块级内容做“结构签名”校验。简单做法是记录上一轮 stable 内容中最后一个块级 Token 的类型和行号如果本轮新增内容改变了那个块的闭合状态就放弃增量做一次全量重渲染否则继续增量。这个判断成本很低却能避开大多数结构回溯问题。如果你不想自己维护这么多状态还有一个折中方案用 React 或 Vue 的响应式框架把 stable 文本作为useMemo的依赖框架会帮你做组件级 diff。它虽然没有“只追加一行 DOM”那么高效但比手动全量innerHTML好太多代码量也更少。对大多数团队这个折中方案是性价比最高的。5. 三种方案横向对比与选型建议5.1 对比表延迟、闪烁、长文性能、实现成本方案实现成本感知延迟中间态闪烁长文本性能滚动/光标保持适合场景全量 marked 重渲染极低无明显字面量跳变差全文线性增长差整树替换内部面板、静态预览、几 KB 内低频更新尾部缓冲 marked低尾巴延迟512~2048 字符几乎无好stable 不变时不重新解析好DOM 只更新尾部待确认区AI 聊天、大部分流式预览Token 闭合管理 增量渲染中高低可精细确认块级边界无最好只渲染增量片段最好可只追加节点长文档协作、在线 Markdown 编辑器、对闪烁零容忍这个表看起来是三个方案层层递进但选型不是“无脑选第三个”。增加实现复杂度意味着增加维护负担如果你的场景用尾缓冲就能很好解决没必要立刻上 Token 方案。5.2 按场景选型聊天流、Markdown 编辑器预览、大文档协同先说聊天流。这是最常见的场景OpenAI 风格的对话框输出chunk 频繁但单条消息文档通常不会特别巨大用户视线固定在末尾。我推荐尾部缓冲 marked代码量小稳定可靠。把灰色 tail 区域做得自然点用户体验就已经非常好了。再说 Markdown 编辑器的实时预览。编辑器场景有两个特殊之处一是用户会滚动回看前面的内容DOM 整树替换导致滚动跳动完全不能忍二是用户可能同时在编辑源码光标和选区必须保持。这里强烈建议用 Token 方案 增量渲染或者至少用框架细粒度 diff避免整棵预览区替换。我见过不少编辑器项目用全量渲染最后都被滚动问题逼着重构。最后是大文档协同。多人实时编辑同一篇长文后端可能同时推送多个 diff文档可能到几百 KB。这种情况下“稳定边界 增量渲染”几乎是必选项而且需要维护更复杂的缓冲队列和文档版本号。普通业务场景大概率不会走到这一步但如果你做的是 Notion 类产品前期的数据结构和渲染管线就要往这个方向设计。5.3 容易漏掉的坑XSS、SSE 乱序、rAF 节流、done 信号不管选哪个方案有几个坑一定会遇到提前记下来能省很多调试时间。XSS 过滤不能只在最后做一遍。marked和markdown-it默认对原始 HTML 的处理方式不同如果你开了html: true流式渲染的稳定区内容一定要经过DOMPurify.sanitize()。有一种更隐蔽的情况链接的javascript:协议即使不开 HTML 选项也可能被带出来。我的习惯是统一走DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })并配置不放行危险协议。尾部灰色区域用textContent展示天然安全。SSE 消息可能乱序或重复。网络波动时后端推送的 chunk 可能后到的先到也可能重发。前端必须用一个序号或id字段排序或者在逻辑上保证追加操作幂等。我踩过的一次线上事故是聊天内容偶尔出现重复段落排查到最后是服务端重试机制导致同一个 chunk 推了两次而前端没有去重。渲染频率要节流。流式输出可能每 20ms 就来一个 chunk如果每次都同步触发marked.parse和 DOM 更新主线程一定扛不住。最优雅的节流是requestAnimationFrame同一帧内多次更新只在帧开始时合并执行一次。代码大概是let scheduled false; function onChunk(chunk) { fullText chunk; if (!scheduled) { scheduled true; requestAnimationFrame(() { scheduled false; renderStream(); }); } }这样渲染频率不会超过显示器的刷新率视觉上也更流畅。流结束的 done 信号一定要处理。许多实现只关注onmessage忘了处理onend或onerror。流结束时pending 里可能还剩最后一段灰色文本如果你不主动 flush用户会看到文档结尾永远有一截半透明文字。更好的做法是在 done 时把 pending 全部确认、做一次最终渲染并清掉灰色 tail 元素。6. 我在生产环境里的实践心得我第一次真正被这个问题打脸是在给一个内部 AI 问答平台做前端的时候。当时的代码很天真——SSEonmessage里直接marked.parse(fullText)然后innerHTML全量替换。小文档跑得很开心大家觉得流式渲染不过如此。直到有人贴了一篇几十 KB 的技术博客让 AI 总结页面在输出中段直接卡到 1~2 秒才刷新一次滚动条疯狂跳动那体验惨不忍睹。后来我改成“尾缓冲 stable 缓存”的组合问题很快缓解。但真正让我笃定的是另一个细节永远不要试图用正则去精确判断 Markdown 是否闭合。我一开始想写一个“尾部未闭合标记检测器”用正则数**、[、反引号的配对情况结果被转义字符、行内代码、HTML 标签这些 case 搞得心态崩溃。后来换成“大尾窗 渲染验证”的思路反而把复杂的语法判断交给了解析器自己代码量少了一个数量级。还有一个小技巧想分享把待确认的 pending 区域做成半透明色而不是直接隐藏。很多项目为了省事会把 pending 完全隐藏这样用户看不到“正在打字”的过程体验反而不如灰色文字自然。流式输出的核心卖点就是“看着内容被打出来”灰色待确认区正好强化了这个感知属于强烈的正向体验。再补充一个容易忽略的点如果你用 React尽量不要把整个 Markdown 文本放进一个useMemo依赖然后每次重新渲染整个组件树而是把“稳定区渲染结果”单独缓存。我见过不少团队用 ReactMarkdown 直接渲染流式文本内容一长每次 chunk 都导致整棵 Markdown 组件树重建。虽然 React 的 diff 避免了直接innerHTML全量替换但解析和虚拟节点创建成本依然很高。正确做法是把稳定区的 HTML 预渲染成字符串用一个dangerouslySetInnerHTML容器展示pending 区用普通文本节点展示这样分析器只在 stable 变化时跑一次React 的调和成本也降到最低。这个场景进阶一点可以做的事情还有很多比如把尾缓冲大小做成动态的根据网络延迟和输出速度自动调整对超长链接做专门的处理提前从源码层截断把 Token 增量渲染和虚拟滚动结合支撑超大文档甚至在后端就把 Markdown 增量解析成 JSON 结构推送过来前端只做渲染。但从零到一解决“标签截断”这个问题我建议你就从尾缓冲开始它用最简单的方式覆盖了 90% 的糟糕体验剩下的再逐步升级。对我来说这道题与其说在考marked的配置不如说在考你能不能意识到解析器的最终态不等于用户的感知态。能说出“中间态”三个字然后给出尾缓冲或者 Token 增量方案这套回答在面试里基本就过关了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进