news 2026/10/14 14:53:12

大模型应用写不出单测?用 Mock 把 LLM 调用做成可回归的工程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
大模型应用写不出单测?用 Mock 把 LLM 调用做成可回归的工程

授权与合规声明
本文为原创技术分享,所有示例代码均运行于作者自建的隔离环境,仅供学习交流。文中涉及的第三方模型与服务以各自官方文档为准,请在服务条款范围内使用。转载请保留出处。

一、为什么 LLM 应用难测

1.1 依赖不可控:模型是外部黑盒

绝大多数大模型应用的代码长这样:service.call_model(prompt)直接嵌在业务逻辑里。模型部署在远端、由厂商控制,它的可用性、延迟、价目表都不在你的版本库里。一旦它在凌晨抖动,你的业务测试就会跟着红,可你改的只是业务代码,根因却在外部。

把模型当成"有状态的外部服务依赖"来看,问题立刻清晰:传统工程遇到这种依赖,第一反应是用桩件把依赖替掉。LLM 应用只是把这个依赖换成了"会说话的接口",解法并没有本质区别。

一个常见误区是"只 mock 掉 HTTP 层就够了"。即便你把请求库替成返回固定报文的桩,业务里对模型文本的解析、对不确定措辞的分支判断,仍然依赖那份你控制不了的输出。真正要替掉的,是"模型调用"这个能力本身,而不只是它脚下的网络。

1.2 输出非确定:同样的输入未必同样输出

即便固定 temperature,很多模型的输出也带随机性,更别说开启了检索增强、工具调用之后的链路。这导致一个尴尬现实:你昨天的测试通过,今天同样的代码同样的 prompt 却可能失败——不是你写错了,是输出变了。

可测的关键不是"让模型变确定",而是"让被测试的逻辑不依赖模型的那份不确定"。业务逻辑里的分支判断、字段抽取、重试策略、降级兜底,都应该是确定性的,应当被确定性地测。

还要认清一点:非确定性不只来自采样。检索增强会换回不同的上下文,工具调用会走不同的分支,模型版本升级也会悄悄改变措辞。把这些变量都关进"被替掉的依赖"里,业务侧才谈得上稳定。

1.3 要真实 key:测试被迫联网、被迫花钱

如果单测必须带真实 api_key 才能跑,那么本地无法跑、CI 上不能跑、离线环境更跑不了。测试从"开发者的反馈工具"退化成了"上线前的抽奖"。

截至 2026-10-13,主流工程实践已经不再把真实模型调用放进单测,而是仅在集成层做少量真实校验。真正的单元测试,应当零外部依赖、零密钥、零网络。

更要命的是成本与限流:真实调用要计费,CI 高频触发会迅速吃掉额度;厂商的速率限制还会让本应并行的用例排队,进一步拖垮反馈速度。把真实调用挪出主链路,这些隐患一并消失。

二、抽接口隔离模型

2.1 依赖注入:把模型当成一个服务依赖

核心思想就一句话:业务代码不要自己 new 一个模型客户端,而是接收一个已经造好的 client。这个 client 可以是真模型,也可以是假的。由外层(启动处、测试处)决定给它什么实现——这就是依赖注入。

这样做之后,业务函数变成纯粹的"输入处理 + 调用接口 + 拼装结果",与模型实现彻底解耦。

注入可以很轻:小项目直接在函数参数里传 client,测试时传 Fake;大项目用构造函数或框架把 client 织入。关键是"谁调用,谁决定实现",业务代码本身不持有任何创建逻辑。这样同一份业务函数,本地用 Fake、生产用真模型、集成用真模型加真实密钥,互不干扰。

2.2 定义 LLMClient 抽象接口

先用一个最小接口把"调模型"这件事收敛起来:

fromabcimportABC,abstractmethodfromdataclassesimportdataclass@dataclassclassChatMessage:role:strcontent:strclassLLMClient(ABC):@abstractmethoddefcomplete(self,messages:list[ChatMessage])->str:"""给定对话历史,返回模型文本。"""raiseNotImplementedError

业务侧只 importLLMClient,永远不直接 import 具体厂商的 SDK。这一步是后面所有可测性的地基。

接口不只是"一个方法"。它同时固定了输入与输出的形状:入参是结构化的对话历史,出参是字符串。后续若要支持流式或结构化输出,也是在接口上加方法,而不是在业务里到处改调用姿势。接口稳定,业务就稳定。

2.3 业务代码只依赖接口,不依赖具体厂商

一个抽取用户意图的函数可以这样写:

