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),理论上文档越长,优势越大。

资源链接

相关推荐
人工智能训练13 小时前
【极速部署】Ubuntu24.04+CUDA13.0 玩转 VLLM 0.15.0:预编译 Wheel 包 GPU 版安装全攻略
运维·前端·人工智能·python·ai编程·cuda·vllm
会跑的葫芦怪13 小时前
若依Vue 项目多子路径配置
前端·javascript·vue.js
源于花海13 小时前
迁移学习相关的期刊和会议
人工智能·机器学习·迁移学习·期刊会议
DisonTangor15 小时前
DeepSeek-OCR 2: 视觉因果流
人工智能·开源·aigc·ocr·deepseek
薛定谔的猫198215 小时前
二十一、基于 Hugging Face Transformers 实现中文情感分析情感分析
人工智能·自然语言处理·大模型 训练 调优
发哥来了15 小时前
《AI视频生成技术原理剖析及金管道·图生视频的应用实践》
人工智能
数智联AI团队15 小时前
AI搜索引领开源大模型新浪潮,技术创新重塑信息检索未来格局
人工智能·开源
不懒不懒15 小时前
【线性 VS 逻辑回归:一篇讲透两种核心回归模型】
人工智能·机器学习
冰西瓜60016 小时前
从项目入手机器学习——(四)特征工程(简单特征探索)
人工智能·机器学习
Ryan老房16 小时前
未来已来-AI标注工具的下一个10年
人工智能·yolo·目标检测·ai