从 O(n) 到 O(n):为 AI 时代构建流式 Markdown 渲染器
Source: Dev.to
如果你构建过 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: Parse 100 chars ✓
Chunk 2: Parse 200 chars (100 old + 100 new)
Chunk 3: Parse 300 chars (200 old + 100 new)
...
Chunk 100: Parse 10,000 chars 😰
总工作量: 100 + 200 + 300 + … + 10,000 = 5,050,000 次字符操作。
这就是 O(n²)。成本不仅仅是增长——它会爆炸。
对于 20 KB 的 AI 响应,这意味着:
| 库 | 解析时间 |
|---|---|
| ant‑design‑x | 1,657 ms |
| markstream‑vue | 5,755 ms(几乎 6 秒 的解析时间!) |
这些都是流行且维护良好的库。问题不在于代码质量——而是架构错误。
关键洞察
一旦 markdown 块“完成”,它将永远不会改变。
想想看。当 AI 输出:
# Heading
This is a paragraph.
在第二个空行之后,段落就 完成 了。已锁定。无论接下来出现代码块、列表、更多段落——该段落永远不会再被触及。
那么我们为什么要重新解析它 500 次呢?
Incremark 实际工作原理
我们围绕这一洞见构建了 Incremark。核心算法:
- 检测稳定边界 — 空行、新标题、代码块结束符。
- 缓存已完成的块 — 再也不触碰它们。
- 仅重新解析待处理块 — 正在接收输入的那块。
Chunk 1: Parse 100 chars → cache stable blocks
Chunk 2: Parse only ~100 new chars
Chunk 3: Parse only ~100 new chars
...
Chunk 100: Parse only ~100 new chars
总工作量: 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 个文件) | 6,484 | 128.55 KB | 519.4 ms | 3,190.3 ms | 14,683.9 ms | 3,728.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: "See footnote[^1] for details..." // reference first
Chunk 2: "More content..."
Chunk 3: "[^1]: This is the definition" // definition later
传统解析器假设文档是完整的。我们构建了“乐观引用”,在流式处理期间优雅地处理不完整的链接/图片,然后在定义出现时再进行解析。
我们也完整实现了数学块($…$)和自定义容器(:::tip),因为这些在 AI 生成的内容中很常见。
我们真正的优势
除去脚注文件,看看标准 Markdown 的性能:
| 文件 | 行数 | Incremark | Streamdown | 优势 |
|---|---|---|---|---|
| concepts.md | 91 | 12.0 ms | 50.5 ms | 4.2× |
| comparison.md | 109 | 20.5 ms | 74.0 ms | 3.6× |
| complex‑html‑examples.md | 147 | 9.0 ms | 58.8 ms | 6.6× |
| OPTIMIZATION_SUMMARY.md | 391 | 19.1 ms | 208.4 ms | 10.9× |
| test‑md‑01.md | 916 | 87.7 ms | 1441.1 ms | 16.4× |
模式显而易见:文档越大,我们的优势越大。
对于最大的文件(17.67 KB):
| 库 | 时间 | 相对 |
|---|---|---|
| Incremark | 88 ms | — |
| ant‑design‑x | 1,657 ms | 慢 18.9× |
| markstream‑vue | 5,755 ms | 慢 65.6× |
O(n) 与 O(n²) 实际对比
传统解析器 在每个块上重新解析整个文档:
Chunk 1: Parse 100 chars
Chunk 2: Parse 200 chars (100 old + 100 new)
Chunk 3: Parse 300 chars (200 old + 100 new)
...
Chunk 100: Parse 10,000 chars
总工作量: 100 + 200 + … + 10,000 = 5,050,000 字符操作。
Incremark 只处理新增内容:
Chunk 1: Parse 100 chars → cache stable blocks
Chunk 2: Parse only ~100 new chars
Chunk 3: Parse only ~100 new chars
...
Chunk 100: Parse only ~100 new chars
总工作量: 100 × 100 = 10,000 字符操作。
这是一种 500 倍的差异,且随着文档规模的增大,这一差距会进一步扩大。
何时使用 Incremark
✅ 使用 Incremark 的场景:
- AI 聊天(带流式输出),如 Claude、ChatGPT 等
- 长篇 AI 内容(推理模型、代码生成)
- 实时 Markdown 编辑器
- 需要脚注、数学公式或自定义容器的内容
- 超过 10 万 token 的对话
⚠️ 以下情况请考虑其他方案:
- 一次性静态 Markdown 渲染(直接使用
marked即可) - 极小文件(例如几行文字)
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
}
我们支持 Vue 3、React 18 和 Svelte 5,API 完全一致——同一核心、三大框架,行为零差异。
接下来
版本 0.3.0 只是开始。
AI 领域正朝着更长的输出、更复杂的推理轨迹和更丰富的格式发展。传统解析器跟不上——它们的 O(n²) 架构注定如此。
我们构建 Incremark 是因为我们需要它。希望你也觉得它有用。
- 📚 文档:
- 💻 GitHub:
- 🎮 实时演示:
- Vue:
- React:
- Svelte:
如果这为你节省了调试时间,在 GitHub 上点一个 ⭐️ 将意义重大。有什么问题?打开 issue 或在下方留言。