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

多个 agent 同时改代码,怎么不互相踩?

并行改代码不互相踩
本课项目的子 agent 是同步的、只读的:泳道图上父 agent 在 delegate 之后整段等待,T4 里不可能写冲突。一旦几个 agent 真的并行写,最朴素的「都在同一个目录里改」会丢修改——页面里两个 agent 同时往同一个文件追加一行,不加锁时 3 行只剩 2 行。现代做法是先切开写的范围:不同任务用 worktree 分到不同的工作目录,同一个文件的写排队执行。两者都不是沙箱。
❓

本课一问:第 1 课的子 agent 只读、而且父 agent 一直等着它。要是几个 agent 同时动手改代码,怎么保证它们不把彼此的修改覆盖掉?

这一课先不碰:子进程能碰到哪些路径、能不能联网——那是操作系统沙箱(A4);多个 agent 之间怎么派活、怎么收工的团队协议(learn-claude-code s13 的其余部分)。

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

先看我们自己的运行:根本没有并行

谁在什么时候干活:主 agent 与子 agent 的泳道(真实 T4 运行)

横轴是录制时的真实时刻(父子两条 trace 的时间戳对齐)。录制时本机同时在跑 LLM 课的训练,生成速度约 25 token/s,秒数只能粗看;要看的是先后与重叠:主 agent 在 delegate 之后整段等待,没有任何两个 agent 同时动手。

如果真有两个 agent 同时写同一个文件 · 构造输入 · 真算法(Pi 的 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 的团队)。

先自己设计:大家都在同一个目录里改

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案几个 agent 在同一个工作目录里各自读、改、写

每个 agent 自己看来都没有错:它读到文件、决定怎么改、写回去。问题出在两次读写之间:另一个 agent 已经写过了。这叫丢失更新;edit 工具的「old_string 必须唯一匹配」(A2)挡不住它——追加一行时,旧内容本来就只出现一次。

改进一:不同任务,不同的工作目录

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
343WORKTREES_DIR = WORKDIR / ".worktrees"
344WORKTREES_ROOT = WORKTREES_DIR.resolve()
345VALID_WORKTREE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
346
347
348def 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
357def _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 */
32export 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 没有并行写,没有自己的数据

演进地图

这条岔路是演进图上的旁支:只有在子 agent 能写、又能并行的时候才用得上。

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

一句话

📌
并行写之前先切开写的范围:不同任务用 worktree,同一个文件排队写;两者都不是沙箱。

回到主线

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

你可能会问