授权与合规声明本文为原创技术分享所有示例代码均运行于作者自建的隔离环境仅供学习交流。文中涉及的第三方模型与服务以各自官方文档为准请在服务条款范围内使用。转载请保留出处。一、为什么 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,abstractmethodfromdataclassesimportdataclassdataclassclassChatMessage: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(rolesystem,content只输出 intent 或 unknown。),ChatMessage(roleuser,contentuser_text),]replyclient.complete(messages)replyreply.strip().lower()returnreplyifreplyin(intent,unknown)elseunknown注意这里没有任何api_key、没有任何 HTTP 调用、没有任何厂商名字。它要的只是一个能回答的 client。于是测试可以给它一个假的 client立刻跑起来。抽接口还顺带解决了一个运维痛点切换或对比不同厂商时只需新增一个实现了LLMClient的类业务代码一行不动。模型供货方从写死在逻辑里变成启动时装配的零件。三、Fake 实现与 fixtures 怎么写3.1 Fake 与 Mock 的区别与选择简单区分Fake 是能用的简化实现Mock 是按预期录制的替身。想验证业务拿到了什么、返回了什么——用 Mock断言调用参数。想让整条链路跑起来、又能离线跑——用 Fake它直接返回固定或规则化的内容。对于 LLM 应用Fake 更实用它能让一整个流程含分支、重试、降级都离线跑通。介于两者之间还有一种录制回放思路先把真实调用录成样本存盘之后用例直接回放这些样本。它比手写 Fake 省事又比每次打真实接口便宜适合从零起步时快速攒出一批回归基线。3.2 写一个可重放的 FakeLLMClientclassFakeLLMClient(LLMClient):def__init__(self,scripted:dict[str,str]):# key 为用户输入特征value 为预设回复self.scriptedscripted self.calls:list[list[ChatMessage]][]defcomplete(self,messages:list[ChatMessage])-str:self.calls.append(messages)user_textmessages[-1].contentforneedle,replyinself.scripted.items():ifneedleinuser_text:returnreplyreturnunknown这个 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/checkoutv4-uses:actions/setup-pythonv5with:python-version:3.11-run:pip install-r requirements.txt-run:pytest tests/unit-q这段配置默认装依赖、跑单元目录全程不读密钥。真实调用那一档被--run-real隔离在外。密钥管理也有讲究真实 key 只存在于 CI 的保密变量里绝不进仓库集成任务从保密存储拉取跑完即弃。即便仓库被公开也不会泄露凭证。集成层低频、少量本来就不该成为常态负担。⚠️ 代码待验证pytest tests/unit-q4.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 当成代码资产纳入评审——每次业务改接口同步补一条 fixtureeval 层一旦跌破阈值先查 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 大模型学习路线」再挑一个实战项目跟练。