s04_子代理不是分身:给主 Agent 一个干净上下文
s04 子代理不是分身:给主 Agent 一个干净上下文
看 s04_subagent.py 的时候,我脑子里冒出来的第一句话其实不是“多 Agent 终于来了”,而是更朴素的一句:
子代理最先解决的,不是角色变多,而是主 Agent 的上下文终于能瘦下来了。
很多人刚开始写 Agent,都会很自然地把所有事都塞进同一段 messages 里。前几轮问题不大,可一旦它开始读文件、跑命令、修报错、再验证,主上下文就会越堆越长。真正有用的结论可能就一句话,但中间那些搜索、重试、失败过程,全都跟着留在历史里。
s04 处理的就是这个问题:把一段局部工作单独拎出去跑,跑完只把摘要带回来。
日志:s04_subagent_20260412_222937.log
先记住一句话
如果只留一句结论,我会留这句:
子代理不是“再开一个 Agent”,而是“再开一份新的消息历史”。
这也是 s04_subagent.py 文件头最想说清楚的事。它强调的不是操作系统层面的新进程,也不是线程,而是 fresh messages=[] 带来的上下文隔离。
SYSTEM = (
f"你是位于 {WORKDIR} 的 coding agent。使用 task 工具委派探索任务或子任务。"
)
SUBAGENT_SYSTEM = (
f"你是位于 {WORKDIR} 的 coding agent。完成指定 task,然后总结你的发现。"
)
这两段 prompt 已经把分工写得很明白了:
- 父代理负责决定什么时候要委派
- 子代理负责接住一个明确任务,然后把结果总结回来
s04 到底在补哪块能力
假设用户只是提了一个很普通的需求:
帮我创建一个带函数注释的
hello_world.py
如果这件事从头到尾都让主 Agent 自己做,它一般会经历下面这些步骤:
- 写文件
- 读文件确认内容
- 运行脚本
- 遇到报错
- 修复编码问题
- 再运行一次
- 最后给出总结
这些步骤都没问题,问题出在它们全都留在主对话里。等下一个任务进来,主 Agent 还得扛着这串中间过程继续推理,上下文很快就会变重。
s04 的思路更像是把这一段局部工作“外包”出去:
这张图里最值钱的地方,不是“有两个 Agent”,而是这三件事:
- 父代理维护自己的
history - 子代理维护自己的
sub_messages - 子代理中间过程不会自动灌回父代理
所以,s04 真正增加的是一条上下文管理策略。
为什么 fresh messages 就够了
第一次看子代理,很多人会先盯着 task 工具看。其实 task 只是入口,真正把上下文切开的,是 run_subagent() 里的这一行:
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}]
for _ in range(30):
response = client.messages.create(
model=MODEL,
system=SUBAGENT_SYSTEM,
messages=sub_messages,
tools=CHILD_TOOLS,
max_tokens=8000,
)
sub_messages = [{"role": "user", "content": prompt}] 这一句看起来特别普通,但它其实就是整个 s04 的支点。
从这里开始:
- 子代理拿到的不是父代理那一长串历史
- 子代理只有一个新的任务提示
- 后面所有读文件、写文件、跑命令、出错重试,都只发生在这份
sub_messages里
换句话说,真正的隔离边界,不是函数边界,而是消息数组边界。
这个点很重要。因为很多人会下意识把“子代理”理解成新进程、新线程,或者别的更重的隔离手段。但这份实现已经说明了,哪怕还是同一个 Python 进程,只要给模型换了一份新的消息历史,它的思考空间就已经被切开了。
父代理和子代理,各自干什么
把 s04 里的核心对象拆开看,其实并不复杂:
如果不想看图,也可以直接看这张表:
| 组件 | 作用 |
|---|---|
agent_loop() | 父代理主循环,负责继续跟用户对话 |
task 工具 | 让父代理可以把一段工作委派出去 |
run_subagent() | 子代理自己的执行回路 |
CHILD_TOOLS | 子代理能使用哪些工具 |
PARENT_TOOLS | 父代理可用工具,包含 task |
AgentTemplate | 解析 agent 定义文件的入口,目前还没接入主流程 |
这里有个容易被忽略的点:AgentTemplate 在这份实现里没有真正跑进主流程,但它也不是摆设。它更像一个信号,说明这套能力后面完全可以从硬编码的 system prompt,继续走到配置驱动的 agent 定义。
task 在父代理眼里,其实就是一个特殊工具
抽象概念一回到代码里,马上就会清楚很多。s04 里父代理可用工具是这样定义的:
PARENT_TOOLS = CHILD_TOOLS + [
{
"name": "task",
"description": "创建一个拥有全新消息上下文的子代理。它共享文件系统,但不共享对话历史。",
"input_schema": {
"type": "object",
"properties": {
"prompt": {"type": "string"},
"description": {"type": "string"},
},
"required": ["prompt"],
},
},
]
这段定义其实就传达了两个关键信息:
- 父代理和子代理共享一部分工具能力。
- 只有父代理额外拥有
task。
为什么不把 task 继续开放给子代理?原因很现实,先把一层委派跑稳,再考虑多层嵌套。否则子代理继续派子代理,复杂度很快就上去了。
父代理真正调用 task 的时候,分发逻辑也很直接:
if block.name == "task":
description = block.input.get("description", "subtask")
prompt = block.input.get("prompt", "")
print(f"\033[32m> task ({description}): {prompt[:80]}\033[0m")
tool_output = run_subagent(prompt)
else:
handler = TOOL_HANDLERS.get(block.name)
tool_output = handler(**block.input) if handler else f"未知工具: {block.name}"
这里最值得看的不是语法,而是这层关系:
对子代理的调用,在父代理眼里就是一次普通工具调用。
只不过这个工具返回的不是文件内容,也不是命令输出,而是一段压缩过的任务摘要。
一张时序图看懂父子代理怎么配合
如果从消息流的角度看,s04 会更清晰:
这个过程放到运行时,其实就能得到一个很实用的理解:
- 子代理负责把局部任务展开做完
- 父代理拿到的是一份能继续决策的压缩结论
所以 task 很像一个“压缩接口”,把一大段中间过程压成父代理还吃得下的一段摘要文本。
共享什么,不共享什么
看 s04 时,最容易混淆的一点就是:父代理和子代理到底隔离到了哪一步。
直接看表会更直观:
| 维度 | 父代理与子代理是否共享 |
|---|---|
| 文件系统 | 共享 |
| Python 进程 | 共享 |
| 工具处理函数 | 共享 |
当前工作目录 WORKDIR | 共享 |
| 消息历史 | 不共享 |
| system prompt | 分开 |
| 可用工具清单 | 分开 |
这也是为什么 safe_path()、run_write()、run_edit() 这些工具函数,不需要专门为子代理再写一套。两边本来就是同一个后端。
但也别漏掉另一面:共享文件系统,意味着子代理做出的修改是真实生效的。
所以 s04 虽然隔离了上下文,但没有隔离副作用。父代理后面完全可以直接读到子代理刚写下来的文件。
看日志,比光看代码更容易明白
这轮运行日志里,有几段特别能说明 s04 到底是不是落到实处了。
1. 父代理先发起 task
日志里先出现的是父代理决定委派:
[2026-04-12 22:32:08] ToolUseBlock(
input={'description': '创建 Hello World Python 脚本',
'prompt': '创建一个名为 hello_world.py 的 Python 脚本...'},
name='task',
type='tool_use')
这一段很关键,因为它说明父代理没有直接开始写文件,而是先把这段工作打包成一个 task(prompt)。
2. 子代理在自己的 sub_messages 里走完整个过程
后面马上就能看到子代理自己的历史:
[2026-04-12 22:32:01] ------------sub_messages--start------------------
[2026-04-12 22:32:01] History (14 items):
...
[2026-04-12 22:32:01] ToolUseBlock(... name='write_file', type='tool_use')
...
[2026-04-12 22:32:01] ToolUseBlock(... name='read_file', type='tool_use')
...
[2026-04-12 22:32:01] ToolUseBlock(... name='bash', type='tool_use')
这说明子代理不是“一次调用就结束”的小函数,它内部同样会经历完整的工具循环。区别只在于,这整段过程被收在了 sub_messages 里,没有直接把父代理主线弄得很乱。
3. 子代理把摘要带回来后,父代理还会继续确认
日志后面还有一段特别有代表性:
[2026-04-12 22:32:08] 'content': [{'type': 'tool_result',
'tool_use_id': 'call_function_lh7ps5nwf83c_1',
'content': '已成功创建 `hello_world.py` 文件!✅ ...'}]
[2026-04-12 22:32:08] ToolUseBlock(... input={'path': 'hello_world.py'}, name='read_file', type='tool_use')
这里有意思的地方在于:父代理拿到子代理的摘要后,并没有立刻结束,而是又主动 read_file 看了一遍。
这就说明,task 不是把父代理“架空”了,反而让父代理多了一种更舒服的工作方式:
- 子代理负责深入执行
- 父代理负责接收摘要,并决定要不要顺手再验证一遍
这比“所有事都让主代理自己做”轻很多,也比“子代理做完就无条件相信”稳很多。
4. 日志顺手还暴露了一个很真实的边界
子代理执行过程中,第一次写出来的脚本因为中文内容和编码问题报错了:
SyntaxError: (unicode error) 'utf-8' codec can't decode byte 0xd1 in position 30
这个细节我觉得很有价值,因为它刚好能提醒我们:
子代理解决的是上下文管理问题,不是所有工程问题。
它确实能让主上下文更干净,但文件编码、平台差异、命令兼容性这些事,照样会在真实执行里冒出来。
换句话说,s04 补的是“怎么组织工作过程”,不是“所有工具从此不会出错”。
运行截图放在这里,一眼就能串起流程
这轮运行的终端输出如下:

