多个 Agent 会话并发在同一个仓库会互相踩踏
我这条内容产线跑到某个阶段之后,一个会话一批任务已经不够用了。同一个内容仓库里,我会同时开着两三个会话,每个会话各带一批子代理,各写各的一批文件。选题不重、文件不重,看上去互不相干。
结果出事的不是内容,是围绕内容的那些动作。下面这两次都是我自己在这条产线上撞出来的,处置也是我自己改的分工,只在我这条产线上验证过。
现象:一个崩得很响,一个悄无声息
第一类踩踏很吵。我这边这批任务还在跑,我顺手起了一次构建想看看结构对不对,构建直接崩了。报错指向的那个文件,我这批人根本没派谁去动它——打开看,内容停在半句话上,明显是另一批任务正在写、还没写完的半成品。
第二类踩踏完全不吵。两个会话各自构建之后,我跑了一遍扫描构建产物的检查脚本,拿到一份问题清单。清单本身格式正常、脚本正常退出、没有任何异常提示。但那份清单里报出的问题,有一部分我有把握是不存在的——它跟我手上的事实对不上。
这两类的共同点是:没有任何一个子代理干错了事。每个代理都老老实实写了自己那份文件,写的内容也没问题。
当时是怎么发现的
这一段比现象本身更值钱,因为两类踩踏的发现路径完全不同,而第二类差点被我放过去。
第一类靠的是「报错指向的对象不属于我」。 构建崩掉本身不难发现,难的是第一反应会跑偏。我最开始默认是自己这批人写错了,回头去查自己派出去的那些文件,逐个看格式、看字段,什么都查不出来。转折点是我把注意力从「错在哪」挪到「错在谁身上」——去看那个报错文件的归属,发现它压根不在我这批任务的清单里。到这一步,问题的性质就从「内容写错了」变成了「我在别人写到一半的时候去读了整棵文件树」。
第二类靠的是「我确定的事实被判错了」。 检查脚本没崩、没报警,它只是给了一份看起来很正常的报告。如果我照单全收,接下来做的就是去逐条修那些不存在的问题。让我停下来的是一条我自己有把握的判断:清单里有些条目,我知道它们是好的。当一份自动检查的报告里出现了你有把握是对的东西被判成错,先怀疑检查环境,别急着怀疑被检查的内容——这是我从这一次里当场学到的问法。顺着这条线往回查,才看到两个会话的构建写的是同一个输出目录,后跑的那次盖掉了前一次的一部分产物,扫描扫的是一份被混在一起的东西,结论自然失真。
顺带一提,同一个根源在第三个地方也露过头:提交的时候用目录级的批量添加,会撞上正在被写入的文件而报错。这个报错当时看着莫名其妙,事后回头看是同一件事——我在一个正在变化的树上做了一个需要树是静止的动作。
为什么会这样:根子在共享状态,不在任务冲突
我一开始判断「这几批任务可以并发」的依据是:选题不重、文件不重、谁也不用等谁。这个依据本身没错,但它只覆盖了任务的输入和输出,没覆盖任务执行过程中会碰到的那些公共的东西。
在同一个仓库里,被公共持有的至少有三样:
- 整棵工作文件树——构建要把它整个读一遍
- 构建的输出目录——每次构建都要往里写
- 版本控制的暂存区——提交要动它
子代理写自己那一份文件,只碰自己那份,没有副作用。但构建和提交不是这样:它们要么读整棵树,要么写同一个目录,要么动同一个索引。只要有两个会话同时做这类动作,或者一个会话在别人还在写的时候做这类动作,就一定会撞上。
还有一层,是 Agent 协作特有的:代理的写入是异步的,文件出现在磁盘上不等于它写完了。代理会在文件首次落盘之后继续精简、继续改。这意味着「我先看一眼没人在写,然后再构建」这种做法本身带着竞态——从你看完到你构建之间那一小段时间里,别人可能刚好开始写下一个文件。我当时以为加个「先查一下」就能规避,实测下来不能,该崩还是崩在半成品上。
改成了什么
改动很朴素,就是把动作按「有没有副作用」重新分了一遍权限:
| 动作 | 谁做 |
|---|---|
| 写自己那批内容文件 | 子代理各自并行 |
| 构建 | 主导者独占,串行 |
| 版本控制的提交 | 主导者独占,串行 |
| 多篇都要改的共享文件(注册表、导航配置这类) | 主导者独占,串行处理 |
配套的几条:
- 代理运行期间不构建。 要构建,等这一批的任务完成通知都到齐了再做。
- 确实需要多个会话同时构建时,各自指定隔离的输出目录,谁也别往同一个地方写。这条修掉的正是上面那个「扫描结果失真」。
- 提交时只列显式的文件名,不用目录级的批量添加,避开正在写入的文件。
- 完成信号只认任务系统给的通知,不认「文件出现在磁盘上」这个观察。
这几条合起来,其实就是一句话:并行的部分只剩下「各写各的文件」,其余动作全部收归单点串行。听上去像是把并发的收益让掉了一部分,实际上让掉的那部分本来就不该并发——它们从来就不是独立的。
我带走的那句话
并发的边界不是「任务是否独立」,而是「它们碰不碰同一份共享状态」。
这两个判据听起来接近,用起来差很远。前者只看任务书:选题不重、产出文件不重,那就可以一起跑。后者要求你把每个动作单独拎出来问一遍:这个动作会不会读写一份不属于我这批任务的东西?
按前者判断,构建和提交都是「我这批任务的收尾动作」,理所当然跟着任务走。按后者判断,它们一个要读整棵树、一个要动公共索引,都对共享状态有副作用,必须收归单点。分权限的判据不是「谁有能力做」,而是「哪些动作对共享状态有副作用」——有副作用的动作收归单点串行。
这条判据我现在会在派活之前先过一遍,而不是等构建崩了再回头找。代价很低:无非是列一列这一批任务除了写自己那份文件之外,还会碰哪些公共的东西。
这篇写的是我自己那套流程里的一次实际情况,不是通行做法。你的场景、工具和团队规模不同,结论未必适用,判断方式可能比结论更值得拿走。
延伸阅读
- 上一篇(协作与移交):多 Agent 协作:有副作用的动作要收归单点串行
- 下一篇(协作与移交):自用 Agent 走向可移交:要拆掉哪些东西
- 这个专题的主论点:Agent 调用没报错,不等于这件事办成了
- 专题导读与七个阶段的地图:把 Agent 当成一个要上岗的员工来带:这个专题讲什么