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

子 agent 能无限派下去吗?轮数预算算谁的?

深度闸与轮数预算
父子共用一个轮数计数时,子 agent 的每一轮都记在父 agent 的账上:t4-sub-dec-s1 的父 agent 自己只要 3 轮,共用计数就得给到 7。不限深度时,每一层都能再派一层。现代做法是每一层各带一份预算、每一层都过一道深度闸:Hermes 的 IterationBudget 与 OpenCode、Codex、Hermes 的深度闸都从 pin 源码移植进页面,在真实事件序列上现算,并与原实现逐条对照。预算防得住停不下来,防不住过早收工。
❓

本课一问:子 agent 自己也是一个循环。它能不能再派出自己的子 agent,一层套一层?父 agent 设的轮数上限,子 agent 的轮数算不算在里面?

这一课先不碰:子 agent 交回来的东西可不可信(第 4 课);上限本身该设多少(A1 第 4 课已经讲过)。

从已有出发

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

A1 第 4 课定过一件事:循环要不要设轮数上限、设多少。现在循环里多了一个工具,调用它会在工具内部再跑一整个循环。T4 的每次运行是这样一串 API 调用(P = 父 agent,S = 子 agent,按真实先后):

运行事件序列父 agent 自己的轮数
t4-sub-text-s1P1 P2 S1 S2 P33
t4-sub-dec-s1P1 P2 S1 S2 S3 S4 P33
t4-sub-text-s2P1 S1 S2 P2 P33
t4-sub-dec-s2P1 P2 S1 S2 S3 P3 P4 P55

子 agent 的全部轮次都发生在父 agent 那一轮的工具调用里面:父 agent 调了 delegate,就一直等到子 agent 交回报告(岔路 B1 的泳道图画的就是这个)。

先自己设计:一个计数器管到底,不限深度

最省事的做法:整个任务只有一个轮数计数器,谁调模型谁加一;delegate 工具谁都能调,想派多深就派多深。

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案父子共用一个轮数计数,不限深度

下面的模拟器默认就是朴素方案:t4-sub-dec-s1、父子共用一个计数、上限 5。按 ▶ 一步步走,看最后一次调用 P3 发生了什么。

轮数预算算谁的:在真实事件序列上跑 Hermes 的 consume / refund 自检中…
预算

数据:事件序列来自本课项目 T4 的真实 trace(父 agent 上限 30、子 agent 15、深度上限 1);预算语义移植自 Hermes 56dc01d9(MIT);期望值由 gen_a07_subagents.py 直接加载 Hermes 原 IterationBudget 类,以及 bun / rustc / python 在四份 pin 源码的逐字切片上跑出(04-实践项目/scripts/parity/)。

改进一:每一层各带一份预算

Hermes 把轮数预算写成一个只有两个动作的小类:consume() 每次调模型之前扣一次,扣不动就返回 False;refund() 退还一次。父 agent 与每个子 agent 各持一份。

iteration_budget.py hermes-agent@56dc01d9:agent/iteration_budget.py:17-59需联网 MIT
17class IterationBudget:
18 """Thread-safe iteration counter for an agent.
19
20 Each agent (parent or subagent) gets its own ``IterationBudget``.
21 The parent's budget is capped at ``max_iterations`` (default 500).
22 Each subagent gets an independent budget capped at
23 ``delegation.max_iterations`` (default 50) — this means total
24 iterations across parent + subagents can exceed the parent's cap.
25 Users control the per-subagent limit via ``delegation.max_iterations``
26 in config.yaml.
27
28 ``execute_code`` (programmatic tool calling) iterations are refunded via
29 :meth:`refund` so they don't eat into the budget.
30 """
31
32 def __init__(self, max_total: int):
33 self.max_total = max_total
34 self._used = 0
35 self._lock = threading.Lock()
36
37 def consume(self) -> bool:
38 """Try to consume one iteration. Returns True if allowed."""
39 with self._lock:
40 if self._used >= self.max_total:
41 return False
42 self._used += 1
43 return True
44
45 def refund(self) -> None:
46 """Give back one iteration (e.g. for execute_code turns)."""
47 with self._lock:
48 if self._used > 0:
49 self._used -= 1
50
51 @property
52 def used(self) -> int:
53 with self._lock:
54 return self._used
55
56 @property
57 def remaining(self) -> int:
58 with self._lock:
59 return max(0, self.max_total - self._used)
👆 带下划线的行可以点:点哪行,讲哪行。

子 agent 那份是在派生时新建的:delegate_tool.py 构造子 agent 时传 iteration_budget=None,旁边的注释就是一句「fresh budget per subagent」:

delegate_tool.py hermes-agent@56dc01d9:tools/delegate_tool.py:1655-1663需联网 MIT
1655 iteration_budget=None, # fresh budget per subagent
1656 **child_optional_kwargs,
1657 )
1658 child._print_fn = getattr(parent_agent, "_print_fn", None)
1659 # Now the child exists, its session id can ride on every relayed event
1660 # (including the spawn_requested below — first emit happens after this).
1661 child_session_ref["session_id"] = getattr(child, "session_id", "") or ""
1662 # Set delegation depth so children can't spawn grandchildren
1663 child._delegate_depth = child_depth
👆 带下划线的行可以点:点哪行,讲哪行。

