前言
Hello~大家好,我是秋天的一阵风
做 AI 对话页,模型边吐字边渲染 Markdown,几乎是标配。面试里这类问题也很常见,对话往往是这样开始的:
面试官: 大模型流式输出 Markdown,前端一般怎么解析、怎么渲染?
候选人: 用marked或markdown-it。每来一个 chunk,就把累积字符串全量 parse 一遍,再把 HTML 写进 DOM。
面试官: 那流式过程中,链接写到[文档](https://ex就断住,或者粗体停在**关键结论还没配对——这时候解析结果和 DOM 会怎么变?整篇重 parse,就能消掉闪烁吗?
前半句答案能交差:流式界面本来就经常全量重画。但后半句一追问,差距就出来了——
答案通常是:不能。 全量重渲染解决的是「更新频率」,没有解决「当前字符串是不是一份可稳定解析的 Markdown」。
下面我们来一起思考这个问题以及对应的解决方案。
一、根本原因:闭合语法遇到了未完成的流式文本
为什么「每来一段就 parse」会闪?原因非常简单:
Markdown 依赖成对标记才能稳定成 HTML;流式文本在任意时刻都可能停在「只开了头、还没闭合」的状态。
1. Markdown 侧:结构要成对才成立
常见语法都是「开头 + 结尾」凑齐后,解析器才明确产出对应节点:
- 粗体:
**…** - 行内代码:
`…` - 围栏代码块:
``` … ``` - 链接:
[文本](url)
若当前累积串只有开头、没有结尾,解析器往往只能把星号、反引号、半截 URL 当普通字符处理,或者产出不稳定的中间结构。
2. 流式侧:缓冲区只会变长
模型输出是持续追加的。前端拿到的是不断变长的 buffer,在某一帧里,你无法根据「后面还会不会补上闭合符」做决定——因为闭合符可能在下一个 chunk,也可能要再等几十个 token。
因此任意时刻都可能出现:
**关键结论还没写完
[参考文档](https://github.com/ver
明确这个问题原因后,我们后面的方案都围绕同一目标:在解析之前,尽量避免把「明显未完成」的结构送进渲染器;或先把它修成可解析形态;长文场景再避免整棵 DOM 反复推翻。
二、延迟渲染:完整部分先输出,未闭合尾巴先留在 buffer
针对根因,最直接的工程手段是 延迟渲染(deferred / delayed rendering):
不把整份
buffer一股脑 parse;只渲染已经完整的前缀,把末尾未闭合碎片继续留在缓冲区,等闭合齐全再放出。
1.做法
- 维护
buffer,每个 chunk 执行buffer += chunk - 检查末尾是否存在未闭合语法,例如:
- 围栏
```出现次数为奇数 **不成对[尚未配对到完整链接
- 围栏
- 若存在未闭合尾巴:只把前面的完整部分交给 marked/remark;尾巴仍留在
buffer - 后续 chunk 补齐闭合后,再将该段送入正式解析与渲染
- 这样用户看不到「先当普通文字、再突然变成代码块」的跳变。
延迟渲染解决的是「什么时候把哪一段送进解析器」,是后续所有进阶方案的基础。
三、代码块上下文:围栏内部按纯文本处理
仅靠延迟渲染仍不够。对话场景里,围栏代码块往往是闪烁的重灾区。
1. 为什么代码块特别容易出问题
代码内容里经常出现 *、_、#、>。它们在正文里是 Markdown 标记,在代码里只是普通字符。若结束围栏尚未到达,解析器却仍按正文规则扫描块内文本,就会把注释、正则、列表符号等误解析成标题、强调、引用,结构一旦错乱,后续闭合后还要再整段纠正,闪烁更明显。
2. 用状态标记「是否在代码块内」
维护一个状态,例如 inCodeBlock:
| 状态 | 行为 |
|---|---|
false(正文) | 按正常 Markdown 规则处理;检测到连续三个反引号 → 置为 true |
true(代码块内) | 后续字符按纯文本输出,不再套用强调/标题/列表等规则;再次遇到三个反引号 → 置为 false |
这是词法层面的上下文锁定:进入代码围栏后,暂时关闭正文语法,直到围栏结束。实现成本低,对稳定性帮助大。
四、remend:解析前把未闭合语法补全
延迟渲染选择「先不画」;另一条路是「先画,但先把字符串修到可解析」。
Vercel 在 streamdown 中拆出的 remend,干的就是后者。
1. 什么是remend
remend 不是又一个 Markdown 引擎,也不改 DOM。它是解析前的预处理器:
流式累积文本 → remend(补全/修复) → marked / remark → HTML
import remend from 'remend';
const completed = remend(accumulatedText);
// 再交给 marked.parse 或 unified/remark
| 输入(流式未完成) | remend 输出 |
|---|---|
This is **bold text | This is **bold text** |
Run `npm install remend | Run `npm install remend` |
[docs](https://exampl | [docs](streamdown:incomplete-link) |
2. remend的原理:责任链,而不是一棵 AST
入口 remend(text, options) 做的事很克制:
- 按配置筛出启用的 handler
- 按
priority排序(数字越小越先执行) - 依次
result = handler.handle(result) - 个别 handler 可触发 earlyReturn,短路后续步骤
流水线示意:
singleTilde → comparisonOperators → htmlTags → setextHeadings
→ links(可 earlyReturn)
→ boldItalic → bold → italic(多种)
→ inlineCode → strikethrough → katex
→ custom handlers
remend的设计要点:
- 每个 handler 签名都是
(text: string) => string,互不依赖 - 不做 AST,字符串扫描,适合高频流式调用
- 选项默认开启(
!== false),inlineKatex例外,需显式打开 - 支持自定义 handler 扩展
3. 流式场景下的调用时序
4. remend的Handler 通用模式:奇偶计数
remend内置的绝大多数补全型 handler 套路一致:
1. 正则匹配末尾是否像未完成语法
2. 检查上下文(代码块 / 数学块 / 链接 URL 等)→ 是则跳过
3. 统计开闭标记数量(奇偶)
4. 奇数 → 在末尾追加闭合标记
以粗体为例(emphasis-handlers.ts 思路):
// 伪代码示意
const match = text.match(boldPattern); // 末尾 **xxx
if (!match) return text;
if (isInsideCodeBlock(text, markerIndex)) return text;
if (!有实际内容) return text;
const pairs = countDoubleAsterisksOutsideCodeBlocks(text);
if (pairs % 2 === 1) {
// 半闭合 **content* 只再补一个 *
if (content.endsWith('*')) return text + '*';
return text + '**';
}
return text;
斜体、删除线、行内代码、块级 $$ 同理。流式特有的半闭合(闭合符也是逐字符到达)只补「差的那一个字符」。
5. 上下文感知:不该补的地方不补
utils.ts 提供位置检测,避免在错误上下文里动手:
| 函数 | 作用 |
|---|---|
isWithinCodeBlock | 是否在围栏代码块内 |
isWithinMathBlock | 是否在 $ / $$ / \(...\) 等数学上下文 |
isWithinLinkOrImageUrl | 是否在链接/图片 URL 部分 |
isWithinHtmlTag | 是否在 HTML 标签内 |
isHorizontalRule | 是否水平线而非强调 |
isWordChar | 词内字符(避免 hello_world 误伤) |
实现上多为从当前位置向前扫描的小型状态机,例如遇到 ``` 就 toggle「是否在代码块」。无 AST,适合热路径。
6. earlyReturn:链接补全后的短路
earlyReturn 不是 handler 函数里的普通 return。每个 handle 都会 return,但默认流水线会继续跑下一个 handler。
earlyReturn 挂在调度层:当 links handler 把文本补成以 ](streamdown:incomplete-link) 结尾时,整个 remend 提前结束,后面的 bold/italic 等不再执行。
原因:占位链接里的字符若再被强调逻辑扫描,容易误补。链接语义一旦用占位 URL 定型,就不应继续改写。
仅默认 linkMode: 'protocol' 启用;text-only 模式不会出现该后缀,earlyReturn 关闭。自定义 handler 目前没有这套短路钩子。
7. 两类 Handler:补全型 vs 预防型
| 类型 | 目的 | 例子 |
|---|---|---|
| 补全型 | 标记未写完,临时补闭合 | **bold → **bold**;`code → `code` |
| 预防型 | 文本「完整」但会被误解析 | 20~25 → 20\~25;列表里 > 25 → \> 25;剥掉末尾残缺 HTML 标签 |
预防型不追求闭合,而是转义、删除或插入不可见字符,打断错误模式(如 Setext 标题误判)。
链接/图片是特例:URL 未完成可补占位链接;图片未完成常直接丢掉(无法有效 skeleton)。嵌套方括号用深度配对,而不是简单 indexOf。
五、分块解析 + 增量渲染
前面几章我们分别解决了「何时送检」「代码块别误解析」「半成品字符串怎么修」。
消息一长,还有一个更实际的问题:每次更新都别把整篇结果 DOM 推倒重来。
1.方案核心
把流式正文拆成两块区域:
- stable:已经完整、可以定稿的段落——只 parse 一次,之后尽量不动
- streaming:仍在增长的最后一段——每来一个 chunk 刷新;展示前用
remend补全未闭合语法
再配上 remend,就形成完整闭环:定稿的钉死,尾巴可修可刷,整棵树不再反复替换。
2.处理流程
每次模型吐出一段 chunk,按下面四步走。
1. 追加到 buffer
先不做解析,只把原文接在总缓冲区末尾:
buffer += chunk
buffer 始终是「迄今收到的全部 Markdown 原文」,切分和渲染都基于它。
2. 按空行切段,分出 stable 与 streaming
用 \n\n 把 buffer 切成若干段。规则很简单:
- 最后一段:一律当作
streaming(还可能继续变长,不能定稿) - 前面的段:若语法看起来完整 → 进入
stable;若不完整(例如只有**核)→ 先 hold,和下一段拼在一起再判断
「看起来完整」可以粗略检查:围栏 ``` 成对、** 成对、行内 ` 成对、链接没有停在 [半截 或 ](半截。
伪代码:
function splitStableAndStreaming(buffer):
segments = buffer.split(/\n\n+/)
stable = []
hold = "" // 语法还不完整的前段,暂存
for i, segment in segments:
piece = hold ? hold + "\n\n" + segment : segment
isLast = (i == last index)
if isLast:
return { stable, streaming: piece }
if isComplete(piece):
stable.push(piece)
hold = ""
else:
hold = piece // 等下一段补齐再判
return { stable, streaming: hold }
举个例子,当前 buffer 是:
# 标题
**核
切分结果:
stable: ["# 标题"]
streaming: "**核"
再来几个字,变成:
# 标题
**结论**
## 下一节
则:
stable: ["# 标题", "**结论**"]
streaming: "## 下一节"
注意:一段内容不会因为以 # 开头就立刻进 stable。只有后面出现空行、它变成「非最后一段」,并且通过完整性检查后,才会定稿。这和延迟渲染的思路一致——尾巴永远先当未完成处理。
3. 分区渲染
切分完成后,页面上分两层画:
function render(stable, streaming):
// 已定稿:原文直接 parse,块级 key 稳定,DOM 尽量复用
for block in stable:
parseAndMount(block) // 每块只在首次进入 stable 时正式 parse
// 还在流式:先 remend 再 parse,仅刷新这一块
if streaming:
preview = remend(streaming)
parseAndUpdateTail(preview)
要点:
- stable 里的块定稿后不再改写内容,列表 / 虚拟 DOM 用稳定 key,避免整区重挂
- streaming 每次可变;
remend只修展示用的尾巴(例如**核→**核**),不写回 buffer,定稿仍以原文为准
4. 流结束时收尾
流式结束(done / flush)时,若最后的 streaming 已经完整,把它并入 stable 再 parse 一次;若仍不完整,可按产品策略:继续 remend 预览,或等业务侧补全后再定稿。
function flush():
{ stable, streaming } = splitStableAndStreaming(buffer)
if streaming and isComplete(streaming):
stable.push(streaming)
streaming = ""
render(stable, streaming)
小结
整条链路可以总结为下个步骤:
- chunk 只追加进
buffer - 空行切段 → 前段完整进 stable,末段永远是 streaming
- stable 定稿钉死;streaming 先 remend 再局部刷新
- 流结束再把完整尾巴并入 stable
这样既接上了前几章的延迟渲染与 remend,又把长文场景下「整树反复替换」压到最小。
总结
流式 Markdown 闪烁的根因,是成对语法撞上未完成文本,HTML 结构在「不像语法」和「像语法」之间来回切换。
应对上,先用延迟渲染只放出完整前缀,未闭合尾巴留在 buffer;
代码块内再按纯文本处理,避免正文规则误伤。半成品要先画时,用 remend(源码见 streamdown/packages/remend,配套组件 Streamdown)在解析前做责任链补全——奇偶计数、上下文跳过、链接 earlyReturn,以及补全型 / 预防型两类 handler。
消息变长后,再拆成 stable 定稿钉死与 streaming 分区刷新,避免整棵 DOM 反复替换。