第 5 章:Subagent——用独立上下文承接局部探索
开场:派给「小弟」的活,为什么他自己还带了一本笔记本?
上一章你把复杂任务拆成了可观察的步骤。新的问题马上出现:如果「调研三个网站」这一步抓回两万字网页、十次搜索和几百行报错,所有细节都涌进主 Agent 的 history,主线很快会被探索的噪声淹没。
想象一家餐厅的厨房。主厨(父 Agent)要决定今晚的菜单,他派三个帮厨(子代理)分别去三个菜市场调研:肉价、菜价、海鲜价。帮厨跑完回来,主厨需要的只是一句话:「猪肉今天 30 块一斤,牛腩 42,摊主说周四补货」——他不需要帮厨把每个摊位聊过的每句话、走过的每条巷子都背一遍。
可这里有个危险的习惯:有的主厨会把自己的记事本(完整对话记录)直接塞给帮厨。 那上面记着 VIP 客人的忌口、餐厅的采购底价、甚至昨晚和老板吵架的事。帮厨只需要「去问肉价」,却拿到了不该看的全部秘密——这就是上下文泄露。
于是这一章的问题来了:把一个子任务派出去,和把一段数据塞给一个函数,区别到底在哪? 答案是:Subagent 不是一个更慢的函数,而是一个一次性、窄任务、受限工具、独立上下文的执行边界。它在局部完成自己的 Tool Loop,只把符合返回契约的结果交回父 Agent。
视频把 Subagent 比作「派小弟」。工程上它首先解决的是什么?
- A:让多个模型同时跑,节省墙钟时间
- B:为局部探索建立一次性、窄任务、受限工具、独立上下文的边界,避免噪声淹没主线
- C:让主 Agent 能写更多文件
- D:替代上一章的计划状态机
普通函数为什么不是 Subagent
先排除一个常见错觉:「用函数封装一次模型调用」就是 Subagent。不是。看这个函数:
def summarize(text: str) -> str:
return text[:200]function summarize(text: string): string {
return text.slice(0, 200);
}普通函数输入和输出由调用者确定,没有自己的 LLM Context,不会在内部自主选择多个工具,也没有独立的预算、轨迹和取消生命周期。
Subagent 恰恰相反:
- 接收的是任务契约,不是固定的参数运算;
- 拥有独立的 messages 和 system prompt;
- 在局部的 Tool Loop 中多步执行——自己决定搜什么、重试什么;
- 有 child run、权限、预算和终态;
- 返回总结、证据或结构化错误。
决定性区别就一条:独立的 Agent 生命周期。 如果流程完全确定、不需要模型自主决策,普通函数更便宜、更稳定、更容易测试。不要为了「多 Agent」给系统增加不确定性——先问自己:普通函数够不够?
一个普通函数和一个 Subagent 的决定性区别是什么?
- A:Subagent 拥有独立的 Agent 生命周期——自己的 messages、工具循环、预算、轨迹和终态
- B:函数不能返回值
- C:Subagent 必须调用网络
- D:函数代码更短
委派契约:不能只丢一句「帮我查一下」
父 Agent 派出子任务,不应该只说「帮我查一下」。最小契约至少包含六个字段:
from dataclasses import dataclass
@dataclass(frozen=True)
class ChildTask:
task_id: str
objective: str
expected_output: str
allowed_tools: frozenset[str]
max_turns: int
timeout_seconds: float
required_evidence: tuple[str, ...] = ()interface ChildTask {
readonly taskId: string;
readonly objective: string;
readonly expectedOutput: string;
readonly allowedTools: ReadonlySet<string>;
readonly maxTurns: number;
readonly timeoutSeconds: number;
readonly requiredEvidence: readonly string[];
}
function createChildTask(
input: Omit<ChildTask, "requiredEvidence"> & {
requiredEvidence?: readonly string[];
},
): ChildTask {
return {
taskId: input.taskId,
objective: input.objective,
expectedOutput: input.expectedOutput,
allowedTools: input.allowedTools,
maxTurns: input.maxTurns,
timeoutSeconds: input.timeoutSeconds,
requiredEvidence: input.requiredEvidence ?? [],
};
}每个字段约束一件事:做什么;返回什么格式;能做哪些动作;最多想/行动多少轮;最多跑多久;哪些结论必须带来源或产物证据。
上下文隔离是另一条纪律。父 Agent 传给子代理的是「最小任务包」:
允许传:任务目标、必要背景、输入引用、返回 Schema、权限与预算
默认不传:完整父 history、全部用户记忆、其他子任务草稿、
与任务无关的密钥名、未脱敏轨迹、父 Agent 的隐式推理子代理结束后,父 Agent 收到的是结构化结果,不是把子 history 原样拼回去:
{
"task_id": "source-a",
"status": "succeeded",
"summary": "……",
"evidence": ["repo://path#symbol"],
"artifacts": [],
"usage": {"turns": 4},
"error": null
}父 Agent 仍对最终答案负责。子代理的总结不是自动可信的事实——父 Agent 收到 succeeded 后仍要检查 evidence 是否可解析、有没有缺失。
父 Agent 派一个「读来源 A」的子任务,默认不该传给子代理的是?
- A:任务目标、返回 Schema 和允许的工具白名单
- B:预算和超时
- C:完整父 history 和与任务无关的用户记忆
- D:必要背景和输入引用
权限白名单:共享工具定义 ≠ 共享权限
主 Agent 和子代理经常共用一套工具定义——避免重复维护。但这里有个必须分清的点:共享工具定义,不等于共享工具权限。
父 Agent 能写文件,不代表研究型子代理该能写文件。教学仓库 step09 明确不给子代理三类东西:dispatch_subagent(避免递归派遣失控)、update_todos(避免污染父计划)、以及和角色不匹配的写入工具。
权限不能靠角色 Prompt 自觉。系统提示里写「你只能读」只是劝;真正管用的是两道代码闸:
- 过滤:给子代理的 Tool Schema,只放进白名单里的工具;
- 再校验:子代理调用工具前,
CapabilityPolicy.require_tool()再拦一次。
角色 Prompt 解释职责,代码决定权限。这条原则从第 1 章到现在没有变过,只是又多了一个作用点。
子代理的生命周期是一次性的:
requested → queued → running
→ succeeded
→ failed
→ timed_out
→ cancelled终态后,子上下文销毁或按隐私策略脱敏存档。它不会自动变成固定队友。所以看一条子任务,你要能同时说出它的边界、权限、预算、当前状态和结果证据。
父 Agent 能写文件,研究型子代理只该只读。正确做法是?
- A:在子代理的系统提示里写「你只能读」
- B:靠模型自觉克制
- C:用代码过滤子代理可见的 Tool Schema,并在调用前再次 require_tool()
- D:给子代理配一个更小的屏幕
并发、超时与级联取消:派出去只是开始
「并发派出去」只是开始,终止和竞态才是 Harness 真正要做的活。三个工程点:
并发适合什么。 相互独立、读多写少、I/O 等待长的子任务适合并发;共享可写资源、结果有顺序依赖、一个失败需要取消其他任务的场景,必须想清楚再并行。
超时要有终态。 每个子任务有自己的 timeout_seconds 和 max_turns,超时进入 timed_out 而不是永远转下去。
级联取消。 父任务取消了,正在调外部工具的子代理必须跟着取消,否则界面显示「父 Run 已取消」,子代理却还在开工单、写文件。取消要沿父子关系传播,已开始的外部副作用要支持幂等和补偿。
完整流程长这样——注意父 Agent 从头到尾没有读取子代理的完整思考,只观察生命周期事件和最终契约:
动手:让两个失败发生,再用一个验证收住
理论和手感之间隔着一道墙,亲手撞一次才会留下印象。下面两个实验都是故意做错的。
失败一:把完整父 history 塞给子代理。
把下面这段直接复制到课程仓库根目录运行:
uv run --python 3.12 python - <<'PY'
parent_history = [
{"role": "user", "content": "偏好:只用中文"},
{"role": "user", "content": "私有项目代号:ORCHID"},
]
child_task = {"objective": "统计三个文件的行数", "required_context": []}
bad_child_context = list(parent_history) + [{"role": "user", "content": child_task["objective"]}]
good_child_context = [{"role": "user", "content": child_task["objective"]}]
assert any("ORCHID" in m["content"] for m in bad_child_context)
assert not any("ORCHID" in m["content"] for m in good_child_context)
print("bad messages =", len(bad_child_context))
print("isolated messages =", len(good_child_context))
PY错误方案把「ORCHID」这个私有代号一起带进了子任务——输入 Token 不降反升,子代理还可能遵循父对话里和局部任务冲突的旧指令。隔离方案只含任务目标。检查自己写的委派:有没有直接 parent.messages.copy()?
失败二:只靠角色 Prompt 控权限。
# 只靠 Prompt 控权限——危险
subagent_system_prompt = "你是只读研究助手,不要写任何文件"
# 但仍把 write_file 的 Schema 注入了子代理可见的工具列表
# 模型只要调用它,Harness 就可能执行
# 修复:Schema 过滤 + 调用前 CapabilityPolicy.require_tool() 双重检查interface ToolSchema {
name: string;
}
// 只靠 Prompt 控权限——危险
const subagentSystemPrompt = "你是只读研究助手,不要写任何文件";
// 但仍把 write_file 的 Schema 注入了子代理可见的工具列表
const visibleToolSchemas: ToolSchema[] = [
{ name: "search" },
{ name: "write_file" },
];
// 模型只要调用它,Harness 就可能执行
// 修复:Schema 过滤 + 调用前 CapabilityPolicy.require_tool() 双重检查跑完后用现成实验验证「并发 + 单次结算 + 级联取消」这套 Harness 机制真实存在——它用模拟来源,离线即可复现:
cd "$(git rev-parse --show-toplevel)/references/repos/ai-agent-book/chapter10/parallel-web-research"
USE_LLM=0 uv run --python 3.12 python demo.py --agents 3 --compare观察输出里这几点:
- 多个子 Agent 并行执行,状态表实时刷新;
- 首个命中只结算一次、terminate 只广播一轮;
- 收到终止信号的子 Agent 在安全点 ack 后退出(级联取消);
- 并行与串行的墙钟对比。
如果结果不符合,按这个顺序排查:
- 重复结算 → 结算前有没有加锁、有没有幂等判断;
- 终止没传开 → 父取消 token 有没有传到所有 child task;
- 子任务卡住 →
timeout_seconds和max_turns有没有生效。
门禁·代码:跑通上面这条 demo(自检通过),把输出保存为证据。
本章小结
这一章把「派小弟」翻译成了一组工程机制。四件事连起来看:
Subagent 首先是上下文隔离机制,其次才是并发机制。 它的价值不是「再调一次模型」,而是为局部探索建立独立上下文,只把符合契约的结果交回父 Agent。
普通函数够用就别上 Subagent。 决定性区别是独立的 Agent 生命周期。确定性流程用函数,需要自主决策的探索才委派。
委派要签契约,权限要过代码。 ChildTask 写清做什么、返回什么、能用哪些工具、跑几轮、多久超时。共享工具定义不等于共享权限——Schema 过滤 + 调用前 require_tool() 双重把关。
派出去只是开始。 并发、超时、级联取消才是 Harness 的活。父 Agent 始终对最终答案负责,子代理的总结不是自动可信的事实。
一句话记住这一章:
把一段探索装进一次性、窄任务、受限工具、独立上下文的执行边界里,只让结论和证据流回主线。父负责决策,子负责探索,Harness 负责边界。
如果上面这些话你自己也能说得出来(不看资料),就说明这一章的知识已经连成一片。
复述与证据
合上资料,画出一次委派的完整生命周期:父派遣 → 子运行 → 结构化回传 → 父验证 → 综合答案。标出「最小任务包」里传了什么、没传什么,以及两道权限闸分别在哪。
再追问三个问题:
- 为什么共享工具实现不等于共享权限?
- 子代理为什么不该默认继承父 Agent 的完整用户记忆?
- 父 Agent 收到
succeeded后,为什么仍要验证 evidence?
门禁·证据:提交一次真实委派的父子事件流,标出 parent_run_id、child_run_id、allowed_tools 和结果 evidence。
24 小时后:不看资料,写一个 dispatch_many()——稳定返回输入顺序、单项超时不吞掉其他结果、父取消时级联取消。
有兴趣可以继续看
以下资料按优先级排。至少完成一项 P0,并保存完成证据。
视频P020 分钟 · 视频 05 [00:00:55–00:05:22] 隔离生命周期,再看 [00:05:22–00:07:14] 并发与安全边界
- 预计
- 20 分钟
- 位置
视频 05 [00:00:55–00:05:22] 隔离生命周期,再看 [00:05:22–00:07:14] 并发与安全边界- 阅读任务
- 分清哪些内容留在子上下文、父 Agent 最终收到什么。
- 完成证据
- 画父/子两条 messages 时间线,标出唯一交汇点。
教学仓库P045 分钟 · Step 09,重点 SUBAGENT_SPECS、run_subagent()、主循环 dispatch 分支
- 预计
- 45 分钟
- 位置
Step 09,重点 SUBAGENT_SPECS、run_subagent()、主循环 dispatch 分支- 阅读任务
- 追踪 _TOOL_SCHEMAS → SUBAGENT_SPECS → run_subagent → dispatch_subagent 的累积 diff。
- 完成证据
- 为五种预设身份整理权限矩阵,找出最宽权限角色。
书与实验P060 分钟 · ai-agent-book 第 10 章不共享上下文 + chapter10/parallel-web-research
- 预计
- 60 分钟
- 位置
ai-agent-book 第 10 章不共享上下文 + chapter10/parallel-web-research- 阅读任务
- 运行 demo.py --compare,观察首次命中结算与级联终止。
- 完成证据
- 保存 winner、重复命中、终止广播次数和串并行耗时。
论文P150 分钟 · AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation
- 预计
- 50 分钟
- 位置
AutoGen: Enabling Next-Gen LLM Applications via Multi-Agent Conversation- 阅读任务
- 观察可编程多 Agent 对话如何组合角色、消息与可执行能力,辨认本章更窄的一次性委派。
- 完成证据
- 把一个 conversation pattern 改写成 ChildTask 契约,列出保留与删除的状态。
本文提到的代码与出处(有兴趣逐条核对时用):
claude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/code/step09_subagent.pyclaude-agent-examples@54a18980334541773f13940aa0c0475728d30ee0:build-agent-example/doc/step09_subagent.mdai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:book/chapter10.mdai-agent-book@1c18370279f8f0457bf2c44dfa585d08d4d5f281:chapter10/parallel-web-research/agents.pymini-emperor/backend/src/mini_emperor/agent.pymini-emperor/backend/src/mini_emperor/goals.py