跳到主要内容

当两个 Agent 共享一个工具:多 Agent 系统中的并发 Bug

· 阅读需 11 分钟
Tian Pan
Software Engineer

当你输入“启动另一个智能体来并行处理”的那一刻,你就已经成为了一名分布式系统工程师。你可能没有注意到。框架让它变成了一行代码的改动,演示运行良好,延迟也降低了。但在底层,你刚刚引入了两个在没有协调的情况下读写共享状态的进程 —— 而困扰了数据库领域五十年的每一个竞态条件 (race condition)、更新丢失 (lost update) 和脏读 (dirty read),现在都潜伏在你的智能体堆栈中,伺机而动。

这种情况之所以棘手,是因为故障看起来不像并发 Bug,而像是某个智能体出错了。输出在语法上是有效的,流水线显示绿色,没有抛出异常 —— 然而,客户被收取了两次费用,或者文件丢失了一半预期的内容,又或者一个智能体自信地根据另一个智能体已经覆盖的数字采取了行动。你去调试那个“愚蠢的智能体”,发现它的提示词 (prompt) 没有任何问题,因为提示词根本就不是问题的症结所在。

多智能体系统的卖点在于智能体是独立的。它们运行自己的推理循环,拥有自己的上下文窗口,做出自己的决策。这种独立性恰恰是一种错觉。当两个智能体接触到同一个文件系统、同一个数据库行、同一个草稿本或同一个 API 令牌的那一刻,它们就不再独立了 —— 它们是竞争共享资源的并发进程,而没有人针对这种竞争进行设计。

独立性的错觉

让我们来看看在典型的智能体部署中,“共享”到底意味着什么。两个研究智能体都将发现写入共享内存库。规划器和执行器都更新一个跟踪进度的任务对象。三个编码智能体都在同一个代码库检出中运行。一群工作智能体都使用同一个带有单一速率限制的 API 密钥进行身份验证。每一个都是竞争点,而且没有一个会主动声明自己是竞争点。

设计师的心理模型是一个整洁的组织架构图:管理者智能体负责分发任务,子智能体去完成孤立的工作,结果返回并合并。而运行时的现实更像是四个实习生在关闭了修订追踪的情况下编辑同一个 Google 文档。组织架构图说他们是独立的,但文档的内容却给出了相反的答案。

让这比经典分布式系统更糟糕的是时间差。传统服务的延迟在某种程度上是可预测的。大语言模型 (LLM) 智能体则不然 —— 一次调用在 200 毫秒内返回,下一次可能需要 9 秒,因为模型决定进行更深入的思考,或者某个工具超时并重试。你在生产环境中遇到的交错情况比你本地测试运行产生的任何情况都要多得多。在每次请求中只有万分之一发生概率的竞态,一旦你在不可预测的智能体时长内处理数百万个请求,就会变成每天都会发生的事故。

更新丢失,重生

多智能体系统中最常见的损坏是更新丢失,准确把握其机制非常重要,因为修复方案取决于此。

智能体 A 读取一个共享状态对象 —— 假设是一条显示五个子任务中已完成三个的任务记录。智能体 B 几乎在同一时间读取同一条记录,同样看到已完成三个。智能体 A 完成了它的子任务,将计数设置为 4 并写入。智能体 B 完成了它的子任务,将计数设置为 4(它从未看到 A 的写入)并写入。最终状态显示五个中完成了四个。一个子任务的完成就这样无声无息地消失了。没有错误,没有日志行。记录格式非常完美,只是数据错了。

这是教科书式的“读-改-写”竞态,而这本教科书已经有六十年的历史了。它之所以不断出现,是因为智能体框架将状态暴露为普通的易变对象 —— 一个字典、一个 JSON 对象,或者一行你先 SELECT 然后 UPDATE 的数据。读取和写入是两个独立的操作,中间存在间隙,任何其他智能体都可以溜进这个间隙。框架给了你共享内存的便利,却没要求你履行同步访问的义务。

同样的模式会产生脏读 —— 智能体 B 读取了智能体 A 正在修改到一半的状态,基于一个永远不会成为最终结果的值采取行动,并在下游产生一个言之凿凿的错误答案。它还会导致重复执行:一个工作智能体的工具调用成功了,但确认信息丢失了,编排器重试,导致一个非幂等的操作运行了两次。如果该操作是“向客户收费”或“发送电子邮件”或“创建工单”,你现在就在现实世界中制造了一个重复项,任何回滚都无法完全撤销。

四种看起来无害的共享资源

