AGAgent 学习路线
第 6 / 20
CHAPTER 06 · GPT 生成学习页

Subagent 分身协作

把自包含的探索交给干净上下文的子 Agent,只把有证据的结论带回父任务。

01 / 路线

先看它怎样跑起来

从输入到验收

这一章不是几个孤立知识点,而是一条会产生结果的因果链。

关键判断

Subagent 分身协作

亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。

  • 隔离探索轨迹能控制父上下文增长。
  • task 是一次性子任务,不等同于持续队友。
  • 父 Agent 仍拥有最终编排和验收责任。
1父 Agent 拆分问题
2创建一次性 Runner
3子 Agent 独立探索
4返回证据结论
点击播放,观察动作怎样传递0 / 4
02 / 正文

顺着原文把边界看清

14 个小节29 组代码0 行表格

按原文顺序阅读。摘要只负责定位,真正的边界、例外和代码都在展开内容里。

01导读:问题背景与本章目标让一个 Agent 追查深调用链里的 bug,常见的失控是这样:它读文件、搜符号、跑命令,几十轮工具结果很快就塞满历史。即使第 5 章的 TODO 还在,父任务仍然要同时记住最初目标、当前计划和一大段探索轨迹;每多一次工具调用,模型要处理的上下文就更长。

让一个 Agent 追查深调用链里的 bug,常见的失控是这样:它读文件、搜符号、跑命令,几十轮工具结果很快就塞满历史。即使第 5 章的 TODO 还在,父任务仍然要同时记住最初目标、当前计划和一大段探索轨迹;每多一次工具调用,模型要处理的上下文就更长。

这时不应把所有工作都压进同一条对话。更合适的做法是:把一个自包含子任务交给一次性 Subagent,让它从全新历史开始探索。父 Agent 最后只接收一句有证据的结论,而不接收中间的搜索和读文件记录。

第 6 章新增的正是这个能力:task

图片
图片

02先看一眼一次真实的委派父子日志交错——以及那如果子 Agent 想越界写呢。

npm run ch06 -- --prompt '调用 task 独立检查本项目使用的测试框架,只返回有文件证据的结论',在 stderr 上会看到父子交错的日志。

# stderr 上父子交错
[Hook] UserPromptSubmit     (父)
[Hook] PreToolUse: task     (父)
[Hook] UserPromptSubmit     ← 子 Agent 那次 run() 也打日志!
[Hook] PreToolUse: read_file (子)
# ...
# Hook 是共享实例,所以子 Agent 的 run() 也会打 UserPromptSubmit 和 Stop

第 2 点是本章一个真实的观测局限:共享 HookRegistry 的代价是日志里分不清父子。想区分的话,Hook 回调可以自己维护一个嵌套深度计数——但那是读者的扩展,本章不做。

那如果子 Agent 想越界写呢?子 Agent 的 write_file 照样走第 3 章权限:工作区内弹审批、工作区外被硬边界 deny。`task` 自己是 allow(读取类),但子 Agent 的写入照样弹到你面前——审批框上没有标注「这是子 Agent 发起的」,因为父子共享 identity。


03这章解决什么问题第 5 章的 todowrite 解决的是“单次会话如何持续保存计划”。它让最新的完整计划快照不断回到消息流,但不会减少探索过程本身带来的上下文。

第 5 章的 todo_write 解决的是“单次会话如何持续保存计划”。它让最新的完整计划快照不断回到消息流,但不会减少探索过程本身带来的上下文。

第 6 章解决的是“如何隔离一段独立探索”。父 Agent 调用 task 后,运行时创建一个新的 AgentRunner

父 Agent
  assistant: task(description=...)
        │
        ▼
一次性 Subagent
  system: 专注完成委派任务,不再委派
  user: description
  history: 全新
  tools: shell/read_file/write_file/edit_file/glob/todo_write
        │
        ▼
父 Agent
  tool: 子 Agent 的 finalText

