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,导致无处进行拦截。
借鉴自六边形架构的原则在这里直接适用:领域代码不应该知道某个特定供应商的存在。它应该只知道某个对象满足一个契约——generate(prompt, tools) -> Response——并且该对象是被传入的。这是一个经典的端口/适配器分离。领域层的提示词编排逻辑从不导入 openai 或 anthropic。适配器层负责在端口契约和供应商 SDK 之间进行转换。测试时传入不同的适配器;生产环境则传入真实的适配器。
有两点具体的改进特别适用于 LLM:
第一点是 提示词注入(这里是指依赖注入意义上的,而非安全意义上的)。提示词是你系统的输入,而不是其中的硬编码常量。如果提示词模板作为字符串字面量存在于你的编排函数中,你的测试就无法改变它,你的评估也无法对其进行版本控制。将提示词放在加载器接口(loader interface)之后。加载器在生产环境中可以是基于文件的,在测试中是基于字典的,或者在你开始进行提示词 A/B 测试时是基于数据库的。编排函数从不关心这些。
第二点是 工具注入。Agent 工具应该作为满足工具接口契约的对象列表传入,而不是从注册中心导入。在测试中,你传入记录 Agent 尝试调用内容的桩工具(stub tools)。在生产环境中,你传入真实的工具。这是你能编写“断言 Agent 以 status='pending' 调用了 search_orders 工具”这类测试的唯一方法,而无需模拟整个工具实现并祈祷模拟与现实一致。它还让你能干净地模拟工具故障——编写一个在收到特定输入时抛出特定异常的桩非常简单,而要让真实工具按指令失败则非常痛苦。
测试信心分类法:每一层能告诉你的和不能告诉你的
测试固件的层级是不可互换的。它们以不同的信心水平回答不同的问题。明确这一点可以防止“测试通过但生产环境崩溃”的失败模式,因为你不再指望第一层(Tier 1)能解决第三层(Tier 3)关心的问题。
一个大致的分类:
-
第一层(桩/伪造对象):对控制流正确性(你的重试逻辑、错误处理、对格式良好的输出的解析)有很高的信心。对模型行为的信心为零。对真实模型产生与你的桩相匹配的输出的信心为零。
-
第二层(录制的录像带):对针对特定模型版本的协议正确性(请求头、Schema、流式行为、工具调用格式)有很高的信心。对行为的信心为中等——录像虽然准确但已冻结,且模型自录制以来可能已经发生了漂移。对当前实时模型行为的信心为零。
-
第三层(实时调用):对测试运行时的行为有很高的信心。但速度慢、成本高、不稳定,且依赖于精心挑选的输入,你需要能够对这些输入的正确输出进行断言,而又不依赖于脆弱的精确匹配。
知道该选择哪一层取决于测试的目的。一个宣称“当用户发送垃圾信息时,我们的处理程序返回回退响应”的测试是第一层测试——模型无关紧要。一个宣称“当我们请求 JSON 对象时,解析器可以读取模型返回的内容”的测试是第二层——你需要真实形状的输出,但不需要它是实时的。一个宣称“模型仍然拒绝用法语回答医疗问题”的测试是第三层——任何录制或桩都无法替代针对当前模型的实时调用,因为这正是你要测试的行为。
应对漂移的安全网:评估(Evals)与追踪(Traces)的独立角色
分层固件套件是必要但不充分的。它将行为固定在编写时已知的预期之上。它无法告诉你模型本身何时在你脚下发生了变化。这项工作属于测试套件本身之外的两样东西。
评估(Evals) 是按计划运行(而非按提交运行)的黄金数据集回归测试。它们选取一组带有已知良好输出(或已知良好属性)的精选输入,并针对当前生产环境中的任何模型版本运行。当分数下降时,你就知道模型发生了漂移,即使你的所有单元测试都是绿色的。保持数据集足够小以便手动审核——50 到 200 个示例就足够了——但要让每个示例都是你真正关心的案例。包含一千个自动生成的示例的黄金数据集是一个泥潭;而包含八十个由人刻意挑选的真实用户提示词的黄金数据集则是一把手术刀。
生产追踪(Traces) 弥补了剩余的差距。你的测试套件和评估工具覆盖了你预见到的行为。追踪覆盖了你未预见到的行为。将每个生产追踪链接到产生它的确切提示词版本、模型配置和检索上下文。当你部署六周后发现回归时,第一个问题总是“发生了什么变化”,如果没有这些溯源信息,你将无法回答。这不是测试保真度(test-fidelity)的问题,而是测试保真度的补充。
经验法则
如果你只能从中领悟到一条准则:Mock 应该测试你的代码对模型的响应,而不是模型本身。任何硬编码“模型返回此确切字符串”的测试,都会在字符串发生变化的那一刻无声无息地失效。而任何编码“模型返回某些字符串,且我们的解析器能正确处理它”的测试,其正确性在供应商更新后依然有效。当你发现自己在手写 Mock 响应时,问问自己:一个真实的响应——记录一次,永久回放——是否能更忠实地告诉你同样的事情。几乎在所有情况下,答案都是肯定的。而且,从手写转向录制所付出的努力,要远小于维护一个不断偏离事实的 Mock 的持续成本。你想要的测试套件应该是六个月后仍然有意义的,而不是今天运行最快的。
- 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
