DeepAgents(十三):怎么看清子 Agent 都在干什么
怎么看清子 Agent 都在干什么
讲完怎么派活,下一个问题就来了:活派出去以后,我怎么知道它们各自在干什么。
我一开始的想法很朴素,主 Agent 都在流式输出了,子 Agent 应该也在这条流里。真跑了一段才明白,默认情况下它们不在。
主 Agent 派出去的子 Agent,默认在流里是隐身的:同一段活,关掉 subgraphs 只给 6 条事件,打开给 13 条。分清谁在干活靠一个字段,lc_agent_name。
一、同一段活,开与关差在哪
先交代我用的那段活。主 Agent 收到一句「跑一遍」,在同一条消息里派 1 个子 Agent 出去;子 Agent 先调一次工具,再回一句话;主 Agent 收到回执,收尾。
这段活跑完,流里是这样:
| 怎么看 | 收到几条 | 按名字归组 |
|---|---|---|
关掉 subgraphs |
6 条 | 全是主 Agent |
打开 subgraphs |
13 条 | 主 Agent 6 条,子 Agent 7 条 |
关掉时那 6 条是 5 个模型片段加 1 条工具回执,namespace 一栏始终是空的。打开后多出来的那 7 条,namespace 全都落在一个带 tools: 前缀的新格里。
**子 Agent 的模型片段、它调的那件工具、工具给它的回执,全在那个新格里。**关掉 subgraphs,你只能看到主 Agent 收到的那一条 task 回执,中间过程一条都没有。
换句话说,主 Agent 说完「我派了个活出去」,流就沉默了,直到子 Agent 干完回来。
二、分清谁在干活的那个字段
打开 subgraphs 之后,每条事件都带一份 metadata。我数了下,一共 11 个键。
里面有一个叫 lc_agent_name。这段活跑下来它只有 2 个取值,一个是我给主 Agent 起的名字,另一个是子 Agent spec 里的名字。
| 谁 | 名字从哪来 |
|---|---|
| 主 Agent | 建 Agent 时挂在 config 上,用的就是 name 参数 |
| 子 Agent | 每次被调起时注进去,用的是 spec 里的 name |
2 个注入点分别在框架的 2 份源码里,一处给子 Agent 盖名字,一处给主 Agent 盖名字。所以不用去猜 namespace 里那串编号对应谁,直接读这个字段就行。
同一段活里,这个名字还能顺手告诉我们各层产出了多少条:
| 名字 | 模型片段条数 | 出现过的节点 |
|---|---|---|
| 主 Agent | 6 条 | model、tools |
| 子 Agent | 7 条 | model、tools |
两边节点名一模一样。这也是为什么光看节点名分不出主次,得看名字字段。
三、模型不吐字,流就是假的
这件事我是拿 2 个模型对照出来的。
同一个 Agent、同一段活,只换模型:一个会在内部一片一片吐 token,另一个一次给整条消息。
| 模型 | 收到几条 | 模型片段 | 整条消息 |
|---|---|---|---|
| 不吐 token | 6 条 | 0 条 | 4 条 |
| 会吐 token | 13 条 | 11 条 | 0 条 |
不吐 token 的那个,流里一条模型片段都没有,只有几条整块的 AIMessage。前端拿到的是一句话忽然出现,看着就像卡住了。
所以流式要成立,模型自己得真吐 token。外层接口再漂亮,模型那边一口气给整条,聚合出来还是整条。
我把真模型也接上量了一遍,用的是智谱的 GLM-5.2,让它答一句 40 字左右的话,跑 2 遍:
| 第几遍 | 分成几段 | 第一段什么时候来 | 第一段到最后一段 | 整条链总耗时 |
|---|---|---|---|---|
| 第 1 遍 | 34 段 | 1.4 秒 | 0.7 秒 | 2.0 秒 |
| 第 2 遍 | 29 段 | 1.8 秒 | 0.6 秒 | 2.4 秒 |
这 2 行有个地方值得看:第一段等的时间,比后面所有字吐完的时间加起来还长。等首字 1.4 到 1.8 秒,剩下的字 0.6 到 0.7 秒就全出来了。
做流式界面的时候,这个账决定了体验的瓶颈在哪。用户盯着屏幕不动的那段时间,花在等首字上。
四、另有一套:投影
框架还有一套更新的流式接口,开法是 await astream_events(version="v3")。它不给零散事件,给几条分好类的投影。
我数了下,一共 6 条,每条背后是一个专门的变压器:
| 投影 | 归谁管 |
|---|---|
messages |
MessagesTransformer |
tool_calls |
ToolCallTransformer |
subagents |
SubagentTransformer |
subgraphs |
SubgraphTransformer |
lifecycle |
LifecycleTransformer |
values |
ValuesTransformer |
同一段活,底下跑过去的协议事件是 37 条:values 8 条、messages 23 条、tools 4 条、lifecycle 2 条。
这里有个很容易误判的地方:本层投影只收本层。
我在这段活上分别读了 3 条投影。messages 给 2 条,正好是主 Agent 自己的 2 次模型调用,子 Agent 的 token 一条都没有。tool_calls 给 1 条,只有主 Agent 那次 task 调用,子 Agent 内部调的工具不在里面。
所以投影看着方便,但它是个收口的视图。要用它看子 Agent 内部,得顺着另一个入口往下钻。
五、那个 handle 给的三样东西
第 4 节说的另一个入口,是 subagents 这条投影。它每条给一个 handle,一个 handle 对应一个子 Agent 的一次执行。
handle 上有三样东西值钱:
| 字段 | 给什么 |
|---|---|
name |
子 Agent 的名字,就是第 2 节那个字段 |
cause |
派它出去的那次工具调用,带 tool_call_id |
status |
起止状态,跑的时候是 started,读完是 completed |
cause 是这套接口最值钱的地方。原来在旧接口里,想知道「这个子 Agent 是谁派的」,得自己去主 Agent 的调用列表里对 id。现在这条因果边直接给出来。
钻进去读子 Agent 内部的写法,有一点讲究:要在遍历到它的那一刻当场读。
我试过先把手柄攒起来,等整条流跑完再回头读,结果一条都拿不到。它是个推迟订阅的流,错过那一刻,缓冲里就是空的。
六、到达顺序不稳
并发这件事,讲派活那篇已经踩过一次。这次换成投影接口,结果还是不稳。
同时派 3 个子 Agent,看它们的 handle 按什么顺序到达,我跑了 5 遍:
| 跑第几遍 | handle 的到达顺序 | 跟发起顺序一致 |
|---|---|---|
| 1 | C、A、B | 否 |
| 2 | A、B、C | 是 |
| 3 | A、B、C | 是 |
| 4 | C、A、B | 否 |
| 5 | C、B、A | 否 |
5 遍里 2 遍是顺的。讲派活那篇里那条远端的路,6 遍一遍都不顺。
2 处的结论一样:认人得靠 cause,不能靠位置。位置有时候看着对,那是运气。
七、子 Agent 炸了,流里还剩什么
我让子 Agent 内部那件工具抛一个异常,然后看 2 条路各自收到什么。
lifecycle 这条给得挺完整,2 条事件:先一条 started,再一条 failed,error 里就是异常原文。
handle 那条给的是状态变化:读它的时候 status 会转成 failed,error 里也是异常原文。
但有件事得留心。异常会从迭代里抛出来,把当前这一轮读打断:本层那条工具视图一条都收不到。
我第一遍就把 handle 和 lifecycle 放在同一个循环里读,异常先把整段读带走了,lifecycle 那边一条都没读着,看着像「框架什么都不给」。分成 2 个 run 单独读,failed 事件就在那儿。
所以真要审计子 Agent 的失败,单独订阅 lifecycle,别跟 handle 混在一个迭代里。
这和讲派活那篇的结论接得上:默认装配下,子 Agent 不算一层隔离。区别只是这一次,流里还留下了失败的原因。
八、怎么用
理完这些,我在「看清子 Agent 在干什么」这件事上留下 4 条。
先打开 subgraphs。 这是最便宜的一步,一行参数,同一段活从 6 条变成 13 条,子 Agent 的中间过程全出来了。
认人用 lc_agent_name,别用位置。 节点名两边一样,到达顺序也不稳,只有这个名字是可靠的。
要知道「谁派的它」,就用投影接口的 cause。 旧接口得自己对 id,新接口直接把这条因果边给你。代价是这套接口还标着实验性。
要盯失败,单独订阅 lifecycle。 别把它跟 handle 放在同一个迭代里读,异常会先把整段读带走。