Skip to content

你从第 05 章进入,还缺 03/04 前置。

自测代码证据锁定

第 5 章:Subagent——用独立上下文承接局部探索

← 上一章:任务规划 · 下一章:Agent Team

开场:派给「小弟」的活,为什么他自己还带了一本笔记本?

上一章你把复杂任务拆成了可观察的步骤。新的问题马上出现:如果「调研三个网站」这一步抓回两万字网页、十次搜索和几百行报错,所有细节都涌进主 Agent 的 history,主线很快会被探索的噪声淹没。

想象一家餐厅的厨房。主厨(父 Agent)要决定今晚的菜单,他派三个帮厨(子代理)分别去三个菜市场调研:肉价、菜价、海鲜价。帮厨跑完回来,主厨需要的只是一句话:「猪肉今天 30 块一斤,牛腩 42,摊主说周四补货」——他不需要帮厨把每个摊位聊过的每句话、走过的每条巷子都背一遍。

可这里有个危险的习惯:有的主厨会把自己的记事本(完整对话记录)直接塞给帮厨。 那上面记着 VIP 客人的忌口、餐厅的采购底价、甚至昨晚和老板吵架的事。帮厨只需要「去问肉价」,却拿到了不该看的全部秘密——这就是上下文泄露。

于是这一章的问题来了:把一个子任务派出去,和把一段数据塞给一个函数,区别到底在哪? 答案是:Subagent 不是一个更慢的函数,而是一个一次性、窄任务、受限工具、独立上下文的执行边界。它在局部完成自己的 Tool Loop,只把符合返回契约的结果交回父 Agent。

视频把 Subagent 比作「派小弟」。工程上它首先解决的是什么?

  • A:让多个模型同时跑,节省墙钟时间
  • B:为局部探索建立一次性、窄任务、受限工具、独立上下文的边界,避免噪声淹没主线
  • C:让主 Agent 能写更多文件
  • D:替代上一章的计划状态机

普通函数为什么不是 Subagent

先排除一个常见错觉:「用函数封装一次模型调用」就是 Subagent。不是。看这个函数:

python
def summarize(text: str) -> str:
    return text[:200]
ts
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 派出子任务,不应该只说「帮我查一下」。最小契约至少包含六个字段:

python
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, ...] = ()
ts
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 传给子代理的是「最小任务包」:

text
允许传:任务目标、必要背景、输入引用、返回 Schema、权限与预算
默认不传:完整父 history、全部用户记忆、其他子任务草稿、
          与任务无关的密钥名、未脱敏轨迹、父 Agent 的隐式推理

子代理结束后,父 Agent 收到的是结构化结果,不是把子 history 原样拼回去:

