一个工具通过了测试。你从一个智能体(agent)调用它,看着它读取记录、转换、写回并返回一个清晰的结果。几周以来,它每次都表现完美。然后你将智能体集群从一个 worker 扩展到十二个,结果一个客户报告说他们的订阅在同一分钟内被升级了两次。工具没有变,只是调用它的并发量变了。
这是单智能体测试无法捕获的失败模式,因为单智能体测试永远不会产生触发该模式的条件。从结构上看,单个调用者是一个串行工作负载。你的工具默默依赖的所有并发假设——读取时没有其他人在写入、自增的计数器是属于它自己的、保存时正在编辑的草稿依然存在——在只有一个调用者时都是理所当然成立的。工具并非正确,只是未经过测试。这两者是不同的,而在第二个智能体出现之前,这种差异是不可见的。
这种问题之所以让团队措手不及,是因为智能体层对编写工具的人隐藏了并发性。构建 update_account 端点的后端工程师知道它会被许多客户端疯狂调用,并为此进行了设计。但暴露给智能体的工具通常最初只是某人用一个下午写成的薄包装,好让模型能“完成某件事”。它感觉就像一个函数调用。在追踪(trace)中它看起来也像一个函数调用。而在我们大多数人的心智模型中,函数并不会在同一时间有其他函数在其中运行。智能体框架让一个分布式系统问题看起来像是一个本地问题。
单个调用者无法证明任何事情
思考一下你的评估(eval)套件实际演练了什么。它发送了一个提示词,智能体选择了一个工具,工具运行到完成,然后返回结果。即使你运行了一百个评估案例,你也只是把它们当作一百个串行片段来运行。工具从未观察到一个其先决条件被同行修改的世界。
并发 bug 并非你侥幸错过的稀有事件。它们是你的测试设置在结构上无法产生的另一类行为。你可以将评估覆盖率从 100 个案例增加到 10,000 个,但在“两个调用者同时运行”的情况下,覆盖率依然为零,因为失败模式的覆盖范围不等于输入的覆盖范围。串行测试工具在并发维度上只有一个样本,即“并发量 = 1”。
实际后果是:“工具通过测试”只是针对你不会在生产环境中运行的工作负载所做的陈述。一旦编排器(orchestrator)将子任务分发给并行 worker,或者你为两个不同的用户运行两个恰好触碰同一共享资源的智能体,亦或是单个智能体的重试与其原始调用发生重叠,你就进入了未测试的领域。多智能体系统中的竞态条件(Race conditions)是并行执行的必然结果,而非边缘案例——而且系统在损坏状态时不会报错。它只会根据已经过时的读取结果返回一个看起来很合理的回复。
智能体碰撞的发生地
具体说明这里的“共享状态”意味着什么是有帮助的,因为争用点(contention points)并不总是显而易见的数据库。
可变记录。 两个智能体都对同一客户运行 read account → modify → write account。它们都读取了版本 5。智能体 A 写入了带有其更改的版本 6。智能体 B 写入了带有其更改的版本 6,覆盖了 A 的操作。这是典型的丢失更新(lost update),而且没有任何错误日志——两次写入都“成功”了。
计数器和配额。 一个用于减去信用余额、增加使用计数器或从固定池中索取名额的工具,本质上是等待发生的竞态。两个智能体都读取到“剩余 3 个名额”,都认为可以继续,都执行了减法,结果你把四个任务分配到了三个名额中。
共享 API 凭证。 许多智能体使用相同的密钥、针对相同的供应商速率限制(rate limit)调用同一个下游 API。使用单个调用者测试工具时永远不会触及配额。并行运行十二个智能体,它们就会集体冲破限制——每个智能体的个体行为都是合理的,但聚合起来就是一次自残式的拒绝服务。这是搬到你系统内部的“嘈杂邻居”(noisy-neighbor)问题:由于没有人按调用者核算速率限制这一共享资源,一个智能体集群由于自身的资源竞争而陷入饥饿。
草稿和暂存状态。 一个在共享暂存位置构建文档、购物车或计划的智能体,会假设该暂存区是其专有的。指向同一键(key)的两个智能体会互相破坏对方的中间工作,最终产出的结果是两者都非本意的混乱合并。
重试重叠。 这种情况甚至不需要第二个智能体。一个智能体调用工具,从智能体的角度看调用超时了,智能体进行重试——但原始调用仍在服务器上运行。现在,“第一次”和“第二次”调用变成了同一个工具的并发调用者,在同一状态上发生竞态。超时并不能告诉调用者工作是否已完成,因此重试是默认行为,而重复的副作用(side effects)则是默认结果。
幂等性和锁定是工具设计的硬性要求 修复方法并不是编写一个聪明的模型提示词。而是要将每一个触及可变状态的工具视为其本质:一个拥有多个客户端的并发服务器。这意味着有两点是不可逾越的底线。
状态变更工具的幂等性。 每一个下单、发送消息、发起退款或写入记录的工具调用都应该携带一个幂等键(idempotency key)——这是一个由重试时不会改变的因素(如工作流运行 ID、步骤索引和操作类型)生成的稳定标识符。工具将第一次调用的结果存储在该键下,并在任何重复调用时返回相同的存储结果,包括存储的失败信息。Stripe 的幂等键模型是这里的清晰参考:第二次调用是一个空操作(no-op),直接返回第一次调用的答案。有了这个机制,重试重叠引发的竞争就不再重要了,因为第二次调用无法产生第二次副作用。关键在于,代理(Agent)不应该通过其推理来生成这个键——编排层应该确定性地推导出它,这样即使模型在“重试时推理逻辑不同”,仍然会产生相同的键。
针对共享可变状态的锁定或比较并交换(CAS)。 对于“读取-修改-写入”模式,工具必须弥合读取和写入之间的间隙。乐观并发通常是代理工具的默认正确选择:读取带有版本号的记录,并使用 UPDATE ... WHERE id = ? AND version = ? 进行写入。如果版本已变动,则写入影响的行数为零,工具检测到冲突,然后重新读取并重试,而不是静默覆盖。当写入竞争激烈且重试循环会导致系统震荡时,悲观锁(在修改期间租用记录)则更为适用。无论哪种方式,决策都属于工具的设计范畴,而不是寄希望于两个代理永远不会撞在一起。
推论是对不需要共享的状态进行每代理隔离。 许多竞争是由于意外造成的。如果每个代理的草稿空间、草稿或工作集都存在于以代理 ID 或任务 ID 为命名空间的键下,由于没有共享,也就没有竞争。只为那些真正必须共享的事物保留共享可变状态,默认隔离其他一切。最省钱的并发错误修复方式,就是通过从一开始不共享资源,在设计阶段就将其消除。
使用集群将产生的并发压力进行负载测试 你无法通过阅读代码找到这些错误,因为代码看起来没问题——错误存在于操作的交织中,而不是任何单行代码中。你需要通过故意重现这种交织来发现它们。
在一个工具进入多代理系统之前,针对共享资源发起一波并发调用,观察哪里会崩溃。使用 ThreadPoolExecutor 对同一条记录发起 50 个同步调用,使用 pytest-asyncio 启动重叠的协程,或者使用 Locust 之类的负载工具模拟扇出(fan-out)——任何这些手段都能让在串行测试套件中永远不会出现的更新丢失或双重递减问题浮出水面。目标不是吞吐量数字,而是制造出你的评估套件在结构上忽略的精确条件。
使测试并发量与集群实际产生的并发量相匹配。如果你的编排器向 12 个工作线程扇出任务,那么测试时就要使用超过 12 个并发,并计入重试放大效应——负载下的那 12 个代理可能会产生 30 个运行中的调用。还要测试糟糕的时机:在工具的读取和写入之间注入延迟,以确保两个调用者必然发生交织,而不是寄希望于调度器为你完成这一点。一个需要 5 毫秒窗口才能触发的错误,最终会在生产环境中每天触发一千次;你希望它先在 CI 中触发。
并且要测量正确的事项。一个报告“所有 50 个调用都返回 200 OK”的并发测试几乎没有告诉你任何信息——更新丢失同样会返回 200 OK。要对最终状态进行断言:在 50 次并发递增后,计数器的值必须为 50。在 50 个代理分别从拥有 10 个名额的池中认领名额后,必须恰好分配了 10 个,且 40 个被明确拒绝。并发下的正确性是最终状态的属性,而不是响应代码的属性。
观念的转变 单代理原型之所以是一个陷阱,恰恰是因为它能运行。它演示效果好,能通过评审,并能顺利发布。代价会在稍后到来,以客户被重复收费和副作用重复触发的形式呈现,到那时,该工具已被接入打入了几十个代理,修复工作就成了一场迁移,而不再是一个设计选择。
防止这种情况发生的转变很小,但至关重要:不要再把代理工具看作是模型调用的函数,而要把它看作是集群调用的服务。根据定义,函数只有一个调用者。而服务的调用者数量取决于你部署的代理数量及其重试次数,无论你是否为其进行了设计,这些调用者都客观存在。幂等键、乐观锁、每代理状态隔离和并发负载测试并不是你在发布后才添加的加固措施。它们是任何在多代理运行系统中修改状态的工具的基准——而当你发现这个系统有用时,它就是这样的系统。
会员专享
余下内容仅对会员开放。 会员可读完整内容 —— 每一个公开发布的观点背后,那些框架、决策与推理的全貌。
— 完整文章,包含未公开存档的部分 — 可落地的工作框架,附带权衡与决策依据 — 新文章抢先看,先于公开发布 登录以继续阅读→ 随时取消 · 一次订阅,畅读全部