每个 agent 的循环怎么用这份预算,只在两处:循环条件,和每次调模型前的 consume()。

conversation_loop.py hermes-agent@56dc01d9:agent/conversation_loop.py:1656-1669需联网 MIT
1656 api_call_count += 1
1657 agent._api_call_count = api_call_count
1658 agent._touch_activity(f"starting API call #{api_call_count}")
1659
1660 # Grace call: the budget is exhausted but we gave the model one
1661 # more chance. Consume the grace flag so the loop exits after
1662 # this iteration regardless of outcome.
1663 if agent._budget_grace_call:
1664 agent._budget_grace_call = False
1665 elif not agent.iteration_budget.consume():
1666 _turn_exit_reason = "budget_exhausted"
1667 if not agent.quiet_mode:
1668 agent._safe_print(f"\n⚠️ Iteration budget exhausted ({agent.iteration_budget.used}/{agent.iteration_budget.max_total} iterations used)")
1669 break
👆 带下划线的行可以点:点哪行,讲哪行。
Hermes 每个 agent 的循环还能不能再转一圈(conversation_loop.py:1634)
⇔ ∧ ∨
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
共用预算的朴素方案,就是让子 agent 的 budget 指向父 agent 的同一个对象:子 agent 每转一圈,父 agent 的 remaining 就少一格。

把模拟器切成「父子各一份」,再走一遍:

轮数预算算谁的:在真实事件序列上跑 Hermes 的 consume / refund 自检中…
预算

数据:事件序列来自本课项目 T4 的真实 trace(父 agent 上限 30、子 agent 15、深度上限 1);预算语义移植自 Hermes 56dc01d9(MIT);期望值由 gen_a07_subagents.py 直接加载 Hermes 原 IterationBudget 类,以及 bun / rustc / python 在四份 pin 源码的逐字切片上跑出(04-实践项目/scripts/parity/)。

🎯

试三件事:① 在上面这个实例里保持父上限 5,切「父子各一份」和「父子共用一份」,看 P3 的格子从 ✓ 变成 ✕;② 把子上限拖到 2,看子 agent 在第 3 次调用前停下、父 agent 仍然跑完——在本课项目里,父 agent 收到的报告末尾会多一行 [helper stopped: max_rounds after 2 rounds];③ 退还规则切成「构造:只调 grep 的轮也退还」,看两份计数器各少了几格(T4 里没有 execute_code,按原文一次也不会退)。

各家的预算放在哪一层:

父 agent 的上限子 agent 的上限父子是否共用出处
learn-claude-code不设(while True)30 轮各算各的s01_agent_loop/code.py:88;s06_subagent/code.py:274
CoreCoder v0.7.050 轮20 轮各算各的agent.py:31;tools/agent.py:50
OpenCode每个会话 agent.steps ?? Infinity同左,子会话自己计步各算各的session/prompt.ts:1178-1179
Hermespin 500(最新版不设)pin 50(最新版 250)各算各的,总数可超过父上限iteration_budget.py:17-30;delegate_tool.py:1463-1466,1655
本课项目T4 里 30T4 里 15各算各的;子 agent 用完时报告里写明scripts/record_t4.py:63,70;miniagent/subagent.py:103-104
Claude Code—maxTurns(可选,无缺省值),到了就标记为 partial各算各的官方文档 sub-agents
🔍 同一个仓库里,注释和代码说的不一样

Hermes 的 AIAgent 构造函数签名还写着 max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)(agent_init.py:470),而派生子 agent 的代码传的是 iteration_budget=None # fresh budget per subagent。深度闸那边也一样:注释写「default 2 for parity with the original MAX_DEPTH constant」(delegate_tool.py:3181),常量却是 MAX_DEPTH = 1(:128)。读源码时以执行路径为准,注释只当线索——这也是本课把原实现真跑一遍再对照的原因。

改进二:每一层都过一道深度闸

深度闸的写法各家几乎一样:数出自己在树里的深度,和一个配置值比。OpenCode 沿着会话的 parentID 往上数:

task.ts opencode@v1.18.32:packages/opencode/src/tool/task.ts:104-117需联网 MIT
104 const parent = yield* sessions.get(ctx.sessionID)
105 let current = parent
106 let depth = 0
107 while (current.parentID) {
108 depth++
109 current = yield* sessions.get(current.parentID)
110 }
111 if (depth >= (cfg.subagent_depth ?? 1)) {
112 return yield* Effect.fail(
113 new Error(
114 `Subagent depth limit reached (${cfg.subagent_depth ?? 1}). Increase "subagent_depth" to allow nested subagents.`,
115 ),
116 )
117 }
👆 带下划线的行可以点:点哪行,讲哪行。

