HuanCode Docs

Harness实战:Subagent——大任务拆小,上下文隔离

Agent工作越久,上下文越臃肿。Subagent机制把子任务放到独立messages[]中执行,只把精炼摘要带回父Agent。结合Plan模式,计划阶段列步骤,执行阶段委派subagent,上下文始终干净。

写在前面

上一篇我们让 Agent 学会了"先想后做"——Plan 模式。模型先输出计划,用户确认后再执行。

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

Agent 执行每一步——读文件、跑命令、查日志——输出都永久留在 messages[] 里。10 轮下来上下文可能涨到几万 token。更要命的是,这些历史输出会干扰模型的注意力


问题:一个词的答案,315 行的代价

假设你让 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 行工具输出。但真正需要的答案只有一个词:"Jest"

后续所有 API 调用都要带上这 315 行历史,反复付费,还稀释模型对当前任务的注意力。


解决方案:Subagent——独立上下文,用完即弃

核心思路:把子任务的脏活放到独立的 messages[] 里做,只把干净的结果带回来。

┌──────────────────────────────────────────────────────┐
│  父 Agent (messages[])                                │
│                                                       │
│  User: "分析技术栈,然后更新 README"                  │
│  Assistant: 我先调研技术栈 → tool_use: task            │
│                                                       │
│  ┌────────────────────────────────────────────────┐   │
│  │  子 Agent (sub_messages[] — 独立,用完即弃)    │   │
│  │                                                │   │
│  │  bash → cat package.json         (200行)       │   │
│  │  bash → cat jest.config.js       (30行)        │   │
│  │  bash → ls src/                  (20行)        │   │
│  │                                                │   │
│  │  最终回复: "TypeScript 5.3 + React 18 + Jest"  │   │
│  │  ★ 整个 sub_messages[] 在这里丢弃              │   │
│  └────────────────────────────────────────────────┘   │
│                                                       │
│  tool_result: "TypeScript 5.3 + React 18 + Jest"      │
│  Assistant: 好的,现在编辑 README...(继续干净工作)   │
└──────────────────────────────────────────────────────┘

父 Agent 只多了一条 tool_result,内容是精炼摘要。那 250 行中间输出?消失了。


Subagent 的三个核心价值

很多人以为 subagent 就是"压缩上下文"。这是最直接的收益,但不是全部。

1. 上下文隔离

子 Agent 用独立的 sub_messages[],所有中间工具调用和结果都在里面。执行完毕,extract_text(response) 只提取最终文本返回给父 Agent。

父 Agent 的 history 里只多了一条简短的 tool_result,而不是几十条中间步骤。

实际体验:我在测试 /plan 分析项目技术栈并更新 README 时,父 Agent 自己跑了 6 个 bash + 2 个 subagent。即便用了 subagent,父 Agent 的上下文已经膨胀到让下一次 API 调用等了很久。如果没有 subagent,那两个子任务的所有 read_file 结果也堆进父 history,会更慢。

2. 任务分解

父 Agent 只需说"分析技术栈"、"写 README",不需要关心子任务怎么做。每个 subagent 独立决策、独立执行。

这实现了关注点分离——父 Agent 负责全局协调和最终交付,子 Agent 负责具体的研究和执行细节。

3. 失败隔离

subagent 挂了或输出垃圾,父 Agent 的 history 不会被污染。父 Agent 可以重试、换策略、或跳过这一步继续。

这在生产环境至关重要——一个子任务的失败不应该搞崩整条流水线。

类比:subagent 就像你派同事去调研,他回来只给你一页总结,而不是把他查的所有资料都倒在你桌上。调研过程出了问题?你可以派另一个人重新查,自己手头的工作不受影响。


实现

在上一篇的 Plan 模式基础上,增加三样东西。

1. task 工具定义

给父 Agent 注册一个 task 工具:

TASK_TOOL = {
    "name": "task",
    "description": (
        "Run a subtask in an isolated context. "
        "The subtask gets its own clean message history, "
        "only the final text summary is returned. "
        "Use for research, analysis, or any work whose "
        "intermediate output the parent does not need."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "prompt": {
                "type": "string",
                "description": "Task description for the subagent",
            }
        },
        "required": ["prompt"],
    },
}

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

2. run_subagent() 函数

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

MAX_SUBAGENT_TURNS = 30
SUBAGENT_SYSTEM = f"""You are a focused agent at {WORKDIR}.
Complete the given task, then provide a clear summary of results.
Do not ask for clarification — work with what you have."""


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


