- docs/02 架构设计: 新增 aichat审查/异步审批构想/流式渲染调研/generating状态机/密钥迁移健壮性/工作流脚本执行边界/条件表达式引擎/F-07 trait下沉/Agent架构说明/任务推进构想/功能创意池;更新功能决策记录+归档/对抗论证/文档记录规范/经验记录
- docs/03 模块文档: 新增 AI对话引擎/DAG引擎详解;更新 df-knowledge/df-nodes/df-storage/df-workflow/df-ai
- docs/05 代码审查: 新增 全栈审查/全局review/架构审查/近期改动审查/工作区多角度走查/自研memo流式渲染审查
- docs/09 问题排查: 新增 aichat-apikey-401
- docs/INDEX+README 索引同步;docs/todo 待办看板(2026-06-15 汇总)
- PROGRESS.md Sprint 22-25;URGENT.md 加急清单快照(5 项 P0 已全修)
- scripts/cleanup_orphan_tasks.{py,sh} 孤儿任务清理工具
- .gitignore 补 *.broken.bak + tmp/ 噪音排除
9.8 KiB
AI Chat 流式 Markdown 渲染调研(2026-06-15)
范围:
AiChat.vue流式渲染优化。当前 AR-1 修复(commit f58743e)用「流式纯文本短路」防掉帧,副作用是流式过程无格式。本调研找不掉帧且有格式的更好方案。 方法:3 路并行调研(机制方案 / 现成库 / Vue3 落地)+ 主代理整合。 性质:调研 + 方案,未实施。 关联:aichat审查报告-2026-06-14.md AR-1;近期改动代码审查-2026-06-15.md §亮点「renderMd 流式纯文本短路」。
§1 问题根因
现状(src/components/AiChat.vue:200,347):
<div v-html="renderMd(text, isLastAi(msg) && store.state.streaming)"></div>
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 全文仍偏重。
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-containerchrome 结构 / 暗色--ms-*变量体系依赖.darkclass 而 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(streaming-markdown 增量 append、Paint flashing 验证)
- Markdown Chatbot with Memoization — Vercel AI SDK Cookbook(块级 diff + memo 官方实现)
- Streaming Backends & React: Controlling the Re-render Chaos — Sitepoint(rAF 实测数据)
- antfu/shiki-stream — GitHub
- markstream-vue — GitHub
- vercel/streamdown — GitHub
- HuggingFace chat-ui: webworker for markdown parsing #1733
- shikijs/shiki Discussion #891(GrammarState 流式高亮底层)