四集下來,我們的代理人已經會讀、會寫、會有選擇地記住,也能在被截斷之後繼續往前走。它還做不到的:分工。每一次觀察、每一次工具呼叫、每一個推理步驟,全都塞在同一個上下文視窗裡。像「重構 src/lib/ 底下的所有東西」這種任務,會把我們上週建起來的遮罩檢視也戳出一個大洞。

今晚我們從架構層面把它修好。我們引入一個 sub-agent——一個全新的 Claude,擁有自己的 system prompt、自己的工具集,最關鍵的是,自己的上下文視窗。主代理人負責調度,子代理人負責做一件有範圍的事並回報一段精簡的摘要。主代理人接著繼續它的計畫,從頭到尾都沒看過子代理人啃過的那堆吵雜原始資料。

這就是 Anthropic 那個發現背後的模式:「token 使用量可以解釋 80% 的成果品質變異。」脈絡工程研究 提供了理論;今晚我們補上大約 90 行程式碼。

今晚要做什麼

  1. 一個 runSubagent(brief, tools, model?) 函式——自成一體的 tool-use 迴圈,擁有自己的訊息歷史
  2. 一個對主代理人公開的 spawn_subagent 工具
  3. 一個硬性預算:子代理人有最大回合數上限與最大輸出長度上限
  4. 一份「回傳契約」——子代理人的最後一則訊息就是原封不動(在上限之內)送回主代理人的內容

agent.ts 不變。新增一個 subagent.ts

正確的心智模型

主代理人是一位 經理。它讀起來應該像一份計畫:「先找出 auth 是在哪裡接上的,然後確認我們有沒有現成的 rate-limit middleware,最後提出一個 patch。」上面每一個子步驟都是一個 任務,而每個任務都可以交給一位子代理人——它的全部工作就是那個子步驟,別的都不管。

有兩件事讓這個做法既便宜又安全:

代價是調度會多一趟往返延遲,除錯也會稍微難一點。對於觀察量 > 4000 tokens 的任務,這兩個代價都可以接受。

動手寫 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。 子代理人得到的是另一種人格:話少、專注在任務上、禁止四處探索。這是讓子代理人真正有用的最關鍵那條槓桿。一個愛講話的子代理人就是一個壞掉的子代理人。

工具允許清單。 不是每個子代理人都需要每個工具。這由主代理人決定。一個「找相關檔案」的子代理人可能只拿到 list_dirrun_bash,沒有 apply_patch。把最小權限原則套用到 prompt 上。

內部沒有遮罩迴圈。 子代理人的上下文視窗在設計上就是小的——它會在幾個回合之內結束。第 04 集那套機器在這裡是殺雞用牛刀。

apply_patch 沒有另外的確認關卡。 如果主代理人把 apply_patch 授權給了子代理人,子代理人會透過 runToolFn 承接同一套確認流程。關卡屬於工具,不屬於代理人。這就是我們把 runToolFn 傳進來的原因——子代理人什麼都不用重寫。

輸出的硬性上限。 主代理人最多拿回 maxOutputChars 個字元。如果子代理人試圖把整個檔案倒出來,我們就截斷。經理原本就該問一個更好的問題。

把工具公開給主代理人

加到主要的 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 集裡不能孵化子代理人。這不是什麼難題,但它的失敗模式(無限扇出、預算爆掉、除錯不透明)確實是難題,我寧可先出一個讀者可以推理得清楚的版本。

接進 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。循環引用但無害——工具執行器不需要知道自己現在是在服務主代理人還是子代理人。

一次帶子代理人的真實 session 長什麼樣子

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?

主代理人的上下文只增加了:

就這樣。子代理人內部走了 4 個回合,處理過好幾份 list_dir 和 grep 的輸出——這些全都沒進到主代理人的訊息歷史。這就是壓縮發生的地方。不是靠什麼聰明的文字瘦身,而是靠 把原始資料留在牆的另一邊

怎麼挑該給子代理人做的任務

不是每個任務都值得動用子代理人。我大致的規則是:

經理是在規劃時就決定的。實務上,只要工具描述夠誠實(「Prefer this over reading many files yourself」),Claude 這件事做得很好。

我在寫這集時踩到的坑

子代理人以為它可以閒聊。 第一次跑,子代理人的摘要是三段的「這問題真有意思!我發現的是……」。修法:加強 system prompt,強制要求結構化、簡潔的輸出。在比較長的任務上偶爾還是會看到散文式的內容偷偷冒出來;一條更嚴格的「請以項目清單回覆」指示會有幫助。

透過迴圈變成無限扇出。 早期草稿裡我讓子代理人以工具名稱的子字串比對來授權工具。主代理人打了個 ["*"],結果拿到了所有東西,包括 spawn_subagent。它就開始遞迴。我在 12 層深、燒掉大約 $0.60 之後把它殺掉。修法:只做完全比對;並且無條件把 spawn_subagent 從允許清單裡剝掉。

子代理人的 apply_patch 需要自己的確認關卡。 因為工具程式碼呼叫的是同一個 confirmPatch,使用者會看到來自子代理人編輯動作的 y/N。這樣沒問題,但不夠明顯。日誌裡要清楚寫是「哪個代理人在請求確認」。在正式版 Claude Code 裡,這通常是操作者最不喜歡遇到的驚喜。

成本可見性。 一個子代理人可以在操作者沒察覺的情況下燒掉 8 回合 × 2K max_tokens。加一行每個子代理人的成本紀錄:[subagent] tokens: in=… out=…。今晚的程式碼為了篇幅省掉了它,但第 06 集會把它補回來,順便做一次比較正式的計費盤點。

下一集要修的東西

我們現在有架構能力了,但沒有辦法衡量它。第 06 集——第一段弧線的最終集——完全是關於評估:

如果第 05 集給了我們架構,第 06 集就會給我們那個 feedback 迴圈,讓我們可以在不退步的前提下反覆迭代。

快速查閱 — 第 05 集

| 是什麼 | 在哪裡 | |---|---| | 子代理人執行器 | runSubagent(opts, runTool, TOOLS) | | 主代理人的工具 | spawn_subagent | | 回合上限 | 8 | | 輸出上限 | 2000 字元 | | 模型 | claude-sonnet-4-5(跟主代理人一樣;system prompt 不同)| | 工具允許清單 | 由經理傳入,且要扣掉 spawn_subagent | | 回傳格式 | "SUBAGENT_RESULT (N turns, reason):\n\nTEXT" | | 遞迴政策 | 第 05 集不允許 |

最小可行的子代理人一回合:

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. 子代理人有一份 不一樣 的 system prompt——話少、有範圍、禁止探索。
  2. 子代理人拿到的是工具的 子集,永遠不是全部。
  3. 絕對不要把 spawn_subagent 授權給子代理人。
  4. 回合數 輸出量都要設上限;任一個撞到上限都是訊號,不是成功。
  5. 日誌要清楚寫是哪個代理人在請求確認。
  6. 挑子代理人任務的依據是「觀察量的體積」,不是「看起來很複雜」。

下一站是第 06 集——我們終於要建起那個 feedback 迴圈。