从0打造编码Agent 阅读 + 实验 ≈ 35 分钟 页内实验 ×1
A7 子 agent:一个 agent 忙不过来、或需要别人把关时,怎么分工? 第 1 课

要在整个仓库里找全 parse_date 的调用点,都读进主上下文吗?

搜索该不该读进主上下文
主 agent 自己 grep,24 行结果里真正的调用点只有 5 行,却从此每一轮都跟着重发。把搜索交给一个新开 messages[] 的子 agent,主窗口只收一段报告:本课项目的 4 组真实对照里 3 组主上下文峰值更低。但省下的是主窗口,不一定是总 token;6 次运行也一次都没通过验收——这一课只回答「隔离」这一件事。
❓

本课一问:parse_date 修好之后,要在整个仓库里找全它的调用点、逐个判断还兼不兼容。这些搜索结果,都该读进主 agent 的上下文吗?

这一课先不碰:交接时该把什么交给子 agent(第 2 课);子 agent 还能不能再派、轮数算谁的(第 3 课);谁来判定做完了(第 4 课);几个 agent 同时改代码(岔路 B1)。

从已有出发

A5 第 1 课拆开过一轮请求:模型 API 不记得上一轮,harness 每一轮都把整段历史重发;历史里涨得最快的是工具结果,读回来的文件从那一轮起每轮都跟着重发。A5 的办法是满了再压缩。这一章换一个方向:有些东西一开始就不进主窗口。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

贯穿任务还是 test_parse_date。它修好之后自然冒出下一个问题(大纲里任务约束第 5 条):parse_date 改成了「日在前」,仓库里还有谁在用它、它们受不受影响?本课项目把这一步录成了 T4(任务 tasks/callers),任务原文就是给 agent 的这段话:

parse_date() in dateparse.py was recently fixed so that DD/MM/YYYY strings are read day-first (see AGENTS.md). Find EVERY place outside tests/ where parse_date is called, directly or under another name, and decide for each call site whether it is compatible with the fixed day-first behaviour. Write your findings to CALLERS.md as a markdown table with the columns: file | line | compatible (yes/no) | reason. List only real calls, not imports or comments. Do not modify any other file.
🔀

为什么这里换了例子:修 test_parse_date 本身只要读一两个文件,用不上分工;「找全调用点」要搜整个仓库、读很多文件而最后只需要一张表,正是子 agent 被设计出来要做的那类活。任务仓里有 6 个真实调用点(1 个经别名 to_date 调用),其中 2 个不兼容;验收脚本在 agent 视野之外。

先自己设计:主 agent 自己 grep、自己读

最直接的做法:主 agent 自己 grep parse_date,看结果,需要时再逐个读文件,最后写表。本课项目的「不用子 agent」两次运行就是这么做的。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案主 agent 自己 grep、自己读,结果全留在主窗口

t4-nosub-s2 第 1 轮那次 grep parse_date 的 24 行,按任务仓源码逐行分类:

这一行是行数例子
真实调用点5app/billing.py:33
import6app/billing.py:8
注释 / docstring3app/__init__.py:1
函数定义本身1dateparse.py:21
测试文件(任务说了不算)9tests/test_dateparse.py:6

第 6 个调用点 app/importers/legacy.py:15 写的是 to_date(...),搜 parse_date 根本搜不到它;两次运行都漏了。

这些行一旦进了主窗口,就和 A5 里的 dateparse.py 一样:之后每一轮请求都带着它们。主窗口里搜索类结果累计:t4-nosub-s1 563 token,t4-nosub-s2 1,120 token(录制模型的 tokenizer 数的内容 token)。

改进:子 agent,就是一份新的 messages[]

learn-claude-code 的 s06 把子 agent 写到了最小:一个工具 task,调用时在工具内部同步跑一个嵌套的循环,从一份只有 prompt 的新 messages[] 开始,跑完把最后一条回复的文本当作这次工具调用的结果交回去。

