# Step 38: 子代理 AgentTool
一句话导读:给主 AI 一个
Agent工具,让它能把一个独立子任务外包给一个全新的QueryEngine实例去跑,子任务的所有中间过程都被隔离,主代理只拿回一句结论。
# 一、这一步做了什么(What)
新增一个名为 Agent 的工具。主代理调用它时传入 { description, prompt },系统会:
new一个全新的 QueryEngine(子代理),它有自己独立的对话历史、自己的工具集;- 让子代理围绕
prompt自主跑多轮(调工具、读文件、执行命令); - 跑完后,只把子代理最后一轮的文本结论作为
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 返回主代理 → 主代理继续/总结
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 遗留的两个坑:
- maxRounds 默认 10 → 25:多步任务叠加 TodoWrite 更新很容易吃满 10 轮,导致「还没看到工具报错就被静默截断」。
- 轮次耗尽要广播
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 多个子代理再把结果汇总。