# LLM 应用的测试金字塔：做评测，不做断言

> LLM 输出非确定，断言精确值的测试对主链路失效。本文给出四层测试金字塔——零 LLM 单测、mock 集成、真实执行 smoke、--run-live 门控的在线评测——与五种客观判据（结构分、成对偏好、编译通过、来源闭合、变形关系），以及让评分器先自证判别力的 golden 回归门和钉死 git_sha+prompt_hash 的分数基线。实证基座为一个带 351 个测试的生产级写作 agent。

- Canonical (HTML): https://kaguc.com/blog/llm-testing-pyramid-zh/
- Date: 2026-07-29


*English version: [The testing pyramid for LLM applications: evals, not assertions](/blog/llm-testing-pyramid/)。“LLM 应用工程”系列第 8 篇。实证基座与全系列相同：一个生产级 AI 写作 agent（读数据→出图→生成 LaTeX→编译 PDF），全项目 351 个测试函数。*

## 问题：非确定输出让断言失效

传统测试的核心动作是断言精确值：`assert f(x) == y`。在 LLM 主链路上这条路走不通——同一输入两次生成的报告不会逐字相同，断言固定字符串的测试要么持续红，要么被标成 flaky 后失去公信力。常见的应对是两种坏均衡：只测不含 LLM 的边角逻辑，主链路裸奔；或者硬写精确断言，红了就重跑。真正的代价在改动时兑现：提示词改一个词，生成质量可能静默下滑，而 CI 没有任何信号——[第 11 篇](/blog/deterministic-boundary-zh/)把这列为概率部件的第四种失效模式“回归不可测”。

我们在项目里收敛出的答案是一句话：**LLM 应用做的是评测（eval），不是断言精确值的测试（test）**。断言并没有被放弃，而是换了对象——从“输出等于什么”换成“输出满足什么性质”：结构分过阈值、成对偏好不劣于旧版、编译出 PDF、引用与数字来源闭合、变形关系成立。确定性代码照旧精确断言，两类测试分层并存。

## 理念：评测驱动开发

方法论调研（项目内 docs/testing/01-research，2026-07，WebSearch 多源整理）给出的业界共识可以压缩成三条：

1. **EDD（Eval-Driven Development）**：先定评判标准，再写 agent；每次改 prompt/流程→跑 eval→看分数变化→决策。评测不是事后验收，是开发循环的方向盘——agent 版的 TDD。
2. **行为测试三分类**（CheckList，ACL 2020）：MFT 最小功能测试、INV 不变性测试、DIR 方向性测试。迁移到本项目：埋了缺口的素材，体检必须报出该缺口（MFT）；删掉数据文件，缺口数必须增加（DIR）。
3. **变形测试（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 手动、发版前或夜间跑。

```bash
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 篇](/blog/judge-validity-zh/)的主题。

**变形关系是最便宜的防幻觉判据**。来源校验模块的 DIR 测试原文如下：

```python
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` 明显分开：

```python
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`：分数漂移时，第一个问题“是代码变了还是提示词变了”直接可答。开发闭环全景：

```mermaid
flowchart TD
    A[改代码 / 改 prompt] --> B{pytest：L1+L2+smoke，秒级}
    B -->|红| A
    B -->|绿，未动质量相关| Z[提交]
    B -->|绿，动了 agent/质量相关| C[pytest --run-live 或 run_evals.py]
    C --> D[真跑 agent → 评分器/评委打分]
    D --> E{对比阈值与 eval_baseline.tsv}
    E -->|达标| F[绿：分数追加基线] --> Z
    E -->|倒退| A
    Z --> R[发版前：默认套件 + --run-live 全绿 → 才 Nuitka 打包]
```

落地时序的实测数据：体系建成当天（2026-07-01）默认套件 144 passed + 7 skipped（live），在线 `--run-live` 7/7 通过；两天后加 golden 门，248 passed 无回归；截稿时全项目 351 个测试函数。flaky 治理只有两招：LLM 用例断阈值/区间/变形关系而非精确串，评委用交换平均降方差。

## 适用边界

1. **小金集不是质量证明。**调研提到聚合指标可信需要数百例量级（该数字未独立核验）；本项目在线金集起步时只有 1 个报告样例夹加 2 个强弱对。这个规模的在线 eval 是冒烟门——防大倒退——不是质量度量，扩样例之前别用它下“质量提升了”的结论。
2. **客观判据只测得到可测的。**8 项结构分是 reference-free 的底线判据：判“这篇是否扎实可编译”，不判“写得好不好”。底线之上的偏好质量要靠评委，而评委必须先做效度验证——没验过的评委门比没有门更危险。
3. **L2 绿不等于质量绿。**mock 集成测试验证接线与流程事件，对模型真实输出的质量一无所知。把 L2 通过当质量信号，是这套分层最常见的误读。
4. **在线 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 多源整理）；除本项目实测数据外，二手结论未逐一独立核验。

