跳到主要内容

原本运行良好的工具,直到两个智能体同时调用它

· 阅读需 11 分钟
Tian Pan
Software Engineer

一个工具通过了测试。你从一个智能体(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)的两个智能体会互相破坏对方的中间工作,最终产出的结果是两者都非本意的混乱合并。

加载中…
References:Let's stay in touch and Follow me for more thoughts and updates