code.py learn-claude-code@0dcafa2:s06_subagent/code.py:256-307需联网 MIT
256SUB_TOOLS = list(BASE_TOOLS)
257SUB_HANDLERS = dict(BASE_HANDLERS)
258
259
260def extract_text(content) -> str:
261 if not isinstance(content, list):
262 return str(content)
263 return "\n".join(
264 getattr(block, "text", "")
265 for block in content
266 if getattr(block, "type", None) == "text"
267 )
268
269
270def run_subagent(prompt: str) -> str:
271 print("\n\033[35m[Subagent started]\033[0m")
272 messages = [{"role": "user", "content": prompt}]
273
274 for _ in range(30):
275 response = client.messages.create(
276 model=MODEL,
277 system=SUB_SYSTEM,
278 messages=messages,
279 tools=SUB_TOOLS,
280 max_tokens=8000,
281 )
282 messages.append({"role": "assistant", "content": response.content})
283
284 tool_calls = [
285 block for block in response.content if block.type == "tool_use"
286 ]
287 if not tool_calls:
288 force = trigger_hooks("Stop", messages)
289 if force:
290 messages.append({"role": "user", "content": force})
291 continue
292 print("\033[35m[Subagent done]\033[0m")
293 return extract_text(response.content) or "(no summary)"
294
295 results = []
296 for block in tool_calls:
297 output = execute_tool(block, SUB_HANDLERS)
298 print(f" \033[90m[sub] {block.name}: {output[:100]}\033[0m")
299 results.append({
300 "type": "tool_result",
301 "tool_use_id": block.id,
302 "content": output,
303 })
304 messages.append({"role": "user", "content": results})
305
306 print("\033[35m[Subagent stopped]\033[0m")
307 return "Subagent stopped after 30 turns without a final answer."
👆 带下划线的行可以点:点哪行,讲哪行。

本课项目的 delegate 工具是同一个思路(miniagent/subagent.py),另加了三条:子 agent 只有只读工具、权限默认拒绝;预算独立(T4 里 15 轮);父 agent 收到的工具结果是 Helper report: 加子 agent 的最后一段话。下面是同一个 seed 的三次真实运行并排,三列共用一根纵轴。

主窗口与子窗口:每轮请求多大,交接与报告各多少 token(真实 T4 运行) 主 agent 的请求子 agent 的请求其中搜索结果父→子 交接子→父 报告
seed 点任意一根柱,下方看那一轮的细节

数据:本课项目 miniagent 的 T4 真实运行(Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server,2026-09-26;src/data/precomputed/a07-subagents.json)。柱高 = 服务端 usage 的 prompt_tokens;绿色段与流带 = 用录制模型 tokenizer 数的内容 token(不含聊天模板的标记,所以比柱高的增量略小)。三列共用同一纵轴。思考截取约 500 字、工具输出约 900 字。

🎯

试三件事:① 看上泳道三列的最高柱:不用子 agent 的那列,峰值是哪一轮、绿色(搜索结果)占了多少;② 看中间一列的两条流带:蓝色往下是父 agent 交给子 agent 的任务(19 token),绿色往上是回到父窗口的报告(192 token),子 agent 自己那两轮的 grep 输出一个字都没进上泳道;③ 切到 seed 2,找出唯一一组「用了子 agent、主窗口峰值反而更高」的是哪一列,再点它第 1 轮的柱子看为什么。

读数(全部来自服务端 usage):

