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

前言

如果你开发过 AI 聊天应用,一定遇到过这个问题:随着对话越来越长,页面越来越卡。

原因很简单:每次 AI 输出新的 token,你都要把整个 markdown 字符串重新解析一遍

假设 AI 输出了 10,000 个字符,分 500 次推送,传统方案的解析工作量是:

复制代码
1 + 2 + 3 + ... + 10000 ≈ 50,000,000 字符

这就是 O(n²) 的灾难。

而 Incremark 的方案是:

复制代码
10000 字符(每个字符最多解析一次)

这就是 O(n) 的优雅。

今天,我将从架构设计到实现细节,完整解析 Incremark 是如何做到这一点的。

目录

  1. 问题分析:传统方案为什么慢?
  2. 核心思路:增量解析
  3. 边界检测:识别稳定边界
  4. 双引擎架构:速度与兼容的平衡
  5. 打字机效果:AST 层的动画方案
  6. 实战对比:真实场景基准测试

一、问题分析:传统方案为什么慢?

1.1 流式场景的特殊性

AI 流式输出的特点是:内容是增量的,但大部分解析器只支持全量解析

ts 复制代码
// 传统方案:每次都全量解析
async function handleStream(stream) {
  let content = ''
  for await (const chunk of stream) {
    content += chunk
    const html = marked.parse(content) // 每次都从头解析
    render(html)
  }
}

这段代码的问题在于:即使只新增了 10 个字符,marked.parse 也要扫描整个 content

1.2 解析复杂度分析

假设最终文档长度为 N,分 K 次推送(每次推送 N/K 个字符):

方案 单次解析量 总解析量
全量解析 递增:N/K, 2N/K, ..., N O(NK) ≈ O(N²)
增量解析 恒定:约 N/K O(N)

当 N = 10,000,K = 500 时:

  • 全量解析:约 5000 万字符
  • 增量解析:约 1 万字符

500 倍的差距,这就是为什么长文档会卡的原因。


二、核心思路:增量解析

2.1 关键洞察

Markdown 文档可以分为独立的"块"(Block):标题、段落、代码块、列表等。

核心洞察:一旦一个块"完成"了,后续的输入不会再影响它。

例如:

markdown 复制代码
# 标题

这是一个段落。

```新的内容...

当我们看到第二个空行时,就知道"这是一个段落"已经完成了。后续无论输入什么,这个段落都不会变化。

2.2 Block 生命周期

Incremark 将每个块标记为两种状态:

状态 说明 处理策略
Pending 正在接收中,可能变化 每次只重新解析这一块
Completed 已确认完成 永不重新解析
ts 复制代码
interface Block {
  id: string
  node: RootContent
  status: 'pending' | 'completed'
}

这个不变量保证了:每个字符最多只被解析一次


三、边界检测:识别稳定边界

3.1 什么是稳定边界?

稳定边界是指:当我们看到这个位置时,可以确定之前的内容已经"完成"了。

常见的稳定边界:

类型 示例 说明
空行 \n\n 段落结束
新标题 \n# 前一个块结束
围栏结束 ``````````` 代码块结束
缩进回退 列表项缩进减少 列表结束

3.2 上下文状态机

为了准确识别边界,解析器维护了一个状态机:

ts 复制代码
interface BlockContext {
  inFencedCode: boolean     // 是否在代码块中
  fenceChar: string         // 围栏字符(` 或 ~)
  fenceLength: number       // 围栏长度
  inContainer: boolean      // 是否在容器中
  containerDepth: number    // 容器嵌套深度
  listStack: ListInfo[]     // 列表嵌套栈
  blockquoteDepth: number   // 引用嵌套深度
}

3.3 边界检测算法

ts 复制代码
findStableBoundary(): { line: number, contextAtLine: BlockContext } {
  // 从后往前扫描
  for (let i = this.lines.length - 1; i >= this.pendingStartLine; i--) {
    const line = this.lines[i]
    const context = this.contextAtLine[i]
    
    // 在围栏块内部,继续等待
    if (context.inFencedCode) continue
    
    // 在容器内部,继续等待
    if (context.inContainer) continue
    
    // 空行可能是边界
    if (line.trim() === '') {
      // 检查下一行是否是新块的开始
      if (this.isNewBlockStart(i + 1)) {
        return { line: i, contextAtLine: context }
      }
    }
  }
  
  return { line: -1, contextAtLine: this.currentContext }
}

四、双引擎架构:速度与兼容的平衡

4.1 为什么需要两个引擎?

在实际开发中,我们面临两个矛盾的需求:

  1. 极致性能:AI 流式场景需要毫秒级响应
  2. 完整功能:需要支持脚注、数学公式、自定义容器等

单一引擎很难同时满足这两点。

4.2 Marked 引擎:极速派

marked 是一个轻量级的 Markdown 解析器,以速度著称。但原生 marked 不支持很多高级语法。

我们为 marked 开发了一套自研扩展:

ts 复制代码
// packages/core/src/parser/ast/MarkedAstBuildter.ts
class MarkedAstBuilder implements IAstBuilder {
  private extensions = [
    createFootnoteDefinitionExtension(),  // 脚注
    createBlockMathExtension(),            // 块级数学
    createInlineMathExtension(),           // 内联数学
    createContainerExtension(),            // 自定义容器
    createInlineHtmlExtension()            // 内联 HTML
  ]
}