json
{
  "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 自觉。系统提示里写「你只能读」只是劝;真正管用的是两道代码闸:

  1. 过滤:给子代理的 Tool Schema,只放进白名单里的工具;
  2. 再校验:子代理调用工具前,CapabilityPolicy.require_tool() 再拦一次。

角色 Prompt 解释职责,代码决定权限。这条原则从第 1 章到现在没有变过,只是又多了一个作用点。

子代理的生命周期是一次性的:

text
requested → queued → running
  → succeeded
  → failed
  → timed_out
  → cancelled

终态后,子上下文销毁或按隐私策略脱敏存档。它不会自动变成固定队友。所以看一条子任务,你要能同时说出它的边界、权限、预算、当前状态和结果证据。

父 Agent 能写文件,研究型子代理只该只读。正确做法是?

  • A:在子代理的系统提示里写「你只能读」
  • B:靠模型自觉克制
  • C:用代码过滤子代理可见的 Tool Schema,并在调用前再次 require_tool()
  • D:给子代理配一个更小的屏幕

并发、超时与级联取消:派出去只是开始

「并发派出去」只是开始,终止和竞态才是 Harness 真正要做的活。三个工程点:

并发适合什么。 相互独立、读多写少、I/O 等待长的子任务适合并发;共享可写资源、结果有顺序依赖、一个失败需要取消其他任务的场景,必须想清楚再并行。

超时要有终态。 每个子任务有自己的 timeout_secondsmax_turns,超时进入 timed_out 而不是永远转下去。

级联取消。 父任务取消了,正在调外部工具的子代理必须跟着取消,否则界面显示「父 Run 已取消」,子代理却还在开工单、写文件。取消要沿父子关系传播,已开始的外部副作用要支持幂等和补偿。

完整流程长这样——注意父 Agent 从头到尾没有读取子代理的完整思考,只观察生命周期事件和最终契约:

动手:让两个失败发生,再用一个验证收住

理论和手感之间隔着一道墙,亲手撞一次才会留下印象。下面两个实验都是故意做错的

失败一:把完整父 history 塞给子代理。

把下面这段直接复制到课程仓库根目录运行:

bash
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 控权限。

python
# 只靠 Prompt 控权限——危险
subagent_system_prompt = "你是只读研究助手,不要写任何文件"
# 但仍把 write_file 的 Schema 注入了子代理可见的工具列表
# 模型只要调用它,Harness 就可能执行
# 修复:Schema 过滤 + 调用前 CapabilityPolicy.require_tool() 双重检查
ts
interface ToolSchema {
  name: string;
}

// 只靠 Prompt 控权限——危险
const subagentSystemPrompt = "你是只读研究助手,不要写任何文件";
// 但仍把 write_file 的 Schema 注入了子代理可见的工具列表
const visibleToolSchemas: ToolSchema[] = [
  { name: "search" },
  { name: "write_file" },
];
// 模型只要调用它,Harness 就可能执行
// 修复:Schema 过滤 + 调用前 CapabilityPolicy.require_tool() 双重检查

跑完后用现成实验验证「并发 + 单次结算 + 级联取消」这套 Harness 机制真实存在——它用模拟来源,离线即可复现:

bash
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_secondsmax_turns 有没有生效。

门禁·代码:跑通上面这条 demo(自检通过),把输出保存为证据。

本章小结

这一章把「派小弟」翻译成了一组工程机制。四件事连起来看:

Subagent 首先是上下文隔离机制,其次才是并发机制。 它的价值不是「再调一次模型」,而是为局部探索建立独立上下文,只把符合契约的结果交回父 Agent。

普通函数够用就别上 Subagent。 决定性区别是独立的 Agent 生命周期。确定性流程用函数,需要自主决策的探索才委派。

委派要签契约,权限要过代码。 ChildTask 写清做什么、返回什么、能用哪些工具、跑几轮、多久超时。共享工具定义不等于共享权限——Schema 过滤 + 调用前 require_tool() 双重把关。

派出去只是开始。 并发、超时、级联取消才是 Harness 的活。父 Agent 始终对最终答案负责,子代理的总结不是自动可信的事实。

一句话记住这一章:

把一段探索装进一次性、窄任务、受限工具、独立上下文的执行边界里,只让结论和证据流回主线。父负责决策,子负责探索,Harness 负责边界。

如果上面这些话你自己也能说得出来(不看资料),就说明这一章的知识已经连成一片。

复述与证据

合上资料,画出一次委派的完整生命周期:父派遣 → 子运行 → 结构化回传 → 父验证 → 综合答案。标出「最小任务包」里传了什么、没传什么,以及两道权限闸分别在哪。

再追问三个问题:

  1. 为什么共享工具实现不等于共享权限?
  2. 子代理为什么不该默认继承父 Agent 的完整用户记忆?
  3. 父 Agent 收到 succeeded 后,为什么仍要验证 evidence?

门禁·证据:提交一次真实委派的父子事件流,标出 parent_run_idchild_run_idallowed_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 契约,列出保留与删除的状态。

本文提到的代码与出处(有兴趣逐条核对时用):

← 上一章:任务规划 · 下一章:Agent Team

Markdown 是唯一内容源,HTML 由构建流程生成。