8 个 Agent 同时改一个仓库,我踩了哪些坑

0 阅读10分钟

禅道里躺着 30 个未解决的 bug,大部分是五分钟就能改完的小问题,但每个前后要走二十分钟流程:切分支、找代码、改、自测、写解决说明、填 Commit ID。

这活儿显然该自动化,而且显然该并行——8 个 bug,8 个 Agent,一起上。

想法很直接,第一次跑就翻车了。

不是"报错退出"那种干脆的失败,是看起来跑完了,但结果是乱的:Agent 互相覆盖对方改的文件;每个 Agent 跑 git diff 看到的都是 8 个人的改动混在一起;有一个 Agent 想切分支提交,一切,其他 7 个的工作区全变了。

后面还有更隐蔽的:全部 8 个 Agent 报告 type-check 失败——而它们的代码其实没问题。

这篇讲把并行跑通过程中踩的坑,以及每个坑背后那个不太显然的取舍。主要两件事:怎么做物理隔离,和怎么编排两个阶段之间的衔接

(至于"AI 说修好了凭什么信"这个更麻烦的问题,放在下篇。)

代码已脱敏开源:github.com/DonChengChe…


一、物理隔离:一个 Bug 一个 worktree

朴素做法为什么不行

最直接的想法是:开 8 个 Agent,都在同一个仓库目录里干活。

这个方案会以非常难受的方式失败。不是"报错退出"那种失败,是结果看起来跑完了,但内容是乱的

  • Agent A 改了 utils/format.ts,Agent B 同时也在改同一个文件的另一处,后写的把先写的覆盖了
  • Agent C 想看看自己改了什么,跑 git diff,出来的是 8 个人的改动混在一起
  • Agent D 修完想切个分支提交,一切分支,其他 7 个 Agent 的工作区全被改了

最要命的是第二条:每个 Agent 都无法确认"我到底改了什么"。而后面的复核环节完全依赖 diff——diff 不干净,整条流水线就没有意义了。

Git 的锁也帮不上忙。.git/index.lock 能防止两个进程同时写索引,但防不了两个进程同时写工作区里的同一个文件。

用 worktree 做物理隔离

git worktree 正好是为这个场景设计的:同一个仓库,多个独立的工作目录,共享同一份 .git

git -C <repoPath> worktree add -b batchfix/bug-<id> <worktreeDir> <baseBranch>

每个 bug 一个目录、一个分支(batchfix/bug-<id>),全部基于同一个基础分支创建。Agent 只被允许在自己那个目录里活动,prompt 里用绝对路径写死:

你的隔离工作目录(git worktree,已软链 node_modules,只在此目录内改动,用绝对路径):
<worktreeDir>

到这一步,文件冲突、git status 污染、分支切换互相打断,全都消失了。每个 Agent 的 git diff 干干净净,只有它自己的改动。

顺带一提,创建 worktree 这一步是在主会话里串行做的,不是并行。git worktree add 要写 .git 目录,8 个并发跑会撞锁。这一步本身很快,串行完全不值得优化。

翻车现场:node_modules

worktree 建好,跑起来,8 个 Agent 全部报告"type-check 失败"。

原因很蠢:新建的 worktree 里没有 node_modules。它只 checkout 了 git 跟踪的文件,而 node_modules.gitignore 里。

所以 8 个 Agent 跑的每一次 type-check、lint,都是因为找不到依赖而失败的——假失败。更糟的是,如果不注意,这种假失败会被 Agent 当成"我的改动有问题",然后它开始改一些根本没坏的东西。

第一反应是每个 worktree 里装一遍依赖。试了一下,放弃了:

  • 一个中等项目 npm install 几分钟,8 个就是几十分钟,比修 bug 本身还慢
  • 磁盘上多出好几个 G
  • 8 个 install 并发跑,还会因为同时写 npm 全局缓存而互相干扰

最后用的是软链:

ln -s <repoPath>/node_modules <worktreeDir>/node_modules

秒级完成,零额外磁盘占用。

如果是 monorepo / pnpm workspace,root 之外的子包也各有自己的 node_modules,得对每个子包目录同样软链一遍。实在理不清依赖结构的项目,就退一步:在给 Agent 的说明里改成"只跑不依赖装包的检查"。

软链引入了一个新约束,这才是重点

软链解决了问题,但它把 8 个 worktree 重新连回了一个共享资源——刚刚才费劲隔离开的东西,从后门接回来了一部分。

这里有条线必须划清楚:

共享只读状态是安全的,共享可写状态是危险的。

node_modules 里的依赖包,绝大部分时候是只读的——大家都只是 import 它们,谁也不写。所以共享没问题。

build 不一样。build 会往 node_modules/.cache.vitedist 这类共享位置东西。8 个 Agent 同时 build,就是标准的数据竞争:产物互相覆盖,缓存状态错乱,报出来的错误跟谁的改动都对不上。

所以 prompt 里明确禁掉了:

跑非写型自测:优先 type-check、lint、与改动相关的单测(看 package.json 的 scripts)。
不要跑全量 build —— worktree 间共享同一份 node_modules,build 会写共享缓存造成竞争。

只允许非写型检查。

这个约束是有代价的,得承认:type-check 和 lint 抓不到运行时问题,UI 交互类的 bug 靠这两样根本验证不了。用一部分验证能力,换取并行的正确性——这是个明确的取舍,不是免费的午餐。

我觉得这段的普适价值比 worktree 本身大:并行系统里,你以为隔离干净了,往往还剩一条共享的写路径没堵上。找到它,要么堵死,要么禁掉会走这条路径的操作。

