💬面试官:Markdown 流式解析如何避免标签截断?「直接重新让 marked 全部渲染」行不行?

51 阅读10分钟

前言

Hello~大家好,我是秋天的一阵风

做 AI 对话页,模型边吐字边渲染 Markdown,几乎是标配。面试里这类问题也很常见,对话往往是这样开始的:

面试官: 大模型流式输出 Markdown,前端一般怎么解析、怎么渲染?
候选人:markedmarkdown-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.做法

  1. 维护 buffer,每个 chunk 执行 buffer += chunk
  2. 检查末尾是否存在未闭合语法,例如:
    • 围栏 ``` 出现次数为奇数
    • ** 不成对
    • [ 尚未配对到完整链接
  3. 若存在未闭合尾巴:只把前面的完整部分交给 marked/remark;尾巴仍留在 buffer
  4. 后续 chunk 补齐闭合后,再将该段送入正式解析与渲染
  5. 这样用户看不到「先当普通文字、再突然变成代码块」的跳变。

延迟渲染解决的是「什么时候把哪一段送进解析器」,是后续所有进阶方案的基础。

三、代码块上下文:围栏内部按纯文本处理

仅靠延迟渲染仍不够。对话场景里,围栏代码块往往是闪烁的重灾区。

1. 为什么代码块特别容易出问题

代码内容里经常出现 *_#>。它们在正文里是 Markdown 标记,在代码里只是普通字符。若结束围栏尚未到达,解析器却仍按正文规则扫描块内文本,就会把注释、正则、列表符号等误解析成标题、强调、引用,结构一旦错乱,后续闭合后还要再整段纠正,闪烁更明显。

2. 用状态标记「是否在代码块内」

维护一个状态,例如 inCodeBlock

状态行为
false(正文)按正常 Markdown 规则处理;检测到连续三个反引号 → 置为 true
true(代码块内)后续字符按纯文本输出,不再套用强调/标题/列表等规则;再次遇到三个反引号 → 置为 false

这是词法层面的上下文锁定:进入代码围栏后,暂时关闭正文语法,直到围栏结束。实现成本低,对稳定性帮助大。

四、remend:解析前把未闭合语法补全

延迟渲染选择「先不画」;另一条路是「先画,但先把字符串修到可解析」。

Vercel 在 streamdown 中拆出的 remend,干的就是后者。

源码见:github.com/vercel/stre…

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 textThis is **bold text**
Run `npm install remendRun `npm install remend`
[docs](https://exampl[docs](streamdown:incomplete-link)

2. remend的原理:责任链,而不是一棵 AST

入口 remend(text, options) 做的事很克制:

  1. 按配置筛出启用的 handler
  2. priority 排序(数字越小越先执行)
  3. 依次 result = handler.handle(result)
  4. 个别 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. 流式场景下的调用时序

image.png

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~2520\~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\nbuffer 切成若干段。规则很简单:

  1. 最后一段:一律当作 streaming(还可能继续变长,不能定稿)
  2. 前面的段:若语法看起来完整 → 进入 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)

小结

整条链路可以总结为下个步骤:

  1. chunk 只追加进 buffer
  2. 空行切段 → 前段完整进 stable,末段永远是 streaming
  3. stable 定稿钉死;streaming 先 remend 再局部刷新
  4. 流结束再把完整尾巴并入 stable

这样既接上了前几章的延迟渲染与 remend,又把长文场景下「整树反复替换」压到最小。

总结

流式 Markdown 闪烁的根因,是成对语法撞上未完成文本,HTML 结构在「不像语法」和「像语法」之间来回切换。

应对上,先用延迟渲染只放出完整前缀,未闭合尾巴留在 buffer;

代码块内再按纯文本处理,避免正文规则误伤。半成品要先画时,用 remend(源码见 streamdown/packages/remend,配套组件 Streamdown)在解析前做责任链补全——奇偶计数、上下文跳过、链接 earlyReturn,以及补全型 / 预防型两类 handler。

消息变长后,再拆成 stable 定稿钉死与 streaming 分区刷新,避免整棵 DOM 反复替换。