Incremark 0.3.0 发布:双引擎架构 + 完整插件生态,AI 流式渲染的终极方案

171 阅读7分钟

从 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。核心算法:

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

完整数据表:

文件行数大小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 文件)6484128.55 KB519.4 ms3190.3 ms14683.9 ms3728.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: "详见脚注[^1]..."           // 引用先到达
Chunk 2: "更多内容..."
Chunk 3: "[^1]: 这是脚注定义"        // 定义后到达

传统解析器假设你有完整的文档。我们构建了"乐观引用"机制,在流式传输过程中优雅地处理不完整的链接/图片,然后在定义到达时解析它们。

我们选择完整实现脚注、数学公式块($...$)和自定义容器(:::tip),因为这些是真实 AI 内容所需要的。

我们真正的优势

排除脚注文件,看看标准 markdown 的性能:

文件行数IncremarkStreamdown优势
concepts.md9112.0 ms50.5 ms4.2x
comparison.md10920.5 ms74.0 ms3.6x
complex-html-examples.md1479.0 ms58.8 ms6.6x
OPTIMIZATION_SUMMARY.md39119.1 ms208.4 ms10.9x
test-md-01.md91687.7 ms1441.1 ms16.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 3React 18Svelte 5,API 完全一致。一个核心,三个框架,零行为差异。

下一步

这是 0.3.0 版本。我们才刚刚开始。

AI 世界正在走向更长的输出、更复杂的推理轨迹、更丰富的格式。传统解析器跟不上——它们的 O(n²) 架构注定如此。

我们开发 Incremark 是因为我们自己需要它。希望你也觉得它有用。


📚 文档incremark.com 💻 GitHubkingshuaishuai/incremark 🎮 在线演示Vue | React | Svelte

如果这篇文章帮你节省了调试时间,去 GitHub 点个 ⭐️ 吧。有问题?开个 issue 或者在下面留言。