# DSH 会话事件数据流瓶颈分析与处理报告 | 项 | 内容 | |---|---| | 对象 | `@deepseek-ai/dsh` 0.1.0-rc.6 | | 处理 | `dsh-pref-kit` v0.2.0:`llm/stream` chunk 合并(常规)+ 发射限频(可选)+ 实验性视图项 | ## 摘要 模型流式输出的每个 token 生成一个 `assistant/chunk` 事件,同时进入宿主日志内存、JSONL 磁盘与 浏览器会话状态,并逐条推送给每个客户端;事件量随 token 线性放大,多 agent 并发时各会话事件 汇入同一条连接,浏览器事件速率 = Σ token 速率。本工作在事件源头(`llm/stream` 瀑布)合并同类 delta、并可进一步限频发射节奏(离线基准实测事件削减 11–56%:短流高、复杂长会话受 「消息内突发 + 消息间空闲」节奏限制,见[验证](#验证)),不改渲染、不改事件字段、 不改顺序语义与线上协议。 另附轨迹区页面性能阻塞的定位与「插件层能做什么」见 [§4](#4-轨迹区专项)。 ## 问题定位 - 产生:`dsh-agent-loop` `step()` 对流中每个 chunk 执行 `session.append("assistant/chunk", …)`,每 token 一个事件; - 驻留:`Session.log` 追加常驻;`dsh-session-persistence-jsonl` 逐条落盘;客户端 `Session.events` 窗口常驻并保留投影副本; - 推送:`dsh-host-apiproxy` `events.mux` 对每个事件、每个已连接客户端逐条推送,无过滤无批量; - 客户端:会话事件进入 `dsh-client-runtime` 的组装器后按帧合批 flush;流式期间**每个动画帧** 对所有已注册视图(含轨迹 builder)做全量快照重建,轨迹页再叠加全量布局重推与约 10 趟 O(n) 辅助索引——每帧 O(n) 是轨迹区卡顿的根源(详见 §4)。 ## 处理内容 1. Host 插件(`src/host/plugin.ts`,类形态 `PrefKitPlugin extends TypertRemoteService`): **数据流策略与 0.1.0 一致**,在 `llm/stream` 瀑布包装模型流: - 合并同 index 同类的 `text-delta` / `reasoning-delta`; - `tool-call-delta`、`usage`、`finish` 等立即透传,先冲刷挂起文本,保持顺序; - 窗口默认 30ms,0–200ms;唤醒回调可重新武装(修复流首 chunk 为 block-start 时 永久挂起的死锁),窗口 0 严格透传。 2. 配置(Settings → Plugins 卡片): - Host 侧注册 settings 命名空间 `dsh-pref-kit` 作持久层;组合行 config 是 base 层, UI 保存写入用户设置层并热生效,跨重启保留,无需重启; - 配置读写经 Remote `prefKit/get|set|setExperimental`:本包 `exports["./typert"]` 工件由 typert-loader 自动注册进 typert 注册表,客户端卡片走标准 RPC 通道(与内置 Remote 同一网关); - 卡片与内置插件卡片(PluginCard/ValueField)同构:折叠框架、暂存编辑 + 保存/放弃、 统一设计令牌与 locale 字典;分「常规」与「实验」两组,实验项默认关闭并附指引。 3. 实验项(默认关,可能改变页面呈现或组合面貌,不改变接口与协议): - `experimental.replaceTrajectoryView`:以精简轨迹视图接管 `trajectory` 视图 (需先禁用官方 `ui-trajectory` 行;遵守视图环 id 接管、inspect 三件套与 `data-conversation-composer-overlay` 壁纸契约); - `experimental.chatContainment`:向聊天区消息注入 `content-visibility` 渲染提示; - `experimental.rowManager`:运行时按白名单禁用/启用官方行(`Entry.update` + 组合文件持久化,重启后保持;客户端侧需刷新)。 ## 兼容性 合并/限频产物仍是合法 `StreamChunk`:`BlockAssembler` 按 index 累加文本、`session-stats` 取首个 token 时间与 `usage`、客户端 `updateChunk` 按 index 拼接,结果与不合并时一致;线上帧形状不变, 仅帧数与节奏变化。基准内置**逐块协议等价断言**(每配置每 block 拼接文本 == 输入原文、非文本块 顺序一致、关闭合并 == 基线逐项相等)。 ## 4. 轨迹区专项 ### 阻塞定位(部署代码证据) - 流式 chunk 的发布策略为 `animation-frame`(`dsh-client-ui-trajectory` L347-352),经 `Notifier.markFrameDirty`(`dsh-client-runtime` L5674)合批:**只要每秒 ≥1 个 chunk, 每 16.7ms 必然发生一次全视图 flush**,与事件率无关。 - flush 遍历所有已注册视图:`TrajectorySnapshotBuilder.apply()` 即使只收到文本增量, 也会全量重建 `finalized`/`eventLocations`/`callSchemas` 并两次排序(O(n log n)); **即使停在聊天页,该 builder 每帧在后台重建**(隐性税)。 - 快照身份每帧变化 → `TrajectoryView` 整树重渲染:`deriveTrajectoryLayout` 全量重排、 `requestNumbers`/折叠 id/`virtualIndexByRecordId` 等约 10 趟 O(n) 辅助索引每帧重算。 - 行渲染本身已官方虚拟化(`VIRTUALIZATION_THRESHOLD=100`,L3157),卡顿不在 DOM 行数。 ### 插件层能做什么(本插件已实现) - **窗口合并**把每帧 flush 的「有增量帧」频率从 60/s 压到 ≤33/s(30ms 窗口), 每帧 O(n) 重算的总 CPU 同比例下降;窗口调到 100ms → ≤10/s。 - **发射限频**给轨迹区 flush 帧率一个与模型出速无关的硬上界(maxHz/budgetHz), 多 agent 并发下是乘法级收益;代价是发射节奏放慢、缓冲积压。 - 实验性**替换轨迹视图**:不注册 `conversationViews` target、从结构身份稳定的 `chat.legacy` 读数据、流式文本由尾部组件单独订阅——把轨迹专属的每帧 O(n) 税清零。 ### 插件层做不到的(需官方包) - `TrajectorySnapshotBuilder.snapshot()` 增量化、`deriveTrajectoryLayout` 结构签名缓存、 assembler 按订阅惰性 flush、辅助索引结构化——全部封在官方包内部,无拦截点; - `events.mux` 批量化属协议变更,按「不破坏接口与协议」红线明确排除。 ## 验证 ### 离线基准 uv 隔离环境中对生产管线的忠实镜像做基线对比(合成 DeepSeek 风格流、固定种子、虚拟时钟、 两级管线 + 优先级定时器,不连接 DSH 进程;脚本见 `benchmark/benchmark.py`)。 **LARGE-SESSION 场景由自包含的复杂长会话生成器产生**(`generate_large_session`: ≈1.9 万事件 / ≈1.2 万 chunk / ≈1500 工具调用 / 36 轮 / reasoning 为主;参数即 `LARGE_*` 常量,固定种子——任何人在任何机器上可逐位复现,产物落盘 `benchmark/generated-session.jsonl` 供检查复验): | 场景 | 事件数(基线 → 合并 30ms) | 线上+日志字节 | 首块延迟 | |---|---|---|---| | 短回复(600 token + 推理 + 2 工具) | 810 → 441(−45.6%) | 0.2MB → 0.1MB | 16 → 40ms | | 长回复(8000 token + 推理 + 4 工具) | 9674 → 4501(−53.5%) | 2.2MB → 1.1MB | 20 → 28ms | | **复杂长会话(生成器)** | 12210 → 10869(−11.0%) | 3.0MB → 2.7MB | 30 → 60ms | | 6 个并发 agent(各 5000 token) | 35592 → 15788(−55.6%) | 8.1MB → 3.8MB | 18 → 50ms | 窗口扫描(LONG 稠密流):0/30/100ms → 9674/4501/3585 事件;窗口扫描(LARGE-SESSION): 0/15/30/60/100ms → 12210/11358/10869/10497/10462 事件、首块 30/45/60/81/81ms。 **长会话的诚实结论**:生成器按长 agentic 会话的两档节奏建模——消息内突发(6–30ms)会被 窗口合并,但消息间的长空闲(0.5s–60s)使大部分相邻 chunk 间隔远超窗口、各自直通; 30ms 窗口只合并突发段(−11%)。真实运行环境里合并器的最大收益发生在「第一次启用」。 - 协议等价断言全部通过(逐块拼接文本 == 输入原文、非文本块顺序一致、关闭合并 == 基线)。 ### 实验性策略消融(E1/E2 轨迹视图替换) 数据流基准无法直接测量客户端渲染成本,因此另建**客户端每帧成本模型**(同脚本, `client_frame_ablation`):镜像部署 flush 循环(`dsh-client-runtime` 的 `markFrameDirty` 每动画帧合批、`dsh-client-ui-trajectory` 的官方 builder 每帧 全量快照重建 + `deriveTrajectoryLayout` + 约 8 趟 O(n) 辅助索引;每帧工作单位 = 2×n·log₂n + 8×n,节点数 n = block-start 结构节点;pref-kit 替代视图不注册 conversationViews target,每帧常量)。帧脏判定由合并后输出的到达节奏驱动。 **权重是模型参数,绝对单位仅示意,结论看比值**: | 场景 | 结构节点 | 脏帧数 | 官方轨迹视图(累计工作) | pref-kit 替代(累计工作) | 比值 | |---|---|---|---|---|---| | 短回复 | 4 | 300 | 14.4k | 60 | ×239 | | 长回复 | 6 | 3279 | 236.0k | 656 | ×360 | | 复杂长会话 | 2672 | 5631 | 208.2M | 1.1k | ×184.9k | | 6 并发 agent | 30 | 2930 | 1.4M | 586 | ×2.4k | 结论:轨迹视图替换的收益随**结构节点数**与**流式时长**放大——短流 ×239、长流 ×360、 并发 ×2.4k、复杂长会话 ×184.9k(该场景节点数 2672 且脏帧最多,是官方每帧 O(n) 税最重的形态);替代视图把每帧成本压到常量级,且完全不注册视图 target、 不改协议。 **E3(content-visibility 渲染提示)与 E4(运行时行管理)无法离线量化**: E3 作用于浏览器绘制管线(跳过视口外布局/绘制),E4 的收益取决于被禁行自身的 每事件开销——两者都需要真实浏览器/真实运行环境测量:DevTools → Performance, 开启/关闭开关各录一段长会话流式输出,对比长任务(>50ms)数量与脚本/渲染耗时; E4 另可在插件清单页确认被禁行的 fiberPhase 已卸载。 ![fig1:合并器离线基准](figures/fig1-coalescer-baseline.png) ### 完整性校验 - `scripts/verify-session-log.js`:校验日志中 `tool/call` 参数与 `assistant/message` 内容块完整,统计最近一轮 chunk 事件数; - UI:Settings → Plugins 的 `dsh-pref-kit` 卡片分常规/实验两组(热生效、跨重启保留), 窗口调 0 或关开关可关闭合并做对比; - Remote:`POST /api/prefKit/get|set|setConfig|stats` 与内置 Remote 同一网关通道 (typert 严格清单校验参数与返回值;`set` 保留 0.1.0 导线兼容); - 日志:启动时输出 `[dsh-pref-kit] llm/stream coalescer active, window = …ms (rateLimit=…)`。 ## 取舍 - 首个 token 延迟增加 ≤ 窗口大小(默认 ≤30ms);adaptive 开启后稀疏流首块延迟大幅下降; - 限频把「发射节奏」换成「帧预算上限」:模型出速长期超过预算时缓冲积压、总时长拉长; 流结束强制直通积压,内容与顺序无损; - 组合行 config 是设置 base;用户设置写入用户设置文档,跨重启保留; - 实验项(轨迹替换 / 聊天渲染提示)可能改变页面呈现,默认关闭,随上游升级可能需跟版。