Codex v1 把同样的判断放在两个地方:能不能看到 spawn 工具(spec_plan.rs:602-605,超过深度就不把协作工具给模型),以及调用时再查一次:

registry.rs codex@8cabf5a:codex-rs/core/src/agent/registry.rs:64-78需联网 Apache-2.0
64fn session_depth(session_source: &SessionSource) -> i32 {
65 match session_source {
66 SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) => *depth,
67 SessionSource::SubAgent(_) => 0,
68 _ => 0,
69 }
70}
71
72pub(crate) fn next_thread_spawn_depth(session_source: &SessionSource) -> i32 {
73 session_depth(session_source).saturating_add(1)
74}
75
76pub(crate) fn exceeds_thread_spawn_depth_limit(depth: i32, max_depth: i32) -> bool {
77 depth > max_depth
78}
👆 带下划线的行可以点:点哪行,讲哪行。

下面四个深度闸都是从 pin 源码移植的,同一组「调用者深度 × 配置值」逐格现算;期望值由 bun、rustc、python 分别在四份原实现上跑出,页面加载时逐条对照(右上角的计数)。

深度闸:同一组输入,四个原实现 自检中…

「调用者深度」= 发起派生的那个 agent 在树里的深度(主 agent = 0,它派出的子 agent = 1……)。✓ = 这一层还能再派子 agent;✕ = 被闸住(悬停看原实现返回的报错原文)。Claude Code 一列按官方文档(2026-09-26 核对)的「默认最多 3 层,环境变量 CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH 可改」;Codex v2 一列按 pin 里的测试 multi_agents_tests.rs:2420(v2 的 spawn 不看 agent_max_depth)。

数据:事件序列来自本课项目 T4 的真实 trace(父 agent 上限 30、子 agent 15、深度上限 1);预算语义移植自 Hermes 56dc01d9(MIT);期望值由 gen_a07_subagents.py 直接加载 Hermes 原 IterationBudget 类,以及 bun / rustc / python 在四份 pin 源码的逐字切片上跑出(04-实践项目/scripts/parity/)。

🎯

试三件事:① 保持缺省配置,确认四个实现在「主 agent 能派、子 agent 不能再派」上一致;② 把 OpenCode 和 Hermes 都设成 0:一个连主 agent 都不能派,一个照样能派——Hermes 把低于 1 的值抬到 1(delegate_tool.py:700-737);③ 看 Claude Code 一列:它的缺省是最多 3 层,和其余几家的 1 不一样。

两种闸:运行时闸(调用时查深度、超了报错,OpenCode、Codex、Hermes、本课项目)和结构闸(子 agent 的工具表里根本没有派生工具,learn-claude-code、CoreCoder)。OpenCode 两样都做:深度闸之外,子会话的权限里默认还加一条 task: deny(agent/subagent-permissions.ts:25)。Codex v2 走了另一条路:spawn 不看 agent_max_depth(pin 里有一个测试专门断言这一点,multi_agents_tests.rs:2420),限制的是同时存活的线程数。

⚠
常见误解一:「子 agent 受父 agent 的上限约束。」

在各家的实现里都不是:子 agent 各带一份预算,Hermes 的 docstring 直接写明父子总迭代数可以超过父 agent 的上限。要约束「整棵树一共花多少」,得另加一层(按 token 或按钱的总预算),轮数上限管不到。

常见误解二:「给子 agent 足够的预算,它就会把活干完。」

预算只防「停不下来」,不防「过早收工」。T4 的 4 个子 agent 各有 15 轮,最多只用了 4 轮;t4-sub-dec-s2 的子 agent 用了 3 轮就交了「无法判断」的报告。它不是被截断的,是自己停的——这正是 A1 岔路 B3 的老问题,第 4 课接着讲。

证据三问

问本课的回答
① 它解决什么问题?递归有上界;子 agent 的轮数不挤掉父 agent 收尾的那几轮
② 为什么一定有效?深度闸让调用树的深度 ≤ 配置值;预算独立时,父 agent 能跑的轮数只由它自己的上限决定
③ 与原实现一致吗?预算 792 组网格(运行 × 模式 × 上限 × 退还规则)、深度闸 95 格,JS 移植与原实现逐条一致

演进地图:你在这里

子 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 现数。耗时不作比较(本机有其他任务同时占用)。

一句话

📌
每一层都要有自己的深度闸和预算。
🚦

先停在这里:记住预算与深度闸各管一件事,并且都按层算;预算防停不下来,不防过早收工。
再深一层:在第一个模拟器里把运行换成 t4-sub-dec-s2,找出共用计数时父 agent 至少要多大的上限才能跑完,再和它自己的轮数比。

下一个缺口

干活的 agent 评自己的活,可信吗? → 第 4 课

子 agent 自己停下、交回一段「做完了」的报告;父 agent 也自己停下、写一段「做完了」的总结。这两句话,谁来核对?

你可能会问