首页
/ learn-claude-code 中的 Subagent 机制:用独立 messages[] 实现子代理上下文隔离

learn-claude-code 中的 Subagent 机制:用独立 messages[] 实现子代理上下文隔离

2026-09-06 15:54:48作者:冯爽妲Honey

本文以 learn-claude-code 课程中的 Subagent(子代理)章节为核心,讲解“父代理 + 独立上下文子代理”这一经典 Agent harness 设计:为什么要把大任务拆给子代理、task 工具如何注册、run_subagent() 如何用全新的 messages[] 跑完一个嵌套 agent loop 并只把最终文本作为 tool_result 返回给父会话。读完并对照仓库源码后,你可以理解上下文隔离的边界(消息隔离而非进程隔离)、工具过滤策略(子代理无 task、禁止递归委派),并能在本仓库的遗留版(agents/s04_subagent.py)与新版(s06_subagent/code.py)两条代码线上实际运行和验证该机制。

Subagent 总览:task 工具以全新 messages[] 启动嵌套 agent loop,最终文本作为 tool result 返回父会话

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.

流程分三步:

  1. 父代理调用 task 工具,把子任务的 prompt 派发给子代理;
  2. 子代理从 messages=[] 起步,在自己的上下文里独立执行“模型响应 → 执行工具 → 追加结果 → 再调用模型”的标准 agent loop;
  3. 子代理结束后,只有最后一段文本(摘要)作为普通 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 个基础工具(bashread_filewrite_fileedit_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.pyBASE_TOOLS,共 5 个:bashread_filewrite_fileedit_fileglob),委派结构则完全一致:

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}

(见 s06_subagent/code.py

值得注意的是,新版把 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 >> 交互提示符,文档推荐的三条测试指令依次考察“委派探索”“批量委派”“委派后在父会话验证”三种典型用法:

  1. Use a subtask to find what testing framework this project uses(子代理读文件,父代理只拿结论)
  2. Delegate: read all .py files and summarize what each one does
  3. Use 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)相比遗留版有三处增强:

  1. 工具执行统一走 execute_tool:先触发 PreToolUse hooks(含 deny list 与危险命令审批的 permission_hook、调用日志 log_hook),再执行 handler,最后触发 PostToolUselarge_output_hook(输出超过 100000 字符时告警,见 s06_subagent/code.py)。子代理的每一次 bash/文件操作因此与父代理享有完全相同的治理策略;
  2. Stop hook 可强制续跑:当 stop_reason 不再是 tool_use 时,先询问 Stop hook 是否要注入一条强制 user 消息继续循环,否则才打印 [Subagent done] 并返回文本——与父循环的行为一致;
  3. 超限时显式告警:30 轮未得到最终答案时,返回明确文案 "Subagent stopped after 30 turns without a final answer."(并打印 [Subagent stopped]),而不是遗留版那样静默返回最后一次响应的文本。这提醒父代理:子任务可能没有真正完成。

8. 小结:子代理解决了什么、没有解决什么

综合文档与仓库源码,Subagent 机制可以浓缩为三条结论:

  1. 隔离的是上下文,不是执行环境。子代理与父代理共享进程与工作目录,委派来的“写文件、跑命令”类任务结果即时生效;被丢弃的只是子代理中间消息历史。因此“探索类”任务(读文件、查框架、汇总信息)收益最大,而需要独立沙箱的任务则超出了本机制的边界;
  2. 实现成本极低。一个 task 工具 + 一个带 30 轮上限的嵌套循环函数,父循环零改造;新版进一步把 task 收敛进统一的 TOOL_HANDLERS 分发映射,与课程“加工具 = 加 handler”的核心理念完全一致;
  3. 安全边界不被隔离削弱。测试用例证实子代理的文件工具同样受工作区权限边界约束,危险命令同样会被 deny list 拦截。

理解这一章之后,可以顺着课程继续:s07 用“按需加载技能”解决不同子任务需要不同领域知识的问题(避免把所有知识堆进 system prompt),s08 则直接处理“上下文总会满”的压缩策略——两者与本机制共同构成 harness 层的上下文管理体系。

登录后查看全文
热门项目推荐
相关项目推荐