配置seed 1 主窗口峰值seed 2 主窗口峰值seed 1 总 token(主 + 子)seed 2 总 token(主 + 子)
不用子 agent1,7672,32814,41016,141
用子 agent · 只给任务文本1,3271,62914,232(-1%)11,419(-29%)
用子 agent · 带上父决定2,2811,86321,040(+46%)21,961(+36%)
  1. 主窗口峰值:4 组对照里 3 组更低。不成立的那一组是 sd1:父 agent 先自己 grep 了一遍(622 token 进了主窗口),才把活派出去,于是「自己搜」和「报告」两份都留在主窗口里。子 agent 不是魔法,主 agent 先自己读了,隔离就白做了。
  2. 总 token 不是一个方向。「只给任务文本」的两次总数反而更少,「带上父决定」的两次多了四成左右。原因在下一课:只给任务文本的子 agent 两轮就交差,父 agent 拿着报告直接收尾——花得少,是因为做得少。
  3. 6 次运行 verifier 全部失败:不用子 agent 的两次找到 5/6,用了子 agent 的四次一个都没对上。这一课只证明了「隔离能让主窗口更小」,没有证明「隔离让结果更好」;把它们混在一起,是这一章最容易犯的错。

极小真数字:主窗口到底为这次搜索付了多少

同一个 seed 2,看「搜索结果进主窗口」的那一轮前后,服务端 usage 涨了多少:

t4-sub-text-s2:主窗口第 2 轮 − 第 1 轮 = 1392 − 1045 = 347 token
= +
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
对照同一个 seed 的不用子 agent:第 2 轮 − 第 1 轮 = 1488 − 886 = 602,其中 grep 结果 560 token;它第 2 轮又 grep 了一次,主窗口再涨一截。
同一次运行的总账:11,419 = 8,180(主)+ 3,239(子)
= +
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
不用子 agent 的 seed 2 是 16,141。这次更少,不是因为子 agent 省钱,而是两边都做得少:子 agent 2 轮就交差,父 agent 拿到报告就写表收尾,一个文件也没读。
⚠
常见误解一:「子 agent 省 token。」

它省的是主窗口:主 agent 每一轮要重发的东西变少了,离窗口上限更远。总 token 要把子 agent 自己的每一轮都加上:子 agent 从零开始,自带 system prompt 和工具定义,每轮同样整份重发。本课项目里「带上父决定」的两次,总数比不用子 agent 多了 46% 与 36%;Anthropic 自报它的多 agent 研究系统用的 token 约为普通对话的 15 倍(单 agent 约 4 倍) 。

常见误解二:「子 agent 是隔离的,所以它是安全的。」

隔离的只是消息。learn-claude-code 的 s06 README 原话:「This is message isolation, not process or filesystem isolation」——父子在同一个进程、同一个工作目录里,子 agent 写的文件父 agent 立刻看得到(s06_subagent/README.md:25)。能不能写、能碰哪些路径,仍然是权限(A3)和沙箱(A4)的事;本课项目给子 agent 只读工具、权限默认拒绝,是另外加的。

各家怎么把结果交回来

同一个动作——开一份新的上下文、跑完只交回一段话——各家的差别在「从什么开始」和「交回之前要不要截」:

子 agent 从什么开始交回父 agent 的是什么出处(pin)
learn-claude-code s06只有 prompt 的新 messages[]最后一条回复的文本,不截断s06_subagent/code.py:270-307
CoreCoder v0.7.0新的 Agent,工具表去掉 agent,子 agent 上限 20 轮[Sub-agent completed] + 结果;超过 5,000 字符截成 4,500tools/agent.py:46-67
OpenCode新 session,parentID 指向父会话;可用 task_id 续跑同一个子会话最后一段 text,包在 <task … state="completed"> 里;子 agent 出错时以错误回传tool/task.ts:156-172,200-225,344
Hermes新对话,system prompt 由 goal 与 context 拼成summary;按父窗口剩余空间与 max_summary_chars(24,000)限长,超出的部分落盘并给出读取位置delegate_tool.py:900-976;config_defaults.py:1753
Pi(扩展,非核心)另起一个 pi 进程,--no-session最后一条 assistant 文本;并行模式每个任务 50KB 上限examples/extensions/subagent/index.ts:170-180,294
Codex v1一个新线程(可选 fork 父历史,第 2 课)wait_agent 等到结束,「Completed statuses may include the agent’s final message」multi_agents_spec.rs:275
Claude Code自己的 system prompt、任务、CLAUDE.md 等,看不到父对话只回摘要;到 maxTurns 时标记为 partial官方文档 sub-agents(2026-09-26 核对)
本课项目空历史 + 只读工具,权限默认拒绝Helper report: + 最后一段话;预算用完时追加一行说明miniagent/subagent.py:99-105

