跳到主要内容

并发智能体系统中的竞态条件:那些看起来像幻觉的 Bug

· 阅读需 15 分钟
Tian Pan
Software Engineer

三个智能体并发处理同一个客户账户更新。三者都记录了成功。最终数据库状态同时出现了三处错误,且始终没有抛出任何异常。团队花了两周时间怪罪模型。

问题不在模型。是竞态条件。

这是生产环境多智能体系统中被误诊次数最多的故障模式:由并发状态访问引发的数据损坏,因为下游智能体会基于损坏的输入自信地进行推理,从而被误认为是幻觉。模型并没有在编造内容,它只是在忠实地处理垃圾数据。

读-改-写陷阱

核心漏洞是经典的读-改-写竞态。设想三个并行运行的智能体——一个处理账户状态,一个计算费用,一个执行合规检查。每个智能体读取当前账户记录,进行各自的修改,然后将结果写回。

智能体 A 读取:{status: "pending", fees_applied: false, compliance: "clean"}

智能体 B 读取相同内容:{status: "pending", fees_applied: false, compliance: "clean"}

智能体 C 读取相同内容:{status: "pending", fees_applied: false, compliance: "clean"}

智能体 A 率先写入:{status: "active", fees_applied: false, compliance: "clean"}

智能体 B 随后写入:{status: "pending", fees_applied: true, compliance: "clean"} — 这是完全覆盖写。智能体 A 的状态更新消失了。

智能体 C 最后写入:{status: "pending", fees_applied: false, compliance: "flagged"} — 智能体 A 和 B 的修改全部丢失。

每个智能体都成功了。日志干净无误。但最终账户状态只反映了智能体 C 对原始记录的孤立视图。三个语义操作被提交,只有一个幸存。

下游发生了什么?一个风险评分智能体读取了这条记录,并基于通过剥离三个并发更新中的两个而构造出的数据,生成了详尽且自信的分析。风险评分看起来合理,文字说明连贯流畅,却完全是错误的。

这正是竞态条件被误诊为幻觉的原因:损坏发生在状态层,而非生成层。LLM 做的完全正确——它在基于所获得的上下文进行推理。只是上下文本身错了。

为何这是分布式系统问题,而非 AI 问题

多智能体 LLM 系统继承了数十年数据库工程中的所有分布式系统故障模式,但构建这些系统的大多数工程师以前从未思考过因果一致性。

顺序切换的单线程智能体很简单:智能体 A 运行并写入结果,智能体 B 读取该结果然后运行,以此类推。存在清晰的发生先后关系,状态绝不会同时被两个写入者访问。

并发多智能体系统立刻打破了这一切。一旦有两个智能体同时运行且可能触及相同状态,你就构建了一个分布式系统。所有分布式系统问题都适用:竞态条件、顺序违规、脑裂场景、局部故障恢复。技术栈的某一层是语言模型这一事实,与状态层是否正确完全无关。

生产故障数据印证了这一差距。跨生产多智能体部署的记录故障率从 41% 到 86% 不等,协调失败和状态损坏是主要原因。这些故障中绝大多数并非模型局限性所致,而是在陌生领域中出现的经典分布式系统 bug。

适用的分布式系统原语

乐观锁

防止覆盖模式最简单的手段是乐观锁。每条状态记录携带一个版本号。读操作捕获版本号,写操作仅在版本未变更时才成功。

每个智能体读取:{balance: 100, version: 3}。每个智能体尝试写入时,以 version == 3 作为前提条件。第一个写入成功并将版本提升至 4。所有后续写入立即失败——版本现在是 4,而非 3。写入失败的智能体必须重试:重新读取当前状态,基于最新数据重新计算修改,然后再次尝试。

关键特性:失败是显式的,并在写入时被检测到。没有静默覆盖,没有丢失的更新。当竞争激烈时代价是重试,但对于共享状态访问并非热点的大多数智能体工作负载而言,竞争程度低到重试很少发生。

DynamoDB 的条件表达式直接实现了这一模式。大多数数据库通过乐观锁中间件支持它。任何支持比较并交换语义的键值存储都能用不到五十行代码从头实现。

令人惊讶的是,智能体框架几乎从不鼓励这样做。大多数教程展示的智能体在没有版本断言的情况下读写状态,因为这样演示更简单。生产团队会在六个月后才明白这有多错。

向量时钟与因果排序

乐观锁处理了覆盖问题,但并未捕获更新冲突的原因。向量时钟捕获因果排序:智能体 B 的更新是否在因果上处于智能体 A 之后,或者它们真的是并发的,因此可能存在冲突。