命名这些悄悄变成竞争点的特定资源会很有帮助,因为每一种资源都有不同的修复方法。

  • 共享文件系统。编码智能体是最明显的案例。两个智能体在同一个检出目录中编辑文件会互相覆盖工作,或者争夺 git 的索引锁。成熟的修复方案是 git worktrees:每个智能体拥有自己的工作目录和索引,同时共享对象库,这样文件级的冲突就变成了标准工具可以检测到的合并冲突 —— 而不是在工作过程中的无声覆盖。但 worktrees 只能隔离 git 跟踪的内容。如果两个智能体都在 3000 端口上启动开发服务器,或者写入同一个临时目录,主机上仍然各只有一个,你又回到了竞争状态。

  • 共享数据库行或状态对象。这就是上面描述的更新丢失机器。修复方案是乐观并发控制:给记录附加一个版本号,并将每次写入都变成“比较并交换” —— UPDATE ... SET value=?, version=version+1 WHERE id=? AND version=?。如果有另一个智能体在中间进行了写入,WHERE 子句将匹配不到任何行,更新会明确失败,智能体会在新状态的基础上重试,而不是覆盖它。

  • 具有单一速率限制的共享 API 令牌。十个智能体,一个密钥,一个配额。当智能体都从同一个桶里取水时,所谓的智能体独立性就是一个谎言 —— 一个智能体的突发流量会导致其他九个智能体饥饿,而故障则表现为那些没做错任何事的智能体中出现的无法解释的超时。速率限制也是共享状态,即使它从未在你的代码中以变量的形式出现。

  • 共享草稿本或存储库。通过公共内存块进行“协作”的智能体是在对一个没有模式 (schema) 且没有锁的数据结构进行并发写入。仅追加 (Append-only) 日志可以幸免;对共享块的原地更新则不行。

使工具层具备事务性

当你终于意识到并发问题时,本能反应往往是将修复逻辑推给 Agent——写一个更好的提示词(Prompt),告诉 Agent “先检查是否有其他 Agent 正在处理此任务”。千万不要这样做。LLM 无法通过推理解决竞态条件(Race Condition),因为竞争发生在读取和写入之间的毫秒级间隙,而不是发生在它的推理过程中。协调机制必须存在于工具层(Tool Layer),即模型之下,在那里可以强制执行原子性(Atomically)。

这意味着 Agent 调用的工具应该表现得像一个微型数据库,而不是一堆便利函数:

  • 在每个变更工具上使用幂等键(Idempotency keys)。 每个“扣费”、“发送”、“创建”或“写入”工具都应接受一个根据操作意图派生的幂等键。工具层记录已执行的键,并拦截重复请求。这样,重试操作就变成了一个安全的空操作(No-op),而不是产生第二次扣费。这是整个系统中最廉价的保险,也是最常被忽略的一点。

  • 针对状态更新使用比较并交换(Compare-and-swap)。 严禁盲写(Blind writes)。每次更新都应携带 Agent 读取时的版本号,如果版本号已发生变化,工具则拒绝写入。Agent 通过重新读取并重试(在有限次数内并配合退避机制)来处理拒绝。

  • 默认使用 Agent 作用域(Scoped)的资源,仅在理由充分时才共享。 为每个 Agent 分配独立的工作树、独立的临时命名空间,并在预算允许的情况下分配独立的 Token。将共享视为一种明确且审慎的决定,而不是默认行为。大多数“多 Agent 并发 Bug”实际上是“我们共享了本不需要共享的东西”。

  • 针对部分失败的补偿处理器(Compensation handlers)。 当一个多步骤的 Agent 动作在执行到一半失败时,你需要一种定义明确的方法来撤销或对账已完成的步骤——即应用到 Agent 工作流中的 Saga 模式。趋向于这种设计的生产级多 Agent 规划系统将可靠性视为一种系统属性:通过版本化日志、幂等键以及明确的重试和超时策略来保证,而不是依靠提示词指令。

检测肉眼不可见的损坏

最困难的地方在于,这些 Bug 在结构上是不可见的。输出结果往往格式正确。因此,你必须刻意针对这些问题进行插桩(Instrument)。

记录每次对共享状态的读写操作,包括 Agent ID、时间戳和观察到的版本号。当发生更新丢失时,追踪记录会显示两个 Agent 读取了相同的版本并同时进行写入——这种模式就是典型的特征签名,你可以据此发出告警。在 Agent 工作流结束后运行不变性检查(Invariant checks):如果子任务的完成数应该等于总数,则进行断言;如果余额永远不应为负数,则进行断言。不变性违背是抓获现场的并发 Bug,即使当时没有抛出异常。

同时要接受一个事实:你无法在常规测试中可靠地复现这些问题。它们依赖于执行时机,通常只在负载下或特定的交错执行中出现。通过人工构造的 Agent 进行压力测试,模拟彼此竞争,并注入故障来延迟某个 Agent 的写入以扩大竞争窗口,你就能在一个下午发现原本要在下个月凌晨 2 点才会出现在生产环境中的问题。

总结

“启动另一个 Agent”并不是一种扩缩容原语(Scaling primitive)。它是一个并发决策,并继承了数据库和分布式系统领域所有血泪换来的教训。框架让并行化变成了一行代码的事,但它们并不能让正确性也变成一行代码。这个差距需要由你来弥合。

在你添加第二个 Agent 之前,请先问一个问题:这两个 Agent 共享什么?是一个文件系统、一行数据、一个 Token,还是一个内存块——找到它,要么将其隔离使其不再共享,要么为其添加事务保护以确保共享安全。将你的工具层视为它本质上的数据库。否则,你交付的系统将处于“几乎总是正确”的状态,在出错时静默失败,并让你陷入去调试一个从未出过问题的 Agent 的泥潭。

References:Let's stay in touch and Follow me for more thoughts and updates