拓冰建站拓冰建站
首页 / 资讯中心 / 正文

流式输出下Markdown标签截断:安全边界与增量渲染实践

面试官把这个问题抛出来时我第一反应是有点想笑——因为几个月前我刚好在一个 AI 对话产品里被它折磨过。流式输出、Markdown 渲染、标签截断这三个词组合在一起就是一个典型的“听起来简单、做起来脏”的前端需求。你这边吞一个 token那边界面就要跟着变化用户可能正盯着“python”等着代码块出现结果屏幕先闪出一个纯文本段落等代码块闭合后又猛跳一次阅读位置全丢。这个问题的本质不是“怎么调 marked 的 API”而是“在文本还不完整的时候怎么让界面保持稳定”。这篇文章我把当时的完整思考链路和落地代码拆给你看为什么“直接每次重渲染全部内容”不是最优解、什么条件下它反而能用、真正的解法“安全边界”怎么实现以及 marked 生态下代码块、表格、列表、链接四种最容易截断的语法分别怎么处理。无论你是正在准备面试还是已经踩在流式渲染的坑里这篇都能少走不少弯路。1. 流式截断问题的真实场景一个被低估的渲染一致性问题1.1 用户看到的“逐字蹦出”前端做的是“反复重建”先还原一下现场。大模型接口通常以 SSE 或 WebSocket 方式逐 token 返回内容前端收到一个 chunk 就 append 到缓冲区然后调用marked.parse(buffer)生成 HTML塞进一个v-html/dangerouslySetInnerHTML容器里。逻辑简单但有几个原子性问题绕不开Markdown 语法是成对出现的要闭合、**要成双成对、[要等]和()。只要文本流没结束任何一对符号都可能处于“只出现了开头”的中间状态。解析器是贪婪的marked 遇到一个未闭合的围栏会把后面所有内容吞成一个巨大的codetoken直到遇见另一个围栏行或文本结束。流式输出时这个“结束”可能几秒后才来用户这几秒看到的就是一整块代码高亮区域前面正常的 Markdown 全被吞掉。中间状态会反复横跳比如一段加粗文字**重点**流式输出到**重点时marked 很可能把它当成普通文本等到**补齐后又突然变成加粗。每一次状态切换都是视觉上的闪烁或者跳动。所以面试题里的“标签截断”指的其实不只是div这种 HTML 标签而是所有成对出现的 Markdown 标记符号。面试官想看的是你有没有意识到“解析结果会随着输入变化而剧烈震荡”。1.2 为什么不能把问题简单归因于“marked 不支持流式”也有人会说那换个支持流式的解析器不就行了。但目前的 marked、markdown-it、remark 天生都是“给定完整文本返回完整 token 列表”的一次性 API它们没有内置“当前解析到第几个 token、等待闭合标记”的中间状态对外暴露。你可以自己写状态机去跟踪文本里的围栏、链接、加粗但这基本等于重写一个 Markdown 解析器。工程上更现实的路线是保留 marked 做渲染自己控制“什么时候把什么内容交给它”。理解这一点就能明白为什么大家都说“流式渲染的难点在调度不在解析器本身”。2. 直接“全部重新渲染”能走多远先算清性能账和体验账2.1 理论上可行实测很快打脸先别急着否定“每次重新让 marked 全部渲染”。它在一种场景下确实是正确答案内容很短、token 频率很低。比如你就是做一个一次输出 200 字的“伪流式”演示每次间隔几百毫秒marked 解析一遍也就 1ms 以内全量重渲染用户根本感知不到。但真实 LLM 流式场景一分钟能输出上千字甚至几千字按每 token 3-4 个字符算每秒大概来 5-15 个 token每次 append 都全量重新解析的话文本一到 50KB 以上markdown 解析加 DOM 重建就会冲到几十毫秒到上百毫秒。这还不是最致命的。我做了一组粗测用 marked v12 解析一段 20KB 的混合 Markdown含标题、列表、代码块、表格单次marked.parse耗时约 8-15ms性能其实不算差。但如果每次更新都把整个innerHTML替换一遍浏览器需要销毁全部旧 DOM 节点、创建全部新节点、重新布局、重新绘制这个成本远超解析本身。在低端手机上20KB 文本的 DOM 全量重建能卡出明显的白屏闪烁。2.2 真正的坑状态丢失和阅读位置跳动比性能更隐蔽的是交互状态。滚动位置丢失用户正在看第 20 行新 token 来了内容高度变化如果容器没有做滚动锚定视口会瞬间跳到顶部或底部。文本选择被清空用户想复制刚生成的一行代码鼠标一选中下一个 token 到达DOM 重建选中态直接消失。输入光标漂移如果这个容器里还嵌着可编辑的提示词或者评论框全量替换 DOM 会把焦点全部打掉。这些都是“全量重渲染”方案的隐含代价。面试官其实是想让你算出这笔账解析耗时只是明面成本DOM 重建引发的连锁反应才是大头。2.3 什么时候可以继续用全量重渲染我现在的判断标准是三条文本总长度长期在 10KB 以下token 到达频率不高于每秒 2-3 次渲染区域是只读的没有用户选择和滚动交互。满足这三条直接全量重渲染是最省事、最稳的方案。甚至可以用requestAnimationFrame合并同一帧内的多次追加进一步降低成本。但如果你的产品是正经的 AI 对话、AI 文档大概率三条都不满足那就得换方案。3. 动手前先定义安全边界这是整个方案的地基3.1 安全边界是什么我给出一个可操作的定义在文本流T中找到一个位置P使得T[0:P]是一个“在 Markdown 语法意义下完整闭合”的文本块且T[0:P]渲染出来的 HTML 不会因为T[P:]的后续内容而发生改变。也就是说P之后无论再追加什么都不会影响P之前已经渲染好的内容。这是流式渲染稳定的核心。只要找到这个位置就可以放心地把T[0:P]固化成不可变的 HTML把T[P:]当作“待定区”单独处理。3.2 两个最可靠的安全信号围栏闭合和段落结束最直观的安全边界是代码围栏的闭合。围栏一旦闭合前面这块代码块就固定了后面加多少东西它都不会变。第二可靠的是“前方没有未闭合列表/引用块时的一个空白行”因为空行在大多数 Markdown 实现里会终结当前段落和大多数块级容器。但注意“空行”不是茅房里的绝对安全它有一个常见例外列表列表项内部有些实现里列表项内一行文本后出现空行再出现一段缩进文本仍属于同一个列表项。引用块 text后面跟一个空行下一个可能仍是同一个引用块。表格表格头部、分隔行、数据行之间可以有空行吗标准语法一般不允许但 GFM 的宽容模式会处理得很奇怪。所以我的建议是不要只靠空行判断安全要结合一个极简的块级状态机至少跟踪“当前是否在代码围栏内”和“当前是否在列表/引用嵌套层级内”。3.3 一个可用的安全边界扫描函数下面这个findSafeBoundary是我当时写的简化版本核心思路是逐行扫描维护一个围栏状态和最后的“真正安全行号”function findSafeBoundary(text) { const lines text.split(\n); let fenceChar null; // 当前围栏字符 或 ~ let fenceLen 0; // 当前围栏长度 let safeLine 0; // 已确认安全位置的下一行索引 let lastFencedLine -1; // 最近闭合围栏的结束行 for (let i 0; i lines.length; i) { const line lines[i]; const trimmed line.trim(); // 检测围栏开始或结束 const fenceMatch trimmed.match(/^({3,}|~{3,})/); if (fenceMatch) { const marker fenceMatch[1]; const char marker[0]; const len marker.length; if (fenceChar null) { // 进入围栏 fenceChar char; fenceLen len; } else if (fenceChar char len fenceLen) { // 围栏结束 fenceChar null; fenceLen 0; lastFencedLine i; safeLine i 1; } continue; } // 不在围栏内时空行可以作为一个候选安全边界 if (fenceChar null trimmed ) { // 如果前面没有未闭合的引用层级空行是安全的 // 这里简化为直接记录实际可以再加状态判断 safeLine i 1; } } // 如果扫描结束时还在围栏内安全位置只能退到最后一个围栏闭合行之后 if (fenceChar ! null lastFencedLine 0) { safeLine Math.max(safeLine, lastFencedLine 1); } const linesToKeep lines.slice(0, safeLine); return linesToKeep.join(\n).replace(/\n$/, \n); }这个函数返回的是“已经安全结束的文本块”。当流式文本还在一个未闭合的代码块里时它会把安全位置停在代码块开始之前或最近一个闭合围栏之后而不是把未闭合的围栏强行交给 marked。3.4 为什么不能只找“最后一个 \n\n”你可能会想直接text.lastIndexOf(\n\n)不就完了。实战你会发现两个问题如果\n\n发生在代码围栏内部它其实不能作为安全边界因为代码块内容里空行非常正常。列表块经常连续多行都没有空行比如- item1 - nested item这种情况下中间任何一行都不安全因为下一行可能缩进后被打包成一个嵌套列表。安全边界必须等整个列表块“确实结束了”才算数。所以我的建议是安全边界的扫描逻辑本质上是一个“迷你 Markdown 块级状态机”量级不用太大但必须能识别围栏、空行、列表缩进和引用块标记。这就是这个方案的工程量所在。4. 基于 marked 的增量渲染落地从 lexer 到 DOM 更新的完整代码4.1 核心设计已安全区 待定区双容器的结构确定了安全边界后我把渲染区域拆成两个部分div classmd-stream div classmd-safe v-htmlsafeHtml/div div classmd-pending v-htmlpendingHtml/div /divsafeHtml是对T[0:safeEnd]的渲染结果一旦生成就不再变化pendingHtml是T[safeEnd:]的“临时预览”。这个结构天然解决了闪烁问题已经确定的内容不会因为新 token 到来而重建只有尾部一小段在变。4.2 一个可直接运行的 StreamMarkdownRenderer下面我用一个类来演示完整流程你可以直接改成 Vue 的 composable 或 React 的 hookimport { marked } from marked; import { findSafeBoundary } from ./safeBoundary; export class StreamMarkdownRenderer { constructor(options {}) { this.buffer ; this.safeEnd 0; this.safeHtml ; this.options options; this.pendingMode options.pendingMode || preview; // preview | escaped } append(chunk) { this.buffer chunk; const safeText findSafeBoundary(this.buffer); const newSafeEnd safeText.length; // 只有当安全边界真的前进时才重新解析安全区 if (newSafeEnd this.safeEnd) { // 这里直接重新解析整个安全文本安全文本增长频率很低性能可控 this.safeHtml marked.parse(safeText, this.options); this.safeEnd newSafeEnd; } return this.renderPending(); } renderPending() { const pending this.buffer.slice(this.safeEnd); if (this.pendingMode escaped) { // 纯文本转义最安全但可读性差一点 this.pendingHtml span classmd-pending-text${escapeHtml(pending)}/span; return this.pendingHtml; } // preview 模式补全未闭合语法后渲染 const completed completePartialMarkdown(pending); this.pendingHtml span classmd-pending-preview${marked.parse(completed)}/span; return this.pendingHtml; } snapshot() { return { html: this.safeHtml this.pendingHtml, safeEnd: this.safeEnd, }; } reset() { this.buffer ; this.safeEnd 0; this.safeHtml ; this.pendingHtml ; } }注意我刻意没有在append里直接更新 DOM而是返回 HTML 字符串方便接入框架。实际使用时你可以配合requestAnimationFrame来聚合同一帧内的多次append避免一帧内反复更新 DOM。4.3 为什么安全区要“重新解析整个 safeText”而不是只解析新增段你可能会问既然safeEnd只前进了一小段为什么不直接marked.parse(this.buffer.slice(oldSafeEnd, newSafeEnd))然后拼到safeHtml后面因为 Markdown 块级语法有状态跨行问题。比如- item1 - item2如果安全边界把- item2单独切出来解析你会得到一个独立的无序列表渲染结果可能是两个ul而不是一个包含两个li的ul。类似的还有引用块、任务列表、围栏内的行。所以最稳妥的做法是安全区整体重新解析一遍但只在安全边界前进时才做。这里有个关键认知安全边界推进的频率远低于 token 到达频率。每次 token 来有 90% 概率不会推进安全边界比如还在一个句子中间那安全区就不用重渲染只有尾部待定区变化。只有遇到空行、围栏闭合这些“结构完整点”安全区才会重算一次。这个频率下全量 parse 安全文本完全扛得住。4.4 pending 的两种展示方式纯文本转义 vs 补全后预览待定区怎么展示直接影响产品体验。纯文本转义escaped把未结束的pending字符串用escapeHtml转义后放进一个span视觉上就是一段普通文本。实现最简单绝对安全不会出现“未闭合的把半屏内容变成代码块”的问题。坏处是当用户看到**你好时不会看到加粗只看到两个星号有点丑。补全后预览preview先调用一个completePartialMarkdown给待定区补上缺失的闭合符号再用 marked 解析。比如未闭合的代码块我补一个换行未闭合的**我补一个**。这样用户能看到“代码块正在生成”的真实效果而且因为闭合成对不会出现结构爆炸。坏处是补全逻辑要写好否则可能渲染出一个错误的中间结构。我在生产环境用的是补全预览模式。补全函数大致如下function completePartialMarkdown(text) { let result text; // 统一处理代码围栏 const fenceOpen detectUnclosedFence(result); if (fenceOpen) { result \n\n; } // 行内代码反引号补全 const backtickCount (result.match(//g) || []).length; if (backtickCount % 2 1) result ; // 加粗/斜体补全 const doubleStarCount (result.match(/\*\*/g) || []).length; if (doubleStarCount % 2 1) result **; // 链接地址补全 if (countOccurrences(result, ]() countOccurrences(result, ))) { result ); } return result; }注意这只是兜底实际上面临的情况远比这多所以我还单独处理了表格、列表和 HTML 注释详见下一章。5. 代码块、表格、列表和链接四类最容易截断的语法怎么单独处理5.1 代码围栏语言标识行不是普通文本代码块截断是最典型的问题。流式输出到python时marked 已经把后面所有内容都当作代码了如果你的安全边界没拦住整个聊天区会瞬间变成代码块。处理时除了要识别围栏是否闭合还要注意语言标识符python这个语言标识只出现在开始围栏那一行。一旦识别到围栏开始python这个字符串应该原样保留不能把它当成普通文本转义或补全。围栏内部空行不能视为安全边界。在 pending 补全时要判断“当前是否在未闭合围栏内”如果是直接补一个围栏结束行而不是只补反引号。我给detectUnclosedFence的实现思路function detectUnclosedFence(text) { const lines text.split(\n); let fenceChar null; let fenceLen 0; for (let i 0; i lines.length; i) { const t lines[i].trim(); const m t.match(/^({3,}|~{3,})/); if (m) { const char m[1][0]; const len m[1].length; if (!fenceChar) { fenceChar char; fenceLen len; } else if (fenceChar char len fenceLen) { fenceChar null; fenceLen 0; } } } return fenceChar; // 返回 null 或当前打开的围栏字符 }5.2 表格整表未完成时别让前面的行先“定稿”GFM 表格的结构依赖三行表头、分隔行、数据行。流式输出时最常见的情况是只输出到| 名称 | 数量 |然后还没来分隔行marked 可能把它当普通段落。等分隔行来了它突然变成一个表头。这个跳动虽然不像代码块那么致命但也很烦。处理办法安全边界扫描时如果当前行是表格行并且前方已经出现了一个未闭合的表格即没有等到空行或非表格行就不要把它作为安全位置。也就是说表格必须整体结束后才允许前面的表头部分定稿。补全时更简单如果 pending 里已经有|开头且没有闭合的空行可以补一个|---|---|充当分隔行让表格形状先立起来。5.3 列表项连续项之间不能乱切列表是流式渲染里最隐蔽的坑。比如- 第一步打开文件 - 第二步编辑内容如果安全边界切在第一个列表项之后、第二个列表项之前重新解析- 第一步打开文件单独渲染没问题因为它自己是完整列表。但流式输出时第二个列表项还没来此时第一个列表项的 HTML 已经生成。随后第二个列表项来了你再单独渲染- 第二步又是一个新ul页面出现两个间距很大的列表用户体验割裂。解决思路有两个方向在安全边界判断里增加“列表块未结束不切”的规则遇到列表行后必须遇到“非列表行 空行”才允许推进安全边界。如果产品要求“列表项要逐条渲染”就把整个列表块的 HTML 缓存起来新列表项追加时重新解析整个列表块区域。这本质上是对安全区做了局部重渲染。我当时在实现里选择了一个折中安全边界允许切在“列表项的最后一个文本行”之后但切完后保留一个“列表上下文标记”等下一个列表项来临时从缓存好的列表块的第一个 token 开始重新解析整个列表块而不是把列表项拆成两个独立列表。5.4 链接、图片、行内代码单行内的成对符号这些属于行内语法不会影响布局结构但会造成视觉闪烁。比如请看 [这里](https://example.com)输出到请看 [这里时marked 解析结果是普通文本等](url)到位后突然变成链接。处理这类问题的关键是“别让这类高频行内中间态反复触发安全区重渲染”。我建议的处理方式是链接/图片的未闭合状态通过countOccurrences(text, [) countOccurrences(text, ])或countOccurrences(text, ]() countOccurrences(text, ))来检测在 pending 预览里先补一个)或]。行内代码的反引号奇偶检测可以覆盖大部分情况。强调符号**/*/__最好在补全时统一处理避免用户看到一半加粗一半普通的奇怪文本。这些行内补全规则不追求极端完备因为 pending 只存在几百毫秒等 token 补齐后就会被安全区吞并。目标是“看起来不突兀、不闪断”而不是“每个瞬间都要和最终渲染一模一样”。6. 体验调优防止闪烁、维护滚动位置和控制更新频率6.1 用 rAF 合并 token而不是来一个刷一次LLM 流式接口的 token 到达间隔不是恒定的有时 10ms 来一个有时 200ms 才来一个。如果每个 token 都直接触发渲染同一帧内可能被多次更新浏览器被迫做多次布局白屏概率陡增。正确做法是维护一个“待处理队列”用requestAnimationFrame统一消化class StreamScheduler { constructor(renderCallback) { this.renderCallback renderCallback; this.rafId null; this.tokens []; } push(token) { this.tokens.push(token); if (this.rafId null) { this.rafId requestAnimationFrame(() { this.rafId null; const merged this.tokens.join(); this.tokens []; this.renderCallback(merged); }); } } }这样一帧最多渲染一次。如果浏览器忙到丢帧下帧会自动合并更多 token不会造成无限堆积。6.2 滚动锚定用两个 offsetTop 解决高度跳动只要内容高度在变化滚动位置就必然受影响。最朴素的方法是更新前记录容器内第一个可见元素的offsetTop和容器scrollTop更新后找到那个元素更新后的offsetTop把scrollTop调整成“新 offsetTop 旧 scrollTop - 旧 offsetTop”。也可以用 CSS 的overflow-anchor: auto现代浏览器对流式追加的滚动锚定支持得不错但碰到表格、图片等高度变化大的节点时还是会翻车。我建议用 JS 锚定作为兜底尤其是你渲染的是可滚动的聊天区域。6.3 保留用户选中和光标状态如果页面里有可编辑区域或者“复制按钮”这种交互全量替换 innerHTML 会导致焦点丢失。一个很实用的技巧是在渲染前用document.activeElement记录当前焦点元素渲染后再尝试focus({preventScroll: true})。如果焦点在文本输入框内还要保存selectionStart/selectionEnd。但如果你的 Markdown 渲染区本身就是纯展示的这个坑会小很多。真正的风险是用户在当前 Markdown 结果里选中了一段文字结果下一个 token 到达后 DOM 重建选中的内容消失。要让这种场景不难受唯一的办法就是尽量少重建前面的节点而“安全区 待定区”的结构正好能做到安全区不变的时候不碰它只有待定区变。6.4 让“待定区”视觉上有层次感我最后在做产品时发现一个细节用户其实分不清“文本还没生成完”和“这里就是一段普通文本”。为了让流式状态更明显我给了待定区块一个轻微的高亮或光标提示.md-pending-preview { opacity: 0.9; border-right: 2px solid rgba(100, 100, 200, 0.4); animation: blink-caret 1s step-end infinite; }这样做有两个好处一是告诉用户“这里还在生成等内容稳定后就会变”二是即使发生了轻微的补全渲染跳跃用户也不太会有“内容闪没了”的感觉。这个胶水层看着不起眼但对最终体验影响很大。7. 一套可直接套用的渲染器实现和上线前踩坑记录7.1 一个 React hook 的集成示例把上面的StreamMarkdownRendererStreamScheduler组合起来一个常见的 React hook 长这样import { useEffect, useRef, useState } from react; import { StreamMarkdownRenderer } from ./StreamMarkdownRenderer; import { StreamScheduler } from ./StreamScheduler; export function useStreamMarkdown(getContainerRef) { const rendererRef useRef(null); const [html, setHtml] useState(); if (!rendererRef.current) { rendererRef.current new StreamMarkdownRenderer({ pendingMode: preview, }); } useEffect(() { const scheduler new StreamScheduler((merged) { const nextHtml rendererRef.current.append(merged); setHtml(nextHtml); }); scheduler.push scheduler.push.bind(scheduler); // 暴露给外部调用通过 ref 或 context 传入实际 stream window.__appendToken (token) scheduler.push(token); return () { delete window.__appendToken; }; }, []); return { html }; }真实项目里建议把它包成一个StreamProvider不要污染window。核心点在于append返回的是safeHtml pendingHtml框架只在必要时更新。如果一次没推进安全边界safeHtml等于没变React 的setHtml可以走浅比较不触发大范围 diff。7.2 上线前必须检查的四个雷第一marked 的安全问题。默认 marked 会放行原始 HTML如果你们的 AI 内容可能包含不可信文本一定要在渲染前过一遍DOMPurify.sanitize或者使用marked的renderer把script等标签转义。流式场景里尤其容易漏掉pending 补全阶段可能生成一个临时闭合的 HTML 标签如果没做过滤这就是 XSS 注入点。第二async 扩展和 parse 的兼容性。如果你通过marked.use({ async: true })配置了异步渲染器marked.parse()返回的不再是字符串而是一个 Promise。这时如果直接拼字符串会拿到[object Promise]。我的建议是不要在流式渲染链路里启用异步 extension保持 parse 是同步的否则调度逻辑会被大改。第三SSR/Hydration 问题。如果做的是 Next.js 之类的 SSR不要把流式渲染结果直接塞进服务端 HTML。服务端永远只输出一个稳定的空容器或静态占位客户端拿到流式 token 后再开始渲染。否则会出现 hydrate 时内容不一致的警告甚至把整个页面卡住。第四长文本的“安全区”也会越来越大。虽然安全区不常更新但文本累积到几百 KB 后每次安全边界前进一步全量marked.parse(safeText)的耗时也会水涨船高。真到这一步你再回来做虚拟滚动或者按“段落”为单位对安全区做局部缓存不要让某个段落内部再重新解析。我当时的经验是单次安全区重解析超过 30ms 时就该做分段缓存了。7.3 我对这个方案最终的评价这套“安全边界 待定区补全 rAF 调度”的组合是我试过全量重渲染、纯文本转义、末尾延迟渲染几种方案之后最终稳定跑上线的方案。它当然不是完美的核心难点还是那个安全边界扫描器的状态机需要根据你们产品实际支持的 Markdown 语法子集去调整。面试官如果追问“能不能直接重新让 marked 全部渲染”我的回答思路可以用一句话概括直接全量重渲染没有错但它不应该被当成唯一策略在流式场景里真正优雅的做法是把“已确定”和“未确定”切开把昂贵的全量解析留到结构完整的那一瞬间把中间的震荡压缩到尾部一个很小的待定区里。这样即使用户盯着看也不会产生那种“生成一个字整页闪一下”的糟糕体验。如果你正准备做 AI 对话产品我的建议是从这个双容器结构起步不要一开始就上复杂状态机。先实现围栏闭合检测和空行安全边界跑通后再逐步补列表、表格、HTML 注释这些边角。流式渲染的坑永远比你想的多但每一步补丁下去产品都会比上一版稳一点。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门