DeerFlow 子代理卡片实时元数据:在折叠卡片上展示有效模型与累计 Token 用量的完整设计方案
本文基于 DeerFlow 仓库中的规划文档 plans/subagent-card-runtime-metadata.md 展开,解读"在折叠状态的子代理(subagent)卡片上实时展示有效 LLM 名称与累计 Token 用量"这一特性背后的架构决策、三阶段实施计划与验收标准,并结合仓库源码(SubagentTokenCollector、status_contract、step_events、task_tool 与前端 subtask-result.ts)说明每个设计点是如何落地、如何保持向后兼容的。读完后,你将理解:为什么运行期元数据必须按累计快照而非增量下发、为什么以 task_id 为键、以及终端状态持久化如何做到"不新增数据库迁移即可回放"。
背景:折叠卡片上的两个实时信号
DeerFlow 的 Lead Agent 可以通过 task 工具把子任务委派给子代理并行执行。用户在前端工作区看到每张子代理卡片时可以将其折叠——此时卡片不再展示完整过程,但仍需要回答两个问题:
- 这个子代理正在用哪个配置的 LLM 执行?(模型身份,Model Identity)
- 它目前消耗了多少 Token?(累计用量,Cumulative Token Usage)
规划文档的来源需求是:Conversation request approved on 2026-07-10 — show live token usage and the effective LLM name on collapsed subagent cards。围绕这一需求,文档给出了完整的架构决策集与分阶段(Phase 1/2/3)的建设内容和验收标准。
架构决策:八条不变量
原文档 "Architectural decisions" 一节是整个方案的灵魂,逐条对应了仓库中的具体实现约束:
- 运行时身份(Runtime identity):所有元数据更新都按键控于既有的子代理
task_id,因此同一个 Lead Agent 回合内的并行委派彼此隔离。在 frontend/src/core/tasks/subtask-result.ts 中可以看到前端以SUBAGENT_MODEL_NAME_KEY = "subagent_model_name"与SUBAGENT_TOKEN_USAGE_KEY = "subagent_token_usage"为键从结构化元数据中读取,正是这套键控协议的前端镜像。 - 用量模式(Usage schema):运行期负载携带累计的
input_tokens、output_tokens、total_tokens。它们是快照(snapshot)而不是增量(delta),因此重放的或乱序到达的流帧不会造成重复计数。这正是前端把快照"作为权威累计值合并进任务状态"这一设计的前提。 - 更新节奏(Update cadence):"实时"指的是每次子代理 LLM 响应完成后。大多数 Provider 在响应完成前不会暴露权威用量,因此不存在响应中部的用量帧。
- 模型身份(Model identity):线上契约(wire contract)携带的是为子代理解析出的有效 DeerFlow 模型名。UI 优先展示配置中的显示名(display name),取不到时回退到原始模型名;Provider 的部署标识只作为观测数据,不作为卡片主标签。
- 实时与持久来源:自定义任务生命周期事件(custom task lifecycle events)驱动运行中更新;终态 ToolMessage 元数据从 checkpoint 化的聊天历史中恢复相同取值;持久化的
subagent.end事件保留终态快照供审计/调试消费者使用。 - 兼容性(Compatibility):所有协议新增字段都是可选的。旧 run 渲染时没有运行元数据;缺失的 provider 用量渲染为"不可用"而不是 0;既有 JSON 负载是增量式扩展,不需要数据库迁移。
- 既有总额不动(Existing totals):父 run 与线程(thread)的 Token 记账保持不变;卡片元数据只是一个展示投影(presentation projection),绝不能把用量第二次上报给
RunJournal。 - 特性开关(Feature gate):Token 渲染遵循既有的
token_usage.enabled配置;即使 Token 渲染被禁用,模型身份仍然可以展示。
Phase 1:实时模型身份
用户故事:用户可以折叠一个正在运行的子代理卡片,并立即看到是哪个配置的 LLM 在执行它。
建设内容
规划要求:把有效的子代理模型名通过 task-start 生命周期事件传递出去,并按 task_id 合并进任务状态;在工作区解析友好模型显示名,在折叠卡片头部渲染,且不挤占既有状态指示器。
源码印证
在 backend/packages/harness/deerflow/tools/builtins/task_tool.py 中可以看到落地路径:工具入口先通过 resolve_subagent_model_name(config, parent_model, app_config=...)(定义于 backend/packages/harness/deerflow/subagents/config.py)解析出 effective_model,随后在发出的 task_started 自定义事件 chunk 中携带 "model_name": effective_model。执行器一侧同样持有该名字——backend/packages/harness/deerflow/subagents/executor.py 中 SubagentExecutor 初始化时 self.model_name = resolve_subagent_model_name(config, parent_model, app_config=app_config),并用它构建实际 create_chat_model(name=self.model_name, ...)。也就是说,"卡片上展示的名字"与"真正发请求的模型"来自同一个解析结果,不会出现展示与执行漂移。
验收标准(原文档完整保留)
- 运行中的折叠卡片在 task-start 事件到达时立即显示其有效模型;
- 使用不同模型的并行子代理在各自的卡片上显示正确的模型;
- 配置了显示名的模型优先展示显示名;未知模型回退到原始标识;
- 不带模型字段的旧任务事件仍能正常渲染。
Phase 2:实时累计 Token 用量
用户故事:用户观察一个折叠且正在运行的子代理卡片,能在每次子代理 LLM 调用完成后看到 Token 总量增加。
建设内容
规划要求:在子代理运行期间发布采集器(collector)最新的累计用量快照,并把它挂到任务进度事件上;前端把快照合并进任务状态作为权威累计值,然后在模型标签旁渲染格式化后的总量。同时要保留父 run 的既有记账路径,不新增任何一次记账写入。
源码印证:SubagentTokenCollector 如何产出快照
快照的生产者是 backend/packages/harness/deerflow/subagents/token_collector.py 中的 SubagentTokenCollector:
- 它是一个 LangChain
BaseCallbackHandler,每次子代理执行创建独立实例,caller标识归属; on_llm_end中用_counted_run_ids集合按run_id去重,保证同一次 LLM 调用的重复回调不会双计——这对应架构决策中"重放/乱序帧不会双计"的底线;- 每条记录携带
source_run_id、caller、真实产出的model_name(从response_metadata读取,用于父日志按真实模型分桶,而不是用 Lead Agent 的模型)、input_tokens、output_tokens、total_tokens,以及稀疏存在的cache_read_tokens(仅当 Provider 报告了缓存命中才写入,与父日志按模型分桶的稀疏结构一致); total_tokens缺失时回退为input + output,两者都非正数的响应直接跳过,不伪造 0。
在 executor.py 中,每次 LLM 响应完成后调用 collector.snapshot_records(),将最新累计记录写入共享的 SubagentResult(update_token_usage_records);下一个 task_running 事件携带该快照,折叠卡片即可无记账副作用地更新。子代理结束后,记录经 RunJournal.record_external_llm_usage_records 一次性移交父日志完成唯一一次正式记账——这正是"卡片是投影、不做第二次记账"的实现保障。
验收标准(原文档完整保留)
- 首次完成的子代理 LLM 调用在用量可用时,把折叠卡片从"采集中"更新为非零总量;
- 后续调用用新的累计总量替换卡片快照,而不是把总量再加一次;
- 重放的、重复的或更早的进度事件绝不双计或使显示总量减小;
- 并发子代理按
task_id保持相互独立的总量; - 省略用量元数据的 Provider 显示"不可用/采集中"状态,绝不显示伪造的 0。
Phase 3:终端持久化与边缘路径
用户故事:无论完成、失败、取消、超时还是页面刷新,用户看到的最终模型与 Token 用量都一致。
建设内容
规划要求:把最终模型与累计用量戳入既有结构化任务 ToolMessage 元数据和持久化的 subagent.end 事件;让历史重建逻辑学会读取这些可选元数据,实时快照与终态历史收敛到同一个任务模型上;覆盖所有终态状态,并安全地容忍遗留/畸形元数据。
源码印证一:ToolMessage 元数据契约
backend/packages/harness/deerflow/subagents/status_contract.py 定义了跨前后端的结构化结果元数据契约,其中与本特性直接相关的字段:
subagent_model_name(可选):本次委派 run 使用的有效 DeerFlow 模型标识;subagent_token_usage(可选):Provider 报告时的最终累计input_tokens/output_tokens/total_tokens快照。
两个值得注意的工程细节:
make_subagent_additional_kwargs在生产边界校验:status不在枚举内或stop_reason不在{token_capped, turn_capped, loop_capped}内会直接抛ValueError——拼写错误必须在生产端就失败,而不是以"缺失元数据"的形式悄悄漏给消费者;normalize_token_usage是两个元数据表面的唯一共享校验器(终态 ToolMessage 元数据与持久化的subagent.step/subagent.end事件),要求三个键全部为非负int(显式拒绝bool),任何非 Mapping 或畸形输入返回None——Provider 没有用量时字段整体缺席,前端据此渲染"不可用",而不是 0。
枚举值本身由跨语言共享夹具 contracts/subagent_status_contract.json 钉住(completed / failed / cancelled / timed_out / polling_timed_out),Python 侧 SUBAGENT_STATUS_VALUES 与 TypeScript 侧通过契约测试互相锁定。
源码印证二:subagent.end 事件保留终态快照
backend/packages/harness/deerflow/subagents/step_events.py 的 subagent_run_event 负责把 task_* 自定义流块映射为 RunEventStore 的持久化 kwargs:
task_started→subagent.start;task_running→subagent.step(经build_subagent_step截断到SUBAGENT_STEP_MAX_CHARS = 8192,防止一次大write_file产生无界行);task_completed/task_failed/task_cancelled/task_timed_out→subagent.end,其content在task_id与status之外,额外携带可选的model_name与usage:model_name经非空字符串校验后写入;usage经normalize_token_usage归一化后写入,畸形则整字段缺席;- 大块
result/error文本按SUBAGENT_STEP_MAX_CHARS截断并打result_truncated/error_truncated标志,保证持久化行有界。
这些事件挂在专门的 subagent 类别下(见 SUBAGENT_EVENT_CATEGORY),因此不会混入 list_messages(线程消息流),只通过 list_events 暴露给前端"展开时按需回填"(fetch-on-expand),list_events 支持按 metadata["task_id"] 过滤加 after_seq 前向游标分页——卡片按单个子代理翻阅步骤时不会被 run 级 limit 截断尾部,且全程无 schema 迁移(过滤复用既有 run 级索引)。
源码印证三:前端读取路径
frontend/src/core/tasks/subtask-result.ts 从 additional_kwargs 读取 subagent_model_name / subagent_token_usage(经由 normalizeTokenUsage,见 frontend/src/core/messages/usage.ts),与实时事件流合并到同一任务模型上,完成"live 与 durable 收敛"。
验收标准(原文档完整保留)
- 完成、失败、取消、超时的卡片都保留其最终模型与用量;
- 重新加载线程时从常规消息历史恢复元数据,不需要每张卡片一次请求;
- 持久化的
subagent.end事件包含相同的终态快照,供审计/调试使用; - 不带元数据的遗留卡片、以及没有用量的 Provider,保持可读并显式显示"不可用"状态;
- 右侧线程 Token Usage 总额保持不变,且子代理用量仍只被计数一次;
- 后端测试、前端单测、类型检查、格式化与相关回归套件全部通过。
兼容性与边缘设计:为什么"全部可选"是硬约束
这份规划最值得沉淀的经验是它对兼容性的系统性处理,仓库中多处可见其对应实现:
- 增量式 JSON 扩展,零迁移:
subagent.end的content只是在既有{task_id, status}上追加可选键;ToolMessage 的additional_kwargs同理。旧数据、旧前端读取时看不到新字段即按"不可用"渲染,没有任何读路径依赖新字段存在。 - 缺失 ≠ 0:
normalize_token_usage返回None的语义是"Provider 没报",前端据此渲染 collecting/unavailable 状态;SubagentTokenCollector侧也跳过total_tokens <= 0的响应。两处一致避免了"伪造的零"污染成本曲线。 - 累计快照 + run_id 去重:因为线上是累计值,合并策略天然幂等——重复帧、重放帧、乱序帧都不会改变"取最新累计值"的语义;
_counted_run_ids去重与前端"替换而非累加"的合并逻辑互为补充,把双计风险分别堵在生产端与消费端。 - 投影不记账:卡片消费的是
SubagentResult上共享的快照与subagent.end事件,正式记账只发生在子代理结束时向RunJournal的一次性移交(record_external_llm_usage_records),右侧线程总额因此不受卡片渲染开关影响。 - 遗留值归一化:
status_contract.py中的read_subagent_result_metadata对历史上已 checkpoint 进线程历史的max_turns_reached等已停产状态值做了读侧归一化(映射为turn_capped),避免历史数据在新版本下"悬空"为 in-progress——这是"容忍遗留/畸形元数据"的具体形态。
关键文件索引
| 关注点 | 文件 |
|---|---|
| 方案与验收标准 | plans/subagent-card-runtime-metadata.md |
| Token 快照采集 | backend/packages/harness/deerflow/subagents/token_collector.py |
| 元数据契约与校验器 | backend/packages/harness/deerflow/subagents/status_contract.py |
| 事件构建与 subagent.end 持久化 | backend/packages/harness/deerflow/subagents/step_events.py |
| task_started 携带模型名 | backend/packages/harness/deerflow/tools/builtins/task_tool.py |
| 模型解析 | backend/packages/harness/deerflow/subagents/config.py |
| 跨语言枚举夹具 | contracts/subagent_status_contract.json |
| 前端读取与合并 | frontend/src/core/tasks/subtask-result.ts |
| 事件流文档 | backend/docs/RUN_EVENT_STREAM.md |
小结
这份规划把"折叠卡片上的两个实时信号"拆解为一条清晰的链路:SubagentTokenCollector 按 run_id 去重产出累计快照 → task_started / task_running 自定义事件携带模型名与用量按 task_id 下发 → 前端以替换语义合并并渲染 → 终态时戳入 ToolMessage additional_kwargs 并持久化进 subagent.end 事件 → 刷新后从历史事件恢复同一份快照。贯穿全程的四条不变量——累计而非增量、全字段可选、缺失渲染为不可用、投影不做第二次记账——使整个特性可以在不触碰数据库 schema、不破坏旧 run 回放的前提下平滑上线。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00