跳到主要内容

如何在 CI 中对 AI Agent 工作流进行集成测试,而无需完全 Mock 模型

· 阅读需 13 分钟
Tian Pan
Software Engineer

大多数构建 AI Agent 的团队在经历第一次生产事故后,都会发现同一个测试陷阱。你有两个明显的选择:在 CI 中进行实时的 API 调用(缓慢、昂贵、且具有非确定性),或者将 LLM 完全 Mock 掉(快速、廉价、但内容空洞)。这两种方法都会以不同但可预见的方式失败,而第二种方法的失败模式更糟糕,因为它是隐形的。

Mock 掉 LLM 的团队可能会跑六个月的全绿 CI,发布到生产环境后,才发现代码库中一直潜伏着一个 bug:在 8 步循环的第 6 步,Agent 处理畸形工具响应的方式有问题。那个总是返回 "Agent response here" 的 Mock 根本没有触及编排层。实际的工具分发、重试逻辑、状态累积和兜底路由代码从未被测试过。

好消息是还有第三条路。它与其说是一种单一的技术,不如说是一个由三层测试组成的架构,每一层都旨在捕获不同类别的失败,且无需承担其他方法的成本。

双面测试陷阱

在深入解决方案之前,值得精确分析一下每种幼稚方法所缺失的内容。

实时 API 测试在每个 PR 上都会对 OpenAI 或 Anthropic 发起实际调用。成本积累得很快:一个包含 20 个场景、使用 LLM 作为裁判(LLM-as-judge)进行评分的评估套件,每次运行需要花费 0.50 美元到 3.00 美元。考虑到开发者数量、每天的 PR 数量,以及 PR 经常多次推送的事实,对于一个规模不大的团队来说,在还没实施每晚例行的全面运行之前,每月就要支出 200 到 1,000 美元。延迟是另一个致命伤:一次 15 秒的 LLM 调用会让你 CI 的反馈循环变得无法使用。而且至关重要的是,由于各提供商区域的硬件级浮点运算差异,即使在 temperature=0 的情况下,LLM 的输出也会有所波动,因此今天通过的测试在没有任何代码更改的情况下,明天可能会失败。

Mock 掉 LLM 解决了延迟和成本问题,但却掏空了你实际测试的内容。这种危险是微妙的:你的测试套件达到了 90% 的覆盖率指标,却让整个编排层处于未测试状态。考虑一下,一个返回硬编码字符串的桩程序(Stub)无法测试哪些内容:

  • 当工具返回空结果时会发生什么?
  • Agent 是否正确地将上下文从工具 A 的输出传播到工具 B 的输入?
  • 重试逻辑是在 429 错误时启动,还是默默地吞掉了错误?
  • 在 10 步循环的第 7 步,当累积状态超过上下文窗口时会发生什么?

这些正是困扰生产系统的失败模式。它们都不涉及模型输出质量——它们是纯粹的编排 bug。Mock 掉模型意味着你的测试套件根本无法发现它们。

第一层:使用 Fake LLM 的结构化测试(每次提交,零成本)

第一层使用 LLM 测试替身(Test Doubles)——即 LLM 提供商接口的桩实现,它根据传入的提示词内容进行确定性响应。

StubLLM 实现了与你真实 LLM 客户端相同的接口,但它不是发起网络调用,而是解析提示词中的测试触发器并返回硬编码的工具调用响应:

class StubLLM:
def generate(self, prompt: str) -> Response:
if "trigger_timeout" in prompt:
raise TimeoutError("Request timeout")
if "trigger_rate_limit" in prompt:
raise RateLimitError("429: Too many requests")
# 默认:请求天气工具
return Response(tool_call="get_weather", args={"city": "NYC"})

关键的洞察在于,Stub 不会伪造最终的文本响应。它触发一个特定的工具调用,这会强制你真实的中间件去执行。你的测试架(Harness)分发工具,处理结果,更新状态,并带着下一轮上下文再次调用 LLM。Stub 的第二次调用触发下一步。现在,你在没有进行任何实际 API 调用的情况下,测试了整个编排循环。

这种模式对于测试基础设施关注点特别有价值:你的测试架是否通过 RBAC 正确拦截了 delete_user_account 调用?它是否将已认证的用户身份传播到了工具执行中?当工具在第 4 步超时时,错误恢复路径是否执行?这些是成千上万个确定性测试案例,成本为零,且在毫秒内运行。

