禅道里躺着 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、.vite、dist 这类共享位置写东西。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…