并行写入与那次安静的合并

8 分钟读完

G13
深入解析 · 多智能体系统

把并行的智能体隔离开,并没有消掉写冲突——只是把它推迟到了一次"报告成功"的合并上。

给六个智能体各自一个分支,显而易见的碰撞就消失了:没人覆盖谁,每个工人的测试都过,每次合并都干净。冲突并没有走——它换了探测器。Git 比的是文本,而两处从不触碰同一行的改动,依然可以在语义上互不相容,于是故障在集成之后才到来,没有冲突标记,也没有哪个智能体可以问责。请按接口而不是按任务来划分,并把合并做成一道验证,而不是一道手续。

步骤 1并行度的上限由写集决定,而不是由 token 预算决定。

给扇出定规模的惯常办法是算成本:我养得起多少工人、省下多少挂钟时间。那套计算——以及它那笔不太好看的算术——归扇出税管。对会改动东西的智能体来说,它也不是真正的约束。

真正的约束是写集:每个工人将要触碰的那组可变对象。只读的扇出几乎可以任意扩展,这正是并行搜索、分流与逐文件分析效果那么好的原因。而一旦两个工人可以写同一个对象,扇出就不再是 N 个互不相干的任务,而成了一套并发系统——而并发系统恰好只有三种失效模式,且都是老面孔:

  • 丢失更新。两个工人都读到状态 v1,都写;第二次写抹掉了第一次,而两个工人都没察觉有任何不对。
  • 陈旧读。一个工人照着另一个工人已经作废的状态去做规划,然后为一个不再存在的世界正确地执行了一份正确的计划。
  • 写偏斜。每个工人的写单独看都合法;合在一起却违反了一条谁都没在检查的不变量。这一种能活着穿过隔离,而本文余下的部分讲的就是它。

对于一份内部共享存储——黑板、草稿区、共享记忆表——这些正是共享记忆与黑板所讨论的危害。接下来讲的是更难的那种情形:写到智能体系统之外的世界,那里的并发原语并不归你所有。

步骤 2标准解法把一次大声的失败换成了一次安静的失败。

公认的答案是隔离:每个智能体一个 git worktree 或一个分支、每个工人一个沙箱,于是谁也不共享文件系统。这是正确的第一步,而且它确实消掉了丢失更新与陈旧读——每个工人都拿到一份一致的快照和一处私有的落笔之地。

它消不掉的是写偏斜,因为你继承来的那个探测器是文本式的。当两处 diff 碰到重叠的行时,git 宣告冲突;当它们不重叠时,git 宣告成功。语义上的相容性不是 git 看得见的东西:

# Agent A, branch a/rename-helper — task: tidy the helper's name
# file: utils.py
-def fmt(x):
+def format_amount(x):

# Agent B, branch b/add-receipt — task: add a receipt line
# file: receipt.py  (never opens utils.py)
+from utils import fmt
+total_line = fmt(order.total)

# Both test suites pass in isolation. Both diffs are clean.
# git merge: no conflict. Result: ImportError at runtime.

两个智能体都没做错任何事,而且都没有可以做得更好的信息:A 无从知道一个在它快照之后创建的分支会去 import 那个旧名字,而 B 的快照里本来就有 fmt。这个缺陷是那一对改动的属性,任何逐工人的评审都找不到它。"改名 + 新调用方"是最清楚的例证;同一形状还涵盖:收窄了的函数签名碰上新的调用点、变严了的数据库约束碰上新的插入路径、被删掉的功能开关碰上一处新的读取,以及两个工人各加一个依赖、而它们解析出互不相容的版本。

一次干净的合并不是相容性的证据——它只是"两段文本范围没有重叠"的证据。一旦你让多个智能体在同一个代码库上并行,"所有分支都无冲突合并"就不再是一种状态,而成了一项未经核验的声明。更糟的是,每个工人那次绿色的测试会让这项声明显得已经被核过了:通过的那套测试,针对的是合并之后已不复存在的那个仓库状态。

步骤 3按接口边界划分,不要按任务清单划分。

多数扇出是照着工作被描述的方式划分的——一个智能体一张工单、一个文件、一个失败的测试。那种划分讲的是任务,对"它们的写集是否互不相交"一字未提,而那才是唯一要紧的性质。

改成按工人能够触达的范围来划分。具体做法是:在派发之前,推导出每个工人的写集,并与其他工人的做比对:

  • 模块互不相交,什么都不共享。两个工人在没有共同接口的不同服务里,可以安全并行。放心扇出。
  • 文件互不相交,但共享接口。危险的中间地带——就是上面那个例子。要么把该接口指派给恰好一个工人,并把签名作为一份不得更改的固定契约交给其他工人;要么把这两者串起来。
  • 共享文件或共享 schema。不要并行。把两处改动放进同一个工人的上下文里,让模型至少能看见它们的相互作用。一个智能体带两个任务,胜过两个智能体各看半张图。

