跳到主要内容

级联问题:为什么 Agent 副作用在大规模运行时会呈爆炸式增长

· 阅读需 15 分钟
Tian Pan
Software Engineer

一个团队交付了一个文档处理智能体(agent)。它在开发环境中表现完美:读取文件、提取数据、将结果写入数据库,并发送确认 webhook。他们运行了 50 个测试用例,全部通过。

部署两周后,在 100 个并发智能体实例运行时,数据库中出现了 40,000 条重复记录,三个下游服务收到了数千个虚假的 webhook,一个共享配置文件被两个同时运行的智能体各覆盖了一半。

智能体本身没有出错。系统崩溃是因为没有任何一个独立的智能体测试曾被要求与其他智能体共同处于同一个运行环境中。

这就是级联问题。它既不是模型故障,也不是提示词故障。这是一种系统性故障,单元测试从结构上就无法捕捉到它,因为单元测试在设计上就是隔离执行的。导致生产事故的行为——竞态条件、重试放大、共享状态损坏——只有在多个智能体实例同时与同一个现实世界的资源交互时才会出现。

理解级联问题需要像分布式系统工程师思考服务那样去思考智能体:不要把它们看作正确的程序,而要看作是一个共享、竞争环境中的参与者。

隔离如何掩盖了问题

单元测试给了你清晰的答案,但你问的问题在生产环境中并不存在。测试中的问题是:“给定此输入,该智能体是否能产生正确的输出?”而生产环境中的问题是:“当 100 个该智能体的副本同时针对同一个数据库、文件系统和外部 API 运行时,会发生什么?”

这是两个不同的问题。它们之间的鸿沟就是级联问题产生的地方。

考虑一个处理任务队列的简单智能体:读取下一个未处理的项,处理它,并将其标记为已完成。在隔离状态下,这是正确的。但在有 10 个并发智能体的情况下,它们都会在其中任何一个标记完成之前读取同一个“下一个未处理项”,导致同一个任务被处理了 10 次。这并不是模型错误——智能体完全按照指令行事。这是一个经典的“检查时间到使用时间”(time-of-check to time-of-use, TOCTOU)竞态条件,与分布式数据库工程师几十年来一直在处理的问题一模一样。

同样的结构出现在智能体运行的各个地方:

  • 文件写入:两个更新同一个配置文件的智能体将相互覆盖彼此的更改。最后写入者获胜。两个智能体都成功完成,但结果是损坏的。
  • 重试放大:工具层的一个失败会触发工具内部的重试、智能体 SDK 中的重试以及智能体自身重试循环中的重试。一个简单的网络超时会演变成 27 次 API 调用。
  • 状态累积:如果不使用原子操作,一个向共享日志文件追加内容或更新共享计数器的智能体在并发情况下会产生错误的结果,即使单次追加操作本身是正确的。

ZenML 对 1,200 多个生产部署的分析发现,生产失败最常见的来源并不是模型质量,而是这类基础设施和集成失败。模型表现正确,但系统没有。

三种故障模式详解

重试放大

大多数智能体架构在多个独立层级都设有重试逻辑:HTTP 客户端重试网络错误,工具包装器重试失败的工具调用,而智能体循环则重试失败的步骤。在正常情况下,这些层级是不可见的。但在发生故障时,它们会产生复合效应。

计算方法很简单:如果三个层级中的每一层在失败时都重试三次,那么一个上游错误就会产生 27 次下游调用。如果这 27 次调用是向支付 API 写入数据或向外部服务发送消息,其后果是显而易见的。

修复方法需要跨层级协调重试语义。带有抖动的指数退避(exponential backoff with jitter)可以防止同步的重试风暴。幂等键(idempotency keys)可以防止即使重试成功也导致的重复执行。熔断器(circuit breakers)则能在重试放大升级之前将其制止——在连续 N 次失败后,完全停止尝试该操作,而不是持续冲击一个已经降级的依赖项。

一个金融应用团队报告了一个熔断器配置:它在连续三次失败时跳闸,进入 30 秒的开启状态,然后在恢复之前在半开状态下测试恢复情况。核心见解是:阈值的设置必须足够激进以防止级联反应,但又不能过于激进导致瞬时故障触发不必要的开启状态。

并发变动

当多个智能体实例读取共享状态、对其进行修改并写回时,除非读取和写入是原子的,否则结果将取决于时间顺序。这不是一个新颖的问题——它是促使数据库事务、分布式锁和比较并交换(compare-and-swap)操作出现的相同问题。智能体也无法幸免。

