四集下来,我们的 agent 已经会读、会写、会有选择地记忆,还能在截断之后继续跑。它还做不到的一件事:分工。每一次 observation、每一次工具调用、每一步推理,都挤在同一个上下文窗口里。一个"把 src/lib/ 下面的所有东西都重构一遍"的任务,就算套上上周那层带屏蔽的视图,也会被撑穿。

今晚我们从架构上把这件事修好。我们引入 sub-agent —— 一个全新的 Claude,有自己的 system prompt、自己的一套工具,而且关键在于有自己独立的上下文窗口。主 agent 做 orchestrator。sub-agent 只干一件带作用域的事,然后回一份紧凑的摘要。主 agent 拿着这份摘要继续执行计划,它自始至终都没有看到 sub-agent 咀嚼过的那些嘈杂原始数据。

这就是 Anthropic 那条发现——"token 用量解释了 80% 的结果质量方差"——背后的模式。context engineering 那篇研究 讲的是理论;今晚我们把它落成约 90 行代码。

今晚要造的东西

  1. 一个 runSubagent(brief, tools, model?) 函数 —— 一个自成一体、带独立历史的 tool-use 循环
  2. 一个暴露给主 agent 的 spawn_subagent 工具
  3. 一份硬性 budget:sub-agent 有最大轮数和最大输出长度
  4. 一份"返回契约" —— sub-agent 的最后一条消息就是回给主 agent 的内容,原样返回,带上限

agent.ts 不变。新增一个 subagent.ts

正确的心智模型

主 agent 是个经理。它读起来应该像一份计划:"先找到 auth 是在哪里接进去的,再看我们是否已经有 rate-limit 中间件,然后提一个 patch。"这里每一个子步骤都是一个任务,每个任务都可以扔给一个 sub-agent,那个 sub-agent 的全部工作就是这一个子步骤,别的都不管。

有两件事让这套模式既便宜又安全:

代价是多了一次 orchestration 上的往返延迟,以及调试稍微更麻烦一点。对于 observation 占用超过 4000 token 的任务来说,这个代价可以接受。

实现 runSubagent

新建 subagent.ts

import Anthropic from "@anthropic-ai/sdk";
import type { runTool } from "./agent.js";

const client = new Anthropic();

export interface SubagentOptions {
  brief: string;
  toolNames: string[];         // subset of the main agent's tools
  model?: string;
  maxTurns?: number;
  maxOutputChars?: number;
  systemHint?: string;
}

export interface SubagentResult {
  finalText: string;
  turnsUsed: number;
  reason: "end_turn" | "max_turns" | "error";
  errorMessage?: string;
}

const DEFAULT_MODEL = "claude-sonnet-4-5";
const DEFAULT_MAX_TURNS = 8;
const DEFAULT_MAX_OUTPUT = 2000;

const SUBAGENT_SYSTEM = (hint?: string) => `
You are a scoped sub-agent inside Mini Claude Code. You have a single brief. Complete it, then STOP.

Rules:
- Do the smallest amount of work that answers the brief.
- Use tools only when needed. Do not explore.
- Your final message will be sent back to the manager verbatim. Keep it under ~300 words and structured.
- If the brief is infeasible, say so in one sentence and stop.
${hint ? "\nManager hint: " + hint : ""}
`.trim();

export async function runSubagent(
  opts: SubagentOptions,
  runToolFn: typeof runTool,
  allTools: readonly { name: string; description: string; input_schema: unknown }[],
): Promise<SubagentResult> {
  const model = opts.model ?? DEFAULT_MODEL;
  const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
  const maxOutput = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT;
  const tools = allTools.filter((t) => opts.toolNames.includes(t.name));

  const history: Anthropic.MessageParam[] = [
    { role: "user", content: opts.brief },
  ];

  for (let turn = 0; turn < maxTurns; turn++) {
    let response: Anthropic.Message;
    try {
      response = await client.messages.create({
        model,
        max_tokens: 1500,
        system: SUBAGENT_SYSTEM(opts.systemHint),
        tools,
        messages: history,
      });
    } catch (e) {
      return {
        finalText: "",
        turnsUsed: turn,
        reason: "error",
        errorMessage: e instanceof Error ? e.message : String(e),
      };
    }

    history.push({ role: "assistant", content: response.content });

    if (response.stop_reason !== "tool_use") {
      const text = response.content
        .filter((b): b is Anthropic.TextBlock => b.type === "text")
        .map((b) => b.text)
        .join("\n")
        .trim();
      const capped = text.length > maxOutput ? text.slice(0, maxOutput) + "\n…[truncated by sub-agent budget]" : text;
      return { finalText: capped, turnsUsed: turn + 1, reason: "end_turn" };
    }

    const results = [];
    for (const b of response.content) {
      if (b.type === "tool_use") {
        const out = await runToolFn(b.name, b.input as Record<string, unknown>);
        results.push({ type: "tool_result" as const, tool_use_id: b.id, content: out });
      }
    }
    history.push({ role: "user", content: results });
  }

  return { finalText: "(sub-agent hit turn cap without concluding)", turnsUsed: maxTurns, reason: "max_turns" };
}

