只读审计 origin/staging @ 6b3248bf 后出的四份任务书:
- engine-memoization(BUG-721,纯 Python 可并行):一次重算 45% CPU 在重复
算同一份 Shadbala,过境盘按候选算了 2196 次(应 36),鉴别探针算两遍,
_cached_rows 是死代码。本机等价实验 3358 → 1604 ms,candidate_scores 与
decision_receipt 逐字相同。只做记忆化,不改算法。
- failure-attribution(BUG-722/723/724,独占 route.ts):分类器失败被说成
用户说不清且丢证据;引擎 429 被当成引擎坏、不重试不打日志(复发自
BUG-715);attempt 210s × 2 > maxDuration 240s(复发自 BUG-059)。
- settled-render-split(BUG-725,前端可并行):校正会话流式每帧重渲整条
对话并重跑已结算消息的 Markdown;BUG-473 的咨询面拆分没有跟过来。
- request-dossier-cache(BUG-726,串行在 failure-attribution 之后):一轮
取 3.44 次整份 Case 档案,改成写即失效的请求作用域缓存,零调用点改动。
纯文档推送,不触发门禁、不发布镜像、不部署。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
11 KiB
TASK · 校正会话流式时整条对话每帧重建,已结算消息每帧重跑 Markdown
- 日期:2026-09-15
- 基线 commit:
origin/staging@6b3248bf - 执行分支:
codex/rectification-settled-render-split-20260915 - 独占文件:
frontend/src/components/rectification-agentic-chat.tsx、新建的行组件文件、frontend/src/components/chat-message-row.tsx、frontend/tests/下新增用例 - 与本日其它三单无文件重叠,可并行
- 规模:抽一个行组件 + 把逐消息派生搬进去 + 稳住回调身份。不改任何交互行为、不改任何文案。
1. 事故实证
frontend/src/components/rectification-agentic-chat.tsx:1 973 行,useState 14 个,useCallback 12 个,useMemo 0 个,memo 0 个。
消息列表是直接内联在组件体里的 messages.map((message) => {...})(在 <div className="message-list"> 内),而且 map 体里每条消息都要做非平凡的派生:
{...message}展开出一个新的displayedMessagerectificationTimelineRows({ trace, receipt, activity, settled })—— 每次都新建数组vargaSentenceFromMethods(message.completedReceipt?.methods)choiceCardFromQuestion(question, ...)—— 每次都新建对象- 为带题的消息构造
afterAnswer整棵 JSX(含RectificationChoiceCard)
渲染出的 ChatMessageRow(chat-message-row.tsx)没有 memo,它内部的 ChatMessageContent 对已结算消息走的是 renderProse(spoken, renderMarkdown) 这条没有任何记忆化的分支(StableMarkdownPrefix 的 memo 只覆盖流式那一条)。ChatMessageActions 的 onFeedback / onCopy / onRegenerate 全是内联箭头函数,submitChoice / submitStop / copyMessage / regenerateMessage 也不在那 12 个 useCallback 里,每次渲染都是新身份。
流式输出这一侧是对的:createStreamFrameBuffer 已经把每个 answer.delta 合并成每个动画帧最多提交一次(BUG-473 的成果,该单影响面里就写着本文件)。但每一帧的那一次 setMessages(current => current.map(...)) 会重渲整个列表 —— 于是每秒约 60 次,把整条会话里每一条早已结算的消息连同它的 Markdown 全部重算一遍。
校正会话天生就长:采集 + 探针 + 交付,二三十轮很常见。会话越长越卡,而且卡在最不该卡的时候——正在出结果的那一轮。
2. 根因
BUG-473 修的是「每个网络事件都提交一次」,它在本文件里落地了 stream-frame-buffer。但同一单在咨询面还做了第二件事:chat-transcript.tsx 把「已结算的历史」和「正在流的那一条」拆成两个 memo 组件(SettledMessageList + HistoryMessageEntry,以及 LatestAssistantEntry),历史整体只渲染一次。
校正面只继承了前一半。 BUG-473 的两条防复发(「流式状态必须经 stream-frame-buffer 提交」「流式期间的 Markdown 渲染必须走前缀/尾块切分」)都只约束正在流的那一行,没有一条要求给已结算的行留记忆化边界,所以这半边缺失没有被任何断言拦住;home-streaming-render-split.test.ts 也只覆盖咨询面的那几个组件。
同类前史:BUG-249(Home() 2 723 行、useCallback 与 useMemo 各为 0,每次击键重渲整个组件)。同一个形态,换了个组件。
3. 决策记录
产品 2026-09-15 授权本单,口径:
- 纯性能重构,零行为变化。 交付后校正会话的每一个可见行为——消息顺序、题目嵌入位置、选择卡可点性、跳过提示、交付卡出现时机、重新生成、复制、点赞点踩、停止——必须与改前逐字一致。本单不修任何已知交互缺陷,发现了写进进度记录。
- 不改文案、不改动效、不改间距。 因此本轮不需要更新
frontend/DESIGN.md(AGENTS §7.5 的触发条件是「改 UI」,纯记忆化不算)。若执行中确实动了任何可见样式,则必须同提交更新DESIGN.md。 - 不启用 React Compiler。 BUG-260 的结论是它在本仓对目标组件静默放弃且无法证明生效;本单用显式
memo解决,不碰next.config.ts。 - 不做「把流式文本从
messages里搬出去」的大改。 咨询面走的是streamingText独立 state 的路子,校正面把流式文本写在messages里。改成前者是更彻底的方案,但会动到快照回填、重新生成、题目绑定等一大片逻辑,风险与本单不匹配。逐行memo就够:.map每帧产生新数组不影响 memo,因为 memo 比的是每一行自己的 props,而已结算消息的对象身份在流式期间是稳定的(每帧只有直播那一行的对象被替换)。
4. 硬红线
frontend/src/app/page.tsx一行不许动(1951/2000,AGENTS §6)。- 不得新写第二个聊天输入框、第二套滚动跟随、第二套加载动画(AGENTS §6)。滚动锚点仍是
useConversationScrollAnchor+JumpToLatestButton,本单不得改它们的调用方式。 - 测试总数不得低于开工时
origin/staging的实测;改任何既有断言必须写「原值 / 新值 / 原因」三栏(AGENTS §7.3)。 next build后/仍须○ Static,首屏 JS gzip 变化在 ±2 % 内(上次实测 584 413 B)。- 不得用
React.lazy+Suspense承载 Markdown(BUG-250 的结论:promise 落定后仍需一次重渲,可能露出一帧 fallback)。 - 不得顺手升级依赖、不得顺手修不在本单里的 warning。
5. 任务分解
5.1 抽出逐消息行组件并加 memo
把 messages.map 的整个 body 搬进一个新文件里的 memo 组件(建议 rectification-message-entry.tsx → RectificationMessageEntry),§1 列的那五项派生全部搬进组件内部计算,不再由容器每帧算好再传下去。
组件的 props 只允许:
message(已结算行在流式期间身份稳定)- 标量:
busy、readonly、regenerating、canRegenerate、liveQuestion所依赖的那几个 id 与布尔、choiceNonce、savedTime、copied - 该行自己的
feedback值(不是整张 feedback map) - 一个
actionsRef(见 5.2)
不允许把每帧新建的对象/数组直接当 props 传下去(时间轴行、choice card、afterAnswer JSX 都属此列)。
- 验收:源码合同断言
rectification-agentic-chat.tsx里不再直接调用rectificationTimelineRows/vargaSentenceFromMethods/choiceCardFromQuestion。 - 验收:源码合同断言新组件由
memo(包裹。
5.2 用 actionsRef 稳住回调身份
照搬 chat-transcript.tsx 已经在用的模式:把 submitChoice、submitStop、copyMessage、regenerateMessage、feedback 切换等放进一个 ref 容器,每帧只更新 ref 的 .current,组件内部通过 actionsRef.current.xxx(...) 调用。不要逐个加 useCallback 去追依赖数组——那条路在本文件的 state 规模下一定会漏。
- 验收:源码合同断言新组件的 props 里没有任何函数类型字段(
actionsRef除外)。
5.3 给 ChatMessageRow 加 memo
chat-message-row.tsx 的 ChatMessageRow 目前是裸函数。加 memo 后咨询面同样受益。注意它的 afterAnswer 是 ReactNode prop——由 5.1 保证它在新组件内部构造,从而与该行自身的 props 同源。
- 验收:咨询面既有测试(
home-streaming-render-split.test.ts、chat-markdown-split.test.ts等)全绿,无断言改动。
5.4 结算态 Markdown 也要记忆化
chat-message-content.tsx 里 streaming === false 走的 renderProse(spoken, renderMarkdown) 没有任何缓存。给结算态也套一层按文本内容记忆的组件(StableMarkdownPrefix 已经是这个形状,可直接复用或抽成共用组件)。这一条独立于 5.1,即使行组件的 memo 因为某个 prop 抖动而失效,它也能兜住最贵的那部分。
- 验收:新增断言——同一段文本连续渲染两次,Markdown 解析器只被调用一次(用可计数的桩 renderer)。
5.5 渲染次数回归测试
照 frontend/tests/home-streaming-render-split.test.ts 的手法,为校正面补一份:复用 src/lib/home-streaming-render-probe.ts 的计数器(在新组件里调 noteSettledRowRender()),按帧手工驱动渲染,断言已结算行的渲染次数不随流式帧数增长。
必须照搬那份测试已经做对的两件事:
-
由测试手工驱动渲染次数,不假装
renderToString能反映真实 memo 命中(本仓测试是字符串渲染,数不了重渲染次数——BUG-617 记录里已经写过这条); -
同时跑一份「未拆分」对照,断言拆分后的计数严格小于对照,证明这个取证方法不是恒为真。
-
验收:新测试在改动前跑是红的、改动后是绿的(执行方需在进度记录里贴出这两次运行结果)。
5.6 Bug 历史
同一变更内写进 docs/BUG_HISTORY.md,预占 BUG-725。必须写明:关联 BUG-473(它的影响面包含本文件,但两条防复发都只约束正在流的那一行),以及 BUG-249(同形态前史)。防复发升级为:任何流式会话面都必须为已结算消息保留记忆化边界,并由一条按帧驱动的渲染计数断言钉死;新增会话面必须同时补这条断言。
6. 让步顺序
- 5.4(结算态 Markdown 记忆化)最先做——最小、最独立,单独就能拿走最贵的一块。
- 5.1 + 5.2 是主体,必须一起做(只加
memo不稳住回调身份等于没加)。 - 5.3 可以砍,砍了在进度记录里写明。
- 5.5 不得砍——没有渲染计数断言的性能改动一律视为未通过(这正是本次缺口能存在这么久的原因)。
- 5.6 不得砍。
7. 开工前置命令
git fetch origin --prune
git worktree add -b codex/rectification-settled-render-split-20260915 \
.worktrees/rectification-settled-render-split-20260915 origin/staging
cd .worktrees/rectification-settled-render-split-20260915/frontend
git status -sb | head -1
npm ci
验收命令:
./node_modules/.bin/tsc --noEmit
npm run lint # 0 error
npx tsx --test tests/rectification-*.test.ts tests/home-streaming-render-split.test.ts \
tests/chat-markdown-split.test.ts tests/stream-frame-buffer.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单(无 Docker 时数据库套件照常红)
npm run build # `/` 仍须 ○ Static
8. 真人验收欠账
本仓没有浏览器环境,「长会话流式时是否还卡」只能由产品在真实环境确认。执行方必须在 docs/testing/ 下留一份可照做的条目,至少包含:开一个已有二十轮以上的校正会话 → 发一条会触发长回答的消息 → 观察流式期间页面是否还有明显掉帧、历史消息是否闪烁。
9. BUG 编号起点
基线 6b3248bf 上最大号 BUG-720。本单预占 BUG-725。同日四单并行(721 / 722–724 / 725 / 726),开工时核对实际最大号,冲突顺延并在进度记录写明。
10. 不在本单范围
- 把流式文本从
messages搬进独立 state(§3.4 已否决,风险不匹配) - React Compiler(§3.3)
- 首屏包体积(584 KB gzip 是全站壳的问题,另议)
- 校正面任何交互缺陷(本单零行为变化)