这件事的落地形态是一份被声明出来的契约,而不是一份期望。如果允许某个工人改动一个公开签名,那是监督者每一轮只授予一个工人的特权,而其他工人会被告知该签名已冻结。一个你在派发之前列举不出来的写集,就是一个你即将在合并时才发现的写集——这正是多智能体拓扑给出的普遍教训:把耦合的写入留在同一个智能体里。

步骤 4把集成串行化,并且对整体而不是对零件设闸。

并行地干活并不要求并行地集成,而把这两件事混为一谈正是根上的错误。工人并发地跑;合并则一次一个地通过单一通道,而且每次合并之后都要对集成后的状态做一次验证:

# Integration lane: one merge in flight, verified against the result
for branch in ready_queue():          # serialised, not parallel
    merge(branch, into=integration)
    if not full_suite(integration):     # the whole suite, post-merge
        revert(branch)
        requeue(branch, context=failure_diff())
# N workers, 1 integration point, N verifications of the real state.

有两个细节承着全部重量。测试必须在合并之后、在合并后的树上运行——分支上一次合并前的绿色,测的是一个不会上线的状态。而队列必须串行,因为并发地测两次合并,等于把同样的偏斜又搬到了上一层。这恰恰就是合并队列在做的事,也正是这套模式能从人类团队直接搬过来的原因;智能体特有的机制在为智能体改动设合并队列里。

在这里,回滚也比修补便宜。一次失败的集成有一个已被指认的祸首——刚合进去的那个分支——而把它连同这次集成失败一起作为补充上下文重新入队,正好给了那个工人它在结构上唯一缺的那块信息。反过来想在集成分支上一路往前修,会摧毁这份归因,而那正是功劳分配所警告的问题。

步骤 5在仓库之外,你根本没有合并这一步。

Git 至少还提供了一个能把问题拦下来的地方。多数写入目标并不提供。当并行的智能体去写一个工单系统、一个 CRM、一张电子表格、一条消息频道或一个生产数据库时,没有隔离可以授予,也没有集成通道可以串行——写入立刻落地,而所谓"合并"就是那个系统拿两次并发更新去做的任何事。

  • 有的话,就用目标自己的并发控制。条件写入——对 ETag、版本列或 If-Match 头做 compare-and-set——会把一次无声的丢失更新变成一个你可以重试的、响亮的 409。多数 REST API 都暴露了这个,而多数智能体的工具封装把它丢掉了。
  • 让副作用幂等,并以意图为键。以"预期改动的哈希"为键的写入,会把重复尝试收敛成一次副作用;这一点很重要,因为智能体系统里的重试是层层叠加的——见幂等、重试与副作用安全。
  • 给每个工人互不相交的键区间,而不是一张共享表。按工人给写入划命名空间,是文件系统之外最便宜的真隔离,而且它也能防住各自隔离的运行,互相找到了对方里描述的那种机队规模版本的同一问题。
  • 以上都做不到时,就持一份租约。同一时刻只允许一个工人写这条客户记录,其余排队。租约更慢而且正确,而另一个选项并不是更快——它是以一个你量不出来的比率在出错。

不可逆性同样要紧:一次糟糕的仓库合并是一次 revert,而一次糟糕的 CRM 写入是一张客服工单。这种不对称应当改变你把扇面开多宽,而不只是改变你怎么合并——影响半径这套框架在影响半径里。

步骤 6诚实的权衡。

隔离给你买来互不相干的工人,收你的费是一道你如今得自己造的验证:那个过去会大声失败的合并,现在会安静地成功,而真相唯一存在的地方,是一次在集成后状态上跑的测试。写集互不相交到哪儿,扇面就开到哪儿,不要更宽。在它们重叠的地方,选择只有两个:一个智能体同时持有两处改动,或者一条串行通道同时持有两次合并——而这两者都比花一周去二分查找一个没有任何工具报告过的冲突要便宜。

在任何可变对象上加宽扇出之前:列举每个工人的写集,并拒绝派发两个在某个接口上写集相交的工人。把"可改签名"的特权每轮只授予一个工人,并为其余工人冻结该签名。让合并走单一串行通道,并在每次合并之后(而不是之前)跑全量测试。在仓库之外,用带版本检查的条件写入、以意图为键的副作用、以及按工人划的命名空间。然后给唯一那个能告诉你这套是否奏效的数字上仪表:每合并一个分支出现多少次集成失败。只要它不是零,你的划分就是错的——去修划分,不要去修合并。