交付物放哪:一个差点丢数据的决定

最后一个决策看着琐碎,但踩过一次就再也不会忘。

worktree 目录一开始我放在会话的临时目录里。看起来很合理——临时产物嘛,跑完就清理。

但这些"临时产物"其实是交付物本身。

这套系统默认不 commit、不 push。8 个 Agent 修完 bug,改动就躺在各自的 worktree 里,等人来审。会话一结束、临时目录一清,所有还没合并的改动全没了

所以改成了持久目录,放在各个仓库之外——放在任何一个仓库内部,都会污染那个仓库的 git status,哪怕加了 .gitignore,也是给每个仓库添了一笔本不属于它的配置。

一句话总结这个决策:

设计一个系统时,得先想清楚它的产物是什么、活多久。如果产物的生命周期比会话长,它就不能放在会话的临时空间里。


二、编排:为什么不是"全修完再全复核"

修复和复核两个阶段都定下来了,还剩最后一个问题:它们之间怎么衔接。

直觉写法是错的

最自然的写法是把两个阶段分开,一个跑完再跑下一个:

const fixes = await parallel(bugs.map(bug => () => fixAgent(bug)))
const verifies = await parallel(fixes.map(fix => () => verifyAgent(fix)))

8 个 Agent 并行修,全部修完,再 8 个 Agent 并行复核。代码读起来非常整齐,两行,一目了然。

问题出在第一行末尾那个 await——它是一道屏障

bug 的修复耗时差异极大。 一个 CSS 对齐问题,Agent 一分钟就搞定了;一个状态管理的坑,它可能要读七八个文件、试两轮,十五分钟起步。

那个一分钟修完的 bug,接下来要干等十四分钟,等最慢的那个兄弟修完,才能进入复核。而它的复核 Agent 也在那儿闲着。

8 个 bug,如果耗时分布是 1、2、2、3、4、5、8、15 分钟,屏障方案的第一阶段耗时就是 15 分钟——由最慢的那个决定。前面 7 个加起来的空闲时间超过 70 分钟。

去掉屏障

改成 pipeline,每个 bug 走自己的完整链路,互不等待:

const results = await pipeline(
  bugs,
  (bug) => agent(fixPrompt(bug), { phase: 'Fix', schema: FIX_SCHEMA }).then(fix => ({ bug, fix })),
  ({ bug, fix }) => agent(verifyPrompt(bug, fix), { phase: 'Verify', schema: VERIFY_SCHEMA })
                     .then(verify => ({ bug, fix, verify })),
)

一个 bug 修完立刻进复核,不看别人脸色。那个 1 分钟修完的 bug,第 2 分钟就已经在被复核了;而它被复核的时候,那个 15 分钟的 bug 还在第一阶段挣扎。

差别可以用一句话概括:

墙钟时间 = 最慢的单条链,而不是"各阶段最慢之和"。

屏障方案是 max(修复耗时) + max(复核耗时);pipeline 是 max(修复耗时 + 复核耗时)。前者永远大于等于后者,而且 bug 耗时越不均匀,差距越大。

顺带一个好处:故障隔离。pipeline 里某个 bug 在某一阶段抛错,只有它自己掉出来(结果是 null),剩余阶段跳过,其他 7 个 bug 完全不受影响。所以最后只要过一道:

const clean = results.filter(Boolean)

什么时候屏障才是对的

不是说屏障永远错。它有明确的适用条件:下一阶段需要跨条目的全局信息。

比如:

  • 要先把所有修复结果去重,再决定复核哪些——必须等全部到齐
  • 想做全局早退:"一个 bug 都没修出来就整个跳过复核阶段"——也得先知道总数
  • 下一阶段的 prompt 里要引用"其他条目的情况"做横向对比

而我这里,复核阶段只需要当前这一个 bug 的 diff 和它自己的 bug 描述,不需要知道其他 7 个 bug 发生了什么。

没有跨条目依赖,屏障就是纯粹的浪费。

为什么容易写错

我第一版写成屏障,不是因为没想过性能,是因为那个写法看起来更整齐

两个 parallel,两行代码,阶段划分清清楚楚,符合"先做完 A 再做 B"的思维习惯。而 pipeline 的写法要把两个阶段作为回调传进去,读起来没那么一目了然。

这里有个值得记一下的判断:

代码结构的整齐,和执行结构的高效,是两回事。

阶段在概念上是分离的,不代表它们在执行上需要同步。

写并发代码的时候,看到 await 一个聚合操作,就该问一句:下一步真的需要所有结果吗,还是只需要它自己那一份?

大多数时候,是后者。


更难的问题在下篇

这篇讲的都是"怎么让并行跑对"的工程问题。两个可以带走的判断:

并行系统里,你以为隔离干净了,往往还剩一条共享的写路径没堵上。 找到它,要么堵死,要么禁掉会走这条路径的操作。

代码结构的整齐,和执行结构的高效,是两回事。 阶段在概念上分离,不代表它们在执行上需要同步。

但这些都还只是"跑得对、跑得快"。真正难的问题在后面——每个 Agent 都跟我说"已修复",我凭什么信?

第一版跑完,每一份报告都写着"已确认修复正确,无回归风险"。逐条审下来,错的不在少数——而且错的那几份,说明写得比对的还详细。

那个问题怎么解,写在下篇:《AI 说「修好了」,凭什么信》 ⟦下篇链接⟧

代码已脱敏开源:github.com/DonChengChe…