这里的“隔离”只指消息历史隔离:

  • 子 Agent 看不到父 Agent 的历史;
  • 父 Agent 看不到子 Agent 的中间工具轨迹;
  • 子 Agent 在当前 workspace 内写入的文件会保留;
  • 父子仍运行在同一个 Node.js 进程中,使用同一组 Hook、权限策略、workspace 和 identity。

因此 Subagent 不是操作系统沙箱。shell 仍是 PowerShell 执行,文件路径边界也不是系统级隔离。它解决上下文污染,不会自动获得更少或更多权限。

本章也不引入并发、后台队列、通知、长期存活 worker 或递归 Agent 树。一个 task 调用同步等待一个子 Agent,子 Agent 最多运行 30 轮。


04第 6 章的验收契约export const P06: ChapterProfile = Object.freeze({

这一章在 P05 的能力集合上只增加 subagent

export const P06: ChapterProfile = Object.freeze({
  chapter: 6,
  capabilities: new CapabilitySet([
    "loop",
    "powershell",
    "tool_registry",
    "files",
    "policy",
    "hooks",
    "todo",
    "subagent",
  ]),
});

可观察结果有五条:

  1. 每次 task 都从新的模型请求、工具注册表和消息历史开始;
  2. 父 history 只获得与原 task 调用配对的最终 tool result;
  3. 子 Agent 使用父 Agent 的 Hook、权限策略、workspace 和 identity;
  4. 子工具集中没有 task,递归调用会成为 unknown_tool
  5. 子 Agent 30 轮后仍没有最终文本时,返回结构化 subagent_turn_limit,绝不把最后一个工具结果冒充答案。

此外,当委派描述为空、全空白,或包含未知字段时,task 也和其他工具一样,在进入 handler 之前就返回 invalid_arguments


05一、严格的 task 输入实现位于 code/chapters/ch06/src/features/subagents.ts。task 输入只有一个字段,但依然通过 strict Zod schema 定义:

实现位于 code/chapters/ch06/src/features/subagents.tstask 输入只有一个字段,但依然通过 strict Zod schema 定义:

const taskInputSchema = z
  .object({
    description: z
      .string()
      .transform((description) => description.trim())
      .pipe(z.string().min(1))
      .describe("A self-contained task for the subagent to complete."),
  })
  .strict();

export type TaskInput = Readonly<z.output<typeof taskInputSchema>>;

它表达了三个约束:

  • 描述必须是 JSON string;
  • 首尾空白会被去掉," " 仍然是非法输入;
  • .strict() 拒绝额外字段。

模型会看到由同一个 schema 导出的 JSON Schema:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["description"],
  "properties": {
    "description": {
      "type": "string",
      "minLength": 1,
      "description": "A self-contained task for the subagent to complete."
    }
  }
}

为什么强调“自包含”?因为子 Agent 不会继承父 history。下面这样的委派没有足够信息:

{ "description": "继续处理刚才那个问题" }

更好的描述会明确对象、目标和证据要求:

{
  "description": "检查 package.json 和测试目录,确认项目使用的测试框架;只返回能由文件内容支持的结论。"
}

06二、SubagentTool 只负责执行边界SubagentTool 本身不是第二套 Agent Loop。它注册普通的 ToolDefinition,在 handler 中构造现有 AgentRunner:

SubagentTool 本身不是第二套 Agent Loop。它注册普通的 ToolDefinition,在 handler 中构造现有 AgentRunner

export const TASK_TOOL_NAME = "task";
export const DEFAULT_SUBAGENT_MAX_TURNS = 30;

export const DEFAULT_SUBAGENT_SYSTEM_PROMPT =
  "You are a focused coding subagent working in the current workspace. " +
  "Complete only the delegated task, then return a concise, evidence-based final conclusion. " +
  "Do not delegate further.";

export class SubagentTool {
  readonly toolDefinition: ToolDefinition<TaskInput>;

  constructor(options: SubagentToolOptions) {
    // 参数与运行边界校验省略
    this.toolDefinition = Object.freeze({
      name: TASK_TOOL_NAME,
      description: "Launch an isolated subagent and return only its final conclusion.",
      inputSchema: taskInputSchema,
      effect: "external",
      handler: (input, context) => this.#runTask(input, context),
    });
  }
}

effect: "external" 表示这个工具会发起一段独立执行,而不是直接读文件或写文件。第 3 章的审批策略仍然由工具名和硬边界决定。它不会因为 task 被标记为 external,就把所有子工具误判成磁盘写入;子工具是否允许写盘,仍由各自工具名和权限边界决定。

执行时的关键部分如下:

const tools: unknown = this.#toolsFactory();
if (!(tools instanceof ToolRegistry)) {
  return toolError(
    "subagent_configuration_error",
    "Subagent tools factory must return ToolRegistry",
  );
}
if (tools.names.includes(TASK_TOOL_NAME)) {
  return toolError(
    "subagent_configuration_error",
    "Subagent tools must not include task",
  );
}

const runner = new AgentRunner({
  model,
  tools,
  systemPrompt: this.#systemPrompt,
  workspace: context.workspace,
  identity: context.identity,
  maxTurns: this.#maxTurns,
  hooks: this.#hooks,
  permissionPolicy: this.#permissionPolicy,
});
const result = await runner.run(input.description);
return toolSuccess(result.finalText);

这里没有复制消息循环。父 Agent 和子 Agent 使用同一个 AgentRunner,因此消息配对、工具 dispatch、权限、Hook 和 Stop 行为都走同一套实现。


07三、父 history 为什么保持干净'{"description":"追踪 login() 的调用链并给出文件证据"}',

假设父模型调用:

toolCall(
  "parent-task",
  "task",
  '{"description":"追踪 login() 的调用链并给出文件证据"}',
);

子 Agent 可以进行多轮 read_fileglobshell,但 SubagentTool 只返回它的最终文本:

return toolSuccess(result.finalText);

因此父 history 只会保存四类消息:

user:      父任务
assistant: tool_calls=[task(parent-task)]
tool:      子 Agent 的 finalText, toolCallId=parent-task
assistant: 父 Agent 的最终回答

子 Agent 内部历史不会 append 到父 Agent。父 Loop 仍然会正常把 task 的返回值写成与 parent-task 相同 ID 的 tool message,因此 OpenAI 工具配对契约没有例外。

这也是本章比“让父 Agent 自己继续读文件”更有价值的地方:子 Agent 可以有很长的探索轨迹,但父上下文只留下被委派的动作和可用于后续推理的结论。


08四、历史隔离,运行边界共享子 Agent 使用的是新 AgentRunner,但不是一套新权限系统。构造时直接传入父会话组合根创建的同一实例:

子 Agent 使用的是新 AgentRunner,但不是一套新权限系统。构造时直接传入父会话组合根创建的同一实例:

hooks: this.#hooks,
permissionPolicy: this.#permissionPolicy,
workspace: context.workspace,
identity: context.identity,

这意味着子工具调用的顺序仍是:

PreToolUse Hook
-> PermissionPolicy
-> handler
-> PostToolUse Hook

例如子 Agent 尝试向 workspace 外写入:

{
  "path": "../outside.txt",
  "content": "changed"
}

第 3 章的 workspace 硬边界先产生 deny。即使终端审批提供者会允许写入,它也不会被调用,handler 也不会运行。子模型得到的只是匹配的错误结果:

Error [permission_denied]: Writing outside the workspace is forbidden

反过来,合法的子 write_file 仍按原策略请求审批并写入同一个 workspace。子 Agent 写出的文件不是临时副本,父 Agent 之后可以继续读取、编辑或验证。


09五、为什么必须禁止递归委派子工具工厂创建的是独立标准工具集,其中只保留前六个。它不会注册 task。

P06 的父工具有 7 个:

shell
read_file
write_file
edit_file
glob
todo_write
task

子工具工厂创建的是独立标准工具集,其中只保留前六个。它不会注册 task

运行时还会做第二层检查:

if (tools.names.includes(TASK_TOOL_NAME)) {
  return toolError(
    "subagent_configuration_error",
    "Subagent tools must not include task",
  );
}

为什么同时有“工厂不注册”和“运行时拒绝”两层?前者是正常组装路径,后者保护工厂被错误改写时的配置边界。错误配置在模型调用前返回,不会启动一个可能无限嵌套的子会话。

如果子模型仍然生成 task(...),Registry 会像处理其他未知工具一样返回:

Error [unknown_tool]: Unknown tool: task

子模型下一轮可以根据这个结果继续完成自己的工作或给出结论,但不会真的创建孙 Agent。


10六、30 轮上限和脱敏错误export const DEFAULTSUBAGENTMAXTURNS = 30;

默认上限定义为:

export const DEFAULT_SUBAGENT_MAX_TURNS = 30;

构造 SubagentTool 时允许把上限调低以便测试,但不允许高于 30:

if (!Number.isInteger(maxTurns) || maxTurns <= 0) {
  throw new Error("maxTurns must be a positive integer");
}
if (maxTurns > DEFAULT_SUBAGENT_MAX_TURNS) {
  throw new Error("maxTurns must be at most 30");
}

构造阶段的校验会把配置错误提前暴露在组合根,而不是等第一次 task 调用时才失败。正因为上限在构造时固定,测试可以用更小的 maxTurns 验证边界,而正式运行仍统一封顶 30 轮。

当子模型连续 30 轮只返回工具调用时,公共 Loop 抛出 AgentLimitErrortask handler 在自己的执行边界把它转换为稳定错误:

if (error instanceof AgentLimitError) {
  return toolError(
    "subagent_turn_limit",
    `Subagent exceeded max_turns=${this.#maxTurns} without a final answer`,
  );
}

父模型收到:

Error [subagent_turn_limit]: Subagent exceeded max_turns=30 without a final answer

它不会收到第 30 轮最后一个工具输出,也不能把那个输出误认为子任务结论。

其他异常同样在 task 的边界脱敏:

return toolError("subagent_execution_error", "Subagent execution failed");

这样模型不会看到内部异常中可能出现的 API Key、文件路径或适配器细节。这里的 try/catch 是任务执行边界的受控输出,不是业务逻辑中吞掉失败。父 Agent 能根据结构化 error code 决定重试、改写任务,或直接报告失败。


11七、组合根如何接入 P06buildAgent() 仍是唯一组合根。它先创建前五章的标准工具,再为 P06 创建 SubagentTool:

buildAgent() 仍是唯一组合根。它先创建前五章的标准工具,再为 P06 创建 SubagentTool

const standardTools = createStandardTools(profile, commandRunner, fileSystem);
const tools = standardTools.tools;
const permissionPolicy = permissionPolicyForProfile(profile, fileSystem, dependencies);

const hooks =
  dependencies.hooks === undefined && profile.capabilities.has("hooks")
    ? new HookRegistry()
    : dependencies.hooks;

if (profile.capabilities.has("subagent")) {
  if (hooks === undefined || permissionPolicy === undefined) {
    throw new Error("subagent capability requires hooks and permission policy");
  }

  const subagent = new SubagentTool({
    modelFactory: () => dependencies.model,
    toolsFactory: () => createStandardTools(profile, commandRunner, fileSystem).tools,
    hooks,
    permissionPolicy,
  });
  tools.register(subagent.toolDefinition);
}

createStandardTools() 是前五章工具组装的唯一出口。它会按章节创建 shell 与文件工具集,并在 P05/P06 上把 TodoTracker 同时注册为工具和每轮观察器。父 Agent 与子 Agent 都通过同一个工厂创建独立注册表,因此父级 TODO 状态不会被子级读写,而子级仍复用同一套标准工具契约。

两点值得留意:

  • 每一次 toolsFactory() 都新建一个 ToolRegistry。所以不同子任务不会共享工具注册表,也不会共享 TODO 快照。
  • modelFactory() 返回组合根注入的模型边界。因此真实 CLI 仍只有一个 OpenAI 配置路径,离线测试仍可注入 ScriptedModelClient

第六章固定入口也没有复制运行时:

import { runProfile } from "../cli.js";
import { P06 } from "../core/profiles.js";

process.exitCode = await runProfile(P06, process.argv.slice(2));

12八、用离线测试证明行为第六章的领域测试在 code/chapters/ch06/tests/subagents.test.ts,覆盖:

第六章的领域测试在 code/chapters/ch06/tests/subagents.test.ts,覆盖:

  • strict 参数 schema 和 30 轮上限;
  • 父 history 只有 task 结果,子 history 从 system + user 重新开始;
  • 父子共享 Hook、权限、workspace 与 identity;
  • 每次调用重新执行模型/工具工厂,并获得新的工具注册表、Runner 和子 history;
  • 递归 task 变为 unknown_tool
  • workspace 外写在子 handler 前被拒绝;
  • 30 轮耗尽与未预期异常都返回脱敏结构化错误;
  • 子工具工厂错误地重新注册 task 时,模型零调用。

组合根测试在 code/chapters/ch06/tests/ch06-subagents.test.ts,验证真实 P06 接线:

  • 父请求可见 7 个工具,子请求只可见不含 task 的 6 个工具;
  • 子 Agent 通过同一审批策略写入文件后,父 Agent 能在同一 workspace 继续使用这个副作用;
  • 子 Agent 无法通过审批突破 workspace 硬边界;
  • 父/子消息都保持完整 tool-call 配对。

先运行第六章最小验收:

Set-Location 'F:\笔记\Agent实操\code'
npm run typecheck
npm run test:ch06

本章修改了组合根、profile 和共享 Loop 的调用方式,因此提交前还应运行完整门禁:

Set-Location 'F:\笔记\Agent实操\code'
npm test
npm run lint
npm run format:check
npm run build

这些测试不读取 .env,不访问网络。它们用可脚本化的模型回复证明运行时契约,而不是把一次真实模型调用当作覆盖率。


13九、运行第 6 章Set-Location 'F:\笔记\Agent实操\code'

章节入口:

Set-Location 'F:\笔记\Agent实操\code'
rtk npm run ch06 -- --prompt "调用 task 独立检查本项目使用的测试框架,只返回有文件证据的结论"

统一 CLI 入口:

Set-Location 'F:\笔记\Agent实操\code'
rtk npm run agent-tutorial -- run --chapter 6 --prompt "调用 task 总结 chapters/ch06/src/core 目录职责,再由父 Agent 给出结论"

真实模型是否在某次任务中主动使用 task 仍有随机性,所以更可靠的验证是观察运行日志和离线测试。一次运行里,应该看到父模型先调用 task,随后子模型获得新 history。子 Agent 的中间读写不会回填父 history,父模型只接收最终结论或结构化错误。


14十、这章没有做什么- 不创建子进程或容器,不把 Subagent 描述为安全沙箱;

为了不把后续章节的设计提前塞进来,第 6 章明确不做:

  • 不创建子进程或容器,不把 Subagent 描述为安全沙箱;
  • 不并行执行多个子任务;
  • 不持久化子 Agent history;
  • 不新增后台 job、事件队列或完成通知;
  • 不允许子 Agent 再调用 task
  • 不把最后一个工具输出伪装成最终答案。

本章只增加了一个受控的上下文切分点:父 Agent 把独立工作委派出去,保留最终结论与所有既有运行边界。


15十一、与 Claude Code 的差异本章的 task 是教学子集。Claude Code 内置的 Subagent 也有同样的设计目标:避免主上下文被搜索、日志和文件内容淹没。官方文档见:https://code.claude.com/docs/en/sub-agents。两者的主要差异如下:

本章的 task 是教学子集。Claude Code 内置的 Subagent 也有同样的设计目标:避免主上下文被搜索、日志和文件内容淹没。官方文档见:https://code.claude.com/docs/en/sub-agents。两者的主要差异如下:

  • 消息历史创建方式不同。Claude Code 有三种 Subagent 模式:Normal 使用全新历史,Fork 保留父消息前缀以共享 prompt cache,General-purpose 可读写并支持后台与异步任务。本章只实现最简的 Normal 模式,刻意不引入共享历史,以免破坏“子任务上下文独立”这个教学边界。
  • 递归委派不同。真实 Claude Code 允许嵌套 Subagent,但由 depth limit 和 Agent 工具管理。本章为教学安全,禁止子 Agent 再调用 task,只保留一层委派。
  • 后台与通知不同。Claude Code 支持后台 Subagent、权限冒泡和完成通知。本章的 task 同步等待子 Agent 完成,不引入并发 Job、事件队列或持久化结果。
  • 权限语义一致。真实 Claude Code 中,父级使用 bypassPermissionsacceptEdits 时,子 Agent 不能覆盖。本章的父子共享同一 PermissionPolicyHookRegistry,上下文隔离不会自动降低或提高权限。
  • 停止条件一致。Anthropic 在《Building Effective Agents》中把 Agent 描述为“LLM + 工具循环”,需要明确的停止条件(https://www.anthropic.com/engineering/building-effective-agents)。P06 的 maxTurnsfinalText 就是这个原则的可测试落点:子 Agent 要么给出最终文本,要么返回结构化 subagent_turn_limit

下一章将处理另一种上下文膨胀:不同任务需要不同规范和背景知识,但不应该把所有知识常驻在 system prompt。第 7 章会引入按需加载的 Skill。

16本章小结三句话版本、一定要记住的八条、以及本章「还没做什么」。

三句话版本:

  1. task 把一段自包含探索搬到一个全新历史里跑,父 Agent 只拿回一句结论。
  2. 隔离的只有消息历史和工具注册表;Hook、权限、workspace、identity、进程全部共享。
  3. 委派不创造新路径:子 Agent 该弹的审批照样弹,该拦的越界照样拦。

一定要记住的八条:

#结论出现在哪一节
1「隔离」= 上下文隔离,不是沙箱、不是提权、不是降权这章解决什么问题
2子 Agent 看不到父 history,所以 description 必须自包含
3SubagentTool 不是第二套 Loop,它复用同一个 AgentRunner
4父 history 只多三条:assistant(task)、tool(结论)、assistant(最终回答)
5代价是子 Agent 的推理过程无法审计——所以要求 evidence-based 结论
6禁止递归防三层:提示、工厂、运行时;第三层保证「模型零调用」
7跑满轮数返回结构化错误,绝不把最后一个工具输出冒充结论
8子 Agent 有 todo_write 但没有 toolRoundObserver——刻意的不对称

本章代码边界:P06 只加了「一层同步委派」,没加并行(第 13 章后台任务)、没加持久化、没加沙箱;多 Agent 认领在 17、18 章;Inbox 异步协作在 15 章。

检查你是否真的读懂了:

  1. 子 Agent 在 workspace 里写的文件,task 返回后还在吗?为什么?
  2. 父 Agent 调用一次 task,父 history 里多了几条消息?分别是什么?
  3. 子 Agent 的 write_file 会弹审批框吗?审批框上能看出这是子 Agent 吗?
  4. 给子 Agent 配「永远批准」的审批器,它能写出 workspace 吗?
  5. 子模型自己生成了 task(...) 调用,它会收到什么?
  6. 子 Agent 跑满 30 轮,父 Agent 收到的是第 30 轮的工具输出,还是别的东西?
  7. 子 Agent 的工具列表里有 todo_write。它会收到「计划陈旧」提醒吗?

03 / 自测

换个场景,你还会判断吗?

答完再看理由

每题只测一个边界。先做决定,再看解释。

SCENARIO CHECK01 / 030 分

准备开始