DeepAgents(九):task 工具怎么把活派给子 Agent
task 工具怎么把活派给子 Agent
默认栈里 8 个工具,task 是第二贵的那个:499 token,仅次于 grep 的 597。
贵有贵的原因。另外 7 个都是自己干活,只有它派人干活。
派人这件事牵涉三方:父 Agent、子 Agent、还有中间那段谁都不太留意的过程。我把这三方都抓了一遍,看每一方手上到底有什么。
task 工具只传两样东西:派给谁、干什么。委派一次,父这边只多一条回执,而子 Agent 中间看了什么、写了什么、跑了几步,父一条都看不到。
这套隔离是它最值钱的地方,也是它最容易让人误判的地方。全程不用 API key,读完约 6 分钟。
一、先看这个工具的脸
默认装配的 8 个工具,token 分布是这样:
| 工具 | token | 干什么 |
|---|---|---|
grep |
597 | 按内容搜 |
task |
499 | 派人干活 |
read_file |
456 | 读文件 |
glob |
414 | 按名字搜 |
edit_file |
293 | 改文件 |
write_file |
191 | 写文件 |
delete |
161 | 删文件 |
ls |
118 | 列目录 |
合计 2729 token,task 一个占了 18.3%。
它的入参只有 2 个必填字段:
class TaskToolSchema(BaseModel):
description: str
subagent_type: str
description 是给子 Agent 的任务说明,subagent_type 是派给哪一个。
这里有个容易找错的地方:没有 mode 参数。子 Agent 用 isolated 还是 fork,是装配的时候定在它的规格里,不是调用的时候挑。模型派活时只能选「派给谁」,不能选「怎么派」。
那 subagent_type 能填什么,就是从它的描述里读出来的。这段描述 1419 字符,是一段固定模板加一份子 Agent 清单,清单的每一行长这样:
- general-purpose: General-purpose
agent for ...
清单不是写死的,是从你传的子 Agent 规格里拼出来的:
subagents=[
{"name": "researcher",
"description": "查资料的"},
]
传进去之后清单变成 2 行,描述涨到 1438 字符。如果你自己起一个名字就叫 general-purpose 的子 Agent,默认那个会被顶掉,这 2 个不会并存,描述反而掉到 1081 字符。名字是覆盖的钥匙。
fork 模式的子 Agent 会在自己那一行尾巴上多一句话:
- f1: 继承对话的 (inherits your full
conversation and system prompt ...)
源码里给这句的注释写得很直白,说它是承重的:模板上面有一条「每次调用都是无状态的」,如果不额外写这一句,模型可能不肯把活派给一个 fork 的子 Agent。多这 80 来个字符,是为了让模型敢派。
二、派出去之后,子 Agent 手上有什么
我造了一个最普通的委派:父这边只有一句用户提问,然后它调 task,把活派给默认的 general-purpose。
抓模型每一轮实际收到的消息,结果是这样:
| 第几轮 | 谁 | 消息条数 | system | 手上的工具 |
|---|---|---|---|---|
| 1 | 父 | 2 | 0 字符 | 8 个,含 task |
| 2 | 子 | 2 | 286 字符 | 7 个,没有 task |
| 3 | 子 | 4 | 286 字符 | 7 个 |
| 4 | 父 | 4 | 0 字符 | 8 个,含 task |
看第 2 轮。子 Agent 手上是 2 条消息:一条 system,一条 human。那条 human 的内容就是父填的 description,一个字不多。
父自己的那句「用户的问题」不在这里。父之前干过什么、聊过什么,都不在这里。
另一件事是那条 286 字符的 system。isolated 子 Agent 有一条自己的专属提示词,内容是告诉它「调用你的那个 Agent 只看得到你的最终回复,看不到你的中间过程、工具结果和状态,所以结论要写全」。
这一句放在这里是有道理的:子 Agent 如果按平时习惯只回一句「改好了」,父那边就真的什么都不知道了。
所以 isolated 模式给子 Agent 的上下文,就是「一条专属提示词 + 一条任务描述」。父的对话历史一条都不给,这就是 isolated 的定义。
三、回来的路上,父拿到什么
同一场委派,子 Agent 在它那边跑了 2 轮:先写一个文件,再给结论。父这边最后长这样:
| 序号 | 消息 | 内容 |
|---|---|---|
| 1 | human | 用户的问题 |
| 2 | AI | 发起 task 调用 |
| 3 | tool | 子 Agent 的最终报告:结论是 42。 |
| 4 | AI | 父的收尾话 |
第 3 条就是子 Agent 回来的全部。委派这一趟,父的上下文只多了 2 条消息:一条它自己发起的调用,一条回执。
回执里的文字是挑出来的,取子 Agent 最后一条有正文的 AI 消息。
子 Agent 前面那条「我刚写了个文件」不算,工具调用的回执也不算。只有最后那句结论进得来。
量一下:这条回执 21 字符、13 token,加发起调用那条 AI 消息 40 token,整个委派步骤占父上下文 68 token。
回执取的是子 Agent 最后一条有正文的 AI 消息,长什么样由子自己定。想让它带固定字段,在子 Agent 的规格里加一个 response_format,值填你要的 Pydantic 类:
| 规格里写了什么 | 父收到的那条回执 | 能 json.loads 吗 |
|---|---|---|
| 什么都不写 | 子 Agent 的最终报告:结论是 42。 | 不能 |
response_format=Findings |
{"findings":"3 条线索","confidence":0.82} |
能 |
这份结构的拿法有点绕:它靠给子 Agent 挂一个工具实现,那个工具的名字就是类的类名。实测子那一轮的工具从 7 个变 8 个,多出来那个整块 285 字符,参数名就是类里的字段名。
有 2 件事别指望。它只改这条回执的形态,父的 state 键还是 ['files', 'messages'],结构不进 state。它能跟 mode='fork' 共存,这一点正好跟 skills 相反。
四、中间那一段去哪了
这是这套机制最有意思的地方。
子 Agent 那边跑了 2 轮模型,中间有它自己的 AI 消息和工具回执。这 2 条一条都没进父的消息列表。父看到的只是「派了个活」和「活干完了」。
这是双向的隔离。父的历史不给子,子的过程不给父。
但有一类东西确实会穿过来:文件。
我让子 Agent 写一个 /child.md,跑完看父那边的状态,文件在。子 Agent 写的东西落到同一套虚拟文件系统上,父翻得出来。
原因是子 Agent 跑完会把自己的状态更新交回父这边,而交回的时候有一个明确的排除名单:
| 状态键 | 会不会回来 |
|---|---|
messages |
只挑最后一条结论 |
todos |
不回 |
structured_response |
不回 |
| 文件 | 回 |
todos 不回来的原因源码里写了:它没有定义合并规则,一个子 Agent 的清单交回给父也没有明确含义。structured_response 同理,那是子 Agent 自己的结果结构,交回来会跟父的结果混在一起。
规则是「谁有明确含义谁能过」。
五、子 Agent 不能再往下派
这一条源码里有一句现成的提示语:
You are a subagent and cannot delegate to another subagent. Complete this task yourself instead of calling this tool again.
但我实测下来,这句话只在一条路上会出现。堵住「子 Agent 再派子 Agent」其实有2 条完全不同的路。
第 1 条路是根本不给工具。isolated 子 Agent 那一轮的绑定工具是 7 个,task 不在里面。它想派也没得派,连报错都省了。
第 2 条路是给工具但拦调用。fork 子 Agent 手上是完整的 8 个,含 task。它真去调的时候,工具不执行、也不抛异常,直接返回上面那句话,模型下一轮能读到,然后自己接着干。
2 种形态的差别不小。前者是「能力上没有」,后者是「能力上有但被拒」,后者在日志里看得见,前者连痕迹都没有。
顺带一个后果:那句拒绝的话只出现在子 Agent 自己的消息列表里。父那边从头到尾都不知道子 Agent 试图再派一次活。
六、装配期和调用期会拦下什么
这些边界都实测了一遍,原文照抄:
| 你做了什么 | 结果 |
|---|---|
mode 写成 shared |
SubAgent 'x' has invalid mode 'shared'; expected 'isolated' or 'fork' |
fork 的子 Agent 又给 skills |
SubAgent 'x' cannot set skills under mode='fork'; the parent's skills are inherited instead. |
response_format 填字符串或数字 |
ValueError: Unsupported schema type: <class 'str'>,只认 Pydantic 类、dataclass、TypedDict 和 JSON schema 字典 |
mode 写成 handoff |
通过,它是 isolated 的旧名字 |
| 传一个空的子 Agent 列表 | 不报错,默认那个 general-purpose 照样补回来 |
| 中间件层传空列表 | At least one subagent must be specified |
| 派给一个不存在的名字 | 返回一句话,列出有哪些合法的 |
最后一条值得单独看。模型把 subagent_type 填错时,工具不报错,返回:
We cannot invoke subagent researcher because it does not exist, the only allowed types are
general-purpose
这是一句话,不是异常,图不会因此停住。模型下一轮读到这句,自己改个名字重试就行。
这套设计里,「报错」几乎都用返回文字的方式做,很少真的抛异常。 好处是流程不会因为一次填错就断,代价是如果你不看工具回执,什么都发现不了。
七、所以什么时候该派
回到那份账。一次委派,父的上下文付出 68 token,换来的是子 Agent 那一整段的中间过程不占自己的地方。
什么时候值:
- 活是多步的、要翻很多文件的。这些中间产物如果都堆在父这边,父的上下文会被迅速填满。
- 你只关心结论的。子 Agent 回来只带一句,你不需要看它怎么找到的。
- 几件互不相干的活。描述里明确写了可以在一条消息里发多个
task,并发派出去。
什么时候不值:
- 一步就能干完的。父自己能读一个文件,没必要绕一圈,还多付 499 token 的工具描述。
- 你必须看中间过程的。子 Agent 的过程默认完全不回来,需要过程就得让它写进文件,或者干脆别派。
还有一件事要记住:子 Agent 的报告不会直接展示给用户。描述里写着这句,意思是子 Agent 说的话用户看不到,得由父转述一遍。我曾经以为派出去就完了,实际上派出去还要负责把话带回来。
最后是那句 499 token。它跟 grep 的 597 一样,是你为「手上有这个能力」付的固定成本,跟你派不派活无关。活多的时候它是划算的,活少的时候它就是一份占着位置的说明书。