局限性显而易见:Stub LLM 无法测试提示词注入防御能力、模型输出质量,或者你的系统提示词是否真的能诱导出你想要的行为。它们测试的是模型周围的“轨道”,而不是模型本身。

第二层:通过磁带录制进行确定性重放(每个 PR,近乎零成本)

第二层填补了结构化测试与实时评估之间的空白。VCR 风格的磁带录制(Cassette recording)在传输层拦截 HTTP 调用,将完整的请求/响应对序列化到文件中,将该文件提交到版本控制,并在后续运行中重放。

pytest-recording 通过一个简单的装饰器封装了这一功能:

@pytest.mark.vcr()
def test_multi_step_research_agent():
result = run_research_agent("What caused the 2024 semiconductor shortage?")
assert result.steps_taken <= 8
assert "supply chain" in result.summary.lower()

第一次运行时使用 --record-mode=once:测试进行实际 API 调用并写入磁带文件。之后的所有运行——包括 CI 中的每个 PR——都从磁盘重放。没有 API 调用,结果确定,CI 运行时间与普通单元测试相同。

除了节省成本之外,更深层次的价值在于:如果 HTTP 请求的 Payload 以任何方式发生变化——提示词措辞变动、模型参数更新、输入序列化改变——磁带匹配就会失败,测试就会中断。这能捕获到在传统单元测试中根本无法发现的意外提示词修改。

多轮对话问题。普通的 VCR.py 适用于单次调用测试。多轮 Agent 对话则更难:第 N 轮的请求体包含了模型在第 N-1 轮的响应。如果 Agent 在第 2 步分支走向不同,后续的每个磁带匹配都会失败。解决方法包括:

  • BAML VCR 运行在 BAML 运行时层而非 HTTP 层,保留了跨轮次的核心类型信息,并逐块处理流式响应。
  • vcr-langchain 对 VCR.py 进行了补丁,以捕获非网络的 LangChain 工具调用,尽管在装饰器范围之外初始化的工具不会应用录制。
  • 对于没有专门 VCR 支持的框架,务实的方法是在轮次(Turn)级别而不是会话(Session)级别进行录制——每个独立的 LLM 调用都有自己的磁带,然后由测试将其拼接起来。

一个陷阱:磁带会记录所有内容,包括 Authorization 标头。在将磁带提交到任何公共或共享存储库之前,请配置 filter_headers: ["authorization"]

磁带无法解决的问题。当你升级模型版本时,根据定义,所有磁带都会失效。磁带也无法防御流式 Schema 的更改或新的模型行为。这是预料之中的——磁带是你代码行为的回归测试,而不是模型行为的测试。

第 3 层:工具契约测试(针对每个 PR,进行 Schema 漂移检测)

工具漂移(Tool drift)是生产级智能体(Agent)系统中最为隐蔽的失败模式。外部 API 可能会静默地添加一个必填字段。枚举(Enum)增加了一个新值。浏览器工具在不同的字符计数处开始截断结果。单个组件看起来没问题。智能体也完成了任务。但在内部,编排逻辑(orchestration)正在以某种掩盖不匹配的方式进行静默补偿——直到它再也无法维持下去。

预防模式在工具的输入和输出上均使用 Pydantic schema,然后测试完整的往返过程。你不仅要测试工具函数是否执行,还要测试模型是否选择了正确的工具、是否提取了类型正确的参数、是否收到了有效的响应,以及该响应是否正确地传递给了依赖它的下一个工具。

值得在每个 PR 上运行的九项契约测试包括:

  • Schema 锁定测试:针对实时 schema 重放已知的工具调用;如果必填字段发生偏移或枚举发生变化,则测试失败。
  • 相似工具路由测试:设计对抗性提示(Adversarial prompts),旨在混淆语义相似的工具(测试 “给团队发消息” 不会路由到 “给个人发消息”)。
  • 错误语义测试:每个错误类别(无效参数、权限拒绝、超时、部分结果)都应该触发特定的预定下一步操作。
  • 跨工具交付测试:工具 A 的输出应以正确的类型传递给工具 B。大多数多步骤回归问题都隐藏在这个边界处。
  • 工具清单漂移测试:快照完整的工具列表(名称、描述、作用域),并在每次发布时进行差异对比(diff)。
  • 分页测试:在小规模结果上表现良好的智能体在处理真实数据集时会崩溃;需要显式地测试页面边界。

这些测试成本很低,因为它们使用记录好的响应和 schema 验证——无需调用实时的 LLM。它们能捕捉到实时 API 测试经常遗漏的特定失败类别(智能体与工具接口之间的 schema 漂移),因为模型会以隐藏底层不匹配的方式来适应意外情况。

