从零构建 Agent 框架(三)让子 Agent 隔离上下文并完成委派

从零构建 Agent 框架(三)让子 Agent 隔离上下文并完成委派
Asaakii本文承接系列(一)和系列(二)。上一篇收尾时我计划先接入 SQLite,但写到这里时发现,持久化之前还要先分清一件事:哪些历史应该留下,哪些只是一次查找过程。子 Agent 恰好把这个边界讲清楚。下一篇会先实现逐条追加的会话存储,再讨论何时需要 SQLite。
子 Agent 解决的不是“更聪明”
假设用户问:“这个项目使用什么测试框架?”主 Agent 为了回答,可能会读取依赖文件、测试目录和 CI 配置。答案也许只有一句“使用 pytest”,但读取过程会留下大段文件内容和工具输出。
这些过程在查完之后通常没有价值,却会一直占用主对话的上下文。子 Agent 的作用不是提高模型能力,而是把这类独立、输出很多的工作放到另一份历史里完成,再把必要结论交回主 Agent。
flowchart LR A["主 Agent<br/>保留用户目标与关键历史"] --> B["task<br/>任务描述 + 预期输出"] B --> C["子 Agent<br/>独立 messages<br/>受限工具与单独预算"] C --> D["结构化摘要<br/>状态、结论、证据、限制"] D --> A
这篇实现的是一种最容易理解的模式:fresh-context 子 Agent。它不继承父 Agent 的完整对话历史,只接收委派任务;完成后,父 Agent 只保留摘要,不保存子 Agent 的中间过程。
Claude Code 的当前实现比这个更丰富。命名子 Agent 使用自己的上下文和工具配置,fork 则会继承父对话以复用缓存。本文不试图复刻全部机制,只先实现前一种模式。Claude Code 子 Agent 文档
什么时候值得委派
适合交给子 Agent 的任务通常有两个特征:它能独立完成,并且过程输出很多、最终结论相对短。例如:
- 在代码库中查找某个调用链,只返回涉及的文件和结论。
- 运行测试并汇总失败项。
- 阅读多个配置文件,确认项目使用的框架或构建方式。
下面这些任务则先留在主 Agent 更合适:
- 用户正在持续补充要求,需要频繁来回沟通。
- 规划、实现和验证必须反复共享大量细节。
- 只读一个文件或只改一行代码。
- 用户希望立刻看到结果,启动一个新 Agent 的额外延迟反而不划算。
这也是 Claude Code 文档给出的取舍:子 Agent 适合隔离高输出的自包含任务,但不适合所有工作。官方使用建议
本篇的约束
为了和前两篇保持一致,子 Agent 不新增任意命令执行能力。更重要的是,本篇的默认子 Agent 是研究型、只读角色:它只能使用 list_files 和 read_file。主 Agent 若要写文件或运行测试,仍要自己承担这个副作用,等后续有审批与隔离后再定义专门的执行型子 Agent:
- 读取路径仍限制在
workspace/内。 - 子 Agent 看不到
write_file、run_tests,也不能执行任意命令。 - 子 Agent 没有
task工具,因此不能递归委派。 - 父 Agent 与子 Agent 各自有回合预算。
这里故意不实现并行、后台运行、取消、工作树隔离和跨会话恢复。这些能力需要独立的运行时和状态设计,留到后续章节再做。尤其要注意:父 Agent 的 10 轮加上每个子 Agent 的 6 轮,并不是整个任务的总上限;一次父任务可以连续派出多个子任务。真正放开委派前,还要加任务级的总轮数/费用、最长墙钟时间、最大委派数和取消传播预算。
给父 Agent 增加 task 工具
在上一篇的 agent.py 中保留原来的 tool_schemas(),再加一个只给父 Agent 使用的 schema。委派时不要只传一句模糊的“去看看”,至少写清任务和期望回传格式。
1 | TASK_SCHEMA = { |
task 的参数本身就是主 Agent 对子 Agent 的工作说明。因为子 Agent 没有父对话的上下文,委派时必须把范围说完整。这个限制看似麻烦,却能迫使主 Agent 把目标讲清楚。
复用上一章的循环
先说清这次重构改了什么,不然下面一大块代码容易让人望而生畏。上一篇的 run_agent() 里,有四样东西是写死的:系统提示词 SYSTEM_PROMPT、工具 schema、执行函数 execute_tool、回合上限 MAX_TURNS。这一篇把这四样全抽成参数,得到一个通用的 run_loop():
| 写死在上一篇 | 这一篇抽成参数 | 父 Agent 传入 | 子 Agent 传入 |
|---|---|---|---|
SYSTEM_PROMPT |
system_prompt |
PARENT_SYSTEM_PROMPT |
SUBAGENT_SYSTEM_PROMPT |
tool_schemas() |
schemas |
含 task 的 parent_tool_schemas() |
仅 list_files / read_file 的 readonly_tool_schemas() |
execute_tool |
execute |
execute_parent_tool |
execute_readonly_tool |
MAX_TURNS = 10 |
max_turns |
PARENT_MAX_TURNS = 10 |
SUBAGENT_MAX_TURNS = 6 |
这就是为什么父子 Agent 没有分裂成两套循环:它们跑的是同一个 run_loop(),只是喂进去的这四个参数不同。请特别留意 schemas 和 execute 这两列父子的差异,后面「为什么子 Agent 不能再委派」一节会看到它们正是关闭递归委派的两道锁。
assistant_message_for_history() 与 build_api_messages() 和上一篇几乎相同,唯一变化是 build_api_messages() 现在也把系统提示词当参数收,以便父子用不同提示词。
1 | # agent.py |
两个可能卡住的语法先解释掉:
execute: Callable[[str, str], Awaitable[str]]只是说明「execute是一个函数参数」:它接收两个字符串(工具名、参数 JSON 串),返回一个await后得到字符串的协程。父 Agent 会传execute_parent_tool,子 Agent 会传execute_tool,稍后就能看到。log_prefix是这一篇新加的,用来在终端上区分「谁在调用工具」:父 Agent 用默认空前缀,打印成调用工具: task;子 Agent 传一个缩进前缀,打印成[子] 调用工具: read_file。有了它,子 Agent 在自己上下文里干的活才看得见,否则它会全程静默到只剩一句摘要。
循环没有因为加入子 Agent 而分叉成两套。不同点只在于父 Agent 的 schema 多了 task,以及它遇到 task 时需要启动一条新的循环。
启动子 Agent 并回传摘要
接着补上只读能力集和两个执行函数。先在 tools.py 明确收窄子 Agent 的能力;只过滤 schema 不够,执行层也必须拒绝不在白名单内的调用:
1 | # tools.py |
这里正是 fresh-context 的落点:注意 run_subagent() 里的 sub_messages 开局就只有一条 user 消息,装的是委派任务本身,没有一个字来自父 Agent 的历史。所谓「fresh」不是「没有消息」,而是「从这条委派任务从头开始,一张白纸」。父 Agent 聊了多久、读过什么,子 Agent 全然不知,它只知道被交代的这一件事。
1 | def parent_tool_schemas() -> list[dict]: |
父 Agent 的历史中只会新增一条 task 的工具结果,例如:
1 | { |
status 必须由运行时生成,不能相信子 Agent 在文本里自称“完成”。父 Agent 收到非 completed 结果时,应如实报告未完成、缩小任务或请用户决定是否继续,而不能把它包装成已验证的事实。子 Agent 读过的文件、每次工具调用和它自己的交互历史,都保留在 result.messages 里,但没有被复制进父 Agent 的 messages。以后接入会话存储时,可以决定是否把这些调试记录另存为 trace,而不是混进主会话。
为并发预留一条顺序规则
本文的 run_loop() 故意按顺序执行工具,便于先看清父子边界;它还没有实现并发工具调用。以后若把互不冲突的只读调用改成并发,不能简单地“谁先完成就先 append 谁”。
模型一次回复中给出的 tool_calls 本身有顺序;工具 A、B 可以在执行层并发,B 也可能更早完成,但写回 transcript 时仍应按原始 call 顺序追加各自的结果。这样下一次模型请求、会话恢复和测试看到的是稳定的协议顺序,而不是受磁盘、网络或调度抖动影响的偶然顺序。
同样地,用户的 steering 输入不应夹在一组工具结果中间。更稳妥的边界是:先收齐这批调用及其结果,再把新输入作为下一段完整交互加入历史。课程在“有状态 Agent”一章将这一点写成可执行契约:工具可以反序完成,但 transcript 按 call 顺序落盘;运行中插入的指令只在完整工具批次或文本终态后生效。它是未来做异步桥接时必须补上的一致性规则,不是本篇已经实现的能力。
为什么子 Agent 不能再委派
这是本篇最值得停下来看的一处设计。子 Agent 不能递归地再派出孙 Agent,靠的不是某句 if 判断,而是两道彼此独立的锁,回头看 run_subagent() 里那两行带注释的参数:
- schema 层:子 Agent 根本看不到
task,也看不到写入和测试工具。父 Agent 用parent_tool_schemas()(tool_schemas()+TASK_SCHEMA),子 Agent 只用readonly_tool_schemas()。模型只能调用它在 schema 里见过的工具,子 Agent 的工具清单里压根没有task这一项,自然无从委派。 - 执行层:即便硬调
task也没人执行。父 Agent 的execute是execute_parent_tool(认识task、会启动子循环);子 Agent 的execute是execute_readonly_tool。它只放行白名单中的两个读取工具;就算子 Agent 因为幻觉硬吐出task或write_file,也只会收到越权错误,不会真的打开另一层委派或修改文件。
两道锁只要一道生效就够了,这里两道都上,这正是上一篇「模型能请求什么」和「程序允许什么」是两条线那个原则的延续。递归委派不是被「禁止」的,而是从能力上就不可能发生。想让子 Agent 能再委派,你得同时把这两样换成父 Agent 的版本;忘掉任何一个,它都动不了。
运行与观察
入口仍与上一篇相同:保留一个父 Agent 的 messages 列表,再调用 run_agent()。
1 | result = await run_agent( |
因为加上了 log_prefix,终端会把父级和子级的工具调用分层打印出来,一次典型运行大致长这样:
1 | 调用工具: task |
对着这段回显看,三件事就都看得见了:
- 父 Agent 只调了一次
task,就把这件「要翻好几个文件」的活整体委派了出去,它自己没去逐个读文件。 - 带
[子]缩进的三次调用发生在子 Agent 的独立历史里,父 Agent 的messages完全没沾到这些文件内容。 - 子 Agent 干完后,父 Agent 只拿到一条
status/summary/turns的摘要,据此给出最后那句总结。
如果模型没有委派,而是自己完成了任务,也不算错误。task 是模型可以选择的工具,不是每个请求都必须走的分支。为了便于测试,你可以在用户输入中明确要求“请使用子 Agent”。
到底省下了多少上下文
前面反复说子 Agent「省下主 Agent 的上下文」,这里把两种做法的父 Agent 历史摆在一起,差别就一目了然了。
先看不委派的情况。父 Agent 亲自读三个文件,每个文件的完整内容都会以 tool 消息落进它自己的历史:
1 | # 父 Agent 的 messages(不委派) |
再看委派的情况。同样的活交给子 Agent,父 Agent 历史里只多了一条摘要:
1 | # 父 Agent 的 messages(委派给子 Agent) |
两份历史给出的最终答案一样,但前一种把几百行文件内容永久压在了主对话上,而这些内容查完之后基本再也用不到,却要在后续每一轮请求里重复发送、重复付费、并不断挤占上下文窗口。后一种则把这段「查找过程」隔离在了子 Agent 那次性的历史里。这就是子 Agent 省下的东西:不是让模型更聪明,而是让主对话只留结论、不留翻找的草稿。
这一步省下什么,又付出什么
子 Agent 减少的是主 Agent 的上下文负担,不是总成本。子 Agent 仍然会请求模型、读取文件、执行测试。它还会带来额外延迟,并且摘要会丢失一部分细节。
因此委派前可以问自己两个问题:这件事是否能独立完成?它的中间输出在主对话中是否大多用不上?两个答案都是“是”时,子 Agent 才更可能划算。
另外,父子 Agent 共享同一个 workspace/。上下文隔离不等于文件系统隔离。未来即使只让多个子 Agent 并行读取,也要保留它们原始 tool_call_id 的结果顺序;若放开写入,则必须串行执行或使用 worktree 等独立工作区,避免它们修改同一文件。
跑不通时,先查这几处
父 Agent 从不调用 task,总是自己动手。先确认 parent_tool_schemas() 真的把 TASK_SCHEMA 拼进去了、且 run_agent 传的是它而不是 tool_schemas()。schema 没进去,模型就看不到这个工具。其次是提示词:PARENT_SYSTEM_PROMPT 要明确「遇到高输出且独立的子任务时用 task 委派」。测试阶段可以在用户输入里直接写「请使用子 Agent」逼它走这条路。
子 Agent 反过来调用 task,或意外能写文件。说明你给 run_subagent 传错了 schemas 或 execute,把它们错传成了父 Agent 的版本。对照「为什么子 Agent 不能再委派」一节,子 Agent 必须用 readonly_tool_schemas() + execute_readonly_tool。
报 task 参数无效 或 task 和 expected_output 都不能为空。模型吐的 task 参数 JSON 不合法或缺字段。execute_parent_tool 已经把这些兜成一条文字结果回传给父 Agent,父 Agent 通常会自己重填重试;若反复失败,多半是 TASK_SCHEMA 的参数 description 写得太含糊,模型不知道该填什么。
终端看不到子 Agent 在做什么。检查 run_loop 里那行 print(f"{log_prefix}调用工具: ...") 是否还在,以及 run_subagent 是否传了 log_prefix=" [子] "。这行日志是你唯一能观察子 Agent 内部行为的窗口。
通用手段依旧是打印 AgentResult。子 Agent 返回的 result.messages 里有它读过的每一个文件、每一次工具调用,父对话看不到的中间过程全都在这里,排查子 Agent 为什么给错结论时就靠它。
动手改改看
- 做一个「执行型子 Agent」。只给它增加一个经过审批的
run_tests,并保持write_file不可用。对照研究型子 Agent,想一想为什么能力集应由任务角色决定,而不是图省事地共用父 Agent 的全部工具。 - 把
run_loop里的工具参数也打印出来(现在只打印了工具名)。观察父级task调用的参数,看看模型给子 Agent 写的任务描述够不够完整,这能直观感受expected_output的价值。 - 给父任务加
max_delegations和总墙钟时间;子 Agent 返回budget_exhausted时,确保父 Agent 的最终答复包含warnings,而不是把半截结论当成功。
安全边界没有消失
子 Agent 只是另一条循环,不是安全沙箱。本文仍依赖上一篇的工具限制:
workspace_path()拒绝离开workspace/的路径。- 子 Agent 的 schema 与执行器只放行
list_files/read_file。 task不会被加入子 Agent 的 schema,递归委派被关闭。
父 Agent 一旦执行测试,仍会运行工作目录中的测试代码。不要把不可信代码直接交给本地 Agent;需要处理时,应放到容器或其他隔离环境,并在增加删除、联网、部署等高影响工具前加入审批。
小结
- 子 Agent 适合处理独立且高输出的任务,帮助主 Agent 保留用户目标和关键结论。
- fresh-context 不等于没有消息,而是不继承父 Agent 的完整历史,只从委派任务开始。
- 父 Agent 增加
task,研究型子 Agent 默认只使用两个读取工具,因此不能递归委派或写入文件。 - 子 Agent 的结果以运行时生成的
status、summary、evidence、warnings、turns回传;父 Agent 不保存完整中间过程,也不能把未完成结果当作事实。 - 这不是 Claude Code 的全部子 Agent 机制,也不是并发或文件隔离方案。它是后续实现会话存储、权限和多 Agent 协作前的一块基础。
下一篇会让主会话在重启后仍能恢复:每个模型回复和工具结果都作为事件逐条保存,子 Agent 的完整过程则作为可选 trace 单独关联。先用 JSONL 把数据流看清,再决定何时需要 SQLite 的查询与并发能力。
参考资料








