DeepAgents(十二):主 Agent 怎么同时驱动多个子任务

2026-10-16 05:220 条评论5 次阅读

主 Agent 怎么同时驱动多个子任务

前面讲派活的时候,主 Agent 用的是一件叫 task 的工具,一次派一件,派完就等着它回来。真想同时推进好几件事,就得让它在同一条消息里给出好几个 task 调用。

我一开始以为「同时」是某个开关,翻完源码、又跑了几组对照,才把它是怎么发生的看清楚。

主 Agent 能同时驱动多个子任务,靠的是同一条消息里给出多个工具调用,同一批活一轮派完 1.6 秒,分 3 轮派 4.7 秒,差 2.84 倍。要走得更远还有另一条路:把活扔到远端服务器上,拿一个任务号就走。

一、2 条「同时」,走的是 2 条路

同一个 subagents 列表交进去,DeepAgents 按一个键把它分成 2 拨,看的是 spec 里有没有 graph_id。

没有 graph_id 的,是本地子 Agent,进 SubAgentMiddleware,还是那件 task 工具,子图跑在同一个进程里。带 graph_id 的,是远端子 Agent,进 AsyncSubAgentMiddleware,活会被扔到另一台服务器上去跑。

没有

有

subagents 列表

有没有 graph_id

本地子 Agent: 还是 task

远端子 Agent: 另加 5 件

子图跑在同一进程

活扔到远端服务器

2 条路给主 Agent 装的工具不是一套。我量了一遍:

交进去的是什么 主 Agent 手上几件工具
只有本地子 Agent 8 件
只有带 graph_id 的远端子 Agent 13 件
2 种混在同一个列表里 13 件

多出来的 5 件是异步这条带的:start_async_task、check_async_task、update_async_task、cancel_async_task、list_async_tasks。

这里有个容易看漏的地方。加了远端子 Agent 之后,原来那件 task 并没有被顶掉,它还在。

因为 DeepAgents 发现列表里没有可用的本地子 Agent 时,会自动补一个通用的进来。这 2 条路是并存的关系,主 Agent 可以一边在本地派活,一边往远端扔任务。

二、并发不在子 Agent 里

真正把多个子任务同时推出去的,是「同一条消息里给出多个工具调用」这件事本身。至于这些调用怎么被执行,得看 LangGraph 的工具节点。

它里面有两段代码,一段管同步入口,一段管异步入口。同步那段用 get_executor_for_config(config) 拿到一个线程池,再用 executor.map(...) 把同一轮的调用一批送出去。异步那段换成 await asyncio.gather(*coros),攒成协程一起等。

线程池开多大?get_executor_for_config 从配置里取 max_concurrency,没设就按 min(32, CPU 核数 + 4) 算。我这边量到的是 28。

光看代码不够,我让 8 个子 Agent 每个都去干一段 1.5 秒的活,然后量主 Agent 这条链从头走到尾用了多久。

情形 主 Agent 这条链走了多久
1 个任务 1.6 秒
3 个任务,一轮派完 1.6 秒
3 个任务,分 3 轮派 4.7 秒
8 个任务,一轮派完 1.9 秒
3 个任务,一轮派完(异步入口) 1.7 秒

前 2 行值得多看一会儿。3 个任务各占 1.5 秒,一轮派完总共 1.6 秒。1 个任务单独跑,也是 1.6 秒。

8 个任务一起上,只比 1 个任务多花 0.3 秒。

我还在每个子任务里记了起止时刻,用来判断它们是不是真的重叠。一轮派完那几组,区间两两相交的比例是 3/3 和 28/28,全重叠。分 3 轮派的那组是 0/3,一段都没挨上。

异步入口和同步入口差别很小,1.7 秒对 1.6 秒,差的那点基本是计时噪音。2 条路径的目标一样,都是让同一轮的调用一起跑。

三、想收窄也行

机器不等于可以无上限地铺开。LangGraph 允许从外面传一个 max_concurrency 进去,工具节点的线程池会拿它当上限。

设一下就能看出分了几批。8 个任务,每个还是 1.5 秒:

max_concurrency 分成几批 主 Agent 这条链走了多久
不设 1 批 1.9 秒
4 2 批 3.3 秒
2 4 批 6.4 秒

上限一收,耗时按批数往上走,一点不含糊。

四、结果怎么收回来

派出去的活干完了,结果会以 ToolMessage 的形式落回主 Agent 的消息里。一轮派 3 个,消息长这样:

HumanMessage: 用户的问题
AIMessage: 同时调用 task task task
ToolMessage: call_task1_0 干完了
ToolMessage: call_task1_1 干完了
ToolMessage: call_task1_2 干完了
AIMessage: done

回执的先后跟着调用的先后,8 个任务那组也是从 call_task1_0 一路排到 call_task1_7。这一点比想象中重要,主 Agent 靠这个位置对应关系,才知道哪条回执对应哪件活。

消息量我按 token 估了一遍:1 个任务 59 token,3 个一轮派完 139 token,3 个分 3 轮派 151 token,8 个一轮派完 341 token。