def run_subagent(prompt: str) -> str:
    """在隔离上下文中执行子任务,仅返回最终文本。"""
    sub_messages = [{"role": "user", "content": prompt}]

    for _turn in range(MAX_SUBAGENT_TURNS):
        response = client.messages.create(
            model=MODEL,
            system=SUBAGENT_SYSTEM,
            messages=sub_messages,
            tools=BASE_TOOLS,          # ★ 没有 task 工具,不能递归
            max_tokens=4096,
        )
        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 = BASE_HANDLERS.get(block.name)
                output = handler(**block.input) if handler else f"Unknown: {block.name}"
                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

3. 注册到 dispatch map

# 基础工具(子 Agent 用这个,不含 task)
BASE_HANDLERS = {
    "bash":       lambda **kw: run_bash(kw["command"]),
    "read_file":  lambda **kw: run_read(kw["path"], kw.get("limit")),
    "write_file": lambda **kw: run_write(kw["path"], kw["content"]),
    "edit_file":  lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]),
}

# 父 Agent 的工具列表和 dispatch(含 task)
TOOLS = BASE_TOOLS + [TASK_TOOL]
TOOL_HANDLERS = {
    **BASE_HANDLERS,
    "task": lambda **kw: run_subagent(kw["prompt"]),
}

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

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


Plan + Subagent:组合使用

Plan 模式生成计划,执行阶段用 subagent 委派子任务。只需在执行提示词里加一句:

# 阶段 2:执行时提示模型可以用 task 工具委派
messages.append({
    "role": "user",
    "content": f"{feedback} Now execute it step by step. "
               f"Use the `task` tool to delegate independent subtasks."
})
agent_loop(messages)

运行效果

❯ /plan 分析项目技术栈并更新 README

⏳ Generating plan...

📋 Plan:
1. 调研项目使用的语言、框架、测试工具
2. 读取当前 README 内容
3. 在 README 中补充技术栈说明

 Execute? (y/n) y

⚡ Executing...

  🔀 Subagent started: 调研项目的语言、框架、测试工具...
    > bash: cat package.json | head -30
    > bash: cat jest.config.js
    > read_file: vite.config.ts
  🔀 Subagent done (8 messages)

> task: TypeScript 5.3, React 18 + Vite 5, Jest + Testing Library, pnpm...
> read_file: # My Project ...
> edit_file: Edited README.md

已在 README.md 中补充了完整的技术栈说明。

Plan 阶段的第 1 步(调研)被委派给 subagent,8 条消息的中间过程被隔离,父 Agent 只拿到一段摘要,然后干净地完成步骤 2 和 3。


核心机制解析

为什么禁止递归?

子 Agent 没有 task 工具,不能再派子 Agent。这是刻意的:

  • 防失控:无限递归会无限消耗 API 调用和 token
  • 两层就够:父 Agent 拆解的子任务粒度已经足够小,一个 Subagent 在 30 轮内能搞定

正确的做法是让父 Agent 拆出更多并列的子任务,而不是让子任务嵌套下去:

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

Claude Code 的生产实现也是这个设计——Task 工具的子 Agent 禁止使用 Task

为什么丢弃子历史?

子 Agent 结束后,sub_messages[] 直接被垃圾回收。保留的成本远大于收益:

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

父 Agent 需要的是结论,不是过程。

子 Agent 的系统提示词为什么不同?

SUBAGENT_SYSTEM = """You are a focused agent at {WORKDIR}.
Complete the given task, then provide a clear summary of results.
Do not ask for clarification — work with what you have."""

关键是最后一句——"不要问澄清问题"。子 Agent 没有和用户交互的通道,它只能和工具交互,必须自行判断、自行行动、自行总结。


Claude Code 的 Subagent

Claude Code 的 Agent 工具(在 system prompt 中叫 Task)就是这个模式的生产级实现:

  • 父 Agent 可以调用 Task 派发子任务
  • 子 Agent 在独立上下文中执行
  • 子 Agent 禁止使用 Task(不能递归)
  • 只有最终文本返回父上下文

底层原理完全一样。


小结

  1. task 工具 — 父 Agent 调用它派发子任务
  2. 独立 messages[] — 子 Agent 用干净的上下文,用完即弃
  3. 禁止递归 — 子 Agent 没有 task 工具,只能往宽度扩展
  4. 只返回文本 — 几百行中间输出压缩成几句摘要
  5. 三个价值 — 上下文隔离、任务分解、失败隔离

这个模式的本质是:用一个额外函数调用换取上下文的干净。 对简单任务可能多余,但对复合任务——调研+执行、分析+修改——能显著控制上下文膨胀。

On this page