三层 CI 流水线

整合这些层级:

层级触发条件运行内容成本
结构化每次提交StubLLM 测试、单元测试、Schema 契约检查~$0
重放每次 PRVCR 录制文件(cassette)测试、工具漂移契约测试~$0
实时合并至 main / 每晚完整的 LLM 评估、LLM 作为评委(LLM-as-judge)打分、多轮智能体运行0.500.50–50

实现此方案的 GitHub Actions 结构如下:

name: agent-ci
on:
pull_request:
paths: ["agent/**", "prompts/**", "tools/**"]
push:
branches: [main]

jobs:
fast-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest tests/unit/ tests/replay/ -x --vcr-record=none

live-evals:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
steps:
- run: pytest tests/integration/ --eval-threshold=0.85

重放作业中的 --vcr-record=none 标志非常重要:它会导致测试在任何录制文件缺失时报错,而不是去调用实时 API。这可以防止在 PR 过程中意外产生 API 费用,并确保缺少录制文件的测试会直接报错,而不是通过实时数据静默通过。

这种架构能防止什么

第 N 步的编排 Bug。在一个 8 步循环的第 6 步中,智能体处理空工具响应的方式如果存在 Bug,则需要前 7 步全部成功才能暴露。带有完整历史追踪的重放录制文件是在 CI 中复现此类问题且无需搭建完整环境的唯一可行方法。回归追踪测试模式——在任何代码更改后重放高价值的历史追踪——正是为此设计的。

静默的模型版本回归。据估计,40% 的生产环境智能体故障是由模型更新引起的。应对模式是:固定到特定的模型版本,在升级模型之前针对新模型运行 Schema 锁定测试,并将你的录制文件视为回归基准。任何在模型升级时失效的录制文件都是已确认的回归,需要进行分选处理(triage)。

工具 Schema 漂移。外部 API 更改导致必填字段偏移的情况会被边界处的 Schema 锁定测试捕获,从而避免演变成看起来像是 LLM 质量退化的模型行为变化。

权限策略绕过。StubLLM 模式可以确定性地请求 delete_user_account 工具调用——而在实时调用测试中,你需要依靠真实模型自发生成该调用才能进行测试。这使得针对智能体控制框架的安全测试变得可行,而实时测试则难以做到这一点。

随时间变化的成本概况

一个拥有三名开发者的团队,如果在每次提交时运行第 1 层和第 2 层测试,仅在合并到 main 分支时运行第 3 层测试,通常每月会运行 200–300 个快速 CI 作业和 15–20 个实时评估运行。按照目前的 API 价格,实时评估运行的成本为每月 30–80 美元。快速作业则是免费的。

更重要的指标是测试套件捕捉到了什么。实施这种三层结构的团队一致报告称,在 main 分支上发现的第一波实时评估回归问题其实早已被录制文件测试覆盖——录制文件在引入回归的同一个 PR 中就失效了。第 2 层测试在进行真正的回归预防工作,而不仅仅是为了省钱。

组织层面的转变在于将录制文件视为一等公民的测试工件:在 PR 中进行评审,在提示词更改时刻意更新,并与生成它们的代码一起提交。这是一个微小的流程变化,但对于你在用户发现之前可靠地捕捉智能体回归问题的能力,具有不成比例的巨大影响。

这种方法的局限性

这种架构并没有解决大规模模型输出质量的评估问题。VCR cassettes 测试的是你的编排代码是否执行了与之前相同的操作 —— 它们无法告诉你之前执行的操作是否足够好。在 Tier 3 中使用 LLM-as-judge 进行评分对于衡量实际输出质量仍然是必要的,而且在大规模情况下,这依然既昂贵又存在噪声。

另一个限制是对新失效模式的覆盖。基于 cassette 的回放仅涵盖了在录制期间运行过的路径。从未在特定输入组合上进行过测试的智能体(agent)没有对应的 cassette。这就是为什么针对多样化输入分布的 Tier 3 实时评估仍然至关重要的原因 —— 它们能发现新的失效模式,而一旦发现,Tier 2 回归测试就会将其固定下来。

正确的心智模型:Tier 1 和 Tier 2 防范你已经知道的回归。Tier 3 发现你尚未知晓的回归。没有 Tier 3 的团队会交付尚未发现的回归。没有 Tier 1 和 Tier 2 的团队则会不断地重新发现相同的回归。

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