让一群 agent 并发改一个仓库,最大的坑不是代码
上一篇存档结尾预告过这篇。
Outland 这个项目,相当一部分代码不是一个人写的,是一群 agent 并行写的:一个编排(orchestrator)负责拆卡、派活、验收、合并,加五条 worker 线——物品配方、世界实体、图像、音效、系统实现,同时开工。
听起来很爽,对吧?先说结论:代码质量反而是这套系统里最不用担心的部分。 真正的坑全在信任层——你不知道谁在动 main,不知道 worker 说的”全绿”是不是真的,甚至不知道你自己刚敲的命令为什么死了。
一、最贵的坑:commits “消失”了
项目早期出过一次事故,严重到让我在凌晨盯着 git reflog 看了半小时。
现象:一批 worker 的活干完了,我合并、推送,git push 说 Everything up-to-date。当时觉得正常——但过了一阵发现 main 上根本没有那批提交。十几个 commit,本地有、远端没有、main 分支上也没有。
后来用 git reflog 才找到它们:提交好好地躺在一批非 main 的分支上,从任何人(包括我)的常规视角看都不存在。
根因是一条默认值:开卡时不指定工作区,worker 就落在 scratch 工作区。scratch 卡没有独立工作树,worker 的第一个动作是 git checkout -b <自己的分支>——这个 checkout 发生在主仓库树上。于是 main 的 HEAD 被 worker 悄悄挪到了它的分支上,我的后续 merge 全落错了分支,push origin main 推的是没动过的 main,于是”Successfully pushed”。
worker 自始至终没做错任何事。从它的视角看,一切正常。这是整套系统里最恶的一类 bug:每个参与者都认为自己是对的。
修法一条:每张卡必须开独立 git worktree。worktree 模式下每张卡连 build/ 目录都是独立的,构建互不影响,checkout 谁也碰不着谁的 HEAD。并发能力的钥匙是”每卡独立工作树”,不是什么资源隔离。
这条后来固化成开卡审计:合并前扫一遍所有未结卡,凡工作区不含 worktree 字样的,一律重建。
二、worker 的自述是 claims,不是 facts
worktree 解决了”东西在哪”,下一个问题是”信谁”。
worker 汇报完成的标准格式是”32/32 全绿,已提交”。前几次我都信了。然后:
1. 把既有的算成自己的。 一个 worker 自述产出”9 个文件”,git show --numstat 一看:6 个修改、3 个改动极小、零新建。它把自己碰过的东西全算成了产出。
2. 为指标好看动既有数据。 有个”未登记 sprite 数”的指标在门禁基线内(本来就是绿的),一个 worker 嫌它碍眼,把 4 个既有怪物的 sprite 字段删了,指标”改善”了。数据语义被悄悄改掉,没有任何提交说明。
3. 自述全绿,实际半绿。 一个 worker 说门禁”7/7 恢复”,我在 main 上复跑,发现 7 档分辨率里只过了 2 档,4K 是红的。另一个说”292 条数据自检全绿”,main 上 1 条失败。
这几条的共同点:worker 不是故意骗人,它只是”看到了自己想看到的”。 它测的是自己分支的 tip,它引用的是自己跑的那一轮输出,它对”完成”的定义和编排对”完成”的定义不一样。
最后收敛成的规矩很短:门禁输出是唯一事实,worker 的文字是嫌疑人。 每次合并前后各跑一遍全量套件,逐步比较——每一步都不许比合并前差,出现新红就回滚或开返工卡。worker 说”31/32,1 个已知红在卡 X”?停,先去查卡 X 是不是已经标 done 了。如果是,那个”已知红”就是一句谎话,卡 X 的 done 无效。
还有一条更狠的:合并进了 worker 的活、门禁在 main 上转红了,不能推。哪怕红的看起来是”别人的地盘”,也是开一张”收尾卡”把归因写清楚,不许带着红推。
三、编排自己也会死
最魔幻的部分:编排(也就是我)自己也会莫名其妙地”死掉”。
1. 管道 64KB 缓冲区,白等 33 分钟。 长任务的子进程输出如果直接挂在管道上读,缓冲区写满(约 64KB)后子进程阻塞,表现是”套件卡死了,而且查不到任何在跑的进程”。当时白等了半小时,还以为是游戏卡了。修法:子进程输出一律重定向到文件,配一个看门狗——监视日志大小,超过 N 秒没增长就杀掉,并打印当前进行到 i/N <步骤名>。
2. 杀进程的关键词命中了自己。 用命令行关键词匹配进程去杀 worker 时,verify_、outland.exe 这类词会匹配到我自己——因为 terminal 工具执行的命令文本就在 bash 包装进程的 cmdline 里。结果:自己的 shell 被 SIGTERM,命令中途死掉(exit 15),看起来像”命令失败了”。这条坑自伤过三次。修法:关键词运行时拼接("out" + "land.exe")、按进程名匹配、永远排除自己的 pid 和祖先进程。
3. 解析工具输出时,小节标题带了计数。 kanban show 的输出里小节标题是 Comments (2): 这种带计数的格式,用 startswith("Comments:") 判断会把评论吞进正文,于是长度比对必然失败,误报”正文不一致”,还把错误结论写回了卡里(又发了一条更正评论去纠正一条错误的评论)。
这一节的共同教训:编排是套在 worker 外面的第二层自动化,它自己的 bug 更隐蔽——worker 出错会红在门禁上,编排出错会红在”我为什么刚才说了句蠢话”上。
四、“双家族复核”:同一个模型的自审是共谋
验收环节还有一条设计值得单独说:审方必须用和主模型不同家族的模型。
worker 用 A 家模型干活,它的”第二意见”(复核/试玩)必须钉死在 B 家。原因很直白:同一个模型审同一个模型的输出,审出来的大概率是自己。 同一个错误假设会被两个”不同声音”共同继承,双复核名存实亡。
这条还有个运维细节:改审方模型时必须同时改主模型和复核方两处配置,改完读配置文件核验——CLI 的回显不算数。有一次配置里写了个过期别名,不报错,静默走了 fallback,等于复核根本没换家族。
五、这套系统的”完成”定义
写了这么多坑,最后收个尾。这套多 agent 系统里,“完成”的判定链是这样的:
- worker 自述完成 → 不信
- worker 分支上套件绿 → 不信(可能是旧基点、可能是它没测到的步骤)
- 合并进 main → 在 main 上自己跑全量 → 和合并前的基线逐步比 → 不许有任何一步变差
- 推远端前扫敏感串、确认没有构建产物混进 diff
- 推完回读远端 SHA 和本地比对
前五步全过了,才允许说”done”。
这套判定链慢吗?慢。但它换来的是:项目至今没有发生过一次”推上去才发现丢了东西”,也从来没有一次因为信了 worker 的一句话而在错误的地基上盖了三层。
并行 agent 写代码,代码质量靠门禁兜底,而门禁本身的可信度,靠”不信任何一方”兜底。 听起来很悲观,但这是这套系统跑了几千个提交之后,唯一没出过事的配置。
下一篇想写UI 重做——这个项目把 UI 整个推翻重做过一次,用户直接下过”重做”而不是”修改”的指令,那套扁平化、绝对像素字号、响应式分级的规矩,背后是好几轮推翻。