CoreCoder 的实现只有 35 行,作者自述「Sub-agents only run the plainest synchronous execution」:

agent.py CoreCoder@v0.7.0:corecoder/tools/agent.py:38-72需联网 MIT
38 def execute(self, task: str) -> str:
39 if self._parent_agent is None:
40 return "Error: agent tool not initialized (no parent agent)"
41
42 # import here to avoid circular dep
43 from ..agent import Agent
44
45 parent = self._parent_agent
46 sub = Agent(
47 llm=parent.llm,
48 tools=[t for t in parent.tools if t.name != "agent"], # no recursive agents
49 max_context_tokens=parent.context.max_tokens,
50 max_rounds=20,
51 # the sub-agent answers to the same consent layer and hooks as the parent
52 permission=parent.permission,
53 hooks=parent.hooks,
54 )
55
56 # a sub-agent failure comes back as text, never propagates into the parent
57 # sub-agents share this thread's tracked cwd; restore it on return so a
58 # sub-agent's own cd never leaks into the parent's shell state
59 from .bash import get_tracked_cwd, set_tracked_cwd
60
61 parent_cwd = get_tracked_cwd()
62 try:
63 result = sub.chat(task)
64 # trim long results to avoid blowing up parent's context
65 if len(result) > 5000:
66 result = result[:4500] + "\n... (sub-agent output truncated)"
67 return f"[Sub-agent completed]\n{result}"
68 except Exception as e: # noqa: BLE001
69 return f"Sub-agent error: {e}"
70 finally:
71 if get_tracked_cwd() != parent_cwd:
72 set_tracked_cwd(parent_cwd)
👆 带下划线的行可以点:点哪行,讲哪行。
🔍 Claude Code 的官方交互时间轴里,子 agent 那一步是怎么画的?

Claude Code 文档《Explore the context window》的可播放时间轴里有一段子 agent:它有自己更短的 system prompt、自己的一份 CLAUDE.md、父 agent 写给它的任务,读文件填的是它自己的窗口;那一段的说明是「None of its file reads touch yours. Only the final summary comes back.」页面注明用的是代表性数字(representative numbers),所以本课不引它的 token 数;上面的三列回放就是它的真数据版。

证据三问

问本课的回答
① 它解决什么问题?读很多、只要结论的活,中间结果不进主窗口,不跟着每轮重发
② 为什么一定有效?子 agent 的 messages 与父 agent 的是两个列表,父 agent 只追加一条工具结果;只要报告比中间结果短,主窗口的增量就更小
③ 实验上呢?本课项目 T4:主窗口峰值 4 组里 3 组更低;总 token 两降两升;6 次运行验收全失败 ;Anthropic 自报多 agent 约 15 倍 token

演进地图:你在这里

