English version: The testing pyramid for LLM applications: evals, not assertions。“LLM 应用工程”系列第 8 篇。实证基座与全系列相同:一个生产级 AI 写作 agent(读数据→出图→生成 LaTeX→编译 PDF),全项目 351 个测试函数。
问题:非确定输出让断言失效
传统测试的核心动作是断言精确值:assert f(x) == y。在 LLM 主链路上这条路走不通——同一输入两次生成的报告不会逐字相同,断言固定字符串的测试要么持续红,要么被标成 flaky 后失去公信力。常见的应对是两种坏均衡:只测不含 LLM 的边角逻辑,主链路裸奔;或者硬写精确断言,红了就重跑。真正的代价在改动时兑现:提示词改一个词,生成质量可能静默下滑,而 CI 没有任何信号——第 11 篇把这列为概率部件的第四种失效模式“回归不可测”。
我们在项目里收敛出的答案是一句话:LLM 应用做的是评测(eval),不是断言精确值的测试(test)。断言并没有被放弃,而是换了对象——从“输出等于什么”换成“输出满足什么性质”:结构分过阈值、成对偏好不劣于旧版、编译出 PDF、引用与数字来源闭合、变形关系成立。确定性代码照旧精确断言,两类测试分层并存。
理念:评测驱动开发
方法论调研(项目内 docs/testing/01-research,2026-07,WebSearch 多源整理)给出的业界共识可以压缩成三条:
- EDD(Eval-Driven Development):先定评判标准,再写 agent;每次改 prompt/流程→跑 eval→看分数变化→决策。评测不是事后验收,是开发循环的方向盘——agent 版的 TDD。
- 行为测试三分类(CheckList,ACL 2020):MFT 最小功能测试、INV 不变性测试、DIR 方向性测试。迁移到本项目:埋了缺口的素材,体检必须报出该缺口(MFT);删掉数据文件,缺口数必须增加(DIR)。
- 变形测试(metamorphic):没有标准答案时,改为断言输入-输出之间的关系——“素材更全⇒结构分不降”“删一半数据⇒缺口告警增多”。这直接绕开了“非确定输出无法精确断言”的根本难题。
工具选型的结论是不引重依赖:借鉴 DeepEval 的“eval 即 pytest 用例 + 阈值断言”形态,把自研评分器包进 pytest 用例,零新依赖、可离线、可自托管。
四层:按“是否调真 LLM”切分
| 层 | 跑什么 | 调 LLM | 速度 | 怎么跑 |
|---|---|---|---|---|
| L1 单测 | 纯逻辑:解析/索引/路由/守卫/沙箱白名单/评分器自身/来源校验 | 否 | 秒级 | pytest |
| L2 集成 | mock LLM 驱动流程:run_agent 全流水线、工具循环、端点契约 | mock | 秒级 | pytest |
| smoke | 真实本地执行:matplotlib 出图沙箱、xelatex 编译 | 否 | 数秒 | pytest(有 xelatex 时) |
| L3 在线 eval | 真实 key 跑 agent/评委,评分器+阈值判 | 是(DeepSeek+Qwen) | 分钟级 | pytest --run-live |
分层依据不是教科书式的单元/集成之分,而是两个开关:是否调真 LLM、是否需要真实执行环境。几个设计点:
- L2 的写法:
monkeypatch把agent._client换成 fake,脚本化 tool_calls 与最终内容,断言流程事件加eval_report评分——它验证“接好了”,不验证“质量好”。值得注意的是评分器的复用:同一个eval_report在 L2 给 mock 输出打分、在 L3 给真实输出打分,而它自身是 L1 的被测对象——评分器先被测,才有资格测别人。 - 测试不需要编译:
pytest跑的是 Python 源码、秒级;Nuitka 编译只为产出交付客户的 .exe。开发环 = 改代码 →pytest→ 过。 - 门控:
@pytest.mark.live默认跳过,--run-live才跑,无 key 自动 skip。两把真实 key(DeepSeek 走文本/agent 主链路、阿里云 Qwen 走视觉读图)放backend/.env,不进 git。CI 默认只跑离线三层;L3 手动、发版前或夜间跑。
pytest # L1+L2+smoke(默认,秒级,不联网,CI 必跑)
pytest --run-live # 追加 L3 在线 eval(真实 key,慢、有成本)
python run_evals.py # 一键金样例评测:分数 vs 阈值基线
客观判据:五种可断言的性质
agent 输出不断言固定字符串,断言以下性质:
| 判据 | 实现 | 断言形式 |
|---|---|---|
| 结构分 | eval_report.score_report:8 项 reference-free 检查(可编译骨架/≥3 节/误差分析/引用闭合/数据表/公式/无 AI 套话/无 TODO),返回 0–1 | 分数 ≥ 阈值 |
| 成对偏好 | eval_judge.judge_pairwise:位置交换两评、一致才裁定 | 新版不劣于基线/旧版 |
| 编译通过 | xelatex 真编译 | 出 PDF 且页数 > 0 |
| 来源闭合 | verify.verify:\cite 对 bib 闭合、数据数字可在素材溯源 | 无未定义引用;未溯源数字受控 |
| 变形关系 | metamorphic / DIR | 删数据→缺口增多;凭空数字→未溯源增多;素材更全→结构分不降 |
两点展开。成对偏好必须治位置偏置:LLM 评委系统性偏向第一个候选(arXiv:2406.07791),所以 judge_pairwise 位置交换评两次、结论一致才裁定;评委上岗前还要过 reconstruction_accuracy 自检——先把已知强弱对判对(≥0.7)才有资格判稿。评委自身的效度工程是第 9 篇的主题。
变形关系是最便宜的防幻觉判据。来源校验模块的 DIR 测试原文如下:
def test_verify_metamorphic_fabricated_number_increases_unsourced():
"""DIR 变形:在素材外凭空加一个数字 → 未溯源(疑似编造)数应增多。"""
mat = "测得 1.0 与 2.0"
base = verify.verify("值 1.0 与 2.0", mat)["numbers"]["unsourced"]
more = verify.verify("值 1.0 与 2.0 与 凭空的 7.77", mat)["numbers"]["unsourced"]
assert len(more) > len(base) and "7.77" in more
不需要知道“正确输出”长什么样,只断言方向:凭空多出一个数字,未溯源集合必须变大。这个模式可批量复制:删数据文件→缺口告警增多;补上 .bib→引用缺口消失。
golden 回归门:评分器先自证,才配当门
在线评测最真实,但需要 key、分钟级,护不住“每一次改动”。所以回归门做成双轨:在线门 run_evals.py(真跑生成+评委)之外,离线门 test_golden_gate.py 进常规 pytest、无需 key,三道:
**门一:评分器判别力。**金标准“好/坏”报告必须被 eval_report 明显分开:
assert good["score"] >= 0.75, f"金标准好报告分过低 {good['score']}: {good['flags']}"
assert bad["score"] <= 0.35, f"金标准坏报告分过高 {bad['score']}(分器失去判别力)"
assert good["score"] - bad["score"] >= 0.4, "好坏分差过小:评分器判别力退化"
逻辑顺序很关键:先证明评分器真的编码了质量,才允许它当门。判别力退化的评分器会让后面所有阈值形同虚设,而它自己不会报警——所以它必须被别的测试钉住。
**门二:金标准骨架真编译。**金标准报告骨架必须真被 xelatex 编译通过(本机无引擎则 skip,发布机/CI 装 TeX 后即为硬门)。生成的报告编不过,是提示词或模板退化的最直接信号;这道门护住模板加编译链。
**门三:prompt 形状。**离线跑不了真 LLM、判不了输出质量,但能判提示词的结构:作用域提示词 build_user_scoped 必须仍要求“只重写这一节”、\documentclass 与 \end{document} 必须出现在“严禁输出”的禁令里;全文路径 build_user(gaps=None) 必须与不传 gaps 逐字节一致。一旦有人把提示词改回“输出整篇”,普通 pytest 立刻红。
(另有一条地基检查:金标准报告必须能被确定性分节器正确解析——护住段落级编辑的地基。)
基线:每个分数钉死 git_sha 与 prompt_hash
在线门 run_evals.py 的输出不只是红绿:每次真跑把分数追加进 docs/testing/eval_baseline.tsv,退出码接 CI。文件的实际内容:
| date | git_sha | prompt_hash | metric | name | score | threshold | pass |
|---|---|---|---|---|---|---|---|
| 2026-07-01 21:49 | cb5bba9 | 6f3202abff3a | judge.recon | 强弱对 | 1.000 | 0.70 | 1 |
| 2026-07-03 11:29 | 39b9e29 | 46bed3f78ca1 | report.score | 扫场S参数 | 0.875 | 0.50 | 1 |
| 2026-07-03 11:29 | 39b9e29 | 46bed3f78ca1 | report.compiles | 扫场S参数 | 1.000 | 1.00 | 1 |
| 2026-07-03 11:29 | 39b9e29 | 46bed3f78ca1 | judge.recon | 强弱对 | 1.000 | 0.70 | 1 |
每行同时钉 git_sha 与 prompt_hash:分数漂移时,第一个问题“是代码变了还是提示词变了”直接可答。开发闭环全景:
落地时序的实测数据:体系建成当天(2026-07-01)默认套件 144 passed + 7 skipped(live),在线 --run-live 7/7 通过;两天后加 golden 门,248 passed 无回归;截稿时全项目 351 个测试函数。flaky 治理只有两招:LLM 用例断阈值/区间/变形关系而非精确串,评委用交换平均降方差。
适用边界
- **小金集不是质量证明。**调研提到聚合指标可信需要数百例量级(该数字未独立核验);本项目在线金集起步时只有 1 个报告样例夹加 2 个强弱对。这个规模的在线 eval 是冒烟门——防大倒退——不是质量度量,扩样例之前别用它下“质量提升了”的结论。
- **客观判据只测得到可测的。**8 项结构分是 reference-free 的底线判据:判“这篇是否扎实可编译”,不判“写得好不好”。底线之上的偏好质量要靠评委,而评委必须先做效度验证——没验过的评委门比没有门更危险。
- **L2 绿不等于质量绿。**mock 集成测试验证接线与流程事件,对模型真实输出的质量一无所知。把 L2 通过当质量信号,是这套分层最常见的误读。
- **在线 eval 慢、有成本、有方差,别塞进快环。**它属于发版前、夜间、动了质量相关代码之时。如果产品还在原型期、prompt 天天重写,golden 门的阈值维护成本会超过收益——先有相对稳定的产品定义,再建门。
参考
- CheckList(ACL 2020)——MFT/INV/DIR 行为测试三分类,本文判据设计的直接来源。
- arXiv:2406.07791——LLM 评委位置偏置的系统研究(系统性偏向第一个候选)。
- arXiv:2410.15393——评委校准方法:交换平均、平衡位置校准等。
- arXiv:2504.18827——变形测试在 LLM 上的应用(LLMorph 等)。
- DeepEval——“eval 即 pytest 用例 + 阈值断言”的形态借鉴(未引入其依赖)。
以上外部来源出自项目调研文档(2026-07,WebSearch 多源整理);除本项目实测数据外,二手结论未逐一独立核验。