DeepAgents(十一):子 Agent 为什么不能继续往下派

2026-10-15 05:200 条评论205 次阅读

子 Agent 为什么不能继续往下派

《委派用 isolated 还是 fork》把 2 种模式的开局摊开对照,末尾留了一句:isolated 子手上没有 task,fork 子有,但一调就被拒。

这 2 种「不能派」看着像同一件事,机制其实完全不同。我把这 2 条路都走到头,看挡在中间的是什么。

子 Agent 不能继续往下派,isolated 子和 fork 子走的路不一样:前者从头就没拿到派活的那件工具,后者拿到了,一用就被挡回来。挡它的不是一句提示词,是子自己 state 里一个布尔标记。

全程不用 API key,读完约 5 分钟。

一、mode 那一格,能填的就那么多

装配期就把口子收紧了。我试了 7 个值:

写在 mode 里的 结果
不写这一格 通过
isolated 通过
fork 通过
handoff 通过
forked 被挡
shared 被挡
inherit 被挡

被挡的时候报的是这一句:

SubAgent 'a1' has invalid mode 'forked'; expected 'isolated' or 'fork'

handoff 通过得有点意外。实测它跟 isolated 是一回事:子绑到 7 个工具,task 不在里面。源码里写着它是 isolated 的旧名字,留给老代码用。

fork 还多 2 条限制。子 Agent 自己不能写 skills,报错说父的技能会继承过去。子 Agent 之间也不能重名,因为模型是拿名字挑人的。

还有一条提醒。只挂一个 fork 子,装配时就会收到 beta 告警,说这个特性还在改,接口可能变。

二、两条路,差别在子 Agent 手上有没有那件工具

子 Agent 默认有 7 个工具,都是文件系统那套:

ls read_file write_file edit_file delete glob grep

挂 isolated 子,它拿到 7 个,task 根本不在列表里。挂 fork 子,它拿到 8 个,多的那个就是 task。

顺序也值得看一眼。父自己的工具面是前面 7 个再加一个 task,fork 子那 8 个,前 7 个位置跟父一模一样。

这里有一处不对称。isolated 子手上那 7 个,就是你在规格里写的那几个,框架一个不多给。fork 子那个 task 是框架主动插进去的,插在文件系统工具后面,位置跟父对齐。

isolated 子 fork 子
手上工具 7 个 8 个
里面有 task 吗 没有 有
想派活会怎样 连调用入口都没有 拿回一句拒绝

isolated 子这条路的尽头是「没有」。它连自己不能派活都不知道,因为那个工具的名字从来没出现在它的选项里。

子 Agent 想往下派

isolated 子:没工具

fork 子:有,被拒

连调用都发不出去

拿回一句拒绝

三、为什么不干脆把工具拿走

fork 子明明派不出去,为什么还要把 task 塞给它,再在调用那一刻拦一下。

源码给的答案藏在一句注释里:拒绝发生在调用那一刻,工具照样让它拿着。理由是 fork 子要跟父保持同一副面孔。

理由拆开有 3 条。父的 task 工具紧跟在文件系统工具后面,fork 子也要插在同一个位置,因为测试断言两边的工具顺序必须一致。

fork 子跑的图形态跟父一样,私有通道才能原样带过去。task 工具的描述还要逐字相同,父和子才能命中同一份缓存。

我实测对照了两边的工具面顺序,前 7 个位置确实一一对应,task 落在同一位。

把工具拿走会同时破掉这 3 条。留工具、调用时拒绝,是能同时兼顾的做法。

四、那句拒绝长什么样

fork 子真去调 task,拿回来的是 123 个字符:

You are a subagent and cannot delegate to another subagent. Complete this task yourself instead of calling this tool again.

它不是异常。图不会断,子那一轮照常往下走。我这边跑到第 3 轮、消息涨到 9 条,全程没抛东西。

模型收到的身份是工具回执,不是报错。它读到的意思很直白:这条路不通,活还得你自己干。

连试 2 次也一样。子收到 2 条工具回执,逐字符等于源码里那句话。这道闸读的是一个布尔标记,标记不动,试几次都是同一句。

还有一处细节:这道检查排在校验前面。我让子去调一个根本不存在的子 Agent,拿回来的仍是递归拒绝,没提「不存在」这回事。

最后,整件事只发生在子内部。父这边那句拒绝出现 0 次,父的收尾照常输出,完全不知道子被拦过。

五、挡它的那个标记

调用时怎么知道自己是不是子,靠一个布尔标记。它待在子自己的 state 里,键名叫 _deepagents_forked_context。

我把 3 种子 Agent 的 state 键面都读出来对照:

子 Agent 形态 state 里的键 带标记吗
isolated 子 files、messages 不带
声明式 fork 子 _deepagents_forked_context、files、messages 带,值是 True
runnable 形态的 fork 子 files、messages ⚠️ 不带

标记的 schema 声明也有讲究。官方给它标的是「保留输入、省略输出」。保留输入,父才能把 True 塞进子的初始 state;省略输出,子跑完不会把它带回父这边。

塞标记这件事发生在子开局之前。所以子第一次被模型调用的时候,这个键已经在它的 state 里等着了。

还有一个容易漏的细节。给 fork 子装 task 工具的那个中间件,顺手把这个键声明成了真通道。源码注释解释了原因:没声明的话它只是个普通字段,runtime.state.get() 读不到,那道闸就形同虚设。

拦人的活其实是两道。子 Agent 开局的第一条人类消息前面被接了一段 567 个字符的预告,里面有一句写着「你要是想再派一层,会被拒绝,直接把活干完」。提示词先说一遍,运行时再拦一次。

至于父那边,task 描述里 fork 子那行尾多的 86 个字符,只提了「它继承你的完整对话」,一个字没提它派不出去。

fork 分支给子写上 True

子一调 task 就被拒

跑完回传时,这个键被省略

六、一处缺口

上一节那张表最后一行,值得单独说。

fork 子有 2 种写法。声明式的那种给它一份规格,另一种是 runnable 形态,你直接塞一个编好的 Agent 进去。

# 声明式
{"name": "fk1", "mode": "fork"}

# runnable 形态
{"name": "cfk", "mode": "fork",
 "runnable": my_agent}

我让 runnable 形态的 fork 子去派活。它内部那个 task 工具真的派出去了,孙辈跑了起来,父这边一句拒绝都没见到。同样一条链换成声明式的 fork 子,孙辈压根没起来。

差别就在那个标记。声明式那条分支会给子写上 True,runnable 那条分支只做键的过滤、不写标记。

声明式 fork 子 runnable 形态的 fork 子
子 state 里的标记 写上了 True 没有
调 task 会怎样 被拒 放行,孙辈真的跑起来
边界谁来守 框架 你自己

这个缺口有它的道理。runnable 是你自己编好塞进去的,框架不知道里面装了什么工具,也帮不上这个忙。

非要走这条路,边界就自己兜:塞进去的那个 Agent 别带 task,或者自己在里面再放一道检查。

七、怎么用

3 条记下来就够:

  1. 子 Agent 默认是 isolated,它不能继续往派,这是设计好的,不属于缺功能。
  2. 想让子 Agent 接着你的对话往下走,用声明式 fork。它手上有 task,调用那一刻会被拒。
  3. 走 runnable 形态的时候别指望这道闸,边界得自己守。

回头看不难。防递归这件事,做法是先让 fork 子跟父长得一模一样,再在一个布尔标记上做文章。子 Agent 能看见那件工具,也能伸手,伸手那一刻被按住。