首页
/ 让步骤耗时摘要始终跟随工具卡片:deepseek-harness TUI 计时卡片的定位机制

让步骤耗时摘要始终跟随工具卡片:deepseek-harness TUI 计时卡片的定位机制

2026-09-04 10:33:14作者:蔡丛锟

本文基于 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 一节给出了最终方案,核心是三点:

  1. 组件所有权与挂载位置解耦。耗时摘要成为独立的 StepTimingComponent,不再是 AssistantMessageComponent 的子节点。StreamingAssistantComponent 仍然"拥有"这个组件并通过 timing 字段暴露它,但渲染器把它作为跟随助手消息的兄弟节点挂入聊天流——所有权归流式助手组件,位置由渲染器决定。
  2. 每次工具卡片追加就重定位。每当 open step 的一次 tool/calltool/result 事件向聊天追加一张卡片时,trailStreamingTiming() 就把这行 footer 重新移动到聊天流的最尾部(tail)。因此无论 step 中途追加了多少张卡片、发生多少次重渲染,摘要都"trail"(紧随)在该 step 的最后一条消息之后。
  3. step/end 时原地完成并钉住。step 结束时,footer 已经在尾部,此时直接在原地把摘要"完成化"(从运行中状态变为最终状态),并在下一个 step 的输出接续之前保持钉住。另外,removeStreaming 与推理内容(reasoning)开关触发的重建,会把 footer 与其流式组件一起摘下、再一起挂回,保证两者生命周期始终绑定。

记录特别强调了事件顺序让这套机制"exact"(精确成立):

Event ordering makes this exact: within a step the loop appends tool/call and tool/result before step/end, so the footer is repositioned while streaming is still set, then frozen when the step ends.

也就是说,agent 循环在同一 step 内先追加 tool/calltool/result,最后才发 step/end。重定位发生在 streaming 状态仍然生效的窗口内(footer 可被移动),step/end 之后冻结——不存在"footer 位置与状态机竞争"的空窗期。

修复后同一 step 的顺序变为:

助手消息文本
工具卡片 A(tool/call → tool/result)
工具卡片 B
  └─ 耗时摘要(Model wait 1.2s · Completed 3.4s)   ← 正确:始终位于 step 最后一条输出之后

源码印证:耗时数据从哪里来,footer 在哪里独立

虽然组件名(StepTimingComponenttrailStreamingTiming)是 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" 一节给出了三个被否决的替代方案及其理由,这是理解该设计约束的关键:

  1. 保留摘要在助手消息内部,改为把工具卡片重排到摘要上方。 否决理由:工具卡片天然属于"请求它们的助手文本之后"。把卡片挪到助手消息上方、摘要下方,会扭曲转写顺序(transcript order),让聊天记录失去时间语义。
  2. 整个 turn 只重算一条尾部 footer,而不是每个 step 一条。 否决理由:多 step 的 turn 需要展示每个 step 各自的已完成耗时;折叠成一条会丢掉逐 step 的分桶数据——而现有的 timing 测试恰好钉死了这些逐 step 分桶。
  3. 只在 step/end 处理器中重定位 footer。 否决理由:工具卡片在 step/end 之前就已渲染。如果只在 step 结束时移动 footer,那么流式进行过程中(未完成态的 footer)仍会停在工具卡片上方;而且它无法跟踪 step 中途的重渲染。这正是"每次 tool/call/tool/result 都重定位"这一设计存在的原因。

三个替代方案从"顺序语义""数据保真度""时序覆盖"三个维度把可行空间收窄到当前方案:footer 必须独立于助手消息、必须保持逐 step 粒度、必须在每次卡片追加时而非仅在 step 结束时重定位。

回归保障:快照与单元测试双重固化

记录 Consequences 一节列出了该修复的验收面:

  • 包级快照(package snapshots)固化了新的渲染顺序,覆盖:untrusted-controlscordis-tools-pendingadvanced-cards-*code-mode-pendingdynamic-workflow-pendingsurface-before-compaction。这些快照名对应工具调用挂起、非可信控制、代码模式、动态工作流、压缩前表面等典型带工具卡片场景——即所有"摘要与卡片可能错位"的路径。
  • 示例转写(example transcripts)同样钉住新顺序:todo-planbash-terminal-cardcode-modeparallel-file-readsdynamic-workflowcordis-dynamic-toolchaincode-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 计时事实是如何从事件流一路推导到展示层的。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
527
590
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
904
1.82 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
889
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.52 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.33 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
981
502
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384