这段代码里有五个地方值得一提:

**一个精简版 system prompt。**sub-agent 有一份不一样的人格:简短、任务导向、禁止探索。这是让 sub-agent 真正好用的最重要的一根杠杆。一个话痨 sub-agent 就是一个坏 sub-agent。

**工具 allow-list。**不是每个 sub-agent 都需要所有工具。由主 agent 来决定。一个"找相关文件"的 sub-agent 可能只拿到 list_dirrun_bash,没有 apply_patch。把最小权限原则套到 prompt 上。

**内部没有屏蔽循环。**sub-agent 的上下文靠"活得短"来保持小 —— 它在几轮之内就死掉了。第 04 集那套机器在这里属于杀鸡用牛刀。

**apply_patch 没有额外的确认门。**如果主 agent 把 apply_patch 授权给 sub-agent,那 sub-agent 通过 runToolFn 继承的是同一条确认流程。这道门属于工具,不属于 agent。这也是为什么我们要把 runToolFn 传进去 —— sub-agent 完全不用自己重写任何逻辑。

**输出硬上限。**主 agent 最多拿回 maxOutputChars 的内容。如果一个 sub-agent 想把整个文件甩回来,我们就截断。这时候经理该反思的是:是不是应该问一个更好的问题。

把工具暴露给主 agent

加到主 TOOLS 数组里:

{
  name: "spawn_subagent",
  description:
    "Dispatch a scoped task to a fresh sub-agent. Use for large searches, exploration, or focused refactors. The sub-agent has its own context window; you will only see its final summary. Prefer this over reading many files yourself.",
  input_schema: {
    type: "object",
    properties: {
      brief: {
        type: "string",
        description: "A single-paragraph task description. Be specific about what to return.",
      },
      tools: {
        type: "array",
        items: { type: "string" },
        description: "Subset of tool names to grant. Never include spawn_subagent (no recursion).",
      },
    },
    required: ["brief", "tools"],
  },
},

注意那句显式的"no recursion"注释。在第 05 集里,sub-agent 不能再 spawn sub-agent。这不是一个难解的问题,但它的失效模式(fan-out、budget 爆炸、调试黑箱)是难的,我宁愿先发一个读者能推理清楚的版本。

接进 runTool

agent.ts 里:

import { runSubagent } from "./subagent.js";

// inside runTool:
if (name === "spawn_subagent") {
  const brief = String(input.brief);
  const requestedTools = (input.tools as string[]).filter((t) => t !== "spawn_subagent");
  console.log(`[subagent] brief: ${brief.slice(0, 100)}${brief.length > 100 ? "…" : ""}`);
  console.log(`[subagent] tools: ${requestedTools.join(", ")}`);

  const result = await runSubagent(
    { brief, toolNames: requestedTools },
    runTool, // pass ourselves so the sub-agent reuses tool implementations
    TOOLS,
  );

  console.log(`[subagent] done in ${result.turnsUsed} turns, ${result.reason}`);
  return `SUBAGENT_RESULT (${result.turnsUsed} turns, ${result.reason}):\n\n${result.finalText}`;
}

我们把 runTool 传进了 runSubagent。循环引用,但没有副作用 —— 工具执行器根本不需要知道自己现在是在为主 agent 服务,还是在为 sub-agent 服务。

一次带 sub-agent 的真实会话长什么样

you › are there any files under src/lib that don't have a matching test?

cc  › I'll dispatch a sub-agent to enumerate this cleanly.

