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 的实现