# AI Chat 流式 Markdown 渲染调研（2026-06-15）

> 范围：`AiChat.vue` 流式渲染优化。当前 AR-1 修复（commit f58743e）用「流式纯文本短路」防掉帧，副作用是流式过程无格式。本调研找不掉帧且有格式的更好方案。
> 方法：3 路并行调研（机制方案 / 现成库 / Vue3 落地）+ 主代理整合。
> 性质：调研 + 方案，**未实施**。
> 关联：[aichat审查报告-2026-06-14.md](aichat审查报告-2026-06-14.md) AR-1；[近期改动代码审查-2026-06-15.md](../05-代码审查/近期改动代码审查-2026-06-15.md) §亮点「renderMd 流式纯文本短路」。

---

## §1 问题根因

现状（`src/components/AiChat.vue:200,347`）：

```vue
<div v-html="renderMd(text, isLastAi(msg) && store.state.streaming)"></div>
```
```js
function renderMd(text, isStreaming=false){
  if (isStreaming || !mdReady) return escapeHtml(text)  // 流式纯文本短路
  return _purify.sanitize(_marked.parse(text))
}
```

掉帧两因素叠加：
- **(A) 解析成本**：每个 delta 全量 `marked.parse()` O(N)，N 随回复增长，后期单帧解析几十 ms。
- **(B) 重渲染成本**：`v-html` 整段替换触发子树重布局。

当前方案牺牲「流式格式」躲掉 (A)(B)，但 delta 20-80ms 一个，流式时用户看到裸 markdown 源码（`#`、`-`、```` ``` ```` 符号），结束才出格式。

---

## §2 五大机制对比

| 机制 | 消除瓶颈 | 流式有格式 | 成本 | 适用 | 独立够吗 |
|---|---|---|---|---|---|
| 1. rAF 批量节流 | 中（砍频率） | 是 | 低 | 全部 | 否（长文本单次仍重） |
| 2. 块级 diff + memo | **高**（砍到 O(末块)） | 是 | 低-中 | 全部 | **基本够（主流首选）** |
| 3. Web Worker 解析 | 高（主线程归零） | 是 | 中 | 超长文档/重 sanitize | 通常与 2 叠加 |
| 4a. 代码块延迟高亮 | 高（代码块零成本） | 代码块流式无色 | 低 | 有代码块 | 仅代码块，需叠加 |
| 4b. shiki-stream 增量高亮 | 高 | 代码块流式有色 | 中 | 有代码块 | 仅代码块 |
| 5. 虚拟滚动 | 中（砍 DOM 不砍 parse） | 是 | 中 | 长会话消息列表 | 否 |

**机制 2（块级 diff + memo）是业界主流首选**——Vercel AI SDK 官方 cookbook 做法。原理：`marked.lexer()` 按双换行/代码围栏切块，每块独立 memoize，只有「正在生长的末块」重 parse，已完成块缓存跳过。解析成本从 O(全文) 降到 O(末块)。

**大厂可见行为**：ChatGPT / Claude / Gemini 流式时均有格式，代码块流式时等宽 + 基本着色。Chrome 官方文档明确反对「整篇重 parse + innerHTML 替换」（正是当前实现），推荐 streaming-markdown 增量 append。

**rAF 实测数据**（Sitepoint，React 18.3 生产构建 M2）：朴素逐 token setState 平均 commit 18ms（80 token/s 达 52ms 可见卡顿），rAF 批量后 3-5ms。

---

## §3 现成库评估

| 库 | Vue3 兼容 | 流式 | 安全 | 维护 | 结论 |
|---|---|---|---|---|---|
| **markstream-vue** | ✅ 原生组件 | ✅ 双模式（虚拟窗口 + 增量批处理） | ✅ 内置 safe HTML | ✅ 2.2k star / open issue 2 / 近乎日更 / 尤雨溪背书 / 1.0 稳定 | **首选** |
| streamdown (Vercel) | ❌ React 绑定 + 强绑 Tailwind/shadcn | ✅ | ✅ rehype-harden | ✅ | 仅参考（issue #19 求 Vue 版未解决） |
| streaming-markdown | ⚠️ callback 驱动需自己接线 | ✅ token 级 | ❌ 需自配 | 功能不全 | 参考 |
| marked（当前用） | ✅ | ❌ 无官方流式 API | ❌ 需自配 DOMPurify | ✅ 36.9k star | 不直用做流式层 |
| markdown-it | ✅ | ❌ 无原生流式 | - | ✅ | 同上 |
| antfu/shiki-stream | ✅ 组件 | ✅ 增量高亮 | - | ✅ 511 star | 代码块高亮专库 |

**核心结论**：有 Vue3 原生可直接用的流式 markdown 库 **markstream-vue**（尤雨溪推荐），命中全部诉求——流式有格式、不掉帧、TS-first、内置 sanitize、`final` prop 解未闭合 token 卡死。内部已含 marked + Shiki + sanitize 组合，等于替你造好轮子。

**降级路径**：若样式侵入太重，只用其解析内核 `stream-markdown-parser`（框架无关纯函数）+ 现有 DOMPurify 自控渲染。

---

## §4 落地方案（递进）

### 方案 A：rAF 节流渲染（最小改动）
流式时 `requestAnimationFrame` 每帧最多 parse 一次（封顶 60fps），`v-html` 绑响应式 `streamingHtml`。流式结束取消 rAF + 强制最终渲染落 mdCache。

- 性能：频率从「每 delta」降到「每帧」，主线程有空隙让浏览器 paint。
- 观感：流式全程有格式；**瑕疵**：未闭合代码块（末尾 ```）会闪烁。
- 成本：低（~60 行）。
- 风险：超长回答（>20k 字）每帧 parse 全文仍偏重。