有意思的是第 3 列和第 2 列。同一批 3 件活,一轮派完比 3 轮派还省 12 token。因为首尾那 2 条消息只需要一份,分成 3 轮就得多出 2 轮来回。

五、子任务炸了会怎样

这件事我本来是顺手试的,结果有点意外。我让其中一个子 Agent 内部的工具抛一个异常,然后看主 Agent 收到什么。

什么都没收到,整条链直接挂了。异常从子 Agent 内部一路冒到最外层,主 Agent 走不到头,手里一条 ToolMessage 都没有。

翻了下 LangGraph 里默认的错误处理,函数叫 _default_handle_tool_errors,里面只有这么几行:

if isinstance(e, ToolInvocationError):
    return e.message
raise e

它只兜「工具调用本身出问题」这一类,别的异常原地重新抛出去。子 Agent 内部那些业务异常不在这一列里。

为了确认这一点,我又试了一次让主 Agent 自己直接调的工具抛异常,结果一样,也是整条链挂掉。

所以在这套默认装配下,子 Agent 不算一层隔离。它内部出的事会掀翻主 Agent。想保住主 Agent,得在工具上自己加一层兜底,或者在派活之前就把可能失败的活挑出去。

六、另一条路:把活扔出去

带 graph_id 的那个 spec 是另一套东西。它不建本地子图,而是拿 graph_id 当远端服务器上的图名,通过 LangGraph SDK 去那边建会话、起后台任务。

要跑通这条路,得有一个实现了 Agent Protocol 的服务器。为了把整条链走完,我照着它会用到的那几个端点写了一个最小的服务器放在本地,只有建会话、起任务、查任务、取内容、取消这 5 个端点。

远端 spec 要填 4 样东西:

字段 填什么
name 子 Agent 的名字
description 干什么用的,主 Agent 靠它决定派给谁
graph_id 远端服务器上的图名
url 那台服务器的地址

主 Agent 收到的 5 件异步工具里,start_async_task 只要 2 个参数:一个干活的描述,一个子 Agent 类型。剩下的都围着任务号转。

一轮派 3 个任务,服务器那边收到 6 次请求:3 次建会话,3 次起任务。主 Agent 这条链走完用了 70 到 110 毫秒。

**这里也有个不稳的地方。**同一个回合派出去的 3 个任务,回执落回消息里的先后跟发起的先后对不上。我连着跑了 6 遍,一遍都没对上。

原因藏在这类工具的返回方式里。task 那件工具返回的是一条 ToolMessage,工具节点会把它们按调用顺序拼好。而这 5 件异步工具返回的是 Command,也就是带着状态更新的指令,谁先落进状态谁排在前面,跟并发调度的先后走。

所以拿任务号的时候不能靠位置。这也正是 check_async_task 那几件工具的描述里反复强调「把任务号原样传回去」的原因。

七、一个任务的全过程

把它跑完一轮,服务器那边一共收到 14 次请求。这个账很能说明这条路的形状。

派出去 6 次

查进度 6 次

收尾 2 次

步骤 服务器收到的请求 次数
一轮派 3 个 建会话 3 次,起任务 3 次 6
查第 1 个,此时在跑 查任务 1 次 1
再查一次,此时已完成 查任务 1 次,取会话内容 1 次 2
列出全部 查任务 2 次 2
追加一条指令 起任务 1 次 1
取消 取消 1 次 1
合计 14

有 3 处细节值得单独拎出来。

第一,查到一个已完成的任务时,它会多要一次会话内容。因为状态本身只回答了「成没成」,结果在会话里,得再取一次。

第二,列表那一步只发了 2 次查询,不是 3 次。3 个任务里有 1 个已经是终态,终态不会再变,代码里就把这类任务的实时查询跳过了。任务越多,这一步省下来的请求越多。

第三,追加指令走的是「同一个会话,起一个新任务」。任务号不变,内部的任务编号换成了新的。后面取消用的也是这个新编号。

还有一处。如果远端那个任务失败了,主 Agent 收到的是这么一条:

{"status": "error",
 "thread_id": "thread-044",
 "error": "远端那边的活没干成"}

它是一条正常回执,主 Agent 不会挂。这是这 2 条路在出错这件事上最大的分别:远端的事失败在远端,主 Agent 这边只是一条消息。

八、怎么用

理完这些东西,我在派活这件事上留下 4 条:

**同时推进的活,凑齐在一条消息里派出去。**一轮派完和分 3 轮派差 2.84 倍,消息还少 12 token,没有理由不凑。

**给并发留个上限。**默认上限是 CPU 核数加 4,这个数不一定是你想要的。传一个 max_concurrency 进去,能安心不少。

**别指望子 Agent 帮你兜住失败。**它内部炸了会把主 Agent 一起带走。要么在工具上加兜底,要么派之前就筛一遍。

**真要长时间跑、又不想占着主 Agent,就把它扔到远端。**代价是得先有那台服务器,还得自己管任务号。任务号不能靠位置记,得老老实实传回去。