learn-claude-code 中的 Subagent 机制:用独立 messages[] 实现子代理上下文隔离
本文以 learn-claude-code 课程中的 Subagent(子代理)章节为核心,讲解“父代理 + 独立上下文子代理”这一经典 Agent harness 设计:为什么要把大任务拆给子代理、task 工具如何注册、run_subagent() 如何用全新的 messages[] 跑完一个嵌套 agent loop 并只把最终文本作为 tool_result 返回给父会话。读完并对照仓库源码后,你可以理解上下文隔离的边界(消息隔离而非进程隔离)、工具过滤策略(子代理无 task、禁止递归委派),并能在本仓库的遗留版(agents/s04_subagent.py)与新版(s06_subagent/code.py)两条代码线上实际运行和验证该机制。
1. 问题:父会话的 messages[] 会无限膨胀
在 learn-claude-code 的设定中,一个 agent 的全部状态就是发给模型的 messages 数组。随着 agent 持续工作,这个数组不断增长:每一次 read_file 的完整文件内容、每一条 bash 命令的输出,都会永久留在上下文里。
文档用一个典型场景说明问题:父代理被问“这个项目用的什么测试框架”,为了回答它可能连续读取 5 个文件,但父代理真正需要的答案只有一个词——“pytest”。那 5 个文件的全文却永远占据了后续所有轮次的上下文预算,挤压模型对当前任务的注意力。这就是子代理要解决的核心矛盾:中间过程信息是完成任务所必需的,但对父会话却是纯噪声。
2. 方案:父子双上下文,隔离的是消息而不是进程
文档给出的解决思路是一个父子双上下文的同步委派结构(引自 docs/ja/s04-subagent.md):
Parent agent Subagent
+------------------+ +------------------+
| messages=[...] | | messages=[] | <-- fresh
| | dispatch | |
| tool: task | ----------> | while tool_use: |
| prompt="..." | | call tools |
| | summary | append results |
| result = "..." | <---------- | return last text |
+------------------+ +------------------+
Parent context stays clean. Subagent context is discarded.
流程分三步:
- 父代理调用
task工具,把子任务的prompt派发给子代理; - 子代理从
messages=[]起步,在自己的上下文里独立执行“模型响应 → 执行工具 → 追加结果 → 再调用模型”的标准 agent loop; - 子代理结束后,只有最后一段文本(摘要)作为普通
tool_result回到父会话;子代理的整段消息历史(可能包含 30 次以上工具调用)被直接丢弃。
这里有一个必须澄清的边界:子代理隔离的是会话上下文,不是进程或文件系统。父代理与子代理运行在同一个 Python 进程里,共享同一个 WORKDIR 工作目录,所以子代理写的文件、执行的命令对父代理立即可见。源码注释把它概括为:“Process isolation gives context isolation for free”——反过来讲,本机制恰好相反:不依赖进程隔离,仅用“新的消息列表”就拿到了上下文隔离。这一设计取舍在 agents/s04_subagent.py 的模块 docstring 中有明确说明。
3. 工具层设计:task 工具与父子工具集
3.1 父代理比子代理多一个 task 工具
核心规则只有一条:父代理的工具集 = 全部基础工具 + task;子代理拿到全部基础工具但没有 task,因此子代理无法再次委派,递归派生被从工具层面直接封死。
文档给出的工具定义(对应 agents/s04_subagent.py 中的真实实现):
PARENT_TOOLS = CHILD_TOOLS + [
{"name": "task",
"description": "Spawn a subagent with fresh context.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string"}},
"required": ["prompt"],
}},
]
在 agents/s04_subagent.py 中,CHILD_TOOLS 包含 4 个基础工具(bash、read_file、write_file、edit_file),每个工具都带完整的 JSON Schema 描述;TOOL_HANDLERS 字典负责把工具名映射到执行函数。父代理循环在遇到 block.name == "task" 时走特殊分支——取出 prompt 参数、打印一行派发消息,然后同步调用 run_subagent(prompt)(见 agents/s04_subagent.py),其余工具则统一查表执行。也就是说,task 在父代理眼中只是“返回值比较特殊的普通工具”,父循环代码不需要为委派做任何结构性改造。
3.2 新版代码线:基础工具扩充为 5 个,并接入 Hooks
当前 17 课主线中的同一机制位于 s06_subagent/code.py,它的工具集在遗留版基础上增加了 glob(基于 s06_subagent/code.py 的 BASE_TOOLS,共 5 个:bash、read_file、write_file、edit_file、glob),委派结构则完全一致:
SUB_TOOLS = list(BASE_TOOLS) # 子代理工具集:不含 task
SUB_HANDLERS = dict(BASE_HANDLERS)
TASK_TOOL = {
"name": "task",
"description": "Run a subagent with fresh conversation context and return its final text.",
"input_schema": {
"type": "object",
"properties": {"prompt": {"type": "string", "minLength": 1}},
"required": ["prompt"],
},
}
TOOLS = [*BASE_TOOLS, TASK_TOOL]
TOOL_HANDLERS = {**BASE_HANDLERS, "task": run_subagent}
值得注意的是,新版把 task 直接注册进了 TOOL_HANDLERS 分发映射("task": run_subagent),父循环不再需要 if block.name == "task" 的特判分支——这与课程 s02 确立的“加一个工具 = 加一个 handler”的分发模式一脉相承。同时 prompt 参数额外加了 minLength: 1 约束,避免空提示词启动无意义的子代理。
4. 核心实现:run_subagent() 的嵌套 agent loop
4.1 遗留版实现(与文档一一对应)
agents/s04_subagent.py 中的 run_subagent() 是文档代码的完整落地:
def run_subagent(prompt: str) -> str:
sub_messages = [{"role": "user", "content": prompt}] # fresh context
for _ in range(30): # safety limit
response = client.messages.create(
model=MODEL, system=SUBAGENT_SYSTEM, messages=sub_messages,
tools=CHILD_TOOLS, max_tokens=8000,
)
sub_messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
break
results = []
for block in response.content:
if block.type == "tool_use":
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown tool: {block.name}"
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(output)[:50000]})
sub_messages.append({"role": "user", "content": results})
# Only the final text returns to the parent -- child context is discarded
return "".join(b.text for b in response.content if hasattr(b, "text")) or "(no summary)"
从源码结构看,这个函数体现了四个关键设计:
- 全新上下文:
sub_messages从仅含一条 user 消息的列表开始,父代理的历史完全不会被复制进来; - 独立 system prompt:子代理使用专门的
SUBAGENT_SYSTEM("You are a coding subagent ... Complete the given task, then summarize your findings."),明确指示它“完成任务后总结发现”,而父代理的SYSTEM则指示它“用 task 工具委派探索类子任务”(见 agents/s04_subagent.py); - 30 轮安全上限:与外层父循环一样是
for _ in range(30)而非死循环,防止模型陷入无限工具调用; - 只返回最终文本:返回语句只拼接最后一条响应里的 text 块,没有 text 时返回占位符
"(no summary)"。子代理积累的所有tool_result随函数返回被整体丢弃。
4.2 工具输出的截断防线
遗留版中,父代理的 run_bash 把输出截断到 50000 字符,run_read 也带 50000 字符上限(agents/s04_subagent.py);run_subagent 内每次工具结果同样执行 str(output)[:50000]。这意味着即便子代理连续 30 次读取大文件,其上下文增长也被单条结果 50KB 的硬上限约束住——虽然这些结果最终会被丢弃,但截断同时保护了子代理自己的上下文。
5. 与 s03 相比的变化
文档给出了 s03 → s04 的组件级变更对照表,这里完整保留:
| 组件 | Before (s03) | After (s04) |
|---|---|---|
| 工具 | 5 个 | 5 个(基础)+ task(仅父代理) |
| 上下文 | 单一共享上下文 | 父 + 子上下文隔离 |
| 子代理 | 无 | run_subagent() 函数 |
| 返回值 | 无 | 仅摘要文本 |
这个对照表点明了本章节的最小增量:没有引入新的状态机、没有修改父循环结构,只增加了一个工具和一个函数,就实现了上下文隔离能力。这也是该课程“每课只加一个机制”的教学约束。
6. 运行与验证
6.1 运行遗留版(文档配套代码)
cd learn-claude-code
python agents/s04_subagent.py
运行后进入 s04 >> 交互提示符,文档推荐的三条测试指令依次考察“委派探索”“批量委派”“委派后在父会话验证”三种典型用法:
Use a subtask to find what testing framework this project uses(子代理读文件,父代理只拿结论)Delegate: read all .py files and summarize what each one doesUse a task to create a new module, then verify it from here
由于父/子共享工作目录,第 3 条指令能验证“消息隔离但文件系统共享”的边界:子代理创建的模块,父代理随后可以直接用 read_file 读到。
6.2 运行新版(17 课主线 s06)
仓库 README 说明 docs/ 与 agents/ 属于旧版 12 课过渡线,同一主题的当前主线版本是 s06_subagent/:
cd learn-claude-code
python s06_subagent/code.py
新版运行时的观察点比遗留版更丰富(来自 s06_subagent/README.ja.md 的“试してみよう”一节):
- 子代理启动/结束是否打印
[Subagent started]/[Subagent done]; - 子代理的每次工具调用是否以
[sub] ...前缀单独打印(s06_subagent/code.py); - 父代理最终是否只收到
task返回的最终文本。
6.3 测试用例给出的可验证断言
tests/test_s06_subagent.py 用 mock 掉 Anthropic client 的方式,对三个关键行为做了精确断言,可以作为理解机制的“权威规格”:
- 工具集结构:
BASE_TOOLS恰为{bash, read_file, write_file, edit_file, glob};父代理工具集 = 基础集 ∪{task};子代理工具集 = 基础集,且断言"task" not in child_names——直接固化了“禁止递归委派”这条规则; - 全新消息与最终文本返回:mock 出“第一轮 tool_use → 第二轮 end_turn”的两次响应,断言子代理第一次
messages.create收到的messages恰好只有一条 user 消息(证明上下文从零开始),且run_subagent的返回值就是最后一段文本 "The note says child input."; - 权限边界继承:子代理调用
write_file写工作区外的路径时,被permission_hook拦截为 "Permission denied by user",且文件确实未创建——证明子代理与父代理共用同一套权限检查,隔离的是上下文而非安全边界。
7. 从 s04 到 s06:同一机制的完整设计决策
新版 s06_subagent/code.py 在遗留版骨架上补齐了课程 s03/s04 引入的权限与 Hooks 能力,README 用一张表总结了子代理机制的五个关键决策,这张表是对全文设计意图的最佳归纳(来自 s06_subagent/README.md):
| 决策点 | 选择 | 理由 |
|---|---|---|
| 会话 | 全新的 messages[] |
父会话历史不复制进子代理 |
| 执行环境 | 同一进程、同一 WORKDIR |
文件系统的变化对两个循环都可见 |
| 返回值 | 仅最终文本 | 子代理的工具调用与结果不进入父 messages |
| 委派深度 | SUB_TOOLS 不含 task |
本机制只允许一层委派 |
| 工具策略 | 共享 Hooks | 父与子使用同一套权限检查 |
对照源码,新版 run_subagent()(s06_subagent/code.py)相比遗留版有三处增强:
- 工具执行统一走
execute_tool:先触发PreToolUsehooks(含 deny list 与危险命令审批的permission_hook、调用日志log_hook),再执行 handler,最后触发PostToolUse的large_output_hook(输出超过 100000 字符时告警,见 s06_subagent/code.py)。子代理的每一次bash/文件操作因此与父代理享有完全相同的治理策略; - Stop hook 可强制续跑:当
stop_reason不再是tool_use时,先询问Stophook 是否要注入一条强制 user 消息继续循环,否则才打印[Subagent done]并返回文本——与父循环的行为一致; - 超限时显式告警:30 轮未得到最终答案时,返回明确文案
"Subagent stopped after 30 turns without a final answer."(并打印[Subagent stopped]),而不是遗留版那样静默返回最后一次响应的文本。这提醒父代理:子任务可能没有真正完成。
8. 小结:子代理解决了什么、没有解决什么
综合文档与仓库源码,Subagent 机制可以浓缩为三条结论:
- 隔离的是上下文,不是执行环境。子代理与父代理共享进程与工作目录,委派来的“写文件、跑命令”类任务结果即时生效;被丢弃的只是子代理中间消息历史。因此“探索类”任务(读文件、查框架、汇总信息)收益最大,而需要独立沙箱的任务则超出了本机制的边界;
- 实现成本极低。一个
task工具 + 一个带 30 轮上限的嵌套循环函数,父循环零改造;新版进一步把task收敛进统一的TOOL_HANDLERS分发映射,与课程“加工具 = 加 handler”的核心理念完全一致; - 安全边界不被隔离削弱。测试用例证实子代理的文件工具同样受工作区权限边界约束,危险命令同样会被 deny list 拦截。
理解这一章之后,可以顺着课程继续:s07 用“按需加载技能”解决不同子任务需要不同领域知识的问题(避免把所有知识堆进 system prompt),s08 则直接处理“上下文总会满”的压缩策略——两者与本机制共同构成 harness 层的上下文管理体系。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00