具体的故障模式取决于资源类型:

文件:两个智能体读取一个 JSON 配置文件,添加一个条目并写回,将静默地丢失其中一个智能体的条目。第二次写入会在没有报错的情况下覆盖第一次写入。

数据库:除非数据库在约束层面(而不仅仅是在应用层面)强制执行唯一性,否则执行“先检查后行动”(如果记录不存在,则插入)的智能体在并发情况下会创建重复记录。

外部 API:如果在检查之后、修改之前有另一个智能体修改了资源状态,那么在修改前检查资源状态的智能体看到的将是过时的状态。乐观锁(optimistic locking)模式可以处理这种情况:在你的写入请求中包含你读取的版本号,并让服务器拒绝与新版本冲突的写入。

通用原则是:不要将智能体设计成基于它们并不排他拥有的状态来做决定。要么获取排他所有权(锁),要么使用在写入中包含前提条件的原子操作(比较并交换),或者将操作设计为无论并发修改如何都是安全的(幂等性)。

跨会话的状态损坏

智能体会话之间共享的可变状态会导致一类更隐蔽的问题。如果一个智能体将中间结果写入共享缓存、追加到共享上下文或更新共享内存库,就会在原本并非设计为相互作用的会话之间创建隐式依赖。

一个智能体的清理操作可能会删除另一个智能体会话仍依赖的状态。一个将任务标记为“处理中”的智能体会阻止另一个智能体接手该任务,即使第一个智能体在未完成任务的情况下崩溃了。如果智能体在写入共享上下文时没有协调的淘汰机制,该上下文就会无限制地增长。

避免这种情况的模式是:将所有共享状态视为“仅追加”(append-only)并进行显式版本化。不要修改共享记录,而是创建新版本。默认使用会话级状态,只有在显式设计并测试了共享语义时,才提升为共享状态。为每个写操作分配一个会话标识符,以便你可以追踪哪个智能体写入了什么内容。

幂等性要求

解决级联问题最有效的单一手段是让每个智能体工具调用都具备幂等性:使用相同的输入调用一次或多次,产生相同的结果和相同的副作用。

幂等性并不能防止重复调用 —— 重试、竞态条件和网络故障仍然会产生重复调用。但幂等性使重复调用变得安全。

实现方式是使用幂等键(idempotency keys):由调用者生成并在每个请求中包含的唯一标识符。服务器将该键与结果一起存储;如果再次看到相同的键,它将返回缓存的结果,而不再重新执行操作。Stripe API 多年来一直采用这种模式,因为支付处理正是那种重复执行会导致灾难性代价的领域。

对于智能体,幂等键应该在工具调用层级生成,而不是在请求层级。单次智能体调用可能会进行数十次工具调用;每个工具调用都需要自己的键,这样在工具层级的重试就不会重新执行已经成功的操作。

目前大多数智能体框架 —— 如 LangChain、AutoGen、Claude 的 SDK —— 都不会自动管理工具调用的幂等键。这意味着开发者必须为任何具有副作用的工具调用(写入、发送、创建、删除)手动实现它。只读操作(查询、查找、获取)天然是幂等的,不需要键。

实践范围:审计你的智能体工具调用,并将每个调用分类为“只读”或“带副作用的写入”。在写入类别中构建幂等键的生成和存储。这虽然不是什么光鲜亮丽的工作,但它是“安全的重试”与“导致事故的重试”之间的本质区别。

先沙箱再执行

幂等性处理的是操作已经发生的情况。而沙箱化处理的是你根本不确定操作是否应该发生的情况。

“先沙箱再执行”(Sandbox-before-execute)模式在提交智能体操作之前,先针对真实环境的沙箱模拟运行这些操作。智能体写入测试数据库、发送到预发布 API 端点、修改文件系统的副本。如果模拟执行成功并通过验证,则进行实际执行。如果失败或产生意外结果,则不会产生现实世界的副作用。

这种模式对于具有不可逆后果的操作最有价值:发送电子邮件、处理付款、永久删除记录。对于可逆操作,维护模拟基础设施的成本可能会超过其带来的收益。

实现这一点需要维护与生产资源等效的预发布环境,这是许多团队试图回避的运维负担。一个更轻量级的版本:根据爆炸半径(只读、可逆写入、不可逆写入)对每个工具调用进行分类,并仅对不可逆类别应用“先沙箱再执行”。这将运维开销限制在那些一旦出错代价最高的动作上。