defextract_intent(client:LLMClient,user_text:str)->str:messages=[ChatMessage(role="system",content="只输出 intent 或 unknown。"),ChatMessage(role="user",content=user_text),]reply=client.complete(messages)reply=reply.strip().lower()returnreplyifreplyin("intent","unknown")else"unknown"

注意:这里没有任何api_key、没有任何 HTTP 调用、没有任何厂商名字。它要的只是一个"能回答的 client"。于是测试可以给它一个假的 client,立刻跑起来。

抽接口还顺带解决了一个运维痛点:切换或对比不同厂商时,只需新增一个实现了LLMClient的类,业务代码一行不动。模型供货方从"写死在逻辑里"变成"启动时装配的零件"。

三、Fake 实现与 fixtures 怎么写

3.1 Fake 与 Mock 的区别与选择

简单区分:Fake 是"能用的简化实现",Mock 是"按预期录制的替身"。

  • 想验证"业务拿到了什么、返回了什么"——用 Mock,断言调用参数。
  • 想让整条链路跑起来、又能离线跑——用 Fake,它直接返回固定或规则化的内容。

对于 LLM 应用,Fake 更实用:它能让一整个流程(含分支、重试、降级)都离线跑通。

介于两者之间还有一种"录制回放"思路:先把真实调用录成样本存盘,之后用例直接回放这些样本。它比手写 Fake 省事,又比每次打真实接口便宜,适合从零起步时快速攒出一批回归基线。

3.2 写一个可重放的 FakeLLMClient

classFakeLLMClient(LLMClient):def__init__(self,scripted:dict[str,str]):# key 为用户输入特征,value 为预设回复self.scripted=scripted self.calls:list[list[ChatMessage]]=[]defcomplete(self,messages:list[ChatMessage])->str:self.calls.append(messages)user_text=messages[-1].contentforneedle,replyinself.scripted.items():ifneedleinuser_text:returnreplyreturn"unknown"

这个 Fake 既能返回预设结果,又记录下每次调用,方便断言"模型到底被调了几次、传了什么"。

Fake 不只是"返回正确文本"。想验证重试与熔断逻辑,就让它抛异常;想验证超时兜底,就让它睡眠后返回;想验证降级分支,就让它返回不符合预期的脏数据。把这些故障模式做成可切换的开关,你就能在离线环境里把生产才可能遇到的脆弱点一遍遍压测。

3.3 fixtures:固定输入到预期输出的用例对

fixtures 就是"输入→预期输出"的成对样本,存成文件,单测直接读:

-input:帮我查一下订单expect_intent:intent-input:今天天气不错expect_intent:unknown-input:我要退货expect_intent:intent

测试时把每一行变成一个用例:用 Fake 返回expect_intent对应的文本,再断言extract_intent的输出。新增回归用例,只改 yaml,不动代码。

fixtures 必须像代码一样被评审、被版本化。一条写错了期望的 fixture,比没有 fixture 更危险——它会让本该红的代码绿着上线。每次业务改接口语义,同步补一条 fixture;每次模型侧调整,先确认是不是 fixture 本身过期,再怀疑业务代码。

四、不依赖真实 key 的 CI 怎么跑

4.1 测试默认走 Fake,集成才走真实

项目里建议明确两条路径:

  • pytest tests/unit默认用 Fake,无网络、无密钥,人人本地可跑。
  • pytest tests/integration --run-real仅显式加 flag 才跑真实模型,且只在合并前的人工检查里执行。

这样单测永远不会因为"厂商抽风"而红,CI 的稳定性回归到你自己的代码质量。

日常开发里,提交前跑一遍pytest不应有任何环境变量依赖:只要命令能离线全绿,说明可测性真正成立。若某条用例"在我机器上要联网才过",那就是一条还没被隔离干净的依赖,应当回炉重做。

4.2 CI 配置要点

name:unit-testson:[push,pull_request]jobs:test:runs-on:ubuntu-lateststeps:-uses:actions/checkout@v4-uses:actions/setup-python@v5with:python-version:"3.11"-run:pip install-r requirements.txt-run:pytest tests/unit-q

这段配置默认装依赖、跑单元目录,全程不读密钥。真实调用那一档被--run-real隔离在外。

密钥管理也有讲究:真实 key 只存在于 CI 的保密变量里,绝不进仓库;集成任务从保密存储拉取,跑完即弃。即便仓库被公开,也不会泄露凭证。集成层低频、少量,本来就不该成为常态负担。

⚠️ 代码待验证

pytest tests/unit-q

4.3 让 CI 在分钟级完成

判据之一是"CI 分钟级完成"。Fake 路径没有网络往返,单测通常是毫秒级,规模较大的用例集合也能在一两分钟内收尾。把真实调用从主链路移走,CI 不再被外部延迟拖垮,反馈速度回到工程该有的节奏。

