HuanCode Docs

第4课:Subagent —— 拆解大任务,上下文隔离

父Agent把大任务拆成子任务,每个子任务用独立上下文执行,只有最终结果返回。用30行Python实现Claude Code的子Agent隔离机制。

系列导读

这是**《12课拆解Claude Code架构》**系列的第 4 课。

前三课我们造了一个有完整工具链和规划能力的 Agent:第 1 课建了 Agent Loop,第 2 课加了 Tool Use dispatch,第 3 课用 TodoWrite 引入了显式规划。

但有一个问题一直在恶化——上下文膨胀

第 4 课的格言:

"大任务拆小,每个小任务干净的上下文"

这一课,我们给 Agent 装上 task 工具,让它能把子任务派发给独立的 Subagent,用完即弃。


上下文膨胀:一个真实的痛点

假设你让 Agent 回答一个简单问题:

"这个项目用什么测试框架?"

Agent 的工作过程:

第1轮: bash → cat package.json          (输出: 200行JSON)
第2轮: bash → cat jest.config.js        (输出: 30行配置)
第3轮: bash → cat tsconfig.json         (输出: 50行JSON)
第4轮: bash → ls tests/                 (输出: 15行文件列表)
第5轮: bash → head -20 tests/setup.ts   (输出: 20行代码)

5 轮下来,messages 数组里塞进了 315 行工具输出。但父 Agent 真正需要的答案只有一个词:"Jest"

这不是个例。Agent 执行的每一步——读文件、跑命令、查日志——输出都永久留在 messages 数组里。10 轮下来上下文可能涨到几万 token。50 轮下来,你可能已经逼近上下文窗口的上限。

更要命的是,这些历史输出不只是占空间——它们会干扰模型的注意力。模型需要在一堆已经过时的文件内容中找到当前任务的关键信息,准确率和推理质量都会下降。

上下文膨胀:315行输出只为一个词


解决方案:父子隔离架构

核心思路极其简单:把子任务的脏活放到一个独立的上下文里做,只把干净的结果带回来。

┌────────────────────────────────────────────────────────┐
│  父 Agent (messages[])                                 │
│                                                        │
│  User: "重构这个模块,先弄清楚测试框架"               │
│  Assistant: 我先调查测试框架 → tool_use: task          │
│                                                        │
│  ┌──────────────────────────────────────────────────┐  │
│  │  子 Agent (sub_messages[] — 独立,用完即弃)      │  │
│  │                                                  │  │
│  │  User: "这个项目用什么测试框架?"                │  │
│  │  Assistant: → bash cat package.json  (200行)     │  │
│  │  Assistant: → bash cat jest.config.js (30行)     │  │
│  │  Assistant: → bash ls tests/         (15行)      │  │
│  │  Assistant: "项目使用 Jest 测试框架,配置..."    │  │
│  │                                                  │  │
│  │  ★ 整个 sub_messages[] 在这里丢弃               │  │
│  └──────────────────────────────────────────────────┘  │
│                                                        │
│  tool_result: "项目使用 Jest 测试框架,配置在..."     │
│  Assistant: 好的,现在开始重构... (继续干净地工作)     │
│                                                        │
└────────────────────────────────────────────────────────┘

关键机制:

  1. 父 Agent 拥有 task 工具 —— 可以把一个提示词派发成子任务
  2. 子 Agent 用独立的 sub_messages[] 启动 —— 完全干净的上下文
  3. 子 Agent 拥有除 task 外的所有工具 —— 能干活,但不能递归
  4. 只有最终文本返回给父 Agent —— 几百行的中间过程,压缩成几句话
  5. 子 Agent 的消息历史直接丢弃 —— 不污染父上下文

效果:父 Agent 的 messages 里只多了一条 tool_result,内容是精炼的摘要。那 315 行中间输出?消失了。


核心机制拆解

机制一:task 工具定义

给父 Agent 注册一个 task 工具,参数就是一个提示词:

TASK_TOOL = {
    "name": "task",
    "description": (
        "Run a subtask in an isolated context. "
        "Use this for research, analysis, or any work whose "
        "intermediate output the parent does not need to see. "
        "Returns only the final text summary."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "prompt": {
                "type": "string",
                "description": "The task description for the subagent",
            }
        },
        "required": ["prompt"],
    },
}

这就是父 Agent 发起子任务的唯一接口。描述里明确告诉模型——这个工具适合"不需要看中间过程"的任务。

机制二:Subagent 循环