```js
const streamingHtml = ref('')
let rafId = null, lastStreamText = ''
function scheduleStreamParse(text){
  if (rafId !== null) return
  rafId = requestAnimationFrame(()=>{
    rafId = null
    if (text === lastStreamText) return
    lastStreamText = text
    streamingHtml.value = _purify.sanitize(_marked.parse(text))
  })
}
watch(()=>store.state.streaming,(s,old)=>{
  if(old && !s){ if(rafId) cancelAnimationFrame(rafId); rafId=null; lastStreamText=''; streamingHtml.value='' }
})
onBeforeUnmount(()=>{ if(rafId) cancelAnimationFrame(rafId) })
```

### 方案 B：方案 A + 代码块流式降级（体验最佳，推荐起步）
在 A 基础上，流式期把 fenced code 块正则抠出降级为纯文本（`<pre class="md-code-streaming">`），其余正常 marked。流式结束一次完整 marked 替换。

- 消掉方案 A 的「未闭合代码块闪烁」瑕疵——AI 回答大量含代码块，高频可见。
- 成本：中（~100 行 + CSS）。
- **推荐起步方案**（A→B 平滑叠加，B 只替换 `doStreamParse` 一个函数，不返工）。

### 方案 C：Web Worker（彻底解耦）
marked+sanitize 移 Worker，主线程零 parse。Worker 内 DOMPurify 需 jsdom shim（或换 `sanitize-html` 免 DOM）。

- 成本：高（~150 行 + worker 文件 + jsdom）。
- 适用：极端长回答/多会话。方案 B 不够再上。

### 方案 D（换库）：直上 markstream-vue
用 `<MarkdownRender :content :final />` 替换整个 renderMd 手写层。

- 成本：中（库接入 + 样式对接）。
- 收益：省自造轮子，拿到虚拟窗口 + 增量高亮 + 未闭合处理全套。
- 风险：样式侵入、新依赖、迁移工作量需评估。

---

## §5 推荐结论

