跳到主要内容

如何在 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 漂移检测)

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