要做到分钟级,还有一个隐藏前提:eval 层别贪大。固定一小批最有代表性的样本就够守住底线,不必每次把全量语料都跑一遍。CI 的快慢,最终取决于你把多少"重活"挡在了主链路之外。

五、测试金字塔:单测 / 集成 / eval 三层分工

5.1 单元测试层:快、廉、可离线

金字塔底座是单元测试,用 Fake 覆盖所有业务逻辑分支:字段抽取、异常兜底、重试上限、意图映射。这一层要占绝大多数用例,目标是在没有网络、没有密钥的笔记本上也能全绿。

经验上,单测可占到全部用例的八成以上,集成与 eval 各自只占零头。比例越偏底座,CI 越稳、越快。把宝贵的人力和算力留给顶层的少数真实验证,而不是在底座里反复打真实接口。

5.2 集成测试层:验证接线而非质量

中间层做"接线验证":确认 Fake 之外,真实 client 的接入方式、鉴权头、超时设置都正确。它不评价"模型答得好不好",只确认"链路通不通"。频率低、数量少、可手动触发。

这层的断言应当落在"结构"上:返回是不是合法 JSON、必填字段在不在、错误码有没有被正确映射。不要在这里逐字比对模型文本——那会把质量焦虑混进接线检查,让本该稳定的集成用例变得飘摇。

5.3 eval 回归层:守住输出不退化

顶层是 eval:用一批固定 fixtures 跑真实或近真实模型,检查关键指标(如意图准确率是否跌破阈值)。这层最慢、最贵,所以只跑在关键节点,用来防止"模型一升级、业务就崩"。

eval 的指标可以分层:对结构化字段用精确匹配,对自由文本可用另一套模型做裁判打分,但一定要保留一个确定性的核心指标作为底线。没有底线指标的 eval,容易变成"看起来在测、其实测不准"的形式主义。

这份资料是什么:这一章的测试金字塔分层示例与三层配置模板,可直接对照改造你的 CI。放在资料包里,扫码即可获取:

六、常见坑

6.1 非确定输出怎么断言

不要断言"模型原话等于某字符串"。正确做法是:在 Fake 里把输出固化,单测只验证"业务对这份输出的处理是否正确";真实 eval 层则用范围、正则、或结构化解析后的字段做软断言,而不是逐字比对。

如果一条本应确定的单测开始偶发失败,先别加重试掩盖——那通常是它仍偷偷触碰了某个非确定源。顺着失败往回找,把那个源也替掉,测试才会重新变硬。用重试粉饰 flaky,只会把定时炸弹埋得更深。

6.2 fixtures 腐化:用例过期怎么办

fixtures 会随业务演进过期:字段改名、意图新增,旧样本就失准了。对策是把 fixtures 当成代码资产纳入评审——每次业务改接口,同步补一条 fixture;eval 层一旦跌破阈值,先查 fixture 是否过期,再查代码。

给 fixture 腐化加一道早发现机制:eval 周期性跑,而不是只在出事时跑。准确率缓慢下滑往往比断崖更可怕,因为它容易被当成"模型波动"忽略。把样本集当活文档维护,它才会在你改坏逻辑时第一时间叫停。

6.3 把 prompt 写进业务代码的代价

当 prompt 散落在十几个函数里,你想改话术就得全局搜,想测分支就得猜模型会回什么。把 prompt 收到接口侧、把逻辑收到业务侧,二者各归其位,单测和运营调整互不干扰。

另一个常被忽略的代价是 prompt 不可审计。把话术放进版本化的独立文件,每次改动都有 diff、有评审、可回滚;反之散在代码里,出了问题连"上周改了哪句"都难追溯。可测性和可运维性,其实是一回事。

七、最小落地清单

7.1 三步接入可测试化

第一,定义LLMClient抽象接口,业务只依赖它。第二,写一个FakeLLMClient和一份 fixtures 文件。第三,把单测默认指向 Fake,真实调用挪到带 flag 的集成层。三步即可让第一个函数拥有离线单测。

落地时可以先不动生产装配,只给被测函数加一个可选的 client 参数,默认走真实、测试时传 Fake。这样改造是渐进的,不需要一次性大重构,风险可控,也更容易在团队里推起来。

7.2 判据:你做对了吗

三条硬判据:测试离线可跑;失败时能定位到具体代码行而非"模型又抽风";CI 在分钟级完成。三条都满足,说明可测性真正落地了。

举个反例帮助自检:一条单测红了,你打开报错,看到的是extract_intent第几行断言失败,而不是某个远端超时报错——这就是"失败定位到代码"的应有样子。若你还在靠肉眼比对模型输出,说明隔离还没做透。

