AI 的依赖注入:在不损失测试保真度的情况下模拟模型调用
我调查过的最残酷的 Bug 报告来自一个团队,他们的 CI 在六周内一直显示为绿色(通过)。每一次提示词更改都通过了完整的测试套件。每一次工具调用都有一个模拟(mock)。每一次集成测试都断言了大模型在预发布环境中返回的精确字符串。然而,每一个测试都在撒谎。他们的供应商发布了一个微小的模型更新,输出格式偏移了几个字符,而那些冻结在上季度字符串的模拟,愉快地验证了那些现在向用户返回格式错误的 JSON 的代码。
这就是我想谈论的失效模式。在代码结构层面,AI 应用的依赖注入很容易做对(你的提示词运行器接受一个客户端接口,你在测试中传入一个伪造对象,搞定)。但在“保真度”层面,也就是真正重要的属性上,很难做对:通过的测试能否预测生产环境不会崩溃?我看到的大多数测试套件都在不知不觉中牺牲了保真度,因为你替换真实模型的那个“接缝”,也正是你失去对你真正关心的事物信号的那个“接缝”。
修复方法不是“更仔细地模拟”。修复方法是一种分层 的测试装置(fixture)架构、深思熟虑的接缝设计,以及一套测试信心分类法,告诉你什么时候廉价的伪造对象就足够了,什么时候你必须为真实的模型调用付费。这三者共同构成了一个测试套件,它在每次提交时仍然只需几秒钟即可运行,但不再对生产行为撒谎。
没人谈论的保真度鸿沟
模拟一个 LLM 调用非常容易,但也具有欺骗性的误导。你代码里某处有一个 chat.completions.create,你用一个返回 {"choices": [{"message": {"content": "..."}}]} 的 MagicMock 替换它,测试就通过了。问题在于,你的模拟只是特定模型在特定时间点输出的一个快照。你的代码与模型之间的实际契约比那个快照丰富得多:分词行为、工具调用参数形状、拒绝模式、结构化输出中的空格怪癖、温度(temperature)与提示词的交互方式,以及那些并不显而易见的情况——模型突然决定你的系统提示词意味着一些与你预期不同的东西。
当团队针对模拟的字符串编写断言时,他们并不是在针对模型测试应用程序。他们是在针对他们对模型的记忆测试应用程序。而这种记忆是会腐烂的。供应商发布静默更新。模拟测试继续通过。一份来自跟踪此类行为的团队的报告指出,Bug 通过 LLM 生成的测试进入了生产环境,因为这些测试的断言与当前的实现挂钩,而不是契约——它们关注精确的返回值和模拟的内部逻辑,而不是行为属性。测试套件在重构后显示为绿色,而生产环境中实际运行的路径却是损坏 的。
辨别方法很简单:如果你的 LLM 模拟返回的是一个手写的、在系统中任何真实模型输出中都不匹配的字符串,那么你写的测试只有在你更改测试本身时才会失败。它与现实脱节。
分层测试装置架构
摆脱困境的方法是像数据库测试对待数据装置(fixtures)那样对待 LLM 测试装置:将其视为一种深思熟虑的分级体系,每一层都有不同的成本/保真度权衡。在实践中行之有效的三层架构:
第一层 —— 存根伪造(Stub fakes)。一种类似 FakeListLLM 的对象,按索引返回预设的响应。LangChain 提供了一个,每个严肃的 LLM 框架也都有类似的实现。它们的存在是为了让你测试应用程序的基础结构:控制流、错误处理、重试逻辑、工具调用周边的状态机。它们不是在测试模型,而是在测试模型周围的一切。请大量使用它们。它们的运行时间以毫秒计。多写这类测试。
第二层 —— 录制磁带(Recorded cassettes)。使用 pytest-recording (VCR.py) 或等效工具向供应商录制一次真实的 HTTP 流量,然后在后续的每次测试运行中回放该录音。第一次运行是真实的且缓慢的;之后的每次运行都是确定且快速的。至关重要的是,录音磁带包含了供应商响应的实际形状——请求头、流式增量、工具调用的 JSON 模式、拒绝模式的细微编码。当供应商更改该形状时,你可以重新录制并对比磁带以查看变化。这就是你如何在不为每次测试付费的情况下,将行为锁定在特定模型版本上的方法。像 vcr-langchain 和 baml_vcr 这样的项目专门为此模式而生。
第三层 —— 实时调用(Live calls)。一小组经过精心挑选的测试,它们确实会调用供应商,使用真实的 API 密钥,且不在每次提交时运行。通常是每晚运行。在合并到主分支(main)之前运行更好。这些是你针对模型漂移的“金丝雀”:它们回答了“无论我们的冻结模拟和录制磁带怎么说,模型是否仍然按照我们的预期行事?”这一问题。你希望这一层规模小且成本高,而不是规模大且被平均化,因为它的任务是捕捉模型本身的回归。
错误的做法是将这些层级混为一谈。用真实调用运行所有测试既昂贵、缓慢且具有不确定性。用存根伪造运行所有测试虽然廉价但会撒谎。这种架构之所以有效,是因为每一层都在测试不同的东西。
接缝设计:让提示词和工具可注入,而不污染产品代码
分层架构的前提是你实际上可以在测试时替换模型,这是你的生产代码必须支持的一种设计决策。这就是许多 LLM 代码库陷入糟糕均衡的地方:产品代码中长出了 if TESTING: 分支,或者“模型客户端”变成了一个带有 use_mock 标志的上帝对象,或者每个调用点都直接导入 SDK,导致无处进行拦截。
- https://medium.com/@vuongngo/effective-practices-for-mocking-llm-responses-during-the-software-development-lifecycle-73f726c3f994
- https://anaynayak.medium.com/eliminating-flaky-tests-using-vcr-tests-for-llms-a3feabf90bc5
- https://pypi.org/project/pytest-recording/
- https://github.com/amosjyng/vcr-langchain
- https://lincolnloop.com/blog/avoiding-mocks-testing-llm-applications-with-langchain-in-django/
- https://engineering.block.xyz/blog/testing-pyramid-for-ai-agents
- https://atlan.com/know/how-to-test-ai-agent-harness/
- https://python.langchain.com/api_reference/core/language_models/langchain_core.language_models.fake.FakeListLLM.html
- https://langwatch.ai/scenario/testing-guides/mocks/
- https://arize.com/resource/golden-dataset/
- https://www.confident-ai.com/docs/llm-evaluation/core-concepts/test-cases-goldens-datasets
- https://docs.bswen.com/blog/2026-03-21-llm-model-drift-production/
- https://dev.to/jamesdev4123/when-generated-tests-pass-but-dont-protect-llms-creating-superficial-unit-tests-24c0
- https://dev.to/sizan_mahmud0_e7c3fd0cb68/hexagonal-architecture-a-complete-guide-to-building-flexible-and-testable-applications-k1l
