Subagent 分身协作
把自包含的探索交给干净上下文的子 Agent,只把有证据的结论带回父任务。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
Subagent 分身协作
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 隔离探索轨迹能控制父上下文增长。
- task 是一次性子任务,不等同于持续队友。
- 父 Agent 仍拥有最终编排和验收责任。
顺着原文把边界看清
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",
]),
});可观察结果有五条:
- 每次
task都从新的模型请求、工具注册表和消息历史开始; - 父 history 只获得与原
task调用配对的最终 tool result; - 子 Agent 使用父 Agent 的 Hook、权限策略、workspace 和 identity;
- 子工具集中没有
task,递归调用会成为unknown_tool; - 子 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.ts。task 输入只有一个字段,但依然通过 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_file、glob 或 shell,但 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 抛出 AgentLimitError。task 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 中,父级使用
bypassPermissions或acceptEdits时,子 Agent 不能覆盖。本章的父子共享同一PermissionPolicy和HookRegistry,上下文隔离不会自动降低或提高权限。 - 停止条件一致。Anthropic 在《Building Effective Agents》中把 Agent 描述为“LLM + 工具循环”,需要明确的停止条件(https://www.anthropic.com/engineering/building-effective-agents)。P06 的
maxTurns和finalText就是这个原则的可测试落点:子 Agent 要么给出最终文本,要么返回结构化subagent_turn_limit。
下一章将处理另一种上下文膨胀:不同任务需要不同规范和背景知识,但不应该把所有知识常驻在 system prompt。第 7 章会引入按需加载的 Skill。
16本章小结三句话版本、一定要记住的八条、以及本章「还没做什么」。⌄
三句话版本:
- task 把一段自包含探索搬到一个全新历史里跑,父 Agent 只拿回一句结论。
- 隔离的只有消息历史和工具注册表;Hook、权限、workspace、identity、进程全部共享。
- 委派不创造新路径:子 Agent 该弹的审批照样弹,该拦的越界照样拦。
一定要记住的八条:
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 「隔离」= 上下文隔离,不是沙箱、不是提权、不是降权 | 这章解决什么问题 |
| 2 | 子 Agent 看不到父 history,所以 description 必须自包含 | 一 |
| 3 | SubagentTool 不是第二套 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 章。
检查你是否真的读懂了:
- 子 Agent 在 workspace 里写的文件,task 返回后还在吗?为什么?
- 父 Agent 调用一次 task,父 history 里多了几条消息?分别是什么?
- 子 Agent 的 write_file 会弹审批框吗?审批框上能看出这是子 Agent 吗?
- 给子 Agent 配「永远批准」的审批器,它能写出 workspace 吗?
- 子模型自己生成了 task(...) 调用,它会收到什么?
- 子 Agent 跑满 30 轮,父 Agent 收到的是第 30 轮的工具输出,还是别的东西?
- 子 Agent 的工具列表里有 todo_write。它会收到「计划陈旧」提醒吗?
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始