从 O(n²) 到 O(n):为 AI 时代打造的流式 Markdown 渲染器
如果你开发过 AI 聊天应用,你可能注意到一个令人沮丧的问题:对话越长,渲染越卡。
原因很简单——每次 AI 输出新的 token,传统 markdown 解析器都会从头开始重新解析整个文档。这是一个根本性的架构问题,而且随着 AI 输出越来越长,问题只会越来越严重。
我们开发了 Incremark 来解决这个问题。
2025 年 AI 的残酷现实
如果你一直关注 AI 的发展,你会发现数据变得越来越夸张:
- 2022:GPT-3.5 的回复?几百个字,问题不大
- 2023:GPT-4 把输出提升到 2,000-4,000 字
- 2024-2025:推理模型(o1、DeepSeek R1)输出 10,000+ 字的"思考过程"
我们正在从 4K token 的对话走向 32K,甚至 128K。没人谈论的一个事实是:渲染 500 字和渲染 50,000 字的 Markdown 是完全不同的工程问题。
大多数 markdown 库?它们是为博客文章设计的,不是为会"大声思考"的 AI 设计的。
为什么你的 Markdown 解析器在骗你
当你通过传统解析器流式传输 AI 输出时,底层发生了什么:
Chunk 1: 解析 100 字符 ✓
Chunk 2: 解析 200 字符 (100 旧 + 100 新)
Chunk 3: 解析 300 字符 (200 旧 + 100 新)
...
Chunk 100: 解析 10,000 字符 😰
总工作量:100 + 200 + 300 + ... + 10,000 = 5,050,000 字符操作。
这是 O(n²)。成本不是线性增长——而是爆炸式增长。
对于 20KB 的 AI 回复,这意味着:
- ant-design-x:1,657 ms 解析时间
- markstream-vue:5,755 ms(将近 6 秒的解析!)
而这些都是流行的、维护良好的库。问题不在于代码写得不好——而在于架构选择错误。
核心洞察
关键在这里:
一旦一个 markdown 块"完成",它就永远不会改变。
想想看。当 AI 输出:
# 标题
这是一个段落。
在第二个空行之后,这个段落就完成了。锁定了。无论后面来什么——代码块、列表、更多段落——这个段落永远不会再被动了。
那我们为什么要重复解析它 500 次?
Incremark 的工作原理
我们围绕这个洞察构建了 Incremark。核心算法:
- 检测稳定边界 — 空行、新标题、代码块结束符
- 缓存已完成的块 — 永不再动
- 只重新解析待处理的块 — 当前正在接收输入的那个
Chunk 1: 解析 100 字符 → 缓存稳定块
Chunk 2: 只解析 ~100 新字符
Chunk 3: 只解析 ~100 新字符
...
Chunk 100: 只解析 ~100 新字符
总工作量:100 × 100 = 10,000 字符操作。
这是 500 倍的减少。每个字符最多只被解析一次。这就是 O(n)。
完整基准测试数据
我们对 38 个真实 markdown 文件进行了基准测试——AI 对话、文档、代码分析报告。不是合成测试数据。总计:6,484 行,128.55 KB。
完整数据表:
| 文件 | 行数 | 大小 | Incremark | Streamdown | markstream-vue | ant-design-x |
|---|---|---|---|---|---|---|
| test-footnotes-simple.md | 15 | 0.09 KB | 0.3 ms | 0.0 ms | 1.4 ms | 0.2 ms |
| simple-paragraphs.md | 16 | 0.41 KB | 0.9 ms | 0.9 ms | 5.9 ms | 1.0 ms |
| introduction.md | 34 | 1.57 KB | 5.6 ms | 12.6 ms | 75.6 ms | 12.8 ms |
| footnotes.md | 52 | 0.94 KB | 1.7 ms | 0.2 ms | 10.6 ms | 1.9 ms |
| concepts.md | 91 | 4.29 KB | 12.0 ms | 50.5 ms | 381.9 ms | 53.6 ms |
| comparison.md | 109 | 5.39 KB | 20.5 ms | 74.0 ms | 552.2 ms | 85.2 ms |
| complex-html-examples.md | 147 | 3.99 KB | 9.0 ms | 58.8 ms | 279.3 ms | 57.2 ms |
| FOOTNOTE_FIX_SUMMARY.md | 236 | 3.93 KB | 22.7 ms | 0.5 ms | 535.0 ms | 120.8 ms |
| OPTIMIZATION_SUMMARY.md | 391 | 6.24 KB | 19.1 ms | 208.4 ms | 980.6 ms | 217.8 ms |
| BLOCK_TRANSFORMER_ANALYSIS.md | 489 | 9.24 KB | 75.7 ms | 574.3 ms | 1984.1 ms | 619.9 ms |
| test-md-01.md | 916 | 17.67 KB | 87.7 ms | 1441.1 ms | 5754.7 ms | 1656.9 ms |
| 总计 (38 文件) | 6484 | 128.55 KB | 519.4 ms | 3190.3 ms | 14683.9 ms | 3728.6 ms |
诚实面对:我们慢的地方
你会注意到数据中有些奇怪的地方。对于 footnotes.md 和 FOOTNOTE_FIX_SUMMARY.md,Streamdown 看起来快得多:
| 文件 | Incremark | Streamdown | 原因 |
|---|---|---|---|
| footnotes.md | 1.7 ms | 0.2 ms | Streamdown 不支持脚注 |
| FOOTNOTE_FIX_SUMMARY.md | 22.7 ms | 0.5 ms | 同上——它直接跳过了 |
这不是性能问题——这是功能差异。
当 Streamdown 遇到 [^1] 脚注语法时,它直接忽略。Incremark 完整实现了脚注——而且我们必须解决一个流式场景特有的棘手问题:
在流式场景中,引用通常比定义先到达:
Chunk 1: "详见脚注[^1]..." // 引用先到达
Chunk 2: "更多内容..."
Chunk 3: "[^1]: 这是脚注定义" // 定义后到达
传统解析器假设你有完整的文档。我们构建了"乐观引用"机制,在流式传输过程中优雅地处理不完整的链接/图片,然后在定义到达时解析它们。
我们选择完整实现脚注、数学公式块($...$)和自定义容器(:::tip),因为这些是真实 AI 内容所需要的。
我们真正的优势
排除脚注文件,看看标准 markdown 的性能:
| 文件 | 行数 | Incremark | Streamdown | 优势 |
|---|---|---|---|---|
| concepts.md | 91 | 12.0 ms | 50.5 ms | 4.2x |
| comparison.md | 109 | 20.5 ms | 74.0 ms | 3.6x |
| complex-html-examples.md | 147 | 9.0 ms | 58.8 ms | 6.6x |
| OPTIMIZATION_SUMMARY.md | 391 | 19.1 ms | 208.4 ms | 10.9x |
| test-md-01.md | 916 | 87.7 ms | 1441.1 ms | 16.4x |
规律很明显:文档越大,我们的优势越大。
对于最大的文件(17.67 KB):
- Incremark:88 ms
- ant-design-x:1,657 ms(慢 18.9 倍)
- markstream-vue:5,755 ms(慢 65.6 倍)
为什么差距这么大?
这就是 O(n) vs O(n²) 的实际表现。
传统解析器每次收到新 chunk 都重新解析整个文档:
Chunk 1: 解析 100 字符
Chunk 2: 解析 200 字符 (100 旧 + 100 新)
Chunk 3: 解析 300 字符 (200 旧 + 100 新)
...
Chunk 100: 解析 10,000 字符
总工作量:100 + 200 + ... + 10,000 = 5,050,000 字符操作。
Incremark 只处理新内容:
Chunk 1: 解析 100 字符 → 缓存稳定块
Chunk 2: 只解析 ~100 新字符
Chunk 3: 只解析 ~100 新字符
...
Chunk 100: 只解析 ~100 新字符
总工作量:100 × 100 = 10,000 字符操作。
这是 500 倍的差距。而且随着文档增长,差距只会更大。
什么时候用 Incremark
✅ 适合使用 Incremark 的场景:
- AI 聊天流式输出(Claude、ChatGPT 等)
- 长篇 AI 内容(推理模型、代码生成)
- 实时 markdown 编辑器
- 需要脚注、数学公式或自定义容器的内容
- 100K+ token 的对话
⚠️ 考虑使用其他方案的场景:
- 一次性静态 markdown 渲染(直接用 marked 就行)
- 非常小的文件(<500 字符)——开销不值得
双引擎,一个目标
Marked 还是 Micromark? 两者各有取舍。
Marked 极快但缺少高级功能。Micromark 规范完美但更重。
我们的答案:两个都支持。
| 引擎 | 速度 | 最佳场景 |
|---|---|---|
| Marked(默认) | ⚡⚡⚡⚡⚡ | 实时流式、AI 对话 |
| Micromark | ⚡⚡⚡ | 复杂文档、严格 CommonMark |
我们用自定义 tokenizer 扩展了 Marked,支持脚注、数学公式和容器。如果遇到 Marked 无法处理的边界情况,只需一个配置就能切换到 Micromark。
两个引擎产生完全相同的 mdast 输出。你的渲染代码不关心底层用的是哪个引擎。
没人谈论的打字机问题
你知道 ChatGPT 那种丝滑的"打字"效果吗?大多数实现是这样做的:
displayText = fullText.slice(0, currentIndex)
这会不断破坏 markdown。你会看到渲染到一半的 **粗体** 标签、闪烁的代码块、看起来像喝醉了的语法。
我们把动画移到了 AST 层。我们的 BlockTransformer 理解结构——它在节点内部做动画,永远不会跨节点。结果:丝滑流畅的打字效果,同时尊重 markdown 语义。
动手试试
npm install @incremark/vue # 或 react、svelte
<script setup>
import { ref } from 'vue'
import { IncremarkContent } from '@incremark/vue'
const content = ref('')
const isFinished = ref(false)
async function handleStream(stream) {
for await (const chunk of stream) {
content.value += chunk
}
isFinished.value = true
}
</script>
<template>
<IncremarkContent
:content="content"
:is-finished="isFinished"
:incremark-options="{ gfm: true, math: true }"
/>
</template>
我们支持 Vue 3、React 18 和 Svelte 5,API 完全一致。一个核心,三个框架,零行为差异。
下一步
这是 0.3.0 版本。我们才刚刚开始。
AI 世界正在走向更长的输出、更复杂的推理轨迹、更丰富的格式。传统解析器跟不上——它们的 O(n²) 架构注定如此。
我们开发 Incremark 是因为我们自己需要它。希望你也觉得它有用。
📚 文档:incremark.com 💻 GitHub:kingshuaishuai/incremark 🎮 在线演示:Vue | React | Svelte
如果这篇文章帮你节省了调试时间,去 GitHub 点个 ⭐️ 吧。有问题?开个 issue 或者在下面留言。