Incremark 0.3.0 发布:双引擎架构 + 完整插件生态,AI 流式渲染的终极方案
从 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 输出时,底层发生了什么:
▼text复制代码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 输出:
▼markdown复制代码# 标题 这是一个段落。
在第二个空行之后,这个段落就完成了。锁定了。无论后面来什么——代码块、列表、更多段落——这个段落永远不会再被动了。
那我们为什么要重复解析它 500 次?
Incremark 的工作原理
我们围绕这个洞察构建了 Incremark。核心算法:
- 检测稳定边界 — 空行、新标题、代码块结束符
- 缓存已完成的块 — 永不再动
- 只重新解析待处理的块 — 当前正在接收输入的那个
▼text复制代码Chunk 1: 解析 100 字符 → 缓存稳定块 Chunk 2: 只解析 ~100 新字符 Chunk 3: 只解析 ~100 新字符 ... Chunk 100: 只解析 ~100 新字符
总工作量:100 × 100 = 10,000 字符操作。
这是 500 倍的减少。每个字符最多只被解析一次。这就是 O(n)。
完整基准测试数据
测试环境
- 测试文件:38 个文件,共 6,484 行,128.55 KB
- 测试方式:模拟流式输入,逐字符 append
- 测试数据:真实 AI 对话、文档、代码分析报告(非合成数据)
- 对比方案:Streamdown、markstream-vue、ant-design-x
完整测试结果
| 文件名 | 行数 | 大小(KB) | Incremark | Streamdown | markstream | ant-design-x | vs Streamdown | vs markstream | vs ant-design-x |
|---|---|---|---|---|---|---|---|---|---|
| test-footnotes-simple.md | 15 | 0.09 | 0.3 ms | 0.0 ms | 1.4 ms | 0.2 ms | 0.1x | 4.7x | 0.6x |
| simple-paragraphs.md | 16 | 0.41 | 0.9 ms | 0.9 ms | 5.9 ms | 1.0 ms | 1.1x | 6.7x | 1.2x |
| test-footnotes-multiline.md | 21 | 0.18 | 0.6 ms | 0.0 ms | 2.2 ms | 0.4 ms | 0.1x | 3.5x | 0.6x |
| test-footnotes-edge-cases.md | 27 | 0.25 | 0.8 ms | 0.0 ms | 4.2 ms | 1.2 ms | 0.0x | 5.3x | 1.5x |
| test-footnotes-complex.md | 28 | 0.24 | 2.1 ms | 0.0 ms | 4.8 ms | 1.0 ms | 0.0x | 2.3x | 0.5x |
| introduction.md | 34 | 1.57 | 5.6 ms | 12.6 ms | 75.6 ms | 12.8 ms | 2.2x | 13.4x | 2.3x |
| devtools.md | 51 | 0.92 | 1.2 ms | 0.9 ms | 6.1 ms | 1.1 ms | 0.8x | 5.0x | 0.9x |
| footnotes.md | 52 | 0.94 | 1.7 ms | 0.2 ms | 10.6 ms | 1.9 ms | 0.1x | 6.3x | 1.2x |
| html-elements.md | 55 | 1.02 | 1.6 ms | 2.2 ms | 12.6 ms | 2.8 ms | 1.4x | 7.8x | 1.7x |
| themes.md | 58 | 0.96 | 1.9 ms | 1.3 ms | 8.6 ms | 1.8 ms | 0.7x | 4.4x | 0.9x |
| test-footnotes-comprehensive.md | 63 | 0.66 | 5.6 ms | 0.1 ms | 25.8 ms | 7.7 ms | 0.0x | 4.6x | 1.4x |
| auto-scroll.md | 72 | 1.68 | 3.9 ms | 3.5 ms | 39.9 ms | 4.9 ms | 0.9x | 10.1x | 1.2x |
| custom-codeblocks.md | 72 | 1.44 | 3.4 ms | 2.0 ms | 14.9 ms | 2.5 ms | 0.6x | 4.4x | 0.7x |
| custom-components.md | 73 | 1.40 | 4.0 ms | 2.0 ms | 32.7 ms | 2.9 ms | 0.5x | 8.1x | 0.7x |
| custom-containers.md | 88 | 1.67 | 4.2 ms | 2.4 ms | 18.1 ms | 3.1 ms | 0.6x | 4.3x | 0.7x |
| typewriter.md | 88 | 1.89 | 5.6 ms | 4.1 ms | 35.0 ms | 4.9 ms | 0.7x | 6.2x | 0.9x |
| concepts.md | 91 | 4.29 | 12.0 ms | 50.5 ms | 381.9 ms | 53.6 ms | 4.2x | 31.9x | 4.5x |
| INLINE_CODE_UPDATE.md | 94 | 1.66 | 4.7 ms | 17.2 ms | 60.9 ms | 15.6 ms | 3.7x | 12.9x | 3.3x |
| comparison.md | 109 | 5.39 | 20.5 ms | 74.0 ms | 552.2 ms | 85.2 ms | 3.6x | 26.9x | 4.1x |
| basic-usage.md | 130 | 3.04 | 8.5 ms | 12.3 ms | 74.1 ms | 14.1 ms | 1.4x | 8.7x | 1.7x |
| CODE_BACKGROUND_SEPARATION.md | 131 | 2.83 | 8.7 ms | 28.8 ms | 153.6 ms | 31.3 ms | 3.3x | 17.6x | 3.6x |
| P2_SUMMARY.md | 138 | 2.61 | 8.3 ms | 38.4 ms | 157.2 ms | 41.9 ms | 4.6x | 18.9x | 5.0x |
| quick-start.md | 146 | 3.04 | 7.3 ms | 7.3 ms | 64.2 ms | 9.6 ms | 1.0x | 8.8x | 1.3x |
| complex-html-examples.md | 147 | 3.99 | 9.0 ms | 58.8 ms | 279.3 ms | 57.2 ms | 6.6x | 31.1x | 6.4x |
| CODE_COLOR_SEPARATION.md | 162 | 3.51 | 10.0 ms | 32.8 ms | 191.1 ms | 36.9 ms | 3.3x | 19.1x | 3.7x |
| P0_OPTIMIZATION_REPORT.md | 168 | 3.53 | 10.1 ms | 56.2 ms | 228.0 ms | 58.1 ms | 5.6x | 22.6x | 5.8x |
| COLOR_SYSTEM_REFACTOR.md | 169 | 3.78 | 18.5 ms | 64.0 ms | 355.5 ms | 69.1 ms | 3.5x | 19.2x | 3.7x |
| FOOTNOTE_TEST_GUIDE.md | 219 | 2.87 | 12.3 ms | 0.2 ms | 167.6 ms | 45.0 ms | 0.0x | 13.7x | 3.7x |
| P2_COLORS_PACKAGE_REPORT.md | 226 | 4.10 | 11.4 ms | 77.9 ms | 311.6 ms | 80.5 ms | 6.8x | 27.2x | 7.0x |
| FOOTNOTE_FIX_SUMMARY.md | 236 | 3.93 | 22.7 ms | 0.5 ms | 535.0 ms | 120.8 ms | 0.0x | 23.6x | 5.3x |
| BASE_COLORS_SYSTEM.md | 259 | 4.47 | 35.8 ms | 43.0 ms | 191.8 ms | 43.4 ms | 1.2x | 5.4x | 1.2x |
| OPTIMIZATION_COMPARISON.md | 270 | 5.42 | 17.8 ms | 52.3 ms | 366.1 ms | 61.9 ms | 2.9x | 20.6x | 3.5x |
| P1_OPTIMIZATION_REPORT.md | 327 | 5.63 | 20.7 ms | 106.8 ms | 433.8 ms | 114.8 ms | 5.2x | 21.0x | 5.5x |
| OPTIMIZATION_PLAN.md | 371 | 6.89 | 33.1 ms | 67.6 ms | 372.1 ms | 76.7 ms | 2.0x | 11.2x | 2.3x |
| OPTIMIZATION_SUMMARY.md | 391 | 6.24 | 19.1 ms | 208.4 ms | 980.6 ms | 217.8 ms | 10.9x | 51.3x | 11.4x |
| P1.5_COLOR_SYSTEM_REPORT.md | 482 | 9.12 | 22.0 ms | 145.5 ms | 789.8 ms | 168.2 ms | 6.6x | 35.9x | 7.7x |
| BLOCK_TRANSFORMER_ANALYSIS.md | 489 | 9.24 | 75.7 ms | 574.3 ms | 1984.1 ms | 619.9 ms | 7.6x | 26.2x | 8.2x |
| test-md-01.md | 916 | 17.67 | 87.7 ms | 1441.1 ms | 5754.7 ms | 1656.9 ms | 16.4x | 65.6x | 18.9x |
| 【合计】 | 6484 | 128.55 | 519.4 ms | 3190.3 ms | 14683.9 ms | 3728.6 ms | 6.1x | 28.3x | 7.2x |
诚实面对:我们慢的地方
你会注意到数据中有些奇怪的地方。对于 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 完整实现了脚注——而且我们必须解决一个流式场景特有的棘手问题:
在流式场景中,引用通常比定义先到达:
▼text复制代码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 的优势最为明显:
- vs Streamdown:快 16.4 倍
- vs ant-design-x:快 18.9 倍
- vs markstream-vue:快 65.6 倍
为什么差距这么大?
这就是 O(n) vs O(n²) 的实际表现。
传统解析器每次收到新 chunk 都重新解析整个文档:
▼text复制代码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 只处理新内容:
▼text复制代码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 那种丝滑的"打字"效果吗?大多数实现是这样做的:
▼ts复制代码displayText = fullText.slice(0, currentIndex)
这会不断破坏 markdown。你会看到渲染到一半的 **粗体** 标签、闪烁的代码块、看起来像喝醉了的语法。
我们把动画移到了 AST 层。我们的 BlockTransformer 理解结构——它在节点内部做动画,永远不会跨节点。结果:丝滑流畅的打字效果,同时尊重 markdown 语义。
跨框架支持
我们深知前端生态的多样性。Incremark 提供开箱即用的框架适配:
| 框架 | 包名 | 版本要求 |
|---|---|---|
| Vue | @incremark/vue | Vue 3.5+ |
| React | @incremark/react | React 18+ |
| Svelte | @incremark/svelte | Svelte 5+ |
一个核心,三个框架,零行为差异。
所有框架共享:
- 完全一致的 API 设计
- 相同的组件结构和 DOM 输出
- 统一的主题系统(
@incremark/theme) - 相同的性能特性
▼bash复制代码# 选择你的框架 npm install @incremark/vue npm install @incremark/react npm install @incremark/svelte
Vue 示例
▼vue复制代码<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>
React 示例
▼tsx复制代码import { useState } from 'react' import { IncremarkContent } from '@incremark/react' function Chat() { const [content, setContent] = useState('') const [isFinished, setIsFinished] = useState(false) async function handleStream(stream: AsyncIterable<string>) { for await (const chunk of stream) { setContent(prev => prev + chunk) } setIsFinished(true) } return ( <IncremarkContent content={content} isFinished={isFinished} incremarkOptions={{ gfm: true, math: true }} /> ) }
Svelte 示例
▼svelte复制代码<script> import { IncremarkContent } from '@incremark/svelte' let content = $state('') let isFinished = $state(false) async function handleStream(stream) { for await (const chunk of stream) { content += chunk } isFinished = true } </script> <IncremarkContent {content} {isFinished} incremarkOptions={{ gfm: true, math: true }} />
下一步
这是 0.3.0 版本。我们才刚刚开始。
AI 世界正在走向更长的输出、更复杂的推理轨迹、更丰富的格式。传统解析器跟不上——它们的 O(n²) 架构注定如此。
我们开发 Incremark 是因为我们自己需要它。希望你也觉得它有用。
📚 文档:incremark.com 💻 GitHub:kingshuaishuai/incremark 🎮 在线演示:Vue | React | Svelte
如果这篇文章帮你节省了调试时间,去 GitHub 点个 ⭐️ 吧。有问题?开个 issue 或者在下面留言。