[tool] spawn_subagent({"brief":"List every .ts file under src/lib recursively.
For each, check whether a file with the same basename exists under
tests/lib or under src/lib with a .test.ts suffix. Return a plain list
of untested files, one per line. If everything is covered, say so.",
"tools":["list_dir","run_bash"]})
[subagent] brief: List every .ts file under src/lib recursively. For each, check…
[subagent] tools: list_dir, run_bash
[subagent] done in 4 turns, end_turn

Files without tests:
- src/lib/blog.ts
- src/lib/mdx-renderer.ts
- src/lib/i18n-utils.ts

Everything else in src/lib has a matching *.test.ts.

cc  › Three files lack tests: blog.ts, mdx-renderer.ts, i18n-utils.ts.
Want me to draft skeleton tests for one of them?

主 agent 的上下文一共只增长了:

就这么多。sub-agent 内部跑了 4 轮,处理了好几份 list_dirgrep 的输出 —— 这些原始数据一个都没有碰到主 agent 的历史。压缩就是从这里来的。不是靠什么聪明的文本压缩,而是靠把原始数据放在墙的另一边

怎么挑选要不要开 sub-agent 的任务

不是每个任务都值得开 sub-agent。我大致的判断标准:

经理在做计划的时候就要决定。实践中,只要工具描述写得诚实("Prefer this over reading many files yourself"),Claude 这件事做得挺好。

我写这一集时踩过的坑

**sub-agent 以为自己可以聊天。**第一次跑起来,sub-agent 的摘要是三段"这个问题真有意思!我发现……"的开场白。修复办法:加强 system prompt,强制要求结构化、简短的输出。任务偏长的时候,我偶尔还会看到散文式的文字冒出来;再狠一点,写成"用 bulleted list 回复"能更好。

**通过 loop 触发 fan-out。**早期一版草稿里,我允许 sub-agent 用工具名的子串匹配来授权。主 agent 直接写了 ["*"],把所有工具都拿到手,包括 spawn_subagent。然后它就开始递归。我在深度 12 层、大约烧掉 0.60 美元的时候把它掐了。修复办法:只做精确匹配;无条件把 spawn_subagent 从 allow-list 里剥掉。

**sub-agent 的 apply_patch 需要自己的确认。**因为工具代码调的是同一个 confirmPatch,用户会看到一个来自 sub-agent 编辑的 y/N。这本身没问题,但不够直观。日志里要清楚地打出来是哪个 agent 在问。在真正的 Claude Code 里,这通常是操作员最不喜欢的意外。

**成本可见性。**一个 sub-agent 可以在操作员没注意的情况下烧掉 8 轮 × 2K max_tokens。加一行按 sub-agent 计的成本日志:[subagent] tokens: in=… out=…。今晚为了篇幅我没放进去,但第 06 集会通过一次正式的记账 pass 把它补回来。

下一集要解决什么

我们现在有了架构层面的能力,但还没有办法度量它。第 06 集 —— 也是第一季的最后一集 —— 全部围绕评测:

如果说第 05 集给了我们架构,那第 06 集给的就是让我们能迭代它、又不至于回归的反馈回路。

速查表 —— 第 05 集

| 是什么 | 在哪里 | |---|---| | sub-agent 运行器 | runSubagent(opts, runTool, TOOLS) | | 主 agent 工具 | spawn_subagent | | 轮数上限 | 8 | | 输出上限 | 2000 字符 | | 模型 | claude-sonnet-4-5(跟主 agent 一样;不同的 system prompt) | | 工具 allow-list | 由经理传入,剔除 spawn_subagent | | 返回格式 | "SUBAGENT_RESULT (N turns, reason):\n\nTEXT" | | recursion 策略 | 第 05 集里禁止 |

最小可用的 sub-agent 调用:

const result = await runSubagent(
  { brief: "...", toolNames: ["list_dir", "run_bash"] },
  runTool, TOOLS,
);
return `SUBAGENT_RESULT (${result.turnsUsed}, ${result.reason}):\n\n${result.finalText}`;

活到第 06 集要守住的六条规则:

  1. sub-agent 用不一样的 system prompt —— 简短、有作用域、禁止探索。
  2. sub-agent 拿到的是工具的子集,绝不给全套。
  3. 永远不要把 spawn_subagent 授权给 sub-agent。
  4. 同时给轮数输出加 cap;碰到任何一个 cap 都是信号,不是成功。
  5. 日志里标清楚是哪个 agent 在请求确认。
  6. 按 observation 占用量来挑 sub-agent 任务,别按"看起来复杂"来挑。

下一集是第 06 集 —— 我们终于要把反馈回路造起来了。