故事的開始
故事的開始是我想要為我的 tiptap 編輯器增加 AI 流式內容輸出的功能,但 AI 輸出的是 markdown,我需要將其解析為 prosemirror JSONContent,但我又想盡可能節省性能,每次已經穩定的內容避免重複解析,正在生成的塊不斷進行更新,因此有了 incremark 這個小工具。
問題分析
傳統的 Markdown 解析器(marked、remark 等)設計用於處理完整文檔。但在流式輸出場景中,每次收到新內容都需要重新解析全部文本:
收到 chunk 1: 解析 "# Hello"
收到 chunk 2: 解析 "# Hello\n\nWorld"
收到 chunk 3: 解析 "# Hello\n\nWorld ..."
...
這導致了 O(n²) 的時間複雜度。文檔越長,問題越嚴重。
解決方案
Incremark 的核心思想是增量解析:
- 識別已完成的 Markdown 塊(標題、段落、代碼塊等)
- 將已完成的塊"鎖定",不再重新解析
- 只解析新增內容和未完成的塊
這樣將複雜度從 O(n²) 降到 O(n)。
性能測試
為了驗證效果,我寫了一個 Benchmark,模擬不同長度文檔的流式輸入:
| 文檔大小 | 加速比 | 適用場景 |
|---|---|---|
| ~1KB | 2-3x | 短回覆 |
| ~5KB | 9-11x | 中等回覆 |
| ~10KB | 17-23x | 長回覆 |
| ~20KB | 37-46x | 超長回覆 |
説明:
- 加速比與 chunk 大小有關,chunk 越小(如 10 字符),加速比越高
- 真實 AI 輸出通常是小 chunk(幾個到幾十個字符),接近測試中的高加速比場景
- 短文檔場景下加速比不明顯,這是正常的(O(n²) 在 n 小時與 O(n) 差距不大)
測試環境:Node.js 20+,可以通過 pnpm benchmark 自行驗證。
技術挑戰
增量解析的核心難點是塊邊界檢測:如何判斷一個 Markdown 塊已經完成?
比如代碼塊:
```javascript
function hello() {
這時候還不能認為代碼塊完成,因為沒有閉合的 \`\`\`。
Incremark 針對不同塊類型實現了邊界檢測邏輯:
- 代碼塊:檢測配對的 \`\`\` 或 ~~~
- 列表:檢測縮進變化和列表模式中斷
- 引用:檢測 > 前綴的連續性
- 表格:檢測表格行模式
- 段落:遇到空行或塊級元素時完成
使用方式
提供 Vue 和 React 官方集成:
# Vue
pnpm add @incremark/core @incremark/vue
# React
pnpm add @incremark/core @incremark/react
<script setup>
import { useIncremark, Incremark } from '@incremark/vue'
const { blocks, append, finalize } = useIncremark()
async function handleStream(stream) {
for await (const chunk of stream) {
append(chunk)
}
finalize()
}
</script>
<template>
<Incremark :blocks="blocks" />
</template>
侷限性
坦誠地説,Incremark 也有一些侷限:
- 額外的邊界檢測開銷:增量解析需要做塊邊界檢測,這本身有一定開銷。對於極短的文檔(幾百字符),可能收益不明顯。
- 複雜嵌套結構:某些複雜的嵌套場景(如列表中的多層引用)邊界檢測可能不夠精確,會導致部分塊被重新解析。
- 不是萬能的:如果你的場景是一次性渲染完整文檔,傳統解析器可能更簡單直接。
適用場景
- ✅ AI 聊天應用的流式輸出
- ✅ 實時 Markdown 預覽
- ✅ 流式文檔生成
- ⚠️ 短文檔場景收益有限
- ❌ 一次性渲染完整文檔(直接用 marked/remark 即可)
相關鏈接
- 文檔:incremark-docs.vercel.app
- Vue Demo:incremark-vue.vercel.app
- React Demo:incremark-react.vercel.app
- GitHub:github.com/kingshuaishuai/incremark
如果你也在開發 AI 相關應用並遇到類似問題,歡迎試用。有問題或建議可以提 Issue。