A7 子 agent:一个 agent 忙不过来、或需要别人把关时,怎么分工? 岔路课
多个 agent 同时改代码,怎么不互相踩? 并行改代码不互相踩
本课项目的子 agent 是同步的、只读的:泳道图上父 agent 在 delegate 之后整段等待,T4 里不可能写冲突。一旦几个 agent 真的并行写,最朴素的「都在同一个目录里改」会丢修改——页面里两个 agent 同时往同一个文件追加一行,不加锁时 3 行只剩 2 行。现代做法是先切开写的范围:不同任务用 worktree 分到不同的工作目录,同一个文件的写排队执行。两者都不是沙箱。
❓ 本课一问: 第 1 课的子 agent 只读、而且父 agent 一直等着它。要是几个 agent 同时动手改代码,怎么保证它们不把彼此的修改覆盖掉?
这一课先不碰: 子进程能碰到哪些路径、能不能联网——那是操作系统沙箱(A4 );多个 agent 之间怎么派活、怎么收工的团队协议(learn-claude-code s13 的其余部分)。
从已有出发 · 累计数据流 点已学环节看它回答什么
任务 贯穿任务 → ① 编排选择 A0 → 每一轮循环都经过 ② 循环 A1 → ③ 工具层 A2 → ④ 权限闸 A3 → ⑤ 沙箱 A4 → ⑥ 上下文管理 A5
→ ⑦ 记忆与会话 A6 → ⑧ 子 agent本课 A7 → ⑨ 模型接入与计费 A8 → ⑩ 验证与评测 A9
先看我们自己的运行:根本没有并行
谁在什么时候干活:主 agent 与子 agent 的泳道(真实 T4 运行) 展开:时间泳道 聚合:谁交给谁
横轴是录制时的真实时刻(父子两条 trace 的时间戳对齐)。录制时本机同时在跑 LLM 课的训练,生成速度约 25 token/s,秒数只能粗看 ;要看的是先后与重叠:主 agent 在 delegate 之后整段等待,没有任何两个 agent 同时动手。
如果真有两个 agent 同时写同一个文件 · 构造输入 · 真算法(Pi 的 withFileMutationQueue 移植,在浏览器的事件循环里真跑)
不加锁,两个都写 套上 withFileMutationQueue 再写
agent A:第 0 ms 读 CALLERS.md,想 30 ms,追加 billing.py 那一行后写回;agent B:第 10 ms 读,想 10 ms,追加 us_feed.py 那一行后写回(用 setTimeout 排的时间;标签页在后台时浏览器会放慢计时器,两次读仍然都发生在任何一次写之前)。两行内容取自任务的标准答案(expected.json)。下面按实际发生的先后列出每一步。
🎯 试三件事:① 看上面的泳道:t4-sub-dec-s1 的父 agent 在第 2 轮调了 delegate,之后约 120 秒一直在等(斜线段),子 agent 的 4 轮全部落在这一段里;② 切到「聚合」视图,两个节点、两条边,就是整个协作的全部;③ 在下半部分先点「不加锁」,再点「套上 withFileMutationQueue」,对比最后文件里剩几行。
本课项目的 delegate 是同步的:工具调用在子 agent 跑完之前不返回,父 agent 什么也做不了;子 agent 又只有只读工具。所以 T4 里不可能 发生写冲突。并行写只在两种情况下出现:子 agent 在后台跑(OpenCode 的 background: true,需要实验开关,tool/task.ts:98-102;Hermes 里顶层 agent 的委派一律在后台跑,模型不能选,tools/delegate_tool.py:4300-4314),或者多个能写的 agent 同时工作(learn-claude-code s13 的团队)。
先自己设计:大家都在同一个目录里改
① 朴素方案 ② 它在哪失败 ③ 改进方案 朴素方案 几个 agent 在同一个工作目录里各自读、改、写
每个 agent 自己看来都没有错:它读到文件、决定怎么改、写回去。问题出在两次读写之间:另一个 agent 已经写过了。这叫丢失更新;edit 工具的「old_string 必须唯一匹配」(A2)挡不住它——追加一行时,旧内容本来就只出现一次。
改进方案 先切开写的范围:不同任务分到不同的 worktree,同一个文件的写排队
两层办法:目录层先把不同任务的写分开(worktree),文件层再把落到同一个文件上的写串起来(锁或队列)。
✗ 失败点 上面实验台下半部分:两个 agent 同时往 CALLERS.md 追加一行(构造输入,真在浏览器事件循环里跑)。不加锁时 A 读到的是 B 写之前的内容,A 后写,覆盖了 B:表头加两行只剩表头加一行 。在真实的 agent 里,「读」是 read_file 或 edit 之前的那次读取,「想」是一次模型调用,可能几十秒——窗口远比这里的 30 毫秒宽。
看它在哪失败 换成改进方案 重来一遍
改进一:不同任务,不同的工作目录
learn-claude-code s13 的团队章里,任务可以绑定一个 worktree:.worktrees/<名字>,各自一个分支 wt/<名字>,认领这个任务的队友的读写与 bash 都在那个目录里执行。
code.py learn-claude-code@0dcafa2:s13_agent_teams/code.py:340-363 需联网 MIT 340
341 # -- Task-bound Worktrees --
342
343 WORKTREES_DIR = WORKDIR / ".worktrees"
344 WORKTREES_ROOT = WORKTREES_DIR.resolve()
345 VALID_WORKTREE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
346
347
348 def validate_worktree_name(name: str) -> str | None:
349 if not isinstance(name, str) or not VALID_WORKTREE_NAME.fullmatch(name):
350 return ("worktree name must be 1-64 letters, digits, dots, "
351 "underscores, or dashes, and start with a letter or digit")
352 if name in {".", ".."} or ".." in name:
353 return "worktree name cannot contain '..'"
354 return None
355
356
357 def _worktree_path(name: str) -> Path:
358 path = (WORKTREES_DIR / name).resolve()
359 if (not WORKTREES_ROOT.is_relative_to(WORKDIR.resolve())
360 or not path.is_relative_to(WORKTREES_ROOT)
361 or path == WORKTREES_ROOT):
362 raise ValueError(f"Worktree path escapes directory: {name!r}")
363 return path
👆 带下划线的行可以点:点哪行,讲哪行。
Codex 在协作工具的说明里把同一件事写成了给模型的规则:写代码的子任务要拆成「disjoint write set」,并让子模型直接在它 fork 出来的工作区里改(multi_agents_spec.rs:731-732)。据代码侧时效核查(C 报告,2026-09-26 查的是上游最新版,不是本课的 pin),Codex 从 0.156 起默认开启 worktree 支持。OpenCode 给后台子 agent 的提示则是软约束:不要和它碰同样的文件或主题(tool/task.ts:33)。
multi_agents_spec.rs codex@8cabf5a:codex-rs/core/src/tools/handlers/multi_agents_spec.rs:724-733 需联网 Apache-2.0 724 ### Designing delegated subtasks
725 - Subtasks must be concrete, well-defined, and self-contained.
726 - Delegated subtasks must materially advance the main task.
727 - Do not duplicate work between the main rollout and delegated subtasks.
728 - Avoid issuing multiple delegate calls on the same unresolved thread unless the new delegated task is genuinely different and necessary.
729 - Narrow the delegated ask to the concrete output you need next.
730 - For coding tasks, prefer delegating concrete code-change worker subtasks over read-only explorer analysis when the subagent can make a bounded patch in a clear write scope.
731 - When delegating coding work, instruct the submodel to edit files directly in its forked workspace and list the file paths it changed in the final answer.
732 - For code-edit subtasks, decompose work so each delegated task has a disjoint write set.
733
👆 带下划线的行可以点:点哪行,讲哪行。
改进二:同一个文件,排队写
写集合总有切不开的时候。CoreCoder v0.7.0 用一把全局锁包住 edit 与 write(tools/base.py:8 的 FILE_MUTATION_LOCK);Pi 按文件分队列:同一个文件的修改依次执行,不同文件照样并行。上面实验台用的就是 Pi 这个函数的逐行移植:
file-mutation-queue.ts pi@31b513e3:packages/coding-agent/src/core/tools/file-mutation-queue.ts:28-61 需联网 MIT 28 /**
29 * Serialize file mutation operations targeting the same file.
30 * Operations for different files still run in parallel.
31 */
32 export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
33 const registration = registrationQueue.then(async () => {
34 const key = await getMutationQueueKey(filePath);
35 const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
36
37 let releaseNext!: () => void;
38 const nextQueue = new Promise<void>((resolveQueue) => {
39 releaseNext = resolveQueue;
40 });
41 const chainedQueue = currentQueue.then(() => nextQueue);
42 fileMutationQueues.set(key, chainedQueue);
43
44 return { key, currentQueue, chainedQueue, releaseNext };
45 });
46 registrationQueue = registration.then(
47 () => undefined,
48 () => undefined,
49 );
50
51 const { key, currentQueue, chainedQueue, releaseNext } = await registration;
52 await currentQueue;
53 try {
54 return await fn();
55 } finally {
56 releaseNext();
57 if (fileMutationQueues.get(key) === chainedQueue) {
58 fileMutationQueues.delete(key);
59 }
60 }
61 }
👆 带下划线的行可以点:点哪行,讲哪行。
edit.py CoreCoder@v0.7.0:corecoder/tools/edit.py:46-52 需联网 MIT 46 def execute(self, file_path: str, old_string: str, new_string: str) -> str:
47 from .base import FILE_MUTATION_LOCK
48
49 try:
50 with FILE_MUTATION_LOCK:
51 p = Path(file_path).expanduser().resolve()
52 if not p.exists():
👆 带下划线的行可以点:点哪行,讲哪行。
⚠ 常见误解:「worktree 就是沙箱。」 learn-claude-code s13 的 README 专门写了一句:worktree 分开的是 Git 的工作目录与分支,「It is not a sandbox」——shell 命令照样能访问父进程能访问的路径与资源(s13_agent_teams/README.md:290)。一个在 worktree 里的 agent 仍然可以 cd .. 改主目录的文件、写 ~/.ssh;挡住它们的只能是 A4 的操作系统沙箱。文件锁也一样:它只约束愿意走 edit / write 工具的写,拦不住 bash 里的一句 echo >>。
证据三问
问 本课的回答 ① 它解决什么问题? 多个能写的 agent 并行时,彼此的修改不被覆盖 ② 为什么一定有效? 写集合不相交就没有冲突;同一个文件的读改写整体串行,就不会有人读到过期的内容 可证明 证据等级 · 数学可证明 可证明 理论动机 实验验证 在写明的前提下可以严格推导出来,不依赖实验。只要前提成立,换数据、换规模仍然成立。 本条 算法性质:丢失更新需要两次读写区间重叠;队列让同一键上的临界区不重叠。页面上的竞争演示是构造输入、真算法。 出处 待补 ③ 实验上呢? 本课项目的子 agent 同步且只读,T4 没有并行写,没有自己的数据 实验验证 证据等级 · 实验验证 可证明 理论动机 实验验证 由对比实验得出的经验结论,只在出处写明的模型、数据和规模下成立。换了条件可能不再成立,引用时要带上实验设置。 本条 泳道图里的时间来自真实 trace(只能粗看);竞争演示是构造的两次追加,不是录制的运行。 出处 待补
演进地图
这条岔路是演进图上的旁支:只有在子 agent 能写、又能并行的时候才用得上。
子 agent 的演进:每一代都被上一代的某个真数字逼出来
所有 harness 的默认形态;本课项目 T4 的「不用子 agent」两次运行
解决了 不需要交接:主 agent 亲眼看到每一行搜索结果,不会有信息在转手时丢失。
卡在 两次 grep 结果进主上下文 563 / 1,120 token ,之后每轮跟着重发(A5);要判对兼容性还得读 7 个文件(内容 1,818 token),两次都没读——找到 5/6 与 5/6,兼容性判对 3/6 与 2/6。
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 。
本课项目 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 轮 就交差。
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 轮 。
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 现数。耗时不作比较(本机有其他任务同时占用)。
一句话
📌 并行写之前先切开写的范围:不同任务用 worktree,同一个文件排队写;两者都不是沙箱。
回到主线
子 agent 只拿到一句任务描述,会丢掉什么? → 第 2 课
你可能会问
「worktree 对缓存有什么影响?」 Claude Code 的 prompt caching 文档写明,缓存实际上按机器与目录分开:会话里带着工作目录,所以同一个仓库的不同 worktree 也读不到彼此的缓存(2026-09-26 核对)。多开几个 worktree 并行,每一个都要从头写一遍缓存(A8 讲价格)。
「本课项目要不要做后台子 agent?」 MILESTONES.md 的 M7 只做同步子 agent。要做后台,至少得先有本页的两层:给能写的子 agent 分配各自的目录,和同文件的写队列;还要回答父 agent 在等待时能做什么(Codex 的说明是做不重叠的事)。