NVIDIA 红队关于智能体沙箱化的指南强调了一个起初看起来违反直觉的设计原则:目标不是防止所有失败,而是确保失败是受限的。一个在妥善沙箱化的环境中犯错的智能体只会导致一个可恢复的问题。而在不受控环境中犯同样错误的智能体,可能会导致需要数天才能理清的事故。

应用于智能体的分布式系统模式

长期构建分布式系统的团队会立即识别出级联问题 —— 这其实是他们在微服务架构中解决过的问题的更名版。解决方案可以直接迁移。

Saga 模式 处理每个步骤都有副作用的多步操作。Saga 不将整个操作视为原子性的,而是将每个步骤作为单独的本地事务执行,并定义补偿事务,如果后续步骤失败,则撤销之前的每个步骤。如果智能体工作流涉及:预订库存 → 处理付款 → 发送确认,而付款失败了,补偿事务就会释放预订的库存。系统可能会出现瞬间的不一致,但最终会收敛到有效状态。

Outbox 模式(发件箱模式) 解决了双写问题:智能体需要更新自身状态,同时触发外部动作(Webhook、消息、API 调用)。跨系统边界原子性地执行这两个写入是不可能的。Outbox 模式在与状态更新相同的事务中,将外部动作写入本地的 outbox 表,由一个独立的后台进程读取 outbox 并进行外部调用。如果智能体在写入 outbox 和执行外部调用之间崩溃,后台进程会进行重试。外部调用会获得一个派生自 outbox 记录 ID 的幂等键。

持久化执行框架(Durable execution frameworks)(如 Temporal)在基础设施层面实现了这些模式。工作流被定义为代码,但执行在每一步都会被持久化。如果执行进程崩溃,工作流将从上一个持久化步骤恢复。对于运行数分钟或数小时并触及许多外部系统的智能体来说,这种持久性不是可选的 —— 它是使系统在运维上可管理的关键。

可观测性:一项先决条件

如果你无法看到你的智能体在做什么,上述任何模式都没有用。传统监控捕获的是基础设施故障:进程关闭、高延迟、错误率激增。智能体的故障通常看起来不同:基础设施正常,智能体正在运行,但输出是错误的。

有效的智能体可观测性需要在操作(action)层面进行追踪,而不仅仅是在请求层面。每个工具调用都应该发出一个 span:什么工具、什么参数、什么结果、耗时多久、哪个会话、哪个智能体实例。这些 span 使得重建导致特定结果的操作序列成为可能——这是你在生产级智能体系统中能获得的最接近调试器(debugger)的东西。

审计日志是同一要求的面向合规的版本。受监管的行业需要能够回答:哪个智能体在什么时间、基于什么推理采取了这一行动,结果是什么?这不仅需要捕获工具调用的输入和输出,还需要捕获智能体的推理状态——即导致工具调用决策的上下文。

OpenTelemetry 的 AI 可观测性工作组在 2025 年发布了标准,用于以现有可观测性基础设施可以处理的标准格式发出智能体追踪。对于新的智能体部署,从第一天起就针对该标准进行埋点(instrumenting),比在运行中的系统上补课式地增加可观测性要便宜得多。

核心工程纪律

级联问题不是模型问题。更好的模型也无法解决它。这是一个分布式系统问题,需要分布式系统的解决方案,并且需要将智能体视为共享环境中的并发参与者,而不是恰好调用外部 API 的孤立程序。

实际操作顺序:按副作用类型(只读、幂等写入、非幂等写入)对每个工具调用进行分类。为非幂等写入实现幂等键(idempotency keys)。对双写操作应用发件箱模式(outbox pattern)。对带有补偿事务的多步工作流使用 saga 模式。对不可逆操作应用先沙箱后执行(sandbox-before-execute)。使用分布式追踪对工具调用进行埋点。

这不是什么奇特的工程学。正是这种工程纪律让支付系统和库存服务在大规模环境下保持可靠。智能体并不会因为使用了语言模型而豁免。它们受到这些纪律的约束,原因与任何并发系统相同:它们与其他参与者共享状态,并且单个参与者的行为正确并不意味着多参与者的行为也正确。

那些尽早学习到这一点的团队——从幂等工具设计和可观测的操作日志开始——会将时间花在提升能力上。而那些学习较晚的团队则会将时间花在排解那些由“单体行为正确但集体行为错误”的智能体所引起的事故上。

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