跳到主要内容

集成测试的幻象:为什么模拟工具输出会隐藏智能体的真实失败模式

· 阅读需 13 分钟
Tian Pan
Software Engineer

你的智能体通过了每一项测试。CI 流水线显示绿色。你发布了它。

一周后,一位用户报告说他们的批量导出任务悄无声息地只返回了 200 条记录,而不是 14,000 条。智能体访问了分页 API 的第一页,得到了一个干净的响应,以为没有更多内容了,然后就继续下一步了。你的模拟(Mock)一次性返回了全部 200 个条目。而真实的 API 从未告诉智能体还有另外 70 页。

这不是模型的失败。模型的推理是正确的。这是测试基础设施的失败 —— 这种现象在团队构建和测试智能体系统(agentic systems)时非常普遍。

核心问题:模拟冻结了“快乐路径”

在传统的软件测试中,模拟是一种精密工具。你通过模拟依赖项来隔离行为、控制输入并使测试具有确定性。这之所以有效,是因为传统代码是一个函数:给定相同的输入,它会产生相同的输出。你的代码与其依赖项之间的契约是明确且稳定的。

智能体在各个层面上都打破了这一假设。它们是自主的、多步的、非确定性的系统,其中与外部工具的“契约”往往是隐式的、未充分说明的且在不断演变。当你为智能体测试模拟工具输出时,你并不是在隔离行为 —— 你是在为一个不存在的世界进行优化。

模拟的工具响应是完美的。真实的工具响应则不然。这两者现实之间的差距,正是生产环境故障发生的地方。

一个针对模拟进行测试的团队总是会看到:

  • 一次性返回完整的、已分页的结果
  • 干净的、毫无歧义的 200 响应
  • 一致的 Schema,没有缺失或重命名的字段
  • 没有速率限制(Rate limits),没有配额耗尽,没有背压(Backpressure)
  • 完全成功或完全失败 —— 绝不会是部分成功

一个运行在真实工具上的团队最终会看到以上所有问题。唯一的问题是,他们是在 CI 中看到这些,还是在用户的事故报告中看到。

模拟实际上隐藏了什么

分页循环

模拟通常在单个响应中返回所有数据。真实的 API 会分页 —— 并且分页令牌(tokens)可能会过期,页面大小可能与请求的不符,而且在某些响应代码中可能会缺失 has_more 字段。

一个在测试期间从未遇到过分页的智能体,没有针对分页的学习行为。在生产环境中,它处理完第一页并断定数据集只有实际大小的 1/70。没有错误,没有异常 —— 只有悄无声息的错误输出。这种失败在没有外部验证的情况下几乎是不可见的,因为智能体返回的是成功响应。

序列中途的频率限制

模拟响应迅速且从不拒绝。真实的 API 会执行配额限制,而且这些配额通常按每分钟或每小时的窗口重置。在大规模运行时,在负载测试下看起来非常安全的智能体工作流,可能会在工作流的中途步骤突然消耗掉 95% 的可用 API 配额,并进入重试循环,在完成之前就耗尽了 Token 预算。

这种失败更阴险的版本是:API 返回了 HTTP 200,但响应体中埋藏了速率限制消息。智能体读取到 200,假设成功,然后带着错误的数据继续执行。

部分成功的响应

许多真实的 API 采用批量操作并返回混合结果:“成功创建了 10 条记录中的 7 条。详情请见错误字段。”你的模拟返回完全成功。你的智能体从未在某些项目成功而其他项目失败的响应上进行过测试。

在生产环境中,接收到部分成功的智能体必须做出决定:重试失败的项目?向用户报告部分完成?重新获取完整状态以进行对账?如果这些路径都没有经过测试,智能体就会选择默认路径 —— 通常是“假设一切都成功了”。

Schema 漂移与意外的空值

一个来自生产环境智能体工作流的真实案例:一个工具集成开始生成无效的 JSON Schema,其中 type 被设置为 None 而不是 "object"。下游 API 直接拒绝了函数调用。模拟版本一直返回格式良好的 Schema,因此没有任何测试运行过该代码路径。修复非常简单 —— 但发现过程发生在一个面向用户的事故中,而不是在 CI 中。