7.3 从今天的一个函数开始

不必重构整个项目。挑一个已经嵌了call_model的函数,先抽接口、再造 Fake、再补三条用例。跑通第一个离线单测,比读完十篇方法论都实在。

需要提醒的是,可测化带来的收益需要一点耐心才能显形。它不会让模型更聪明,但会让你改代码时心里有底:红的就是你写错,绿的就是真没问题。这种确定性,正是大模型应用从"能跑"走向"敢改"的分水岭。

这份资料是什么:这一章的最小落地清单与可测化自测表,照着勾一遍就能开工。放在资料包里,扫码即可获取:

附表 A:本文引用事实与出处对照表

本文结论依据 / 出处本文位置
主流工程实践已将真实模型调用移出单测,仅在集成层做少量真实校验待验证第一章、第四章
依赖注入是把模型当作外部服务依赖、由外层决定实现的思想软件工程通用设计原则(依赖倒置 / 控制反转),非单一官方出处第二章
Fake 与 Mock 的区分:Fake 是可用的简化实现,Mock 是按预期录制的替身测试替身通用分类(xUnit Test Patterns 中的分类思想)第三章 3.1
单元测试应零外部依赖、零密钥、零网络待验证第一章、第四章
CI 分钟级完成是可测性的判据之一待验证第四章
测试金字塔分单测 / 集成 / eval 三层,底座最宽、顶层最慢最贵待验证第五章
截至 2026-10-13,主流厂商 SDK 均支持以接口形式封装调用待验证全文
非确定输出应避免逐字断言,改用结构化软断言待验证第六章

附表 B:术语速查表

术语含义
LLMClient把模型调用收敛起来的抽象接口,业务侧只依赖它
依赖注入由外层把依赖(如模型 client)传入,而非业务内部自行创建
Fake可用的简化实现,能离线跑通整条链路
Mock按预期录制的替身,用于断言调用参数与次数
fixtures固定"输入→预期输出"的用例对,用于回归
单元测试金字塔底座,快、廉、可离线,覆盖业务逻辑分支
集成测试中间层,验证真实 client 的接线与鉴权是否正确
eval 回归顶层,用固定样本守模型输出不退化
可测性让业务代码可不依赖外部模型被确定性测试的能力

写在最后:这篇用到的资料

写这篇文章时,我把 LLM 应用的可测试化清单、Mock 客户端骨架和 eval fixtures 模板都整理了一遍,顺手也整理了几份配套的东西:

  • LLM 应用单测骨架:抽接口 + Fake 实现 + fixtures 的参考代码。
  • eval 回归用例集:固定输入输出对,跑通即可做回归。
  • AI 大模型学习路线:从应用落地到工程化的系统路径。

资料是我自己整理的,放在下面这个码上,扫码即可获取:



资料较多,建议先看「全套 AGI 大模型学习路线」,再挑一个实战项目跟练。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 14:53:08

RFM客户价值模型:用R/F/M三维度精准识别高价值客户

1. 什么是RFM客户价值模型:一个被低估却极其实用的商业分析工具RFM客户价值模型不是什么新潮概念,也不是某个大厂刚发布的AI营销黑科技。它诞生于20世纪50年代的美国零售业,比Excel还早十几年,靠一支笔、一张纸和一堆销售小票就能…

作者头像 李华
网站建设 2026/10/14 14:52:33

【院士领衔 | EI稳定检索 | 中国·江门】第八届数智交通与物流供应链管理国际学术会议(DILSCM 2026)暨广东省运筹学会交通物流分会年会

第八届数智交通与物流供应链管理国际学术会议(DILSCM 2026)暨广东省运筹学会交通物流分会年会 2026 8th International Academic Conference on Digital and Intelligent Transportation and Logistics Supply Chain Management 交通物流大通道是连接…

作者头像 李华
网站建设 2026/10/14 14:51:45

CWRU轴承数据预处理三大核心:采样率对齐、工频抑制与冲击增强

1. 为什么CWRU轴承数据一上来就卡在预处理上?刚接触故障诊断的某开发者,兴冲冲下载完CWRU(Case Western Reserve University)轴承数据集,满怀信心点开.mat文件——结果第一眼就懵了:采样率有12kHz、48kHz两…

作者头像 李华
网站建设 2026/10/14 14:51:19

.NET异步编程与多线程实践指南

1. 异步与多线程的本质区别在.NET开发中,异步编程和多线程是两种常见的并发处理方式,但它们的底层机制和适用场景有本质区别。以我们基于.NET 8.0的WinForms流程配置工具为例,当使用Microsoft.Extensions.DependencyInjection管理服务时&…

作者头像 李华