5. SubAgent 的实现
前四篇文章我们搭出了一个单 Agent:能循环执行、能记住历史、能规划任务、能加载 Rules 和 Skills。它处理中小型任务已经很顺手了。
但有两个场景,单 Agent 怎么都别扭:
并行任务。 用户说”帮我同时看看这三个目录里有没有敏感信息”。单 Agent 只能串行——读完 A 目录,汇总一下,再读 B 目录,再汇总……三个目录读完,token 已经烧掉一大截,中间它可能还忘了 A 目录的结果。
上下文膨胀。 Agent 跑大型任务时,messages 数组会越来越长——LLM 的每次思考、每次工具调用、每次工具返回结果,全都堆在里面。跑着跑着,上下文窗口满了,前面的关键信息被冲走了。
这两个问题的解法是同一个:让 Agent 能 spawn 子进程,把任务分出去。
就像操作系统不会让一个进程干所有事——遇到重活,fork 一个子进程,干完回收。Agent 也需要同样的机制。
SubAgent As Tool
SubAgent 的本质就是一个 tool。跟 bash、read、write 一样,放在 tools 数组里,LLM 自己决定什么时候调。
const subAgentTool = {
type: "function",
function: {
name: "subagent",
description: "Spawn a sub-agent to handle an independent subtask. Use this when you have multiple independent sub-problems that can be solved in parallel, or when you need to isolate a piece of work to keep the main context clean.",
parameters: {
type: "object",
properties: {
task: { type: "string", description: "The task for the sub-agent to complete" },
},
required: ["task"],
},
},
};SubAgent 不需要 tools 参数——它拿到的工具集是代码写死的。原因跟 Plan-as-Tool 里”subtask 不带 plan 工具”一样:如果让 LLM 决定 SubAgent 有什么工具,它可能把 subagent 工具也传进去,形成 main → sub → sub → sub 的无限递归。工具集由代码定,不给 LLM 选择权。
主 Agent 的 System Prompt 里加一句话:“如果任务涉及多个独立的子问题,可以调用 subagent 工具并行处理。每个 subagent 拿到独立的任务描述和工具集,完成后返回结果。”
当 LLM 决定调 subagent 时,tool_call 大概是这样的:
{
"function": "subagent",
"arguments": {
"task": "检查 /src/api/ 目录下的所有 .ts 文件,找出没有做错误处理的接口"
}
}代码实现
SubAgent 不是一个新类,它直接复用已有的 Agent Loop:
// SubAgent 能用的工具由代码定,不给 LLM 选
const SUBAGENT_TOOLS = ["read", "bash", "write", "edit"];
async function subagent(args: { task: string }) {
const messages = await agentLoop(args.task, {
tools: SUBAGENT_TOOLS,
maxIterations: 10, // 比主 Agent 更紧的约束
systemPrompt: "You are a focused sub-agent. Complete the assigned task and return the result. Do not ask questions.",
});
return extractFinalAnswer(messages);
}几个关键点:
独立的 messages 数组。 SubAgent 接收到的只是一个 task 字符串,它从头构建自己的对话历史。它看不到主 Agent 的 messages——不知道主 Agent 之前干了什么、跟用户聊了什么。这是故意的:SubAgent 的使命是专注完成一个子任务,它不需要知道全局,知道了反而分心。
SubAgent 拿不到 subagent 工具。 这点跟 Plan-as-Tool 里”subtask 执行器不带 plan 工具”的设计如出一辙。如果 SubAgent 自己也能调 subagent,理论上它可以无限递归——每层 SubAgent 再 spawn 一层 SubAgent,直到 token 耗尽。切断这个链条的方式就是不把 subagent 工具传给 SubAgent。
更紧的 maxIterations。 主 Agent 可能跑 20、50 轮,SubAgent 只给 10 轮。子任务应该小而明确——如果 10 轮还没搞定,多半是任务拆分得不对,或者这个子任务本身就不该交给 SubAgent。
并行还是串行,LLM 自己决定
tool_call 支持一次返回多个工具调用。LLM 可以同时发出:
{
"tool_calls": [
{ "function": "subagent", "arguments": { "task": "检查 /src/api/ 的安全问题" } },
{ "function": "subagent", "arguments": { "task": "检查 /src/components/ 的性能问题" } },
{ "function": "subagent", "arguments": { "task": "检查 /src/utils/ 的测试覆盖率" } }
]
}LLM 一次性返回了三个 subagent 调用。这时候就不能串行执行了——三个 SubAgent 之间没有依赖关系,谁先谁后无所谓。
用 RxJS 来编排并行和串行:
import { from, mergeMap, concatMap, lastValueFrom, forkJoin, toArray } from 'rxjs';
if (msg?.tool_calls?.length) {
const subagentCalls = msg.tool_calls.filter(tc => tc.function.name === 'subagent');
const otherCalls = msg.tool_calls.filter(tc => tc.function.name !== 'subagent');
// SubAgent 之间并行,最多同时跑 3 个
const subagent$ = from(subagentCalls).pipe(
mergeMap(tc => from(subagent(JSON.parse(tc.function.arguments))), 3),
toArray(),
);
// bash、write 等工具严格串行
const other$ = from(otherCalls).pipe(
concatMap(tc => from(executeTool(tc))),
toArray(),
);
// 两组之间互不阻塞
const [subResults, otherResults] = await lastValueFrom(
forkJoin([subagent$, other$]),
);
}mergeMap 第二个参数 3 是并发上限——LLM 如果一口气回了 10 个 subagent 调用,同一时刻最多只跑 3 个,其余的排队。不加这个限制,10 个 SubAgent 同时启动,每个内部都在调 LLM API,瞬间触发限流。
forkJoin 把两条流拼在一起:subagent 组和 bash 组互不阻塞。LLM 可能同时返回了 2 个 subagent 和 1 个 write 调用——write 不用等 subagent 全跑完才开始,subagent 也不用等 write 结束。
subagent 之间没有共享状态——每个 SubAgent 跑自己的 messages、自己的工具、自己的 Agent Loop,互不干扰。所以并行安全。而 bash、write 等工具用 concatMap 保持串行——前一个命令的输出可能是后一个的输入。
每个 SubAgent 内部还是标准的串行 Loop,它自己可能调好几轮工具。主 Agent 不关心它内部跑了几轮,只等它返回结果。
Context 隔离:为什么 SubAgent 不该继承主 Agent 的记忆
有一种诱惑是给 SubAgent 传”主线程之前干了什么”——把主 Agent 的 messages 历史或 memory 内容塞给 SubAgent,美其名曰”帮助 SubAgent 理解上下文”。
通常不这么做。
SubAgent 的价值恰好在于它的上下文是干净的。主 Agent 跑了 30 轮之后,messages 数组可能已经塞了上万 token 的旧信息——第一轮读的 package.json、第三轮失败的 bash 命令、第五轮跑偏的思路……这些东西对 SubAgent 当前要处理的子任务没有任何帮助,只会稀释它的注意力。
如果你非要传上下文,传结果摘要而不是原始日志。主 Agent 可以在调用 subagent 的时候在 task 参数里附上一句”当前项目的技术栈是 Next.js + Prisma”,而不是把整个 30 轮的 messages 数组 dump 进去。
设计哲学:代码提供能力,LLM 控制流程
SubAgent 的这种设计背后有一条贯穿整个系列的原则:代码负责提供能力,LLM 负责决定怎么用。
代码做的事情:
- 定义 subagent 工具(名称、参数、描述)
- 限制 SubAgent 可用工具(不给 subagent 工具本身)
- 设置安全边界(maxIterations、超时时间)
- 支持并行执行(RxJS mergeMap 控制并发)
代码不做的事情:
- 替 LLM 判断”现在该不该用 SubAgent”
- 替 LLM 决定”这个任务拆成几个子任务”
- 替 LLM 编排”先跑哪个 SubAgent,后跑哪个”
这些决策全部交给 LLM——它收到任务后,自己判断复杂度,自己决定要不要 spawn SubAgent,spawn 几个,参数是什么。
这就是 Agent 和传统程序最大的区别。传统程序是”代码说了算”——if-else 写死了流程,数据走固定管道。Agent 是”模型说了算”——代码只提供能力菜单,模型自己点菜、自己决定上菜顺序。
你可能会问:如果 LLM 决定错了怎么办?它没调 subagent,导致主上下文撑爆了?
这就是为什么 maxIterations 和 token 阈值存在。它们是代码层面的安全网,不干涉 LLM 的决策自由,但在它搞砸的时候兜底。就像操作系统不会阻止你打开 100 个 Chrome 标签页——但它会内存不足的时候杀进程。
下一篇:5. SubAgent 的实现