从这张图里,基本能直接看出整条链路:
- 用户明确要求“使用 subagent”
- 父代理打印出
> task (...) - 子代理完成文件创建,并返回结构化总结
- 父代理继续确认文件内容
- 最后给出完整结果
所以 s04 带来的变化,不是什么“界面上多了一个按钮”,而是整个执行过程更像在分工协作。
代码里还有 4 个容易漏掉、但很有用的点
1. 子代理被限制在 CHILD_TOOLS 里
这份实现没有把 task 再暴露给子代理,这个决定很克制,也很实用。因为一旦允许递归委派,复杂度马上就会上一个台阶。
先把“一层父代理 + 一层子代理”跑顺,本身就是很好的边界。
2. for _ in range(30) 是必要的安全阀
for _ in range(30): # 安全限值:避免子代理无限循环请求工具。
这行不显眼,但特别重要。只要 Agent 能反复调用工具,就一定要有停止条件。否则子代理一旦陷进反复检查、反复修复,就很容易一直转。
3. AgentTemplate 把后续配置化的方向提前露出来了
class AgentTemplate:
def _parse(self):
text = self.path.read_text()
match = re.match(r"^---\\s*\\n(.*?)\\n---\\s*\\n(.*)", text, re.DOTALL)
虽然它现在还没进主循环,但这个类已经把后面的演进方向透出来了:Agent 的 system prompt、工具、权限,不一定永远都得写死在代码里,也可以放到定义文件里去解析。
4. 子代理返回的是“摘要文本”,不是完整 transcript
return "".join(b.text for b in response.content if hasattr(b, "text")) or "(子代理未返回摘要)"
这一行直接决定了父代理最终拿到什么。它拿到的不是子代理的完整会话过程,而是一段已经收束过的文本。这正是上下文隔离成立的关键。
如果最后把全部 sub_messages 再原样塞回父代理,那 s04 的意义就会被削弱很多。
我对 s04 的一个判断
看完整个实现,我觉得 s04 最有意思的地方,不是“它已经进入多 Agent 阶段了”,而是它把一个很容易被忽略的问题抬到了台面上:
Agent 不只是能力问题,还有上下文容量问题。
平时大家聊 Agent,注意力通常都放在这些点上:
- 模型够不够强
- 工具够不够多
- 提示词写得好不好
但任务一长,另一个现实问题就会立刻出现:主上下文还能不能保持清爽。
s04 给出的答案其实挺实用:
- 不要把所有中间过程都留在主线里
- 局部任务单独放进子上下文
- 最后只把还能继续决策的摘要带回来
这套思路其实和我们平时写工程代码挺像的:主线程别背所有细节,复杂步骤拆出去,最后只拿回关键结论。
最后收一下
如果说 s03 更像是在解决“长任务怎么不跑偏”,那 s04 解决的就是“长任务怎么不把主上下文拖得越来越重”。
它其实没把问题搞复杂,反而抓住了最关键的一点:
给模型一段新的消息历史,本质上就是给它一个新的工作间。
从这个角度回头看 s04_subagent.py,很多代码就都顺了:
task是父代理发起委派的入口run_subagent()是子代理自己的执行回路CHILD_TOOLS决定子代理能做什么- 最终摘要负责把过程压缩成父代理还能继续消费的信息
所以,子代理最先带来的并不是“更花哨的 Agent 编排”,而是更干净的上下文秩序。
致谢
这一组学习内容的主线整理和启发,受益于 shareAI-lab/learn-claude-code。
更多推荐


所有评论(0)