子 agent 的演进:每一代都被上一代的某个真数字逼出来
  1. 所有 harness 的默认形态;本课项目 T4 的「不用子 agent」两次运行
    解决了不需要交接:主 agent 亲眼看到每一行搜索结果,不会有信息在转手时丢失。
    卡在两次 grep 结果进主上下文 563 / 1,120 token,之后每轮跟着重发(A5);要判对兼容性还得读 7 个文件(内容 1,818 token),两次都没读——找到 5/6 与 5/6,兼容性判对 3/6 与 2/6。
  2. learn-claude-code s06、CoreCoder AgentTool、OpenCode task、Claude Code 子 agent、本课项目 delegate
    解决了搜索的中间结果留在子 agent 自己的窗口里;4 组对照里 3 组主上下文峰值更低。
    卡在sub-text-s2 的子 agent 在自己的 grep 里看到了 5 个真实调用点,报回的 9 个「调用点」里 9 个是 import、注释或定义行;父 agent 照抄,verifier 找到 0/6。
  3. 本课项目 with_decisions、Hermes 的 context 字段、Codex v2 的 fork_turns(默认 all)、Claude Code 的 fork
    解决了子 agent 拿到了父 agent 的全部约束;sub-dec-s1 的子 agent 报回的真实调用点行号 5/6。
    卡在两次仍然 0/6:s1 的子 agent 一个文件都没读就猜兼容性,父 agent 全程 write_file 0 次却说「已写进 CALLERS.md」;s2 的子 agent 正则写错,15 轮预算只用了 3 轮就交差。
  4. OpenCode subagent_depth、Codex agent_max_depth、Hermes max_spawn_depth(默认都是 1);Claude Code 默认 3 层;Hermes、CoreCoder、lcc、本课项目的子 agent 预算独立
    解决了递归有上界;子 agent 的轮数不挤占父 agent:sub-dec-s1 的父 agent 只需要 3 轮,父子共用一个计数时得给到 7。
    卡在预算防的是停不下来,不防过早收工:T4 的 4 个子 agent 各有 15 轮,最多只用了 4 轮。
  5. learn-claude-code s17 独立评估器、Codex Guardian(A3)、Anthropic cwc-long-running-agents 的 evaluator、Hermes 后台自省 fork
    解决了完成与否不再由干活的 agent 自己说了算;评审者没有写工具,不能顺手「修好」来掩盖问题。
    卡在T1 与 T4 里最后一句话明确声称完成 20 次,verifier 判失败 12 次;评审者也是模型,也会被说服 → 确定性的判决在 A9。
旁支 多个 agent 同时改代码:worktree 隔离工作目录,同一文件的写串行
learn-claude-code s13、Codex(0.156 起默认开 worktree)、CoreCoder FILE_MUTATION_LOCK、Pi withFileMutationQueue
本课项目的子 agent 是同步的、只读的,T4 里不会发生写冲突;并行写同一个文件时才需要它们。worktree 不是沙箱。
旁支 Pi:核心不内置子 agent,交给扩展
Pi 31b513e3 README「No sub-agents」;examples/extensions/subagent
不是做不出来:子 agent 扩展 1,141 行,权限确认扩展 34 行,都放在核心之外。

数字口径:本课项目 miniagent 用 Qwen/Qwen3-8B-MLX-4bit 本机录制的 T4(2026-09-26,找全 parse_date 调用点,6 次运行 + 4 条子 agent trace;单一模型、每种配置 2 个 seed,只说明现象存在,不说明比例)。token 取自服务端 usage;「内容 token」用录制模型的 tokenizer 现数。耗时不作比较(本机有其他任务同时占用)。

一句话

📌
子 agent 省的是主上下文,不是总 token。
🚦

先停在这里:记住两件事——子 agent = 一份新的 messages[] + 只交回最后一段话;它让主窗口变小,但总账要把子 agent 自己的每一轮加上。
再深一层:在上面的回放里点开 seed 2「只给任务文本」那一列的 S1,看子 agent 自己的 grep 里有哪几行真实调用点,再看它交回去的报告写了什么。或者走岔路:多个 agent 同时改代码,怎么不互相踩?、Pi 为什么不内置子 agent?

下一个缺口

子 agent 只拿到一句任务描述,会丢掉什么? → 第 2 课

t4-sub-text-s2 的子 agent 在自己的 grep 里看到了 5 个真实调用点,报回去的却是 import 所在的行号;父 agent 照抄进表,verifier 一个也没对上。父 agent 任务里写着「只算真实调用,不算 import 和注释」——这句话没传下去。

你可能会问