真实的 API 是不断演进的。字段会被添加、重命名或废弃。模拟会变得陈旧且无人察觉。一个基于六个月前的模拟构建的智能体,可能正在针对一个已不再符合现实的 Schema 进行安静的测试。

级联概率

即使每个工具调用有 98% 的成功率,一个 10 步的智能体工作流端到端的成功概率也只有大约 82%。在多智能体系统中,这种情况会更加复杂 —— 智能体 A 的输出成为智能体 B 的输入 —— 微小的个体失败率会造成巨大的系统脆弱性。模拟测试是隔离地测量每个工具调用。它们永远看不到这种概率级联。

为什么智能体系统打破了测试假设

传统测试假设确定性。给定相同的输入,得到相同的输出。这使得模拟变得有用:它们冻结了来自依赖项的输入,这样你就可以孤立地推理代码行为。

智能体从根本上是不同的。相同的用户意图可能会根据模型采样、对话历史和中间推理产生不同的工具调用。不存在一个可以围绕其进行模拟的稳定“函数” —— 存在的是一个自主的决策者,其行为是从与现实世界状态的交互中涌现出来的。

这产生了一种特定的失败模式:测试耦合于实现细节而非结果。一个检查“智能体是否使用这些确切参数调用了 search_tool”的模拟测试,当智能体推理出一条不同但正确的路径时会失败,而当智能体使用稍微错误的逻辑调用了正确的工具时反而会通过。这两种结果都不是你想要的。

正确的抽象应该从“智能体是否进行了这些调用”转向“当工具表现得像在生产环境中那样时,智能体是否产生了正确的结果”。

录制与回放架构

针对 Agent 测试的正确心智模型是“磁带”。在 VCR 测试模式中,你会在专门的录制会话期间记录与外部系统的真实交互,将这些交互(请求 + 响应对)存储在磁带文件中,并在 CI 中确定性地进行回放。

录制是关键操作。它捕获了:

  • 真实的响应 Schema,包括一些奇葩行为、空值和非预期字段
  • 带有真实 Token 的实际分页结构
  • 真实的错误响应格式和歧义
  • 真实的延迟概貌和超时行为(用于模拟)

当 CI 回放磁带时,外部调用会被屏蔽。如果 Agent 的行为发生偏离,发出了与记录条目不匹配的请求,测试就会立即报错——从而捕捉到行为回归,而不仅仅是 Schema 不匹配。

有几种工具为 Agent 系统实现了这种模式。vcr-langchain 将该模式应用于 LangChain 工作流。WireMock 允许你运行有状态的场景模拟并注入故障响应。这种模式适用于多个粒度:单个工具调用、整个 Agent 会话或跨 Agent 序列。

所需的纪律:当真实的 API 行为发生变化时,必须刷新磁带。磁带应被视为生产制品,与代码一起进行版本控制,并在底层 API 演进时重新生成。过时的磁带总比没有磁带好,但如果过时的磁带与现实脱节,它本身就会变成虚假安全感的来源。

变异测试:注入真实的故障

记录真实行为是必要的,但还不够。大多数真实交互都是成功的。从健康系统中捕获的磁带会复现正常路径(Happy Path)——但你需要测试当事情出错时会发生什么。

Agent 的变异测试通过获取录制的磁带,并故意用真实的故障变体来破坏响应:

  • 在连续几次成功调用后,在序列中间注入 HTTP 429 错误(而不是在第 1 次调用时,因为 Agent 通常被设定为在此时重试)
  • 返回在第二页就失效或无效的分页 Token
  • 在本应为 200 的响应中添加 errors 字段(部分成功)
  • 在响应序列中间重命名一个字段(如 user_iduserId),以模拟 Schema 的演进
  • 在预期有数据的地方返回一个空列表(没有错误,只是没有数据)

目标不是测试每一种可能的变异,而是测试你的 Agent 所使用的实际 API 中可能出现的特定故障类别。如果你的 Agent 集成了具有已知部分成功模式的计费 API,请编写产生部分成功的变异。如果你使用按分钟限制速率的搜索 API,请在序列中可能耗尽分钟窗口的时间点注入 429 错误。