每个智能体维护一个计数器向量——系统中每个智能体各一个。智能体的向量 [5, 1, 2] 表示"我已应用了自己的 5 个事件、智能体 B 的 1 个事件和智能体 C 的 2 个事件。"智能体发送状态更新时附带当前向量,接收智能体在应用更新时合并向量。

有用的特性:如果智能体 A 的向量在元素层面小于智能体 B 的向量,则 A 的更新在逻辑上先于 B 发生。如果两者在元素层面均不小于对方,则更新是并发的,需要合并策略。

这对调试比对防止损坏更有用。用向量时钟值注释的分布式追踪,使得在发生故障时能够精确重建智能体所相信的因果顺序。这能区分"智能体对什么先发生存在分歧"和"智能体在排序上达成一致,但其中一个产生了错误结果"。

在拥有数百个智能体的系统中,向量时钟扩展性较差——向量大小随智能体数量增长。对于拥有有限数量不同智能体类型的大多数生产多智能体系统而言,这是可以接受的。

CRDT 与无冲突收敛

无冲突复制数据类型(CRDT)解决的是另一个问题:在无需任何智能体重试的情况下,希望并发更新自动合并为一致结果的场景。

核心洞见是选择满足交换律和结合律的数据结构与操作——即"A 然后 B"与"B 然后 A"产生相同结果的情形。对于这类操作,并发更新以任何顺序应用都是安全的。

计数器增量是最典型的例子:如果智能体 A 增加 5,智能体 B 增加 3,无论顺序如何,最终计数都是 8。每个智能体可以维护自己的增量寄存器,总和即为最终结果。

仅追加集合的工作方式类似。OR 集合(可观察删除集合)为每个元素附加唯一标识符,因此并发的添加和删除操作永远不会冲突——它们被独立追踪并确定性地合并。

LangGraph 的 reducer 系统是这一思想的有限框架特定版本。当你用 Annotated[list, operator.add] 注释状态字段时,并行节点对该字段的更新会被拼接而非覆盖。这实际上是一个列表 CRDT。对于追加操作它能正确工作,但当顺序重要或更新存在语义依赖时则无能为力。

局限性是显著的:大多数智能体状态本质上不满足交换律。表示工作流阶段的状态字段——从 "pending""active" 再到 "completed"——具有有意义的语义。对其进行并发写入需要冲突检测和解决,而不仅仅是合并。CRDT 最适用于累加器式状态:令牌计数、事件日志、能力集合。

智能体框架实际提供了什么

在生产框架中,LangGraph 对并发状态管理有最明确的模型。它的 reducer 系统要求开发者声明每个状态字段在同一执行步骤中被多个节点更新时如何合并。没有自定义 reducer 的字段使用最后写者获胜语义,这会静默地丢弃并发更新——即上述覆盖问题的根本原因。

重要约束:reducer 必须无论更新顺序如何都能产生正确结果。如果你的 reducer 依赖顺序,它就是错误的。这排除了实现"将此增量应用于当前值"之类有状态转换的 reducer,因为当前值可能因哪个更新先到达而不同。

AutoGen 的 actor 模型从设计上规避了共享状态:每个智能体顺序处理消息,与其他智能体不共享内存。多个智能体需要的状态必须通过消息显式传递。这完全消除了智能体本地状态的竞态条件,代价是使共享上下文的管理更加困难——每次智能体切换都必须包含下一个智能体所需的所有相关上下文。

OpenAI 的 Swarm 走得更远,在调用之间几乎不维护持久状态。顺序智能体激活意味着任何时候只有一个智能体处于控制之中。并发问题不会出现,因为根本没有真正的并行性。这一权衡代价不小——对于能从并行执行中获益的工作流,延迟成本会相当显著。

坦率地说:没有任何主流智能体框架开箱即用地提供健壮的并发状态管理。LangGraph 在这方面有最明确的思考界面;其他框架通过限制并行性的设计约束来掩盖这一问题。

如何判断这不是幻觉

并发 bug 的诊断特征与模型故障在可重现的方式上是不同的。

模型幻觉在相同输入下是一致的。给定相同的上下文,相同的模型会产生相同(或接近)的错误输出。用相同输入运行 10 次提示词,你会看到稳定的错误。

并发 bug 依赖于时序。相同的工作流有时成功,有时失败。失败概率随并发级别增加——并行智能体越多,失败越多。用固定随机种子重复运行相同的智能体工作流,你会看到输入未变但输出各异。

