让步骤耗时摘要始终跟随工具卡片:deepseek-harness TUI 计时卡片的定位机制
本文基于 deepseek-harness 仓库中一篇已归档的 bug-fix 决策记录,完整复盘"TUI 每个 step 的耗时摘要(Model wait … · Completed …)在带工具调用的步骤上渲染错位"这一问题的发现、设计决策与源码验证。读完之后,你将理解 deepseek-harness 客户端如何把一个"跟随聊天尾部"的组件做成稳定的渲染不变量:组件所有权与挂载位置分离、用事件顺序保证重定位时机精确,并用快照与单元测试双重固化渲染顺序。
问题:耗时摘要被"钉"在助手消息下方,工具卡片却出现在更后面
deepseek-harness 的 TUI 聊天视图中,每个 step 结束后会显示一行逐步骤的耗时摘要(per-step timing summary),格式形如 Model wait … · Completed …,用来"收束"当前 step。归档记录 2026-07-27-tui-step-timing-trails-tool-cards.md(中文译本)明确描述了修复前的结构缺陷:
- 耗时摘要曾经是助手消息组件(
AssistantMessageComponent)的子节点,因此它永远紧贴在助手文本下方渲染; - 当一个 step 驱动工具调用时,工具卡片(tool cards)是追加在助手消息之后的聊天条目;
- 结果就是:耗时摘要" stranded "(搁浅)在工具卡片上方——它比该 step 真正的最后一条输出一条消息。
用一个 step 的渲染顺序示意(修复前,灰色为错误位置):
助手消息文本
└─ 耗时摘要(Model wait 1.2s · Completed 3.4s) ← 错误:step 还没结束就"收束"了
工具卡片 A(tool/call → tool/result)
工具卡片 B
由于这行摘要的语义是"为一个 step 画上句号",在任何一个带工具调用的 step 上,它都出现在了错误的位置。
设计决策:独立的 StepTimingComponent,随事件流"追赶"聊天尾部
记录中的 Decision 一节给出了最终方案,核心是三点:
- 组件所有权与挂载位置解耦。耗时摘要成为独立的
StepTimingComponent,不再是AssistantMessageComponent的子节点。StreamingAssistantComponent仍然"拥有"这个组件并通过timing字段暴露它,但渲染器把它作为跟随助手消息的兄弟节点挂入聊天流——所有权归流式助手组件,位置由渲染器决定。 - 每次工具卡片追加就重定位。每当 open step 的一次
tool/call或tool/result事件向聊天追加一张卡片时,trailStreamingTiming()就把这行 footer 重新移动到聊天流的最尾部(tail)。因此无论 step 中途追加了多少张卡片、发生多少次重渲染,摘要都"trail"(紧随)在该 step 的最后一条消息之后。 step/end时原地完成并钉住。step 结束时,footer 已经在尾部,此时直接在原地把摘要"完成化"(从运行中状态变为最终状态),并在下一个 step 的输出接续之前保持钉住。另外,removeStreaming与推理内容(reasoning)开关触发的重建,会把 footer 与其流式组件一起摘下、再一起挂回,保证两者生命周期始终绑定。
记录特别强调了事件顺序让这套机制"exact"(精确成立):
Event ordering makes this exact: within a step the loop appends
tool/callandtool/resultbeforestep/end, so the footer is repositioned whilestreamingis still set, then frozen when the step ends.
也就是说,agent 循环在同一 step 内先追加 tool/call、tool/result,最后才发 step/end。重定位发生在 streaming 状态仍然生效的窗口内(footer 可被移动),step/end 之后冻结——不存在"footer 位置与状态机竞争"的空窗期。
修复后同一 step 的顺序变为:
助手消息文本
工具卡片 A(tool/call → tool/result)
工具卡片 B
└─ 耗时摘要(Model wait 1.2s · Completed 3.4s) ← 正确:始终位于 step 最后一条输出之后
源码印证:耗时数据从哪里来,footer 在哪里独立
虽然组件名(StepTimingComponent、trailStreamingTiming)是 TUI 层的实现细节,从源码结构看,ui-chat 客户端包中的计时契约与完成态 footer 实现为这一决策提供了印证。
计时数据的来源。 assistant 节点投影 在收到 step 结束事件时记录 timing 字段,其中 completedTime: event.time 固化了该 step 的完成时刻。StatsLine 则基于这些字段计算模型等待时长:
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
turn-metrics 契约中同样以 timing.completedTime - timing.firstTokenTime 这类差值推导指标。这说明 Model wait … · Completed … 这行摘要不是展示层的随意装饰,而是由 step/start、token 到达、step/end 等事件时间戳精确推导出来的事实行——这也解释了为什么记录把它当作"收束一个 step"的语义组件,而不是一行可随意挪动的注释文本。
完成态 footer 的独立性。 turn-tail.ts 中有一个明确的注释:"Completed-turn footer Definition independent of any Assistant row"(完成态 footer 定义独立于任何 Assistant 行)。从源码结构看,这与 bug-fix 记录中"摘要不再是助手消息的子节点"的决策方向一致:footer 作为聊天节点贡献的一部分,与助手行解耦后,才能自由地跟随 step 的最后一条消息移动。
为什么拒绝了其他三个方案
记录的 "Alternatives considered" 一节给出了三个被否决的替代方案及其理由,这是理解该设计约束的关键:
- 保留摘要在助手消息内部,改为把工具卡片重排到摘要上方。 否决理由:工具卡片天然属于"请求它们的助手文本之后"。把卡片挪到助手消息上方、摘要下方,会扭曲转写顺序(transcript order),让聊天记录失去时间语义。
- 整个 turn 只重算一条尾部 footer,而不是每个 step 一条。 否决理由:多 step 的 turn 需要展示每个 step 各自的已完成耗时;折叠成一条会丢掉逐 step 的分桶数据——而现有的 timing 测试恰好钉死了这些逐 step 分桶。
- 只在
step/end处理器中重定位 footer。 否决理由:工具卡片在step/end之前就已渲染。如果只在 step 结束时移动 footer,那么流式进行过程中(未完成态的 footer)仍会停在工具卡片上方;而且它无法跟踪 step 中途的重渲染。这正是"每次tool/call/tool/result都重定位"这一设计存在的原因。
三个替代方案从"顺序语义""数据保真度""时序覆盖"三个维度把可行空间收窄到当前方案:footer 必须独立于助手消息、必须保持逐 step 粒度、必须在每次卡片追加时而非仅在 step 结束时重定位。
回归保障:快照与单元测试双重固化
记录 Consequences 一节列出了该修复的验收面:
- 包级快照(package snapshots)固化了新的渲染顺序,覆盖:
untrusted-controls、cordis-tools-pending、advanced-cards-*、code-mode-pending、dynamic-workflow-pending、surface-before-compaction。这些快照名对应工具调用挂起、非可信控制、代码模式、动态工作流、压缩前表面等典型带工具卡片场景——即所有"摘要与卡片可能错位"的路径。 - 示例转写(example transcripts)同样钉住新顺序:
todo-plan、bash-terminal-card、code-mode、parallel-file-reads、dynamic-workflow、cordis-dynamic-toolchain、code-mode-dispill(记录原文为code-mode-dispatch-spill)。 - 单元测试断言"已完成的耗时摘要出现在该 step 工具输出之后",并且在修复前的顺序下该测试会失败——即它是一条能真正捕获该回归的断言,而非恒真测试。
仓库顶层 snapshots/ 目录按 web/、session/、acp/、sdk/ 组织了各客户端的快照语料,这类渲染顺序回归正是该目录承担的职责范围。
小结:一个可复用的 TUI 渲染不变量
这篇 bug-fix 记录浓缩了 deepseek-harness TUI 层的一条通用经验:当某个 UI 元素的语义是"跟随某条消息之后"时,正确的做法不是把它写进那条消息的子树,而是把它做成独立组件 + 事件驱动的尾部重定位:
- 所有权(streaming assistant 持有
timing)与挂载位置(聊天流中的兄弟节点)分离; - 以事件流中确定性的顺序(
tool/call/tool/result先于step/end)作为重定位与冻结的时机,避免竞态; - 生命周期操作(
removeStreaming、reasoning 重建)成对处理 footer 与流式组件,不产生悬挂引用; - 用快照覆盖"带工具卡片的 step"各类场景,用单元测试断言顺序且确保它在旧实现上会失败。
如果你想继续深入,可以从 ui-chat 包的节点投影 与 turn-metrics 契约 入手,查看 step 计时事实是如何从事件流一路推导到展示层的。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00