feat: optimize chat think

This commit is contained in:
imccyu
2026-08-04 12:43:58 +08:00
parent a44c8797b6
commit 6f1d443c59
13 changed files with 296 additions and 66 deletions
@@ -1,4 +1,4 @@
# Agent Note: 需显式启用的推理(reasoning)分片浏览器压力测试车道
# Agent Note: 推理分片的逐帧累计发布与浏览器压力验证
Status: implemented
@@ -6,28 +6,38 @@ Status: implemented
## 问题
长推理流引发的浏览器卡死,需要贯穿 fixture(测试前置数据)异步流、客户端会话归并、React 协调过程和实时 Think 行的完整场景才能显现。小型单元测试可以证明事件语义,却无法暴露渲染器饥饿问题;如果把一个包含 100,000 个分片的场景放入必需的 [Web 浏览器测试车道](2026-07-24-web-gui-browser-e2e-lane.md),则每个 PR(Pull Request)都会增加一项缓慢且有意保持失败状态的复现场景。如果生产方按 `requestAnimationFrame` 的节奏发出事件,渲染器还会对生产方施加隐式背压:页面一旦停滞,生产也随之停滞,从而掩盖触发该回归的实际条件,即网络数据仍会持续到达。
长推理流会连续产生大量 `assistant/chunk`。这些原始事件必须逐个完成排序、日志记录和 `PartialAccumulator` 折叠,以保持重放保真度和最终内容完整;但 React 只需要看到当前累计结果,不需要观察同一浏览器帧内的每个中间态。
异步流的每次 `yield` 都可能形成新的微任务边界,因此仅靠微任务合批的 `Notifier.markDirty()` 会退化为每个分片重建一次 `ConversationSnapshot`、通知一次 `useSyncExternalStore` 并运行一次 React render。即使实时 Think 行保持折叠,100,000 个推理分片仍会让协调、提交和布局工作压住主线程。性能边界必须位于会话接收与 React 发布之间,不能通过减慢生产方或丢弃原始事件来掩盖问题。
## 决策
`pnpm run test:web:stress` 是浏览器性能复现场景的显式入口。其专用 `vitest.web-stress.config.ts` 只纳入 `apps/web/stress-tests/**/*.stress.ts`;默认单元测试与 Web 测试配置不会收集该后缀。该命令会先执行构建,因为 Chromium 消费编译生成的客户端产物;压力测试文件会启动宿主脚手架,因此由 `tsconfig.host.json` 纳管。
`Session.acceptLiveEvent()` 立即追加每个原始事件,并同步更新 transcript、`PartialAccumulator` 及其他会话派生状态。可见的 `block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta` 和 `block-end` 分片通过 `Notifier.markFrameDirty()` 发布:第一项变化调度一次 `requestAnimationFrame`,后续分片只继续更新累积器;帧回调从最新状态重建一个累计快照并通知订阅者一次。`usage`、`finish` 及未知的不可见分片保留在事件窗口中,但不触发无效的 React 通知。会话与历史检查共用同一可见分片分类。
推理场景选择确定性的 `?fixture` 会话,并让一个需显式启用的 fixture 计时钩子精确发出 100,000 个相互独立的 `reasoning-delta` 事件。生产方设定的外部到达节奏为每 16 毫秒发出 128 个事件,并在主线程停顿后补发停顿期间本应发出的事件。这样便能近似模拟独立于渲染器而持续到达的字节流,同时无需同步预装载一个巨大的 fixture 队列。一枚结尾标记证明这些事件经过会话归并,并抵达实时 Think 行。
`Notifier` 用调度种类和代际标记管理待发布工作。普通结构事件继续通过 `markDirty()` 在微任务发布;如果定稿消息、工具事件或错误到达时仍有待执行的帧发布,微任务会取代它,旧帧回调因代际不匹配而失效。`notifyNow()` 同样使旧调度失效,以保留受控输入的同步回响。没有 `requestAnimationFrame` 的环境退回微任务合批。定稿事件可以跳过一次尚未显示的中间 partial,但发布的定稿内容和原始事件序列保持完整。
浏览器端启动一个间隔 50 毫秒的心跳,并在启动该流之前调度一个 DOM 事件。最终报告包含已发出的分片数、最大心跳延迟、已调度交互的延迟及心跳样本。两项延迟指标的预算均为 250 毫秒。在回归仍然存在期间,此显式启用命令会打印测量值并以非零状态退出;它由此成为渲染器修复的验收检查,同时不会把已知性能故障设为默认 CI 门禁。`DSH_WEB_STRESS_HEADFUL=1 pnpm run test:web:stress` 会在可见浏览器中展示同一场景。
实时 Think 行对累计文本的横向跟尾属于纯视觉对齐,不需要在每次 React 提交中同步读取布局。组件内调度器将连续请求合并为每三帧一次,从最新 DOM 读取 `scrollWidth` 和 `clientWidth` 并将 `scrollLeft` 直接更新到最新位置;固定的视觉节奏让摘要变化可读,又不会积压浏览器平滑滚动动画。该节流只作用于 Think 的横向摘要,不延迟 Chat 正文滚动、历史 prepend 锚定或用户触发的 `scrollIntoView`。
默认 fixture 单元测试套件使用三个分片和假定时器来演练该计时钩子。这个小型契约固定输入校验、间隔节奏、并发拒绝、精确事件数及结尾标记交付,而不会把包含 100,000 个分片的工作负载带入 `pnpm test`、`pnpm run test:gui` 或 `pnpm run test:web`。
`pnpm run test:web:stress` 保留为无密钥、需显式启用的浏览器性能证据。确定性的 `?fixture` 会话以独立于绘制的节奏发出 100,000 个 `reasoning-delta`,结尾标记证明事件经过生产会话归并并到达实时 Think 行;50 毫秒心跳和预先调度的 DOM 事件分别测量主线程停顿与交互延迟,250 毫秒预算用于识别明显回归。`DSH_WEB_STRESS_HEADFUL=1` 允许开发者在可见浏览器中使用 Performance 面板分析同一场景;Vite Web 壳层和 tsdown 客户端插件 bundle 始终生成 sourcemap,模块宿主在构建树中每个动态 `client.js` 旁托管 `client.js.map`,因此浏览器可把采样映射回 TS/TSX 源码。该压力车道是手动性能诊断与修复验收证据,不是默认 CI 门禁,也不替代确定性的调度单元测试。
聚焦测试固定 `Notifier` 的逐帧合并、结构事件抢占、失效回调和无 rAF 回退,并在 `Session` 层证明一帧只发布一次最新累计文本且定稿不会被旧帧回调重复通知。fixture 的小型单元测试继续固定输入校验、外部到达节奏、并发拒绝、精确事件数和结尾标记交付,无需把 100,000 分片工作负载带入默认测试套件。
## 曾考虑的替代方案
**必需的 Web 浏览器场景。** 不予采纳:该复现场景目前耗时数十秒,且预期无法满足响应性预算;因此,在生产修复交付之前把它设为必需项会阻塞无关工作。
**在 React 内对快照使用 transition、deferred value 或组件节流。** 不予采纳:会话源仍会逐分片通知 `useSyncExternalStore`,React render 在组件决定延后展示之前已经发生,且多个消费同一快照的组件需要重复实现策略。Think 摘要的视觉跟尾节流位于快照发布之后,只减少同步布局频率,不承担数据发布策略。
**按动画帧控制节奏。** 测量后不予采纳:生产节奏一旦与绘制绑定,生产方就会在渲染变慢时同步减速,使观测到的最大延迟保持在预算以内。这测试的是受背压约束的数据源,而不是问题报告中的连续流工作负载。
**在接收或日志层丢弃、抽样或拼接原始分片。** 不予采纳:原始 `assistant/chunk` 是可重放的会话事实,改变它会损失诊断与 UI 保真度,并把展示频率策略混入数据权威层。
**同步入队 100,000 个事件。** 不予采纳:它会让生产方自身阻塞页面,并让 `FxInbox` 装入一个通过 `shift()` 排空的巨大数组,从而把渲染器成本与 fixture 队列机制混为一谈。
**只使用微任务合批。** 不予采纳:连续异步 `yield` 会在相邻分片间排空微任务队列,使一个微任务调度近似退化为一次分片一次通知。
**真实模型或录制的 HTTP 字节流。** 本复现场景不予采纳:实时数据流不具确定性,而 HTTP 层录制会增加 fixture 成本,却无法改进目标断言。内存 fixture 省略 HTTP/SSE(Server-Sent Events)字节分帧,但保留逐个异步会话事件、生产客户端的会话归并过程,以及观察到卡死的 React 渲染路径。
**按动画帧控制测试生产方节奏。** 不予采纳:生产方会在渲染变慢时同步减速,使页面获得真实网络流不存在的隐式背压,并掩盖主线程饥饿。
**真实模型或录制的 HTTP 字节流。** 不予采纳:实时模型不具确定性,HTTP/SSE(Server-Sent Events)录制也不会改进目标断言。内存 fixture 保留逐个异步会话事件、生产客户端归并和 React 渲染路径,同时控制工作负载与到达节奏。
## 后果
开发者现在拥有一条无密钥且可重复执行的命令,它以精确工作负载复现长推理卡死,并输出机器可读的响应性证据。默认测试套件仍然快速且保持通过,但在诊断期间以及接受渲染器修复之前,都必须显式运行该压力测试车道。其计时阈值是浏览器响应性防线,而非吞吐量目标;硬件差异可能改变总时长,但长达数秒的心跳或交互延迟仍明确表示失败。
流式 `ConversationSnapshot` 的发布频率受浏览器绘制频率约束,React 每帧至多处理一个包含全部已接收文本的累计 partial;结构事件仍可更快发布。接收、排序、日志记录、字符串拼接和累积器更新仍按原始分片执行,因此该决策降低的是快照重建与 React 工作,不把原始流解析成本伪装成已解决。
折叠 Think 摘要的横向布局读写最多每三帧执行一次,并直接追上该时刻的最新位置;React 仍按累计快照正常提交,定稿时摘要恢复到首行。该局部视觉策略不会改变正文滚动和用户交互的即时性。
浏览器压力车道继续提供真实组装应用上的响应性信号和可见 profiling 入口,但硬件与调度差异使其只适合作为显式性能证据。确定性的 focused tests 负责守住发布次数、累计内容与抢占顺序,默认测试车道保持快速。