另一个诊断信号是哪些智能体受到影响。模型幻觉通常局限于单个智能体。并发 bug 产生向下游传播的故障——智能体 C 以只有在智能体 B 收到损坏状态的情况下才合理的方式失败,而那只在智能体 A 和其他某个智能体在写入上存在竞争时才会发生。

分布式追踪使这成为可诊断的问题。有了适当的检测手段,你可以重建所有智能体的精确读写序列,时间戳精确到足以识别重叠访问窗口。"双重读取"模式——两个智能体在几毫秒内读取相同状态键且中间没有写入——是竞态条件可能发生的最清晰指标。

需要关注的具体追踪特征:

  • 工具响应乱序:智能体发起工具调用 T1、T2、T3,响应按 R3、R1、R2 的顺序到达。智能体预期了顺序,其推理现在因交错而被污染。
  • 重叠状态访问:状态键 K 在 t=10ms 被智能体 A 读取,在 t=12ms 被智能体 B 读取。智能体 A 在 t=50ms 写入,智能体 B 在 t=52ms 写入。两次写入相差 2ms——智能体 B 几乎可以确定使用了过时数据。
  • 因果违规:一个在逻辑上应该后于另一个的事件在墙钟时间上先于它,表明消息按因果顺序之外的顺序到达。

OpenTelemetry 已成为捕获这一切的标准检测层。AI 智能体的语义约定——智能体 span、工具调用 span、状态访问 span——正在快速成熟。关键是将状态读写检测为一等 span,包含状态键、值版本和智能体标识,而不仅仅是记录"智能体 A 完成了"。

生产环境实用模式

显式状态版本控制。 为每条共享状态记录添加版本计数器。记录每个智能体读取和写入的版本。这将静默覆盖变为可检测的冲突,并在无需更改任何应用逻辑的情况下使竞态条件在追踪中可见。

写冲突策略。 明确决定当写入因版本冲突而失败时发生什么:用新鲜数据重试、记录并跳过、升级到监督者。不要让这一点未定义——冲突情况下的未定义行为,正是将偶发竞态转化为难以诊断的生产事件的原因。

只读智能体分离。 将智能体分类为读者或写者。读者智能体从不修改共享状态,只消费它。写者智能体被序列化——在给定状态分区上任何时候至多一个写者,配以锁定或比较并交换机制。大多数多智能体工作流都有清晰的读密集阶段(多个智能体收集信息)和写阶段(一个智能体综合并提交)。将工作流结构化以匹配这种分离,可以消除大量竞态。

幂等工具设计。 将智能体工具设计为执行两次与执行一次产生相同结果。这不能防止竞态,但使重试安全——当写入失败且智能体重试时,重新运行工具不会产生重复执行的副作用。当检测到竞态的补救措施是重试时,这是必不可少的。

状态不变量检查。 在每次智能体执行后,断言不变量仍然成立:账户余额为非负值,状态是有效值之一,必填字段存在。这些断言不能防止损坏,但会在接近源头处检测到它,而不是让它在三个智能体后才让什么东西明显崩溃。

首先需要的检测手段

如果没有分布式追踪,你就无法在多智能体系统中调试并发 bug。应用日志是不够的。日志是按智能体的且无序的;它们无法给你识别重叠状态访问所需的跨智能体时序视图。

最小检测技术栈需要:每次智能体激活的唯一 span ID、状态读写的时间戳(包含状态键和版本)、显式的父子 span 关系(以便你能重建调用树),以及能跨智能体边界按时间戳排序和按状态键过滤的后端。

Langfuse、Galileo 和 Maxim 等工具已在 OpenTelemetry 约定之上构建了智能体特定的可观测性。底层模式——追踪执行图,而非仅仅是输出——无论使用什么工具都适用。第一次你在可视化并发工作流中所有智能体操作的瀑布图,并看到两个智能体在同一 10ms 窗口内读取了相同状态键,你就会明白为何这是长期以来缺失的基础设施。

结语:不是模型问题

生产多智能体系统中竞态条件的普遍性,并非对框架或模型的控诉。这是工程师将单智能体思维模式应用于分布式系统问题的必然结果。并行性带来延迟改善,但需要与并行数据库写入数十年来所要求的相同纪律。

分布式系统社区在大多数当前从业者开始职业生涯之前,就已经解决了这些问题——乐观锁、向量时钟、CRDT、消息排序保证。这些模式是众所周知的。新鲜之处在于认识到多智能体 LLM 系统就是分布式系统,并在用户开始提交关于模型幻觉的 bug 报告之前应用这些模式。

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