• thzxx released this 2026-07-21 14:39:28 +08:00 | 4 commits to master since this release

    概述

    本次版本针对 AI 流式输出长内容时应用整体卡死 的性能问题,实施 12 项性能优化(F1-F12),覆盖渲染层、滚动布局、状态与 IPC 批处理、代码清理四个层面。流式 delta 处理频率从 30-50 次/秒降至 ~20 次/秒,长内容输出时不再卡顿。

    核心问题根因

    流式输出长内容卡死的根因链:

    1. 历史消息全量重渲染:每个 text_delta 触发 store 更新,导致所有 AssistantMessage 重新执行(即使内容未变)
    2. ReactMarkdown 全量重解析 O(n²):每个 delta 触发 ReactMarkdown + remark-gfm + rehype-highlight + rehype-raw 全量重新解析,内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死
    3. 滚动节流错误:原 setTimeout(80) debounce + 'smooth' 行为,高频 delta 下 trailing 永不触发,'smooth' 动画占用主线程
    4. IPC 无节流:主进程 1:1 转发所有 text_delta,跨进程开销叠加渲染进程处理成本
    5. 布局重计算扩散:markdown 增量影响整页 reflow

    修复清单(12 项)

    阶段一:渲染层优化(紧急)

    F1: React.memo 跳过历史消息重渲染

    • 文件src/components/chat/MessageItem.tsxsrc/components/chat/AssistantMessage.tsx
    • 方案:用 React.memo 包裹 MessageItem / AssistantMessage
    • agentStatus selector 优化:历史消息(isStreaming=false)固定返回 'idle',避免 agentStatus 频繁变化(thinking/executing/idle)触发所有 AssistantMessage 重渲染
    • 效果:流式 delta 时仅最后一条 message 引用变化,历史消息跳过 re-render

    F3: 流式时用纯文本渲染

    • 文件src/components/chat/AssistantMessage.tsx
    • 方案:流式时用 <Box component="pre"> 纯文本渲染,流结束后自动切换到 ReactMarkdown 一次性渲染
    • 根因:每个 delta 触发 ReactMarkdown 全量重解析 + rehypeHighlight 高亮,内容长度增加时单次解析耗时 O(n) 退化,30+ delta/秒下呈 O(n²) 卡死
    • 效果:流式时绕过 markdown 解析,流结束后一次性 O(n) 渲染(可接受)

    F7: useMemo 缓存 ReactMarkdown 元素

    • 文件src/components/chat/AssistantMessage.tsx
    • 方案:用 useMemo 缓存 ReactMarkdown 元素,依赖 [content]
    • 效果:非流式时 content 不变,useMemo 复用缓存,避免重新创建 ReactMarkdown 组件实例

    阶段二:滚动与布局(结构性)

    F2: 滚动节流 rAF + 100ms throttle + trailing 兜底

    • 文件src/components/chat/MessageList.tsx
    • 问题:原实现用 setTimeout(80) debounce + 'smooth',高频 delta 时 trailing 永不触发,'smooth' 在长列表上触发主线程布局动画
    • 方案
      • 流式时:100ms 节流 + trailing 兜底 + 'auto' 行为(同步布局,无动画占主线程)
      • 非流式时:立即 'smooth' 滚动(新消息发送/接收完成)
      • 用 rAF 同步到下一帧,与 React 渲染合并,避免一帧内多次布局

    F4: content-visibility 准虚拟滚动

    • 文件src/components/chat/MessageList.tsx
    • 方案:CSS content-visibility: auto + contain-intrinsic-size: auto 300px
    • 效果:浏览器原生支持,不可见区域跳过布局和绘制(DOM 节点保留但渲染开销 O(1))。配合 F1 memo + F3 流式纯文本,历史消息开销降至最低
    • 选择理由:相比 react-virtuoso 真正虚拟滚动,content-visibility 零依赖零风险,不与 F2 滚动逻辑冲突,ContextMenu 等绝对定位组件不受影响

    F10: CSS containment 隔离布局计算

    • 文件src/styles/globals.css
    • 方案.prose-metona { contain: layout style; }
    • 效果:隔离 markdown 渲染区域的布局计算,避免增量内容触发整页 reflow 扩散

    F12: 滚动容器 GPU 加速

    • 文件src/components/chat/MessageList.tsx
    • 方案:滚动容器加 transform: translateZ(0)
    • 效果:将滚动容器提升为合成层,滚动时由合成器线程处理,避免主线程重绘叠加卡顿
    • 风险评估:已确认 chat 目录内无 position: fixed 元素;ContextMenu 用 MUI Portal 渲染到 document.body,不受 transform 影响

    阶段三:状态与 IPC 批处理(增强)

    F5: 渲染进程 text_delta rAF 批处理

    • 文件src/hooks/useAgentStream.ts
    • 问题:每个 text_delta 直接调用 updateLastAssistantMessage + updateLastTraceStep,频率 30-50 次/秒,每次触发 store 更新 + React re-render
    • 方案:累积 delta 到缓冲区,用 rAF 每帧 commit 一次,合并多次 store 写入
    • 会话切换保护:缓冲时记录 sessionId,flush 时若 store.currentSessionId 不一致则丢弃(避免跨会话污染)
    • 新迭代卡片创建逻辑:立即处理,不缓冲;且会先 flush 旧缓冲区避免跨消息污染
    • 退出路径:done/error/组件卸载三处都立即 flush,避免最后一段 delta 丢失

    F8: 主进程 text_delta 32ms 节流合并

    • 文件electron/ipc/handlers.ts
    • 问题:主进程 1:1 转发所有流式事件,text_delta 频率 30-50 次/秒,每次 IPC 调用都有跨进程开销
    • 方案:在主进程聚合 text_delta,32ms 间隔合并转发(IPC 频率降至 ~30 次/秒)
    • 事件顺序保证:非 text_delta 事件立即转发前先 flush 缓冲区,保证事件顺序
    • 退出路径:finally 块在注销监听器前 flush 缓冲区
    • 叠加效果:与 F5 渲染进程 rAF 批处理叠加,总延迟约 48ms(人眼不敏感)

    F9: reasoning_delta traceSteps thought 走 rAF 批处理

    • 文件src/hooks/useAgentStream.ts
    • 问题:reasoning_delta 每个 delta 调用 updateLastTraceStep,频率 10-30 次/秒,触发 TraceViewer 等订阅者 re-render
    • 方案:累积 reasoning delta 到缓冲区,rAF 每帧 commit 一次
    • 保留即时更新:message.reasoningContent 保持即时更新(ThoughtBlock 需实时显示思考内容);新迭代卡片创建时的 thought 更新保持即时

    阶段四:代码清理

    F11: 移除 rehypeRaw

    • 文件src/components/chat/AssistantMessage.tsx
    • 方案:移除 import rehypeRaw from 'rehype-raw'rehypePlugins 中的引用
    • 效果
      • 节省 raw HTML 解析开销
      • 安全考虑:防止 AI 输出 HTML 注入
    • 回滚方式:如需恢复内联 HTML 渲染,可重新引入 rehype-raw(依赖仍在 package.json 中保留)

    跳过的项

    F6: store 拆分(跳过)

    • 原方案:将 agent-store 拆分为 chat-store + trace-store
    • 跳过理由
      • 26 个文件引用 agent-store,重构风险高
      • Zustand selector 是细粒度订阅,F1-F5 已解决主要瓶颈
      • 拆分会丢失跨 store 原子性

    修改文件清单

    文件 修改项
    src/components/chat/MessageItem.tsx F1(memo 包裹)
    src/components/chat/AssistantMessage.tsx F1+F3+F7+F11(memo + 流式纯文本 + useMemo + 移除 rehypeRaw)
    src/components/chat/MessageList.tsx F2+F4+F12(滚动节流 + content-visibility + GPU 加速)
    src/hooks/useAgentStream.ts F5+F9(text_delta + reasoning_delta rAF 批处理)
    electron/ipc/handlers.ts F8(主进程 text_delta 32ms 节流)
    src/styles/globals.css F10(CSS contain)
    package.json 版本号 0.3.10 → 0.3.11
    package-lock.json 版本号 0.3.10 → 0.3.11
    README.md 版本徽章 0.3.10 → 0.3.11

    验证

    • TypeScript 类型检查tsc --noEmit 退出码 0,全部修改通过
    • 人工审查
      • F5/F9 渲染进程 rAF 批处理:done/error/组件卸载三个退出路径都正确 cancelAnimationFrame + flush
      • F8 主进程 32ms 节流:finally 块在注销监听器前 flush 缓冲区
      • 新迭代卡片创建前会先 flush 旧缓冲区,避免跨消息污染
      • 全局搜索确认无 rehypeRaw 残留代码引用

    性能瓶颈根因与修复对照

    根因 修复项 效果
    历史消息全量重渲染(P0-1, P0-3) F1 React.memo 跳过
    流式 markdown 全量重解析 O(n²)(P0-2) F3 + F7 流式纯文本 + 非流式 useMemo 缓存
    滚动节流错误(P1-1) F2 rAF + 100ms throttle + auto behavior
    缺失虚拟滚动(P0-1) F4 content-visibility 准虚拟滚动
    IPC + store 写入无节流(P1-3) F5 + F8 渲染进程 rAF + 主进程 32ms 节流
    traceSteps 高频更新(P1-2) F9 rAF 批处理
    布局重计算扩散(P2-5) F10 CSS containment
    rehypeRaw 开销(P3-2) F11 移除
    滚动主线程重绘(P2-6) F12 GPU 合成层加速

    升级须知

    • 用户:直接拉取最新版本即可,无破坏性变更,无配置迁移
    • 开发者
      • 流式渲染行为变更:流式时显示纯文本(白底黑字等宽字体),流结束后切换为 markdown 渲染。如需调整样式,修改 AssistantMessage.tsxprose-streaming
      • 如需恢复内联 HTML 渲染(不推荐),可重新引入 rehype-raw(依赖仍在 package.json)
      • F4 content-visibility 在 Chromium 85+ 支持,Electron 35 满足
    Downloads