这些扩展让 marked 具备了完整的功能,同时保持了极速的性能。

4.3 Micromark 引擎:稳定派

micromark 是 CommonMark 的官方参考实现,以严格和稳定著称。

它的优势在于:

  • 100% CommonMark 兼容
  • 丰富的插件生态(micromark-extension-*)
  • 与 mdast 深度集成

4.4 统一的 AST 输出

无论使用哪个引擎,Incremark 都产出标准的 mdast 格式:

ts 复制代码
interface Root {
  type: 'root'
  children: RootContent[]
}

这意味着:

  • 渲染层完全复用
  • 相同的输入产生相同的输出
  • 切换引擎只需更改初始化代码

4.5 Tree-shaking 优化

为了保证打包体积,引擎选择是在初始化时而非运行时进行:

ts 复制代码
// 默认只打包 marked(极速模式)
import { createIncremarkParser } from '@incremark/core'
const parser = createIncremarkParser({ gfm: true })

// 如需 micromark,单独导入(不影响默认 bundle)
import { MicromarkAstBuilder } from '@incremark/core/engines/micromark'
const parser = createIncremarkParser({
  astBuilder: MicromarkAstBuilder,
  gfm: true
})

这样打包工具可以 tree-shake 未使用的引擎,保持最小 bundle 体积。


五、打字机效果:AST 层的动画方案

5.1 传统方案的问题

很多打字机效果是在字符串层面实现的:

ts 复制代码
// 字符串切片方案
const displayText = fullText.slice(0, currentIndex)

这种方案的问题:

  • 不知道当前字符属于什么结构(标题?代码?)
  • 高频更新时容易导致结构闪烁
  • 无法精确控制 Markdown 语法标记的动画

5.2 Incremark 的 AST 层方案

我们在 AST 层面实现打字机效果:

ts 复制代码
// BlockTransformer 追踪每个文本节点的播放进度
interface TextChunk {
  text: string      // 新增的文本
  isNew: boolean    // 是否是新增的
  effect: 'none' | 'fade-in' | 'typing'
}

这样做的优势:

  • 知道每个字符的语义上下文
  • 可以精确控制动画粒度
  • 永远不会在语法结构中间"断开"

5.3 配置示例

vue 复制代码
<IncremarkContent 
  :content="content" 
  :is-finished="isFinished"
  :incremark-options="{
    typewriter: {
      enabled: true,
      charsPerTick: [1, 3],      // 随机 1-3 字符
      tickInterval: 30,          // 30ms 一次
      effect: 'fade-in',         // 淡入效果
      cursor: '|'                // 光标样式
    }
  }"
/>

六、实战对比:真实场景基准测试

6.1 测试方法

我们使用 38 个真实的 Markdown 文档进行测试:

  • 来自项目文档、博客、AI 对话记录
  • 总计 6,484 行,128.55 KB
  • 模拟 AI 流式输出(每次 1-20 字符随机)

6.2 测试结果

对比方案 平均优势
vs Streamdown 6.1 倍
vs ant-design-x 7.2 倍
vs markstream-vue 28.3 倍

6.3 随文档大小扩展

文件 行数 大小 Incremark ant-design-x 优势
introduction.md 34 1.57 KB 5.6 ms 12.8 ms 2.3x
comparison.md 109 5.39 KB 20.5 ms 85.2 ms 4.1x
BLOCK_TRANSFORMER.md 489 9.24 KB 75.7 ms 619.9 ms 8.2x
test-md-01.md 916 17.67 KB 87.7 ms 1656.9 ms 18.9x 🚀

规律明显:文档越长,优势越大。


七、总结

Incremark 的核心优化思路:

  1. 增量解析:只解析新增内容,已完成的块永不重新解析
  2. 边界检测:用状态机精确识别稳定边界
  3. 双引擎:marked 追求极速,micromark 追求稳定
  4. AST 动画:在语法树层面实现打字机效果

这些优化将复杂度从 O(n²) 降到 O(n),理论上文档越长,优势越大。

资源链接

相关推荐
不惑_1 天前
通俗理解经典CNN架构:LeNet
人工智能·神经网络·cnn
Rabbit_QL1 天前
【Token分析】从单轮到多轮:Ark / OpenAI 风格大模型 API 的上下文管理与 Token 成本分析
人工智能
木头程序员1 天前
AI驱动的时序索引与查询优化:从存储检索到认知检索的跨越
人工智能·深度学习·时序数据库
Tfly__1 天前
Ubuntu20.04安装Genesis(最新)
linux·人工智能·pytorch·ubuntu·github·无人机·强化学习
云飞云共享云桌面1 天前
昆山精密机械工厂研发部门10个SolidWorks如何共享一台服务器来进行设计办公
运维·服务器·网络·人工智能·电脑
FL16238631291 天前
七十四种不同鸟类图像分类数据集3995张74类别已划分好训练验证测试集
人工智能·分类·数据挖掘
程序员猫哥_1 天前
记录我用Vibecoding一句话搭建SaaS后台的体验
人工智能
Mintopia1 天前
🌍 AI 自主决策:从文字到图像与声音的三元赋能之路
人工智能·算法·aigc
小王毕业啦1 天前
2024年-全国地级市之间地理距离矩阵数据
大数据·人工智能·数据挖掘·数据分析·社科数据·实证数据·地理距离矩阵