Meta 在 LLM 引导的变异测试方面的生产经验表明,基于历史故障数据的、有针对性的真实变异,在发现真实 Bug 方面明显优于随机变异。同样的原则也适用于 Agent 的磁带变异:针对你的生产环境实际产生的故障进行埋点设计。

真正有效的三个测试层级

重度依赖 Mock 的测试套件之所以失败,通常是层级问题。团队在某一层进行测试,并假设覆盖了其他层。

操作级验证 (Action-level validation) 检查每个工具调用是否符合其 Schema 合约。这是 Mock 真正发挥作用的地方——你测试的是接口,而不是行为。Agent 是否正确构建了参数?它是否正确处理了响应类型?

任务级验证 (Task-level validation) 检查 Agent 是否能串联工具调用以完成定义的工作流。这是录制与回放磁带的所属位置。磁带提供真实的输入;测试验证结果。你是在测试 Agent 导航真实序列的能力,包括真实工具产生的各种奇葩行为。

用户级验证 (User-level validation) 检查 Agent 完成的工作是否满足原始意图。这通常需要外部状态检查:文件是否真的创建了?数据库记录是否真的更新了?考虑到用户的要求,输出是否有意义?任何程度的 Mock 或磁带录制都无法取代这一层——它需要真实的底层架构或对其进行真实的模拟。

大多数团队只做操作级验证就停止了。磁带录制能带你进入任务级。而用户级验证则是评估框架 (Eval Harnesses)、行为快照测试和生产监控变得必不可少的地方。

接缝注入模式

一种适用于 Agent 系统的实用方法:接缝注入 (Seam Injection)。与其在工具 API 层面进行 Mock,不如在 Agent 接收响应的边界注入响应——即在反序列化之后、输入 Agent 推理的数据管道中。

这样做可以让你:

  • 运行 Agent 实际的工具调用代码(而不是 Mock 包装器)
  • 在不控制传输层的情况下控制响应内容
  • 在多步序列的特定点注入故障,而无需修改 Agent 逻辑
  • 测试渐进的复杂性:从干净的响应开始,然后引入一个故障,接着是复合故障

接缝注入和磁带回放是互补的。磁带捕获现实;接缝则让你能够变异并探索现实的变体。

可观测性增加了什么(以及它不能取代什么)

链路追踪和可观测性平台——跨工具调用的分布式追踪、每一步的延迟、错误分类——对于生产运维至关重要。它们不能替代生产前测试,但它们确实提供了反馈循环,告诉你生产前测试何时遗漏了某些内容。

对于智能体(Agent)工具可靠性而言,至关重要的特定可观测性信号包括:

  • 按端点和错误类型划分的工具调用错误率:区分持久性的 Schema 失败与瞬时的速率限制(Rate Limits)
  • 响应 Schema 的一致性:跟踪跨 API 版本的字段存在/缺失情况,在破坏智能体行为之前检测到偏移(Drift)
  • 部分成功率:返回混合结果的批量操作所占的比例
  • 重试放大因子:每个用户请求触发了多少个智能体层级的重试——失控的重试循环会在反映到账单之前先在这里显现

这些信号闭环了你在生产前测试的内容与你在生产环境中看到的情况。它们还生成了你刷新录制数据(Cassettes)和设计更好变异(Mutations)所需的数据。

你真正需要做的转变

适用于 AI 智能体的测试文化不是“用更好的 Mock 编写更多的单元测试”,而是“围绕真实的执行环境而非理想化的环境来设计测试”。

具体来说:

  • 在编写智能体逻辑之前,而不是之后,记录来自真实系统的工具交互
  • 将录制数据(Cassettes)视为一流的生产制品,当 API 发生变化时它们会失效
  • 根据观察到的生产行为,为每个工具集成构建一个真实失败变异库
  • 在所有三个层级——动作、任务、用户——进行测试,而不仅仅是最容易的那个
  • 使用可观测性来检测生产环境何时与你的测试环境发生偏离,并利用该信号来更新你的录制数据

通过 Mock 测试套件的智能体与你的用户所体验的智能体是不同的。你越早缩小这一差距——通过针对真实的工具行为而非理想化的近似值进行测试——生产环境的故障面就会越小。

你的 CI 没有骗你。它只是在测试那个并不存在的工具版本。

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