**两条路，按风险偏好二选一**：

| 路线 | 方案 | 收益 | 风险 | 工作量 |
|---|---|---|---|---|
| **保守（自研改造）** | 方案 B（rAF 节流 + 代码块降级） | 流式全程有格式、不掉帧、消代码块闪烁、无新依赖 | 超长回答边界需观察 | 中（~100 行 + CSS） |
| **激进（换库）** | 方案 D（markstream-vue） | 同上 + 虚拟窗口 + 增量高亮 + 长期省维护 | 新依赖、样式侵入、迁移成本 | 中-高 |

**渐进建议**：先上方案 B 跑通（验证 rAF 节流 + 代码块降级在当前 delta 频率下不掉帧）→ 观察长回答边界 → 若需更强能力（虚拟窗口/增量高亮）再评估换 markstream-vue。

**当前 AR-1 临时方案（流式纯文本短路）由方案 D 替换后退役**——纯增量收益，流式全程有格式。

> **决策（2026-06-15）：选方案 D**（换 markstream-vue）。理由：自研块级 memo 与 D 复杂度接近，D 白送 shiki 代码高亮 + 虚拟窗口 + 未闭合 token 修复器，自研只在「坚决不引新依赖」时才划算。落地 todo:ARC-260615-08。
>
> **决策转向（2026-06-15，同日复盘）：方案 D 试装后弃用，改自研块级 memo（方案 B 增强版）**。复盘：markstream-vue@1.0.1 接入 + vue-tsc 通过无技术障碍，但「样式 100% 还原现有 .ai-md」成本高且脆——markstream DOM 异构于 marked 输出（代码块 `.code-block-container` chrome 结构 / 暗色 `--ms-*` 变量体系依赖 `.dark` class 而 DevFlow 用 `[data-theme]` / prose 作用域 `.markstream-vue`），4 处对接且随 markstream 升级易漂移。用户优先级是「保留原样式」>「虚拟窗口/shiki 高级能力」，转自研块级 memo：marked 输出标准 HTML → .ai-md 样式零对接，借鉴 §2 机制 2（块级 memo，业界主流）实现 splitBlocks（代码围栏整体一块/非代码双换行切）+ 前块缓存命中 O(末块) + 末块不缓存处理未闭合 token + rAF 节流。结论：**自研块级 memo = 方案 B 增强（rAF + 真块级 memo，非仅代码块降级），零新依赖、零样式对接、D 级流式性能**。§5 原决策表「自研只在坚决不引新依赖时才划算」判断在「不要高级能力、要原样式」前提下反转——此时自研反而最优。落地：ARC-260615-08 自研版已实施，vue-tsc exit 0，待 dev 运行时验证。

---

## 来源

- [Best practices to render streamed LLM responses — Chrome for Developers](https://developer.chrome.com/docs/ai/render-llm-responses)（streaming-markdown 增量 append、Paint flashing 验证）
- [Markdown Chatbot with Memoization — Vercel AI SDK Cookbook](https://ai-sdk.dev/cookbook/next/markdown-chatbot-with-memoization)（块级 diff + memo 官方实现）
- [Streaming Backends & React: Controlling the Re-render Chaos — Sitepoint](https://www.sitepoint.com/streaming-backends-react-controlling-re-render-chaos/)（rAF 实测数据）
- [antfu/shiki-stream — GitHub](https://github.com/antfu/shiki-stream)
- [markstream-vue — GitHub](https://github.com/Simon-He95/markstream-vue)
- [vercel/streamdown — GitHub](https://github.com/vercel/streamdown)
- [HuggingFace chat-ui: webworker for markdown parsing #1733](https://huggingface.co/spaces/jdelavande/chat-ui-energy/commit/7d6fc19984864d960f2875391858ea067b030dc6)
- [shikijs/shiki Discussion #891](https://github.com/shikijs/shiki/discussions/891)（GrammarState 流式高亮底层）
