# Step 38: 子代理 AgentTool

一句话导读:给主 AI 一个 Agent 工具,让它能把一个独立子任务外包给一个全新的 QueryEngine 实例去跑,子任务的所有中间过程都被隔离,主代理只拿回一句结论。


# 一、这一步做了什么(What)

新增一个名为 Agent 的工具。主代理调用它时传入 { description, prompt },系统会:

  1. new 一个全新的 QueryEngine(子代理),它有自己独立的对话历史、自己的工具集;
  2. 让子代理围绕 prompt 自主跑多轮(调工具、读文件、执行命令);
  3. 跑完后,只把子代理最后一轮的文本结论作为 tool_result 返回主代理。

主代理看到的不是几十步中间过程,而是一句「我查完了,结论是 X」。


# 二、面试官视角:为什么要做子代理?(Why)

面试题:Agent 已经能调工具了,为什么还要再套一层"子 Agent"?直接让主 Agent 自己干不行吗?

核心是上下文窗口是稀缺资源,而复杂子任务会产生大量「过程垃圾」。

假设主任务是「修复登录 bug」,其中一步是「先搞清楚项目用的什么鉴权库」。如果主代理自己查,它得:翻 package.json、grep 十几个文件、读三四个源文件——这些工具调用和它们冗长的返回值全部堆进主对话历史。等它查完,主上下文已经被塞满了对最终目标毫无用处的中间输出,剩余可用窗口被大幅蚕食,模型也更容易「跑偏」。

子代理解决的正是这个:

维度 不做子代理 做子代理
上下文 中间过程污染主历史 中间过程留在子引擎,用完即弃,主历史只进一句结论
专注度 主代理要同时想「大目标」和「查库这种琐事」 子代理只盯一个子任务,主代理只管编排
并行 只能串行一件件查 (真实版)可同时派多个子代理并行 fan-out

一句话:子代理是一种"上下文垃圾回收"机制——把一次性的探索过程隔离在一个用完就扔的沙箱里。


# 三、原理:它是怎么工作的(How)

# 数据流

主代理调用 Agent({ description:"查依赖", prompt:"列出 package.json 所有依赖并分类" })
   ↓
Agent.execute → runSubAgent(prompt)
   ↓
new QueryEngine({ registry: subRegistry, 共享 budget/cost, 子系统提示, maxRounds:15 })
   ↓ for await 消费子代理事件(缩进显示),累积"最后一轮无工具调用的文本" = 结论
   ↓
把结论作为 tool_result 返回主代理 → 主代理继续/总结
1
2
3
4
5
6
7
8

# 四个关键设计点

点 做法 原因
工具很薄 Agent.execute 只调注入的 runSubAgent,自己不 new 引擎 造子引擎要 registry / 预算 / 权限一堆依赖,这些只有 index.ts 有,工具层不该知道
防递归 子代理拿到的 subRegistry 里不含 Agent 工具 子代理没法再派子代理,嵌套深度从数据结构上被锁死为 1
共享预算 子代理和主代理共用同一个 budget / cost 子代理花的 token 也进总账,不会出现「子代理偷偷烧钱」
收集结论 只累积「最后一轮、没有再调工具的那段文本」 那才是子代理的最终答复;中间的工具往返不要

# 为什么「复用 QueryEngine」是这一步的题眼

子代理不是新写的东西——它就是 step30 抽出来的 QueryEngine 类,一行 new 就得到一个功能完整的 agent。step30 当时「把对话循环抽成类」看着只是重构,价值到这一步才兑现:因为引擎是个干净的、可实例化的类,「开一个子代理」才这么廉价。这就是模块化的复利。


# 四、深入追问(面试常见 follow-up)

Q:怎么防止子代理无限递归地派子代理,把机器拖垮? A:不靠运行时计数器,而是靠能力裁剪——子代理拿到的工具注册表里根本没有 Agent 工具。它想派也没得派。用数据结构的缺失来表达约束,比写 if (depth > N) 更干净、更不容易漏。

Q:子代理跑很多轮,怎么保证结论被正确提取,而不是把中间的工具输出也当结论返回? A:约定「结论 = 最后一轮不再调用工具时模型输出的文本」。工具调用轮不算数,只有模型「不再需要工具、直接开口回答」的那一轮,才是它的最终答复。

Q:子代理的 token 消耗会不会失控? A:两道闸。一是共享 budget——子代理和主代理花的是同一个预算池,超了一起停;二是子代理有独立的 maxRounds(这里 15)上限,跑满自动收束。

Q:主代理拿不到中间过程,怎么调试子代理干了啥? A:子代理的事件流并没有丢,只是不进主历史。终端里用缩进(↳ 子代理调用:Read)实时展示子代理的活动,用户看得见;只是这些不会占用主代理的上下文。


# 五、踩坑 / 顺带修的两个引擎问题

这一步暴露并修了 step37 遗留的两个坑:

  1. maxRounds 默认 10 → 25:多步任务叠加 TodoWrite 更新很容易吃满 10 轮,导致「还没看到工具报错就被静默截断」。
  2. 轮次耗尽要广播 max_rounds 事件:之前 for 循环跑完直接 return,用户分不清是「答完了」还是「被截断了」。现在 UI 会明确提示「已达最大轮次,被迫停止——任务可能未完成」。

教训:静默终止是最坏的体验,任何「非正常结束」都要有可观测的信号。


# 六、与真实源码的对照

我们的实现 Claude Code 源码
Agent 工具 tools/AgentTool/(Task / Agent 工具)
runSubAgent 造子引擎 真实版同样 spawn 子 query 跑子任务
subRegistry 不含 Agent 真实版限制子代理可用工具、限制嵌套
串行一个子代理 真实版可并行多个 + 不同 subagent_type(如 Explore/Plan)
共享 budget 真实版子代理也计入总预算 / taskBudget

# 七、一句话总结

子代理 = 一次性的、能力受限的独立引擎:用 step30 抽好的 QueryEngine 一行 new 出来,靠「工具集里没有 Agent」防递归、靠「共享 budget」防烧钱、靠「只取最后一轮文本」提结论,本质是给主对话做上下文垃圾回收。

# 下一节预告

一个子代理跑通后,下一步是 step39 多代理编排 / Workflow——并行 fan-out 多个子代理再把结果汇总。