用pytest构建标准化模型评测框架:从流程到实践

用pytest构建标准化模型评测框架:从流程到实践 模型评测最容易被低估的一步不是用什么指标也不是选哪个测试集而是“整个评测过程本身有没有一套稳定、可复用、能自动跑的标准化测试框架”。很多人把模型跑通一个 Demo看了几条输出就得出“效果不错”的结论等到真正要换模型、调参数、对比版本、做回归测试时才发现之前的结果根本没法复现。这篇内容适合正在做模型选型、模型迭代、算法效果回归、或者需要把评测结果写进技术报告和项目总结的开发者。最值得关注的点是评测不是一次性打分组装而是一个需要像软件测试一样被工程化的过程。先说一句判断任何没有固定流程、没有版本记录、没有统一判定标准的模型评测都只能叫“试了试”不能叫“评测”。如果要把模型结果作为功能上线、技术选型或优化的依据就必须把评测流程标准化而且这套流程应该尽量用自动化测试框架来承载而不是靠手工改 Prompt、手动标分、肉眼比对。1. 先想清楚模型评测为什么不能只靠“看效果”1.1 看似能用的模型上线后不一定稳很多项目在接入模型时第一步都是“拿几个典型问题试一下”。试完发现回答完整、逻辑清楚就觉得可以上线。但上线之后问题往往出在那些你没试过的场景上输入格式稍微变了一点、问题换了一种说法、上下文变长、并发量上来输出质量就开始波动。这一点在评测阶段不一定会暴露因为手工试测的时候样本太少而且人脑会自动忽略不重要的错误。我见过一个比较典型的案例某个文本摘要模型的演示效果很好给的三四篇样例都摘得干净利落。但换成真实业务里的长文档之后摘要经常丢掉关键结论有时候还会把表格内容混进正文。问题不在模型本身而在于评测时根本没有覆盖“长文档、带表格、多段落”这些实际场景。评测的第一原则不是证明模型能跑而是把模型放进真实输入分布里看它到底哪些场景行、哪些场景不行。所以我建议不管项目大小模型评测都要先做一件事把你要处理的输入范围明确下来。包括文本长度、格式、语言、领域、语气、是否有噪音、是否需要多轮上下文。这个范围就是测试集的边界也是标准化评测框架的起点。1.2 没有被标准化的评测过程几乎没法复盘做模型迭代的人应该都有过这种体验上一个模型跑了 0.82 的分数换了一个版本之后变成 0.78你根本不记得上次的评测数据是怎么来的。是同一个测试集吗是不是有一批坏数据被中途替换掉了评测脚本前后改过没有随机种子是不是变了如果这些信息没有记录你不仅无法判断模型是变好了还是变差了连定位问题都不知道从哪下手。标准化测试框架真正解决的问题不是“让评测更高级”而是让评测过程具备可复现性。可复现的意思是说任何人、任何时间、任何机器上只要用同一份代码、同一个配置、同一个测试集就能得到同一个评测结果。听起来很简单但做到并不容易。光是“随机种子不一致导致推理结果不同”这一项就能让两次评测差出好几个百分点。1.3 评测标准化要解决的不是一个指标而是一套流程很多人把“标准化”理解为“统一指标”。比如大家都算准确率、F1、BLEU就叫标准化了。但在实际生产里这只是最表层的一步。真正的标准化评测框架至少包含下面这些内容测试集怎么来、怎么清洗、怎么切分、怎么更新、怎么避免测试集污染输入怎么构造Prompt 模板是否统一同一类任务是不是都走同一个入口模型运行环境怎么固定模型版本、推理参数、随机数、批大小、精度模式是否一致指标怎么计算是逐条算完再平均还是先汇总再计算不同的计算顺序结果可能不一样结果怎么记录分数之外还有哪些运行信息需要留档方便之后复盘失败和不稳定情况怎么处理某条推理超时、某条输出格式异常是直接失败还是重试还是跳过这些内容加在一起才叫测试框架。只看一个指标只能叫“算了个数”。2. 标准化测试框架需要先拆出哪些模块真正落地的时候我不建议一上来就写代码而是先把评测框架拆成几个模块确定边界和职责。这样后面不管是加新测试集、新模型、新指标还是换评测脚本都不会变成一堆代码补丁。2.1 测试集固定、分层、可版本化管理测试集是标准化评测的核心资产。它不能是临时拼出来的几十条样例也不能是网上随手下载的一个 CSV 文件而应该是被明确管理的数据集合。固定是第一步。每轮评测用的测试集必须是一致的至少在同一版本内部保持一致。就算要调整测试集也应该通过新建版本的方式而不是在原文件上直接改。这样前后两次评测结果之间才有可比性。分层是第二步。不要只准备一个“全部样本”的测试集要按业务维度切分。比如一个客服意图识别评测可以按意图类型分成售前、售后、投诉、催单等子集一个代码生成模型评测可以按题目难度分成简单、中等、困难或者按是否涉及多文件依赖来划分。分层评测的好处是你能直接看出模型到底在哪个子集上变好在哪个子集上退化。可版本化管理是第三步。测试集文件要放在 Git 或数据版本管理工具里文件命名要带上版本号或日期不能出现“test_final_v3_really_final.csv”这种名字。每次评测时要把测试集版本记录到结果文件里。2.2 任务定义输入输出、判定逻辑和可重复性要求一个模型可能同时承担多种任务比如既能做摘要又能做问答还能做分类。不同任务必须单独定义评测方式。不要把三类任务混在一个评测脚本里否则某个任务效果不好时很难单独定位。任务定义需要写清楚四件事。第一输入结构是什么是一个字符串、一组字段、还是一段对话历史。第二输出结构是什么是纯文本、JSON、还是包含置信度分数的结构化结果。第三判定逻辑是什么是字符串完全匹配、关键词匹配、靠另一个模型打分、还是人工审核。第四可重复性要求是什么比如固定随机种子确保同一批输入每次推理输出一致。这里特别提醒一下判定逻辑是很多人偷懒的地方。有人用“输出里包含目标词”就算对但业务场景里经常出现明明意思完全不对、只是碰巧包含目标词的情况。如果判定标准本身不可信评测框架再规范也没用。2.3 指标层不只选指标还要规定怎么算选指标是相对简单的一步难的是把计算过程规定清楚。以分类任务为例准确率好理解但如果测试集本身类别不平衡你还要决定是算宏平均还是微平均。宏平均先算每个类别的准确率再取平均微平均把所有样本归到一起再算两者结果差异可能相当大。生成类任务更复杂。BLEU 是 n-gram 级别的匹配指标ROUGE 更关注召回BERTScore 用语义向量也算相似度但不同指标对同一批结果可能给出不同排序。评测框架要做的是“先固定指标集合再固定计算方法”不能这次用 ROUGE-L下次用 ROUGE-1换指标的同时还换了模型那就永远定位不了是模型变差了还是指标换了。指标层还需要考虑聚合方式。逐条计算指标再取平均值和把全部预测结果汇总后计算在很多情况下结果不一样。特别是像 F1 这种基于混淆矩阵的指标两种计算方式的差异会更明显。所以评测框架里一定要写明聚合方式。2.4 基线与对照没有参照物分数没有意义单独看一次评测分数很难判断模型到底处于什么水平。标准化评测框架里一定要配置基线。基线可以是上一个线上版本也可以是一个规则系统还可以是开源社区公认的基准结果。有了基线之后每次模型迭代的评测报告就变成了“新模型 vs 基线”的对比。这样你不仅能看出涨分还是掉分还能看出涨分到底发生在哪个子任务、哪个分层数据集上。很多时候全局指标没有明显变化但某个关键子集的指标已经掉了很多如果没有基线对比这种情况很容易被忽略。3. 用 pytest 搭一个可落地的模型评测框架在技术实现层面我推荐用 pytest 来承载模型评测流程而不是自己写一个 Python 脚本循环跑。pytest 是 Python 生态里最常用的自动化测试框架它天然支持测试发现、断言、参数化、插件扩展、失败重试、结果报告这些正好是模型评测需要的能力。3.1 为什么选 pytest 而不是自己写评测脚本很多人第一次做模型评测时习惯写一个eval.py用for循环遍历测试集把结果算完打印一个分数。这种方式对于一次性任务问题不大但一旦进入持续迭代阶段问题就出来了没有统一的测试入口没有失败隔离没有参数化没有统一的报告格式。每条测试用例如果中间有一行报错整个脚本就挂掉前面跑完的结果也丢了。pytest 恰好能解决这些问题。它的测试发现机制让我们可以把每条测试样例当作一个测试用例来执行单条失败不会影响其它用例。参数化可以把不同输入组合批量注入测试函数。conftest 可以统一加载模型、配置、测试集避免重复代码。pytest 插件生态里还有超时控制、失败重试、HTML 报告等能力几乎不用自己造轮子。更关键的一点是pytest 的架构让评测流程和业务逻辑分离。测试用例只负责“给输入、拿输出、做判定”模型加载、数据读取、指标计算都可以放到 fixture 或工具模块里。这样评测框架的可维护性会高很多。3.2 环境准备和目录结构在本地复现这套评测框架需要先准备好基本环境。下面是建议的目录结构model_eval/ ├── conftest.py # 存放 fixture统一加载模型和配置 ├── pytest.ini # pytest 配置注册插件、设置公共参数 ├── requirements.txt # 依赖文件 ├── eval_config.yaml # 模型路径、测试集路径、指标参数等配置 ├── data/ │ └── test_sets/ # 测试集文件建议按版本归档 │ ├── v1.0/ │ └── v1.1/ ├── cases/ │ ├── test_summarization.py │ ├── test_classification.py │ └── test_qa.py ├── metrics/ │ ├── __init__.py │ ├── rouge_metrics.py │ └── accuracy_metrics.py └── reports/ └── eval_results/ # 每次评测的结果目录这里的核心思路是测试集、测试用例、指标计算、结果报告彼此独立。这样即使某一天完全更换模型也不需要大改评测用例。依赖文件方面一般会包含这些基础依赖但具体版本要以你项目实际运行环境为准pytest pytest-timeout pytest-repeat pyyaml transformers # 如果评测的是 Transformer 模型 torch # 按实际推理框架选择需要注意原始项目材料没有给出明确版本号落地时不要盲目安装最新版先确认你使用的模型框架和 Python 版本兼容关系。3.3 最小可运行示例单任务、单指标先从最小规模开始。这里以文本分类模型评测为例演示 pytest 怎么组织一条评测用例。先写一个 conftest.py把模型加载放到 fixture 里保证所有测试用例复用同一个模型实例不会每条样例都重新加载一次import pytest from model_wrapper import load_model, predict pytest.fixture(scopesession) def model(): model load_model(model_path) return model pytest.fixture() def sample_inputs(): # 实际项目中这里会从测试集文件读取 return [ {text: 我想退货, label: 售后}, {text: 你们有什么优惠, label: 营销咨询}, {text: 我的订单还没送到, label: 物流查询}, ]然后写测试用例把每个样本都变成一个可独立执行的测试import pytest pytest.mark.parametrize( text, expected_label, [ (我想退货, 售后), (你们有什么优惠, 营销咨询), (我的订单还没送到, 物流查询), ], ) def test_classification_case(model, text, expected_label): result predict(model, text) assert result expected_label, f输入: {text}, 预期: {expected_label}, 实际: {result}运行方式很简单pytest cases/test_classification.py -v跑完以后pytest 会告诉你每条用例是 passed 还是 failed。这个阶段的目标只有一个让模型在新的测试框架里正常启动、正常推理、正常判定。不要一上来就把 1000 条测试集全灌进去先跑通这 3 到 5 条确认链路没问题再说。3.4 批量评测参数化、并发和超时控制单条链路跑通之后就要处理批量评测。批量评测和单条评测不一样不只是把样例数量变多还要考虑几个工程问题失败隔离、超时处理、并发控制、结果记录。pytest 的参数化天然支持批量输入。你可以把测试集文件读取出来动态生成参数列表。常见做法是在测试模块里读取数据文件然后构造 parametrize 参数import json import pytest def load_test_cases(json_pathdata/test_sets/v1.0/cls_test.json): with open(json_path, r, encodingutf-8) as f: data json.load(f) return [(item[text], item[label]) for item in data] pytest.mark.parametrize( text, expected_label, load_test_cases(), idslambda x: str(x)[:20], ) def test_classification_batch(model, text, expected_label): result predict(model, text) assert result expected_label批量跑的时候建议加上超时控制避免某条输入让推理卡死pytest cases/ -v --timeout30 --timeout-methodthread这里的--timeout30表示单条用例超过 30 秒就标记为失败避免整个评测卡在一个坏样本上。并发方面pytest-xdist 插件可以用-n auto参数自动开启多进程。但需要注意模型推理本身会占用显存或内存多进程并行时如果每个进程都加载一次模型资源占用会成倍增长。如果机器配置不够不要盲目开并发。注意解析批量评测任务时我建议先跑 50 条、单进程、开超时确认没有问题了再逐步增加进程数和样本量。低配置机器能跑通 100 条不代表能压住 1000 条的并发任务。批量评测的另一个核心问题是输出隔离。每条用例是独立执行的如果某条失败不能影响其它条目。pytest 天然满足这一点一条 failed 并不会让整个进程退出跑完所有用例后会汇总失败列表。3.5 结果输出日志、报告和可复用的摘要评测不能只看命令行终端里的 “3 passed, 1 failed”因为终端输出会滚动结果很快就被冲掉了。标准做法是把评测结果落盘。pytest 的 JUnit XML 报告可以直接生成结构化结果适合后续合并进持续集成或数据看板。先安装 pytest 自带的 junitxml 支持不需要额外插件运行pytest cases/ -v --junitxmlreports/eval_results/20250210_classification_v1.0.xml如果你需要更直观的 HTML 报告可以用 pytest-html 插件pytest cases/ -v --htmlreports/eval_results/20250210_classification_v1.0.html但光有 pytest 报告还不够模型评测还需要记录“谁在什么配置下跑的、测试集是哪个版本、模型是哪个版本、推理参数是什么”。这些信息建议写进一个单独的 JSON 摘要文件。简化版如下{ eval_time: 2025-02-10 18:30:00, testset_version: v1.0, model_version: bert-base-chinese, framework: pytorch 2.1.2, metrics: { accuracy: 0.92, per_class_accuracy: { 售后: 0.94, 物流查询: 0.91, 营销咨询: 0.89 } }, total_cases: 300, passed: 276, failed: 24 }这份摘要文件建议和代码一起提交到版本仓库或者至少归档到评测结果目录。这样下次评测时可以直接对比两份 JSON快速定位变化。4. 判断评测结果是否可信先看这几个参数评测框架搭好之后最怕的不是跑不起来而是跑出来一份“看起来正确但实际不可信”的结果。要让评测结果有意义必须对几个关键参数做约束。4.1 样本量、按层切分和随机数种子小样本跑出来的分数波动很大。比如 20 条样本里错了 1 条准确率就直接掉 5 个百分点这个波动可能根本不是模型变化引起的而是样本噪音。所以评测结果里必须写清楚样本量不能只写准确率 0.95 不写是在多少条样本上算出来的。按层切分也很重要。如果评测结果只有一个全局准确率你看不出模型在用户投诉这种高价值场景里的表现如何。所以建议至少按业务类别或难度等级切分每个子集单独出指标。随机数种子是很多人最容易忽略的一点。像基于神经网络的模型推理阶段虽然通常确定性较高但某些实现里如果开启了采样、或者某些算子本身有随机性两次评测结果就会不一样。评测前要把模型推理相关的随机种子固定下来。如果是自回归生成模型还要把do_sampleFalse或固定temperature否则输出不会稳定。4.2 重复运行、稳定性不能只看一次分数模型评测的结果不应该基于单次运行就下结论。尤其是生成类任务同一条输入在同一次运行里可能都会产出不同结果。更稳妥的做法是把同一轮评测重复跑 2 到 3 次观察指标波动范围。如果两次运行分数差在 0.5 个百分点以内基本可以认为评测过程是稳定的。如果波动超过 2 个百分点先不要急着比较模型版本优先排查评测链路本身是不是有随机性。常见原因包括温度参数没固定、GPU 算子有随机性、并发导致批次顺序变化、输出解码策略不一致。4.3 时间与资源评测本身也要可预期模型评测不只是“算分数”它本身是一个计算任务。如果评测集很大或者模型推理很慢一次全量评测可能要跑几个小时。评测框架里要记录总耗时和平均单条耗时这样后续换模型时可以预估成本。资源占用也是一样。评测脚本跑的时候要关注显存和内存占用曲线。如果评测过程出现显存溢出pytest 会报错但不会告诉你具体是哪条用例触发的。排查方式是把测试集按规模切小逐步定位。如果你打算把评测周期做得很频繁比如每次提交代码都跑一次模型评测那就要考虑时间成本是否能接受。评测集可以区分冒烟集和全量集冒烟集只跑几十条高代表性样本用于快速反馈全量集放到夜间或定时任务里跑。4.4 指标一致性不同轮次、不同环境、不同人指标计算的一致性非常容易出问题。同一个模型、同一个测试集可能因为指标计算代码版本不一致算出两个不同的分数。标准化框架里指标模块一定要单独维护并且记录版本。我遇到过最典型的情况某个同事觉得“稍微改一下 ROUGE 的 tokenizer 分词方式”结果全局分数涨了 3 个百分点。但这不是模型变好了而是指标口径变了。所以评测框架里必须把指标模块的版本和配置写进结果文件确保每次评测用的指标计算逻辑一致。5. 常见坑与排查顺序评测跑起来之后一定会遇到各种问题。这里把我实际踩过的几个典型场景整理一下给一个通用排查链路。5.1 分数忽高忽低先查什么如果两次评测分数波动很大排查顺序如下先看测试集有没有变化是不是有人改了测试文件或者读取顺序变了。再看模型权重有没有变化模型文件路径是不是被替换了CACHE 目录有没有被清掉。再看推理参数temperature、top_p、do_sample、随机种子。再看指标计算逻辑指标模块代码是否有改动。最后看运行环境GPU 是不是被别人占用了导致批次顺序或显存调度变化。其中测试集变化和推理参数是最容易出的问题。建议每次评测把测试集的文件哈希记录到摘要里一旦分数变化先比对哈希确认数据没变。5.2 测试集污染、数据泄漏这类问题怎么防模型评测最容易犯的重大错误是测试集泄漏。比如你要做模型微调但测试集里的样本和训练集有重合或者测试集里包含写进过 Prompt 的示例文本。这种情况下测出来的分数会虚高而且上线后立刻打回原形。标准化框架里要做三件事第一测试集必须独立于训练集至少在业务层面严格分离第二测试集不能从公开的通用数据集里直接截取一部分当业务评测集除非你确认它没有出现在预训练数据里第三测试集要定期做相似度去重防止某些模型在训练时已经见过了这些数据。5.3 评测脚本报错时不要急着调模型很多人一看到评测里出现 failed第一反应就是“这个模型效果不行”然后马上调 Prompt、换模型。但实际大量 failed 并不是能力问题而是流程问题。常见原因包括输入格式不匹配比如模型接口要求 JSON你传了纯文本判空逻辑不严谨模型输出了空串或格式异常被误判为错误超时设置过短长文档推理时间超限并发进程过多显存或内存溢出属于环境问题而非模型问题所以排查时要先看 failed 的类型把失败分类再决定下一步。我的经验是先输出每条 failed 用例的输入、预测、期望、错误信息人眼扫一遍往往能找到共同点。如果一批失败都是“超时”那就不是模型能力问题而是资源与超时配置问题。5.4 从单机脚本到持续评测需要补什么当评测框架在单机上稳定下来以后下一步通常是接到持续集成或定时任务里。要做到这一点还需要补几件事命令行入口要支持传参比如测试集版本、模型路径、输出目录不能硬编码在代码里评测命令要能返回非零退出码用于流水线判断是否失败结果文件要有唯一命名建议用评测时间加测试集版本加模型名要有一套“冒烟评测”和“全量评测”的切换机制把快速反馈和完整回归区分开如果团队里有多人参与评测框架的代码和结果摘要都要纳入版本管理。评测不是一个人的私活标准化框架的意义正在于不管谁跑怎么跑结果都能对齐。最后留几个我自己排查时会优先看的点失败样本是真的被模型判错还是输入数据本身标注错误分数的变化能不能在子集指标里定位到具体业务方向评测过程有没有把某个步骤的随机性压到最低。如果这三点都能回答清楚模型评测就算真正进入了可用状态。