Subagent 本质上就是一个迷你版的 Agent Loop,但有三个关键差异:

def run_subagent(prompt: str) -> str:
    """在隔离上下文中执行子任务,仅返回最终文本。"""
    sub_messages = [{"role": "user", "content": prompt}]
    
    # 子 Agent 可用的工具:除了 task 之外的所有工具
    sub_tools = [t for t in ALL_TOOLS if t["name"] != "task"]
    
    for turn in range(MAX_SUBAGENT_TURNS):  # 硬上限,防止失控
        response = client.messages.create(
            model=MODEL,
            system=SUBAGENT_SYSTEM,
            messages=sub_messages,
            tools=sub_tools,
            max_tokens=8000,
        )
        sub_messages.append({
            "role": "assistant", 
            "content": response.content,
        })
        
        # 子 Agent 决定不再调工具 → 任务完成
        if response.stop_reason != "tool_use":
            break
        
        # 执行工具,收集结果
        results = []
        for block in response.content:
            if block.type == "tool_use":
                output = TOOL_HANDLERS[block.name](**block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": output,
                })
        sub_messages.append({"role": "user", "content": results})
    
    # ★ 关键:只提取最终文本,sub_messages 整个丢弃
    return extract_text(response)

三个关键差异:

对比项父 Agent子 Agent
消息列表全局 messages,持续累积局部 sub_messages,用完即弃
可用工具所有工具,包括 task所有工具,排除 task
轮次限制无硬上限(教学版)MAX_SUBAGENT_TURNS = 30

机制三:结果提炼

extract_text 函数极其简单——从 response 的 content blocks 里只取文本:

def extract_text(response) -> str:
    """从响应中提取纯文本,丢弃工具调用块。"""
    parts = []
    for block in response.content:
        if hasattr(block, "text"):
            parts.append(block.text)
    return "\n".join(parts) if parts else "(subagent produced no text output)"

子 Agent 的最后一轮回复通常是自然语言摘要——"项目使用 Jest 测试框架,配置了 TypeScript 转换器,覆盖率阈值 80%"。这就是父 Agent 需要的全部信息。


完整代码:父 Agent 的 dispatch 更新

在第 2 课的 dispatch map 基础上,只需要加两行:

# 工具列表:在原有工具基础上加入 task
PARENT_TOOLS = BASE_TOOLS + [TASK_TOOL]

# dispatch map:加入 task → run_subagent 映射
TOOL_HANDLERS = {
    "bash":  run_bash,
    "read":  read_file,
    "write": write_file,
    "edit":  edit_file,
    "todo":  todo_write,
    "task":  lambda prompt: run_subagent(prompt),  # 新增
}

父 Agent Loop 本身一行不改。当模型决定调用 task 工具时,dispatch map 自动路由到 run_subagent,执行完把结果字符串作为 tool_result 追加到父 messages。

这就是第 2 课 dispatch 架构的威力——加新能力只加注册,循环不碰。


实际运行示例

给 Agent 一个复合任务:

"分析这个项目的技术栈,然后在 README 里补充技术栈说明"

Agent 的执行链路:

父 Agent:
  思考: 这个任务有两步——先调研技术栈,再写文档。
        调研部分会产生大量中间输出,用 task 隔离。

  tool_use: task(prompt="分析这个项目的技术栈,
            包括语言、框架、测试工具、构建工具")

    ┌─ 子 Agent (独立上下文) ─────────────────────┐
    │ bash → cat package.json         (200行)     │
    │ bash → cat tsconfig.json        (50行)      │
    │ bash → ls src/                  (20行)      │
    │ bash → cat vite.config.ts       (30行)      │
    │ bash → cat jest.config.js       (25行)      │
    │ bash → cat Dockerfile           (15行)      │
    │                                              │
    │ 最终回复: "技术栈分析:                      │
    │   - 语言: TypeScript 5.3                     │
    │   - 前端: React 18 + Vite 5                  │
    │   - 测试: Jest + Testing Library             │
    │   - 构建: Docker + GitHub Actions            │
    │   - 包管理: pnpm"                            │
    │                                              │
    │ ★ 340行中间输出在此丢弃                      │
    └──────────────────────────────────────────────┘

  tool_result: "技术栈分析:语言 TypeScript 5.3..."
                (只有5行摘要进入父上下文)

  思考: 拿到技术栈信息了,现在编辑 README。
  tool_use: read(path="README.md")
  tool_use: edit(path="README.md", ...)

  最终回复: "已在 README.md 中补充了技术栈说明。"

对比效果:

指标不用 Subagent用 Subagent
父上下文增长+340 行工具输出+5 行摘要
模型注意力被过时输出稀释始终聚焦当前任务
Token 消耗每轮都带历史子历史隔离不累积

Subagent隔离效果对比


洞见:两个关键设计决策

为什么禁止递归

子 Agent 没有 task 工具,所以它不能再派生子子 Agent。这是刻意的限制。

表面原因:防止失控。 如果子 Agent 能再派子 Agent,一个写得不好的提示词可能导致无限递归,每一层都消耗 API 调用和 token。

深层原因:两层就够了。 实际使用中,父 Agent 拆解的子任务粒度已经足够小——"查测试框架"、"分析依赖关系"、"统计代码行数"。这些任务不需要再拆解,一个 Subagent 在 30 轮内完全能搞定。

Claude Code 的生产实现也是这个设计:Task 工具的子 Agent 禁止使用 Task。如果你需要更深的分解,正确的做法是让父 Agent 拆出更多并列的子任务,而不是让子任务嵌套下去。

✅ 正确: 父 → [子A, 子B, 子C]      (宽度扩展)
❌ 错误: 父 → 子 → 孙 → 曾孙        (深度递归)

为什么丢弃子历史

子 Agent 结束后,sub_messages[] 直接被垃圾回收。为什么不保留?

不是因为没用,而是因为性价比不够。

保留子历史有两个成本:

  1. Token 成本:子历史留在父上下文里,后续每轮 API 调用都要带上,反复付费。
  2. 注意力成本:模型的注意力是有限资源。无关的历史信息会稀释模型对当前任务的聚焦度。

而保留子历史的收益呢?几乎为零。父 Agent 在后续工作中需要引用"子 Agent 第 3 轮读的那个文件的第 47 行"的概率极低。它需要的是结论,不是过程。

这和现实中的管理一样——你派一个工程师去调研技术方案,你要的是一份摘要报告,不是他浏览过的每一个网页的截图。


Subagent 的系统提示词

子 Agent 有自己的系统提示词,比父 Agent 更简洁聚焦:

SUBAGENT_SYSTEM = """You are a focused research and analysis agent.

Your job is to complete the specific task given to you, then provide
a clear, concise summary of your findings.

Guidelines:
- Stay focused on the given task
- Be thorough but efficient
- End with a clear summary of findings
- Do not ask for clarification — work with what you have
"""

注意最后一条——"不要问澄清问题"。子 Agent 没有和用户交互的通道,它只能和工具交互。所以它必须根据提示词自行判断、自行行动、自行总结。


轮次上限:为什么是 30

MAX_SUBAGENT_TURNS = 30

30 不是拍脑袋的数字:

  • 太小(<10):复杂调研任务可能需要读多个文件、跑多个命令,10 轮不够
  • 太大(>50):如果 30 轮都没搞定,大概率是任务拆解粒度不对,继续跑只是浪费 token
  • 30 是经验甜点:覆盖 95%+ 的合理子任务,同时在失控时及时止损

生产环境可以根据任务类型动态调整,但 30 是一个稳健的默认值。


五分钟跑起来

# 进入项目目录
cd learn-claude-code

# 启动第四课
python agents/s04_subagent.py

启动后你会看到 s04 >> 提示符。以下是真实的运行记录:

任务 1:用子任务做调研

s04 >> Use a subtask to find what testing framework this project uses
> task (Find the testing framework used in this project):
  (子 Agent 独立执行:读取 package.json、requirements.txt、
   tests/ 目录、多个测试文件……全部在隔离上下文中完成)

  子 Agent 返回摘要 ↓

## Results
The project uses **two Python testing frameworks**:

| Framework | Version | Role |
|-----------|---------|------|
| **pytest** | 9.0.2 | Primary — used across tests/ and mypackage/tests/ |
| **unittest** | stdlib | Secondary — used in one file with TestCase style |

Notably, the web/ subdirectory has **no JavaScript testing
framework** configured.

父 Agent 只拿到了一段精炼摘要。子 Agent 读了多少文件、跑了多少命令?父 Agent 不知道,也不需要知道。

任务 2:大规模文件摘要(并行子任务)

s04 >> Delegate: read all .py files and summarize what each one does
> bash: (列出所有 25 个 .py 文件)

> task (Summarize top-level .py files): ...
> task (Summarize mypackage .py files): ...
> task (Summarize agents s01-s06 .py files): ...
> task (Summarize agents s07-s12 and s_full .py files): ...
> task (Summarize tests and skills .py files): ...

Here's a complete summary of all 25 `.py` files:

## 📁 Top-Level
| File | Summary |
|------|---------|
| hello.py | greet(name) function returning "Hello, {name}!" |
| greet.py | Minimal module with a single greet(name) function |

## 📁 agents/ — Progressive Agent Harness (s01–s12)
| File | Summary |
|------|---------|
| s01_agent_loop.py | The Agent Loop — minimal core |
| s02_tool_use.py | Tool Dispatch — dispatch map |
| s03_todo_write.py | TodoWrite — planning system |
| s04_subagent.py | Subagents — context isolation |
| ... | (后续 8 个 agent 文件的完整摘要) |

父 Agent 把 25 个文件分成 5 批,派出 5 个子任务分别处理。每个子 Agent 读取多个文件并生成摘要,所有中间的文件内容(几千行)都在子上下文中丢弃,父 Agent 只拿到结构化的汇总表格。

任务 3:创建并验证

s04 >> Use a task to create a new module, then verify it from here
> task (Create a new JavaScript utility module):
  (子 Agent 独立创建 src/utils/stringUtils.js,
   包含 capitalize、reverseString、isPalindrome、truncate 四个函数)

  子 Agent 返回摘要 ↓

> bash: cat src/utils/stringUtils.js
  (父 Agent 自己读取文件验证内容)
> bash: node -e "..." (逐个测试每个函数)

✅ Module created and verified successfully!

| Function | Test Input | Expected | Actual | Status |
|---|---|---|---|---|
| capitalize | 'hello' | 'Hello' | 'Hello' | ✅ |
| reverseString | 'hello' | 'olleh' | 'olleh' | ✅ |
| isPalindrome | 'racecar' | true | true | ✅ |
| truncate | 'Hello, World!', 5 | 'Hello...' | 'Hello...' | ✅ |

注意分工:子 Agent 负责创建,父 Agent 负责验证。 创建过程中的所有中间输出(文件写入、目录创建)留在子上下文里;父 Agent 拿到摘要后,在自己干净的上下文中独立验证结果。这就是"隔离但协作"。


变更表

组件第 3 课 (TodoWrite)第 4 课 (Subagent)
上下文模型单一共享 messages[]父 messages[] + 子 sub_messages[]
子任务机制run_subagent() 函数
工具列表bash, read, write, edit, todo+task (仅父 Agent)
上下文隔离子任务独立上下文,用完即弃
递归控制子 Agent 禁止使用 task
轮次限制子 Agent 30 轮上限
结果传递仅最终文本返回父上下文
新增代码~30 行~50 行

下一课预告

第 4 课解决了上下文膨胀,但 Agent 的能力范围还是固定的——它只能用 system prompt 里写死的知识。

第 5 课:Skills —— 动态加载技能。让 Agent 能根据任务需要,从外部加载专业知识和操作指南。就像一个工程师遇到陌生领域时翻文档,而不是什么都靠脑子记。

# 预告:s05 的技能加载
def load_skill(skill_name: str) -> str:
    skill_path = f"skills/{skill_name}.md"
    return read_file(skill_path)

# 技能内容注入系统提示词
system = BASE_SYSTEM + "\n\n" + load_skill("python-testing")

从"硬编码知识"到"按需加载",Agent 的知识边界第一次变得可扩展。


这是《12课拆解Claude Code架构:从零掌握Agent Harness工程》系列的第 4 课。关注Claw开发者,不错过后续更新。

完整代码和交互式学习平台:github.com/shareAI-lab/learn-claude-code

如果这篇文章对你有帮助,欢迎转发给你的技术团队。

系列目录

  • 第1课:用20行Python造出你的第一个AI Agent
  • 第2课:给Agent加工具 —— dispatch map模式详解
  • 第3课:TodoWrite —— 让Agent先想后做:规划系统
  • 第4课:Subagent —— 拆解大任务,上下文隔离(本文)
  • 第5课:按需加载领域知识——Skill机制
  • 第6课:无限对话——上下文压缩三层策略
  • 第7课:任务持久化——文件级DAG任务图
  • 第8课:后台执行——异步任务与通知队列
  • 第9课:Agent Teams——多Agent协作:团队与邮箱系统
  • 第10课:团队协议——状态机驱动的协商
  • 第11课:自治Agent——自组织任务认领
  • 第12课:终极隔离——Worktree并行执行

On this page