大模型流式输出Markdown标签被截断?前端缓冲分段渲染方案实战
前几天有个同学面试回来跟我吐槽被问了一个看着很基础的问题“大模型流式输出 Markdown 时标签被截断了你们前端怎么处理直接重新让 marked 全部渲染行不行”他第一反应是“那我把完整字符串拼起来重新 parse 一次不就行了”结果面试官追问了一句“如果输出正好卡在js这里呢”他当场愣住了。这个场景其实现在特别常见。ChatGPT、各类 AI 对话、文档生成工具输出都是一段一段往外蹦的网络包再一分片到前端手里的 Markdown 大概率是“半截”的代码块没闭合、链接少了右括号、表格只有表头加两行。这种不完整文本如果直接丢给 marked轻则样式错乱重则整页布局被一个没闭合的div带崩。这篇文章我就聊聊这个问题的本质以及我在实际项目里用的处理方案顺便回答面试官那句“直接全部渲染行不行”——看完你自己就有答案了。1. 问题从哪来流式Markdown的“半截标签”困境1.1 大模型为什么总是吐出半截Markdown先搞清楚数据是怎么到前端的。大模型生成文本是按 token可以粗略理解成“字”或“词”逐步产生的服务端拿到这些 token 后一般不会攒成一篇完整文章再推给你而是走 SSE、WebSocket 这类通道一有结果就往外发。再加上网络传输的分片、服务端的缓冲策略前端可能每次收到几行、几个字甚至刚好卡在一个 Markdown 语法的正中间。我随手写个例子。假设最终要输出的是下面这段 Markdown# 标题 这是正文 js const a hello;但因为流式传输前端拿到的可能长这样 - 第 1 帧# 标题 - 第 2 帧\n\n这是正文\n - 第 3 帧\n\n\\\js\n - 第 4 帧const a hello - 第 5 帧;\n\\\\n 你看第 4 帧内容是一段代码块的第一行但代码块的开头围栏 js 已经出现了闭合围栏还没来。如果这时候直接调用 marked.parse它拿到的就是一个“还没有结束的代码块”。这就像作文写到一半老师拿去批改看到满篇病句就批了一堆红叉但明明后半段还没写出来。 关键点是marked 是完整文档解析器不是流式解析器。它没有增量接口也不具备“猜后面的内容会是什么”的能力。你喂给它什么它就只能按当前字符串去解析。 ### 1.2 标签截断后会怎样一个能复现的坏例子 与其空讲不如直接看 markdown 被截断时 marked 的行为。先看最典型的代码围栏问题 js const { marked } require(marked); // 模拟流式中途代码块刚开了一个头 console.log(marked.parse(js\nconst a ));marked 的处理结果会是一段precode但里面的内容是不完整的而且它已经“认定”当前处于代码块状态。如果后续文本明明是想说别的内容但因为围栏没闭合这些内容会被继续当作代码吞进去用户看到的就是一大片样式奇怪的等宽字体。再来看 HTML 标签截断这个更吓人const { marked } require(marked); // 模拟一个流式中途HTML 标签没闭合 console.log(marked.parse(div classcontent\n\n你好));因为 Markdown 规范里这类原始 HTML 块会被原样输出marked 不会帮你补一个/div。浏览器看到这个未闭合的div会把后续所有 DOM 都塞进这个层级里轻则样式错位重则整个页面的点击区域、滚动容器全部乱掉。下面我用表格整理一下常见截断场景和最终表现未闭合片段marked 直接输出的结果用户会看到的问题js未闭合后续内容被吞进precode整段全是等宽字体高亮失效div classa没有闭合原样输出未闭合标签布局嵌套错乱样式崩坏[说明](https://example.com少右括号链接没有被解析成a文本原样展示链接点不了**重点**还没输出完加粗标记正常输出样式丢失文本不该加粗的部分闪烁表格最后只到分隔行表格整体解析失败或提前结束表格区域反复变化列数不对这些都是我在调试流式输出时真实遇到过的现象。只要不做处理直接把半截字符串给 marked问题必然存在。2. “直接全部重新渲染”为什么是下下策2.1 marked的解析机制和流式场景天然冲突有些人会想每次收到新 chunk我就把累积的完整文本重新给 marked parse 一次不就能拿到最新结果了这个思路听起来很“暴力直接”但它有两个层面上的问题。第一个层面它根本没有解决标签截断。因为你每次拿到的“累积文本”依然是不完整的。只要当前输出刚好停在代码块内部你重渲染多少次输出的 HTML 都还是残缺的。把半截字符串重复倒进解析器只会得到重复的半截结果不会自动长出闭合标签。第二个层面marked 的解析机制是为“全量输入”设计的。它内部要先做词法分析lexer把整段 Markdown 拆成 token 树再做 DOM 渲染。这个过程本身很快但它是“无状态”的——你不能让 marked 记住上次 parse 到哪了下一次接着往下走。所以每次更新都等于从零开始解析。2.2 全量重渲染的三个真实代价就算我们先用某种办法把半截标签补全了再走“全部重渲染”路线工程上依然不推荐。我在项目里踩过的坑主要有三个第一输入框和滚动位置疯狂跳动。如果你的页面里除了 Markdown 渲染区还有输入框、选择框、聊天记录列表那全量重建 DOM 会导致用户正在操作的输入框失焦或者滚动位置一下子被顶到顶部。尤其在流式输出过程中如果你用innerHTML marked.parse(text)去替换整个容器每来一个 chunk 就重建一次用户根本没法正常阅读。第二图片和音视频会重复加载。我遇到过一个场景用户生成的 Markdown 里嵌了一张图片。流式输出过程中如果每帧都全量替换 DOM浏览器会反复加载同一张图片流量和体验都崩了。第三性能是 O(n²) 级别的。假设最终文本长度是 n每次新增 1 个字符都全量解析一次那所有解析工作加起来大概是 1 2 ... n 的量级约等于 n²/2。写几百字的聊天回复没问题但如果 AI 输出一篇几万字的长文前端会越来越卡。我在低端测试机上试过文本到 5 万字符左右时每来一个新 token页面都要卡几百毫秒。2.3 什么时候可以勉强用一下也不是绝对不能全量重渲染。如果满足以下几个条件它可以作为 demo 或快速原型的兜底方案内容很短比如单次回复不超过几百字。交互要求低不需要保留选择状态和滚动位置。并发量小只有自己本地调试用。而且必须已经通过缓冲区补全了不完整标签。所以面试官那个问题如果让我答“直接全部重建”不是不能跑但它解决不了标签截断也没有解决重复解析和 DOM 重建带来的问题。它只是把问题从“解析阶段”推到了“渲染阶段”而且代价更高。3. 治本方案缓冲分段 闭合检查3.1 核心思路让“完整块”先走“半截块”等待真正能解决截断问题的思路是给 marked 加一个“上帝视角”缓冲层。每次收到 chunk先不急着渲染而是把数据放到缓冲区里然后判断缓冲区里的 Markdown 是否已经“安全”。所谓安全是指当前所有跨行语法都已经闭合没有悬空的代码围栏、HTML 标签、链接括号或者行内标记。如果安全就把这一整段交给 marked 解析然后清空缓冲区如果不安全就继续等后续 chunk。这个思路很像你在和别人聊天时对方话说到一半你知道他还没说完不会急着打断他回答而是等他停顿到一个完整语句结束再接过话头。Markdown 的“完整语句”不是按句号划分而是按块级语法边界划分。3.2 安全切分点怎么找要判断“安全”至少要管住这几类跨行语法第一代码围栏。Markdown 里的和~~~都是成对出现的。如果整个字符串里代码围栏的数量是奇数说明当前还处于代码块内部绝对不能切分。第二HTML 标签。用div这类标签时如果开标签已经出现闭合标签还没出现就得继续等。自闭合标签和img、br这类特殊标签不用管。注意 HTML 注释里可能有严谨的正则要处理但作为 demo 可以先按简单逻辑来。第三链接和图片。[文本](url)这种写法如果右括号还没出现也不能认为这一行已经结束。虽然链接一般不会跨多行但流式输出确实可能把一个完整链接在中间截断。第四行内代码和强调标记。一个反引号、两个星号也可能在行尾被截断。不过这类标记很多时候影响比较局部如果你能接受最后一行闪烁也可以只对“最后一行”做等待处理。我在实际开发里用过一种“从后往前找空行”的策略从文本末尾往前找最近的空行\n\n把空行前面的部分截出来检查这段截出来的文本是否安全。如果安全就把它提交给 marked如果不安全继续往前找上一个空行。这样既不会把半截内容交给解析器又能保证大部分完整内容尽快展示。3.3 一个可用的MarkdownStreamBuffer实现下面我给一个可以直接抄的 TypeScript 实现。这个类做的事情很简单维护内部bufferappend(chunk)把新数据追加进去然后尝试切出所有安全块flush()在流式结束时把剩余不安全内容强制交出来repair()负责补全最后没闭合的语法先写围栏检测interface FenceState { inFence: boolean; marker: string; } function scanFence(source: string): FenceState { const lines source.split(\n); let inFence false; let marker ; for (const line of lines) { const match line.match(/^\s*({3,}|~{3,})/); if (!match) continue; const currentMarker match[1]; if (!inFence) { inFence true; marker currentMarker[0]; } else if (currentMarker[0] marker) { inFence false; marker ; } } return { inFence, marker }; }再写一个简化版 HTML 标签配对检查const HTML_TAG_RE /\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s[^]*?)?(?:\/?)/g; function hasUnclosedHtmlTags(source: string): boolean { const stack: string[] []; const re new RegExp(HTML_TAG_RE.source, g); let match: RegExpExecArray | null; while ((match re.exec(source)) ! null) { const fullTag match[0]; const tagName match[1].toLowerCase(); if (fullTag.startsWith(/)) { const index stack.lastIndexOf(tagName); if (index -1) return true; stack.splice(index, 1); } else if ( !fullTag.endsWith(/) ![img, br, hr, input].includes(tagName) ) { stack.push(tagName); } } return stack.length 0; }然后写一个工具函数判断某段 Markdown 是否可以安全渲染function isSafeToRender(markdown: string): boolean { if (markdown.trim() ) return false; if (scanFence(markdown).inFence) return false; if (hasUnclosedHtmlTags(markdown)) return false; // 这里还应该检查链接括号、行内反引号等篇幅原因先省略 return true; }最后是缓冲区主类export class MarkdownStreamBuffer { private buffer ; append(chunk: string): { done: string; remaining: string } { this.buffer chunk; const doneParts: string[] []; let splitIndex this.findSafeSplitPoint(); while (splitIndex 0) { doneParts.push(this.buffer.slice(0, splitIndex)); this.buffer this.buffer.slice(splitIndex); splitIndex this.findSafeSplitPoint(); } return { done: doneParts.join(\n), remaining: this.buffer, }; } flush(): string { const rest this.buffer; this.buffer ; return repairIncompleteMarkdown(rest); } private findSafeSplitPoint(): number { const lines this.buffer.split(\n); for (let i lines.length - 1; i 1; i--) { // 从后往前找空行空行往往是安全边界 if (lines[i - 1].trim() || lines[i].trim() ) { const candidate lines.slice(0, i).join(\n); if (isSafeToRender(candidate)) { return candidate.length; } } } return 0; } } function repairIncompleteMarkdown(markdown: string): string { let output markdown; const fence scanFence(output); if (fence.inFence) { output \n fence.marker.repeat(3) \n; } // 如果有未闭合的 HTML 标签可以根据栈补全闭合标签 // 实际项目中通常直接丢弃或由 sanitize 兜底。 return output; }注意isSafeToRender里我省略了链接和行内标记的检查真实项目里建议把hasUnclosedLink这类逻辑也加上。思路很简单从文本末尾的(往前找[如果[后面没有对应的]就说明链接还未完整。这个缓冲类有个好处它不会无限制地等。只要出现了空行且之前内容安全它就会把前面完整部分吐出去渲染所以用户看到的内容延迟很小。只有在代码块、HTML 标签这种必须成对的场景下才会多等一会儿。3.4 接入React流式输出并处理收尾修复React 里用起来也很直接。我通常把它包成一个 hook放在 WebSocket 或 SSE 的回调里import { useRef, useState } from react; import { marked } from marked; import DOMPurify from dompurify; function useMarkdownStream() { const [html, setHtml] useState(); const bufferRef useRef(new MarkdownStreamBuffer()); const appendChunk (chunk: string) { const { done } bufferRef.current.append(chunk); if (done) { // 每次只更新新增的安全块避免全量重渲染 setHtml((prev) prev DOMPurify.sanitize(marked.parse(done))); } }; const finish () { const remaining bufferRef.current.flush(); if (remaining) { setHtml((prev) prev DOMPurify.sanitize(marked.parse(remaining))); } }; return { html, appendChunk, finish }; }调用时只要在 SSE 的onmessage里调用appendChunk(data)在end或close事件里调用finish()即可。这里我额外加了一层DOMPurify.sanitize因为不能信任 AI 生成的内容一定会输出干净 HTMLmarked 本身对原始 HTML 是不做清理的。这里有一个关键点很多教程只写setHtml(marked.parse(text))造成每帧全量重建。上面的 hook 是从“新增内容”角度去追加 HTML天然避开了全量重建问题锁定的滚动位置也基本不会跳动。4. 从“能用”到“好用”增量diff渲染与性能优化4.1 token级diff还是HTML级patch缓冲分段方案已经能解决“标签截断”这个核心问题了。但如果你要处理的是长时间生成、需要回滚/修改内容的场景可能还要再进一层。一种相对容易落地的方案是 HTML 级 patch。具体做法是每次拿到全量 Markdown 后仍然用 marked 解析出完整 HTML然后用morphdom之类的库把新旧两个 HTML 结构做 diff只更新变化的 DOM 节点。这样虽然解析是全量的但 DOM 更新是局部的可以保住滚动位置、输入焦点和图片加载状态。import morphdom from morphdom; function updateMarkdownContainer(container: HTMLElement, nextHtml: string) { const next document.createElement(div); next.innerHTML nextHtml; morphdom(container, next, { childrenOnly: true }); }morphdom的厉害之处在于它会把新旧 DOM 进行同级对比只修改有变化的节点。比如 AI 改了前面某个段落的几个字它不会把整个列表重建一遍而只是更新那段文字。实测下来对于几千字的 Markdown全量解析加 patch 的耗时通常还在毫秒级。另一种更彻底的做法是在 token 层做增量。你可以把流式 Markdown 按块拆开每个块有自己的 ID更新时只重新渲染发生变化的块。不过这块实现复杂度高而且对大多数聊天/文档工具来说属于过度设计。我自己的项目里只做到了“缓冲分段 追加 HTML”只有在需要修改历史内容时才切到 morphdom 方案。4.2 代码块和表格的专项处理代码块是流式渲染里最容易出问题的部分。如果你的页面要对代码做语法高亮千万不要在代码块还没闭合时就跑去调 highlight 工具。一个更稳的做法是markdown 解析后先不急着对整个 HTML 做高亮而是等缓冲模块吐出一个完整代码块后再单独处理该代码块。具体实现可以用 marked 的自定义 rendererconst renderer { code({ text, lang }) { if (typeof hljs undefined) { return precode${text}/code/pre; } const highlighted hljs.highlight(text, { language: lang || plaintext, }).value; return precode classhljs language-${lang}${highlighted}/code/pre; }, }; marked.use({ renderer });这样在流式输出过程中代码块内容还没有完全到齐时我们不会触发高亮逻辑只有等到完整块被缓冲层吐出来才会走一次高亮性能和安全都更有保障。表格也是类似。如果 AI 正在生成一个几十行的表格你每帧都重新解析并渲染最新几行用户会看到表格一会长一截、一会闪一下。我建议在缓冲层对“表格语法未结束”的情况再多保留一下如果当前缓冲区最后一行以|结尾且再往上找还能找到表格分隔行就暂时不要把最后那部分交给 marked等下一个空行出现再放行。4.3 流式渲染的节流与调度流式场景下网络推送频率可能很高尤其走 WebSocket 时一秒能有几十个事件。如果每个事件都触发一次 React setState即使每次只追加一小段也可能造成渲染堆积。我通常会在接收层加一个requestAnimationFrame或定时器节流let pendingChunk ; let scheduled false; function onChunk(chunk: string) { pendingChunk chunk; if (!scheduled) { scheduled true; requestAnimationFrame(() { appendChunk(pendingChunk); pendingChunk ; scheduled false; }); } }这样能把一帧内的多次推送合并成一次更新渲染压力大幅降低。需要注意如果页面切到后台requestAnimationFrame会暂停可能导致输出停住。更稳妥的替代方案是setTimeout(fn, 16)或直接合并到 Promise 微任务里。我因为踩过这个坑后来统一改成了setTimeout(fn, 16)配合页面可见性检查。5. 常见问题与排查技巧实录5.1 高频问题速查表把我在项目里遇到过的问题整理成表格方便排查现象常见原因建议处理内容输出结束后页面还有一段等宽字体代码块没有在流结束时调用 flush 或没有自动补全围栏finish()里对剩余 buffer 做 repair代码块内容闪烁/先渲染后消失缓冲层没有识别代码围栏把半截块提前交给了 marked修scanFence围栏未闭合不输出页面整体布局崩坏HTML 标签未闭合且未检查 HTML 栈在isSafeToRender里加入标签配对检查滚动位置被顶到顶部全量重渲染 DOM改成追加字符串或使用 morphdom链接显示成纯文本右括号还没到就被切分增加“链接括号未闭合”检查输入框点击不到/样式错乱某个div标签未闭合嵌套了整个页面立即用 DOMPurify 白名单并修复缓冲区长文本越到后面越卡每次全量 parse 和全量重建 DOM防抖 追加渲染 代码块单独处理5.2 快速定位截断点的调试手段调试流式截断最有效的方法是直接看 marked 解析出来的 token 树。你可以把当前 buffer 里的内容交给marked.lexer然后打印 token 类型const tokens marked.lexer(currentBuffer); console.table( tokens.map((t) ({ type: t.type, raw: t.raw?.slice?.(0, 30), text: t.text?.slice?.(0, 30), })) );如果发现最后一个 token 的type是code但它的raw结尾没有闭合的就能立刻确认是代码围栏问题。如果 token 是html你就得检查里面的标签是否配对。这个方法比肉眼盯着 HTML 高效得多。另一个技巧是写一个“慢镜头”测试脚本把一段完整 Markdown 按字符逐个分割然后每次都执行你当前的流式渲染逻辑看它在哪一步开始输出错误 HTML。慢镜头能复现大多数截断问题而且很容易自动化。5.3 别忘了安全过滤流式 Markdown 还有一个容易忽略的点内容来源不可信。尤其 AI 应用可能被 prompt 注入诱导模型输出恶意 HTML 标签。marked 本身不会清理这些所以只要用了dangerouslySetInnerHTML就必须在渲染前过一道DOMPurify.sanitize。有人担心每帧都做 sanitize 会影响性能。我的经验是不要在整篇文本上做只对新增的完整块做。因为缓冲层已经把一个安全块切出来了这个块通常不会太长sanitize 的开销完全可以接受。这也是为什么我建议在appendChunk内部净化而不是在外面等最终整篇文本。6. 面试里我会怎么答个人经验6.1 先定义问题再给方案回到开头那个面试题我会分三层回答。第一层直接全量重渲染行不行答不行。它既不能避免标签截断还会带来 DOM 重建的性能问题。如果非要用也只能作为输出很短、不计较体验的 demo 兜底而且前提是先解决缓冲区问题。第二层正确的做法是什么答做缓冲分段。每次收到 chunk先放进 buffer检测代码围栏、HTML 标签、链接括号等是否完整只把完整块交给 marked 解析。流式结束后对剩余半截块做自动修复。第三层怎样做得更好答加防抖节流、使用 morphdom 做增量 DOM patch、代码块和表格专项处理最后再加一层安全过滤。面试官想听到的其实不是某个 API 的具体用法而是你有没有建立“解析器需要完整输入”的认知以及能不能针对流式场景设计出合理边界。6.2 工程落地中的两个小建议最后分享两个我在实际项目中觉得特别值钱的经验。一个是“能后端配合就别前端硬扛”。如果 AI 输出接口由你自己控制可以在服务端做一层 Markdown 完整块检测按完整段落推送前端压力会小很多。当然真实场景里服务端不好判断用户是不是在等代码块所以前端该做的兜底还是得做。另一个是“不要追求零延迟”。流式输出体验的核心是稳定而不是每个字都第一时间渲染。适当多等一个空行让前端多攒 50 个字符再渲染用户几乎感知不到延迟但页面稳定性会好很多。我曾经为了让效果“ 更实时 ”把缓冲设得太薄结果每秒钟渲染十几次反而造成闪烁和卡顿。后来改成“至少等一个块结束再渲染”体感反而顺滑了。这套东西从原理到落地并不复杂但很能看出一个人对“输入完整性与解析器边界”的理解。如果你也在做 AI 流式输出相关的前端建议直接拿上面的代码跑一跑再用慢镜头测试打一遍你会对 Markdown 解析的细微之处有更深的体感。