从 O(n) 到 O(n):为 AI 时代构建流式 Markdown 渲染器

发布: (2026年1月8日 GMT+8 11:32)
8 min read
原文: Dev.to

Source: Dev.to

如果你构建过 AI 聊天应用,你可能已经注意到一个令人沮丧的现象:对话越长,渲染速度就越慢

原因很简单——每当 AI 输出一个新 token 时,传统的 markdown 解析器会从头重新解析整个文档。这是一个根本性的架构问题,且随着 AI 输出的长度增加,这个问题只会变得更糟。

我们创建了 Incremark 来解决这个问题。


2025 年 AI 的不舒服真相

如果你一直在关注 AI 趋势,你会发现数字已经疯狂增长:

年份典型输出
2022GPT‑3.5 的回复?几百字,没什么大不了的
2023GPT‑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‑x1,657 ms
markstream‑vue5,755 ms(几乎 6 秒 的解析时间!)

这些都是流行且维护良好的库。问题不在于代码质量——而是架构错误。

关键洞察

一旦 markdown 块“完成”,它将永远不会改变。

想想看。当 AI 输出:

# Heading

This is a paragraph.

在第二个空行之后,段落就 完成 了。已锁定。无论接下来出现代码块、列表、更多段落——该段落永远不会再被触及。

那么我们为什么要重新解析它 500 次呢?

Incremark 实际工作原理

我们围绕这一洞见构建了 Incremark。核心算法:

  1. 检测稳定边界 — 空行、新标题、代码块结束符。
  2. 缓存已完成的块 — 再也不触碰它们。
  3. 仅重新解析待处理块 — 正在接收输入的那块。
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

文件行数大小IncremarkStreamdownmarkstream‑vueant‑design‑x
test‑footnotes‑simple.md150.09 KB0.3 ms0.0 ms1.4 ms0.2 ms
simple‑paragraphs.md160.41 KB0.9 ms0.9 ms5.9 ms1.0 ms
introduction.md341.57 KB5.6 ms12.6 ms75.6 ms12.8 ms
footnotes.md520.94 KB1.7 ms0.2 ms10.6 ms1.9 ms
concepts.md914.29 KB12.0 ms50.5 ms381.9 ms53.6 ms
comparison.md1095.39 KB20.5 ms74.0 ms552.2 ms85.2 ms
complex‑html‑examples.md1473.99 KB9.0 ms58.8 ms279.3 ms57.2 ms
FOOTNOTE_FIX_SUMMARY.md2363.93 KB22.7 ms0.5 ms535.0 ms120.8 ms
OPTIMIZATION_SUMMARY.md3916.24 KB19.1 ms208.4 ms980.6 ms217.8 ms
BLOCK_TRANSFORMER_ANALYSIS.md4899.24 KB75.7 ms574.3 ms1984.1 ms619.9 ms
test‑md‑01.md91617.67 KB87.7 ms1441.1 ms5754.7 ms1656.9 ms
总计(38 个文件)6,484128.55 KB519.4 ms3,190.3 ms14,683.9 ms3,728.6 ms

坦诚相待:我们更慢的地方

您会注意到一件奇怪的事:对于 footnotes.mdFOOTNOTE_FIX_SUMMARY.md,Streamdown 看起来快得多。

文件IncremarkStreamdown为什么
footnotes.md1.7 ms0.2 msStreamdown 不支持脚注
FOOTNOTE_FIX_SUMMARY.md22.7 ms0.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 的性能:

文件行数IncremarkStreamdown优势
concepts.md9112.0 ms50.5 ms4.2×
comparison.md10920.5 ms74.0 ms3.6×
complex‑html‑examples.md1479.0 ms58.8 ms6.6×
OPTIMIZATION_SUMMARY.md39119.1 ms208.4 ms10.9×
test‑md‑01.md91687.7 ms1441.1 ms16.4×

模式显而易见:文档越大,我们的优势越大。

对于最大的文件(17.67 KB):

时间相对
Incremark88 ms
ant‑design‑x1,657 ms慢 18.9×
markstream‑vue5,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 3React 18Svelte 5,API 完全一致——同一核心、三大框架,行为零差异。

接下来

版本 0.3.0 只是开始。

AI 领域正朝着更长的输出、更复杂的推理轨迹和更丰富的格式发展。传统解析器跟不上——它们的 O(n²) 架构注定如此。

我们构建 Incremark 是因为我们需要它。希望你也觉得它有用。

  • 📚 文档:
  • 💻 GitHub:
  • 🎮 实时演示:
    • Vue:
    • React:
    • Svelte:

如果这为你节省了调试时间,在 GitHub 上点一个 ⭐️ 将意义重大。有什么问题?打开 issue 或在下方留言。

Back to Blog

相关文章

阅读更多 »