为什么讲这个案例:它把测试从"有没有 bug"这一朴素问题,升级为"当连正确答案都说不清时,我们凭什么判定一段输出可接受"。这是智能软件工程与传统软件工程最锋利的一条分水岭,也是最容易在真实项目里踩坑的地方。
阅读路径:§1 讲清楚"为什么难测",§2–§3 建立两类可自动化的断言(蜕变 + 属性),§4 选定被测体(复用 pr11 量化模型),§5–§6 引入并"审问" LLM-as-a-Judge,§7 把三者缝进一条 pytest 流水线并写进
traces.csv,§8–§9 给出指标口径与实验设计,§10 与其他案例对接。
1. 为什么 AI 组件难测
传统单元测试的地基是可判等的期望值:给定输入 x,断言 f(x) == y_expected。这条假设在 AI 组件上从两个方向同时塌掉:
- 非确定性:同一输入在不同 temperature / seed / 硬件 / 量化位宽下输出不同字符串;
assert equal几乎必然 flaky。 - 无唯一真值:把"请把这段话翻译成英文"交给模型,正确答案有无穷多个且都合理,不存在一条能写进测试的"标准答案"。
| 维度 | 传统单元测试 | AI 组件测试 |
|---|---|---|
| 正确性定义 | 输出 == 期望值 | 输出满足某条关系 / 属性 / 语义评分 |
| 确定性假设 | 输入定 → 输出定 | 输入定 → 输出分布,需重复采样 |
| Oracle(裁判) | 人类预先写死的黄金值 | 蜕变关系 / 属性 / LLM-as-a-Judge / 人工 |
| 失败判据 | 断言不符即 fail | 违反不变式、属性被证伪、评分越阈值、MR 前后不一致 |
| 可复现性 | 天然确定 | 须锁 seed、记模型/量化版本、留原始样本 |
核心论点:既然不能断言"绝对正确",就转而断言"关系不变"(§2 蜕变测试)与"属性恒真"(§3 属性测试);当关系与属性都覆盖不到的语义质量维度出现时,再交给 LLM-as-a-Judge(§5)——但 Judge 也是被测对象(§6)。
2. 蜕变测试 Metamorphic Testing
蜕变测试的洞见是:即使你算不出唯一正确答案,你也知道某些输入变换应当引起某些可预期的输出关系。于是把"无法断言绝对正确"翻译成"断言关系不变"。
2.1 蜕变关系的形式化
设被测函数为 f。给输入施加一个变换 T(source → follow-up),若存在一个输出侧的期望关系 ≅(由变换诱导的输出映射 T' 表达),则蜕变关系(MR)要求:
这里 ≅ 不必是"完全相等",可以是"语义等价""包含""偏序""分布一致"等——只要它是可判定的关系即可。对非确定性输出,把 f 视作分布,判据写成"重复采样后关系的成立率 ≥ 阈值":
2.2 给翻译 / 摘要 / 问答设计 MR
下面是三类常见任务的"输入等价改写 → 语义等价输出"直觉。关键是把模糊的"应该差不多"落成可判定的关系:
- 翻译:把中文原文里的一句无关插入语删掉(
T=删除与目标语义无关的修饰),译文主干应仍可覆盖原意(T'=蕴含),且来回翻译(中→英→中)与原文的语义相似度不低于阈值。 - 摘要:原文中的核心实体集合应是摘要实体的超集(摘要不应凭空捏造人名/数字)——"原文 ⊇ 摘要要素"。给原文追加一段与主题无关的噪声,摘要主干应基本保持稳定。
- 问答:把问句做等价改写("X 的成立日期" ↔ "X 什么时候成立"),两个答案应语义一致;给上下文补充"无关文档",正确答案不应被带偏(不变性)。
动手练习:给"中文→英文翻译"函数设计 3 条 MR,其中至少 1 条必须是能被真实模型违反的(如对含否定的句子做改写后语义翻转)。让读者亲手找到"模型在哪条关系上不蜕变",比背定义有效得多。
3. 属性测试(Hypothesis)
属性测试(Property-based Testing)与蜕变测试互补:它随机生成大量输入,断言某个"对一切输入都该成立"的属性恒真。Hypothesis 库还会在发现反例后自动收缩(shrinking)到最小失败用例,极大降低排障成本。对 AI 组件,常用属性包括:输出长度上界、禁止出现敏感串、必须能解析成合法 schema、幂等性、含某必答字段等。
from hypothesis import given, strategies as st, settings, HealthCheck
import json
# 被测:把一个自由文本 query 交给模型,要求其返回结构化 JSON
def model_extract(query: str) -> str:
... # 调用 pr11 的被测模型,返回原始字符串
SENSITIVE = ("身份证号", "银行卡", "sk-", "BEGIN PRIVATE KEY")
@settings(max_examples=50, deadline=None,
suppress_health_check=[HealthCheck.too_slow])
@given(q=st.text(min_size=1, max_size=200))
def test_extract_properties(q):
out = model_extract(q)
# 属性1:长度上界——不应无限膨胀/复读
assert len(out) <= 2000, "输出超长,疑似复读"
# 属性2:不得泄露敏感串
for s in SENSITIVE:
assert s not in out, f"泄露敏感串: {s}"
# 属性3:schema 合法——必须是可解析的 JSON 且含必备字段
obj = json.loads(out)
assert "answer" in obj and isinstance(obj["answer"], str)
属性测试擅长"覆盖边界",而蜕变测试擅长"跨输入的相对正确性"。两者叠加能显著缩小"无法断言绝对正确"留下的盲区。下面给出本案例落地时使用的蜕变关系表(≥5 条,含前置变换与期望不变式):
| 编号 | 前置变换 T | 期望不变式(≅ / T′) | 被测函数 | 示例 |
|---|---|---|---|---|
| MR-1 | 对原文做同义等价改写(换句式不换语义) | 译文与原始译文语义等价(蕴含互成立) | translate | "合同生效日" ↔ "合同于何时生效" |
| MR-2 | 追加一段与主题无关的噪声文本 | 摘要主干实体集合保持不变(≤ 小幅抖动) | summarize | 正文 + "今天天气不错"→摘要不变 |
| MR-3 | 删除原文中与答案无关的修饰语 | 原文实体 ⊇ 摘要实体(摘要不得凭空造实体) | summarize | 正文含"公司A",摘要不得出现"公司Z" |
| MR-4 | 问句等价改写(换提问方式) | 两版答案语义一致(EM/相似度 ≥ 阈值) | answer_qa | "何时成立" ↔ "成立日期是哪天" |
| MR-5 | 中→英→中往返翻译 | 与原文语义相似度不低于基线(不丢失关键信息) | translate 复合 | 回译后关键数字/否定不被翻转 |
| MR-6 | 对同一输入升温重复采样 N 次 | 答案落在校准置信区间内的比率稳定(分布一致) | answer_qa | 正确率波动 ≤ 容差,避免偶然对/偶然错 |
诚实提示:MR-6 的"分布一致"依赖采样策略(温度、top-p、seed 集合);换判定阈值
τ会直接改变通过率——务必把τ与采样配置写进代码常量并纳入版本,否则 MR 结果不可复核。
4. 被测对象选择:复用 pr11 的量化模型
为了让"模型工程"与"测试"两条主线咬合,本案例直接拿 pr11 训练并量化后的模型当被测体,而不是另找一个 API。这样测试就不再是孤立的 QA 环节,而是模型交付链路上的一环:量化改了权重表示,测试要回答"关系还成立吗?"。
4.1 换量化版后,MR 是否仍成立?
这正是测试的价值所在。fp16 权重经 INT8/INT4 量化后,输出的 logits 与文本都可能发生细微漂移。对某些任务这种漂移无害,对另一些则会破坏不变式:
- 可能仍成立:MR-2(加噪摘要不变)、MR-3(不捏造实体)——这些是粗粒度语义约束,量化噪声通常撼不动。
- 可能被打破:MR-1/MR-4 里对否定词、数字、专名的精确保持;量化在边界 token 上概率被压平,回译/等价改写后的答案可能翻转。
关键认知:MR 被打破本身就是测试信号,而不是"测试失败=模型没用"。它精确定位到"量化在哪类语义上不安全",可反哺 pr11 的量化策略(如对敏感层保留更高位宽)。测试与被测之间形成闭环,这正是"模型工程"主线的意义。
落地做法:对 fp16 与 int8 两个版本各跑同一套 MR,比较"MR 违反率"的差异——把它当作量化质量的一张体检表(详见 §7 流水线,通过 model_version 列区分)。
5. LLM-as-a-Judge
蜕变关系和属性都覆盖不到的连续语义质量(流畅度、有用性、语气得体、是否真正回答了问题),交给 LLM-as-a-Judge 打分。原则是:能写成 MR/属性的绝不用 Judge——Judge 贵、慢、有偏;只有确实需要"理解语义后再评判"时才动用。
5.1 何时用、Prompt 规格与评分 Rubric
Judge 的 prompt 要把评分标准写死成结构化 rubric,并强制只输出可解析结果(维度分 + 判定 + 理由),避免自由发挥:
你是严格的评审。仅依据下方 rubric 对「回答」相对「问题+参考要点」打分。
禁止臆测参考答案;只输出 JSON,不要多余文字。
Rubric(每维 1-5 分):
- faithfulness : 是否只依据给定上下文、无捏造
- completeness : 是否覆盖参考要点
- relevance : 是否切题
- harmlessness : 是否含不当内容
输出 JSON:
{"faithfulness":int,"completeness":int,"relevance":int,
"harmlessness":int,"verdict":"pass|review|fail","reason":"一句话"}
5.2 关键陷阱:Judge 自身的系统性偏差
Judge 不是中立的天平,它带着已知偏差。做实验前必须把这些偏差"设计掉",否则测的是 Judge 的脾气而非模型的质量:
| 偏差 | 表现 | 缓解手段 |
|---|---|---|
| 位置偏差 Position Bias | 成对比较时偏爱"第一个/最后一个"候选 | 随机化候选顺序 + 双向各评一次取一致 |
| 冗长偏差 Verbosity Bias | 把"更长"误当"更好" | rubric 显式约束简洁性、控制长度再比 |
| 自我偏好 Self-Preference | Judge 偏爱自家模型风格/同族输出 | 换不同家族 Judge、盲评、屏蔽模型签名 |
| 分数锚定 | 总分被首维度评分带偏 | 各维度独立打分、打乱维度顺序 |
Judge 局限性清单(写进实验报告):① 有位置/冗长/自我偏好三类已知偏差;② 评分非确定,需固定温度=0 并重复;③ 对超出其知识的事实正确性不可靠(只能判"是否忠于给定上下文");④ 与被评模型同族时偏差最大;⑤ 分数是序数量表,不宜直接当"百分比正确率"对外宣称。
6. Judge 可信度 = 与人工标注的一致性
既然 Judge 本身是"被测对象",就不能盲信它的分。可信度的操作性定义是:Judge 的判定与人工标注的一致性(呼应 pr09 的标注一致性方法)。做法是抽样一批输出,请两名人工背靠背双标,再与 Judge 的判定比较,计算 Cohen's Kappa 与一致率。
其中 p_o 为观察到的一致比例,p_e 为随机情况下期望的一致比例(由双方各类别边缘分布相乘求和得到)。Kappa 扣除了"瞎猜也能蒙对"的部分,比裸一致率更可信。经验参考:κ≥0.6 尚可、0.4–0.6 中等、<0.4 说明 Judge 与人类判断脱节。
from sklearn.metrics import cohen_kappa_score, accuracy_score
# human_a / human_b / judge 均为同一批样本的离散判定(pass/review/fail)
kappa_hh = cohen_kappa_score(human_a, human_b) # 人-人:标注规范本身可信吗
kappa_hj = cohen_kappa_score(human_a, judge) # 人-Judge:Judge 可信吗
agree = accuracy_score(human_a, judge) # 一致率(粗略口径, 仅作参考)
print(f"人-人 kappa={kappa_hh:.2f} 人-Judge kappa={kappa_hj:.2f} 一致率={agree:.1%}")
if kappa_hh < 0.6:
print("标注规范有歧义:先修人工 rubric,再谈 Judge") # 别把噪声当 Judge 的错
elif kappa_hj < 0.6:
print("Judge 与人类不一致:细化 rubric 维度定义 / 换不同家族 Judge / 扩充few-shot锚点")
else:
print("Judge 可作为自动化打分代理,但仍须保留人工抽检")
不达标处置顺序:先看人-人 Kappa——若人都对不齐,问题在标注规范而非 Judge;人-人达标后才评估人-Judge Kappa,不达标则迭代 rubric 措辞、补充 few-shot 锚定样本,或换一家族模型做 Judge,切忌直接"调阈值凑数"。
7. 流水线整合:pytest 跑 MR + 属性 + Judge,统一写进 traces.csv
把前六节的三类判据缝进一条可运行流水线:pytest 负责编排,每个用例产出一行记录,沿用 pr03 起的 traces.csv schema,并新增四列 test_type / mr_id / judge_score / human_label。
import csv, os, json, pytest
COLUMNS = ["case_id", "test_type", "mr_id", "model_version", "input_hash",
"output", "passed", "judge_score", "human_label", "fail_code", "ts"]
TRACE_PATH = os.getenv("TRACES", "traces.csv")
_write_header = not os.path.exists(TRACE_PATH)
@pytest.fixture(scope="session", autouse=True)
def _trace_io(request):
global _write_header
rows = request.config.stash.setdefault("rows", [])
yield rows
with open(TRACE_PATH, "a", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=COLUMNS)
if _write_header:
w.writeheader; _write_header = False
w.writerows(rows)
import json, hashlib
from datetime import datetime, timezone
from hypothesis import given, strategies as st, settings, HealthCheck
from model_client import call_model # 封装 pr11 被测模型(含 model_version)
from mr_defs import MR_TABLE # §3 的蜕变关系表
from judge import llm_judge # §5 的 LLM-as-a-Judge
def _row(case_id, ttype, mr_id, inp, out, ok, score="", human="", fc=""):
return dict(case_id=case_id, test_type=ttype, mr_id=mr_id,
model_version=call_model.version,
input_hash=hashlib.sha256(inp.encode).hexdigest[:12],
output=out[:500], passed=bool(ok),
judge_score=score, human_label=human, fail_code=fc,
ts=datetime.now(timezone.utc).isoformat)
# —— 1) 蜕变测试:逐条 MR 跑 source/follow-up ——
@pytest.mark.parametrize("mr", MR_TABLE, ids=[m["id"] for m in MR_TABLE])
def test_metamorphic(mr, _trace_io):
x = mr["source_input"]
y1 = call_model(mr["fn"], x)
y2 = call_model(mr["fn"], mr["transform"](x))
ok = mr["relation"](y1, y2) # 判定 y2 ≅ T'(y1)
fc = "" if ok else "F-MR" # 违反 → 交 §8 失败编码
_trace_io.append(_row(mr["id"], "metamorphic", mr["id"],
x, y2, ok, fc=fc))
assert ok, f"{mr['id']} 违反不变式"
# —— 2) 属性测试:随机输入 + 恒真属性 ——
@settings(max_examples=30, deadline=None,
suppress_health_check=[HealthCheck.too_slow])
@given(q=st.text(min_size=1, max_size=160))
def test_properties(q, _trace_io):
out = call_model("answer_qa", q)
ok = (len(out) <= 2000 and json.loads(out).get("answer"))
_trace_io.append(_row("PBT", "property", "-", q, out, bool(ok),
fc="" if ok else "F-PBT"))
assert ok
# —— 3) LLM-as-a-Judge:对语义质量打分(仅用于 MR/属性覆盖不到的维度) ——
def test_judge_batch(cases, _trace_io):
for c in cases: # cases 为人工抽检样本
out = call_model(c["fn"], c["input"])
j = llm_judge(c["question"], out, c["keypoints"])
_trace_io.append(_row(c["id"], "judge", "-", c["input"], out,
j["verdict"] == "pass", score=j["completeness"],
human=c.get("human_label", ""),
fc="" if j["verdict"] == "pass" else "F-JUDGE"))
# 注意: 断言用人工对齐后的 Kappa 决定是否放行, 见 §6
assert isinstance(j["completeness"], int)
运行:
pytest -q test_ai.py后,traces.csv每行都带test_type∈{metamorphic,property,judge}与model_version,可直接喂给 §8 的看板聚合脚本,也能按fp16/int8对比 MR 违反率。
8. 指标与失败编码(对接 pr09 / pr08)
测试失败不是终点,而是喂给度量与失败分析的原料。把三类判据的失败映射到 F 码(沿用 pr09 的失败 taxonomy),再聚合出三个看板口径(对接 pr08 的度量看板):
| F 码 | 触发 | 看板指标 | 计算口径 |
|---|---|---|---|
| F-MR | 蜕变关系违反 | MR 违反率 | 违反的 MR 断言数 / MR 断言总数(按 mr_id 与 model_version 分桶) |
| F-PBT | 属性被证伪 | 属性反例率 | Hypothesis 反例数 / 采样数(含最小化反例留档) |
| F-JUDGE | Judge 判 fail/review 越阈值 | 通过率 | verdict==pass 占比;须同时报人-Judge Kappa 以示可信 |
| F-DISAGREE | Judge 与人工判定不一致 | Judge-人工分歧率 | judge!=human_label 的抽检样本占比(对齐 §6) |
import pandas as pd
df = pd.read_csv("traces.csv")
def board(g):
mr = g[g.test_type == "metamorphic"]
jb = g[g.test_type == "judge"]
return pd.Series({
"pass_rate": g.passed.mean,
"mr_viol": (mr.fail_code == "F-MR").mean if len(mr) else pd.NA,
"judge_disag": (jb.human_label.notna &
(jb.judge_score.astype(str) != jb.human_label.astype(str))).mean
if len(jb) else pd.NA,
})
print(df.groupby("model_version").apply(board)) # fp16 vs int8 并排看 MR 违反率
口径纪律:"通过率"永远要和"人-Judge Kappa"绑定呈现——一个只有 95% 通过率、但 Kappa 仅 0.3 的 Judge,它的"通过"是没有说服力的。单看一个数就是自欺。
9. 实验设计:拒绝单次结论(对接 pr14)
AI 组件非确定,一次跑出的通过率毫无意义。规范做法:对每个配置(模型版本 × 判定阈值 × Judge 家族)重复 n 次采样取平均,报告置信区间与版本间比较的效应量 Cohen's d(呼应 pr14 的实证方法)。
例如比较 fp16 与 int8 两版本的 MR 通过率:Welch t 检验给显著性、Cohen's d 给实际差异大小。若统计显著但 d 很小,说明量化差异工程上可忽略——避免"大样本下 p 值必然显著"的误读(pr14 §效应量)。
三条纪律:① 重复次数、seed 集合、温度写进常量并入版本;② Judge 打分也重复(固定温度=0 降噪),报告其自身方差;③ 结论一律"多次均值 ± 95% CI + 效应量",禁止用单次快照下"测得全对"式承诺。
10. 与其他案例的接口
本案例不是孤岛,它站在模型工程主线的下游、度量与实证主线的上游:
| 案例 | 接口关系 |
|---|---|
| pr11 | 被测模型来源:直接拿其训练/量化后的模型当被测体,§4 比较量化前后 MR 是否仍成立,形成"训练↔测试"闭环 |
| pr07 | 安全类失败交护栏:属性测试里"敏感串泄露/有害内容"命中时,不直接放行,转 pr07 的安全护栏做处置与审计 |
| pr08 | 度量看板:§8 的通过率 / MR 违反率 / Judge-人工分歧率作为指标接入 pr08 看板 |
| pr09 | Kappa 与失败编码:§6 的人-人 / 人-Judge Kappa 与 §8 的 F 码 taxonomy 沿用 pr09 方法 |
| pr14 | 实证方法:§9 的重复采样、置信区间、Cohen's d 直接对接 pr14 的实验设计 |
| pr03 | traces.csv schema:沿用 pr03 起的轨迹采集 schema,本案例新增 test_type / mr_id / judge_score / human_label 四列 |
一页纸总结:AI 组件难测源于非确定 + 无唯一真值 → 用蜕变关系断言"跨输入关系不变"、用属性测试断言"对一切输入恒真的性质"、用 LLM-as-a-Judge 兜语义质量 → 但 Judge 有位置/冗长/自我偏好偏差,须以人-Judge Cohen's Kappa 度量其可信度、不达标即改 rubric 或换 Judge → 三者缝进 pytest 写入 traces.csv,失败映射到 F 码接入 pr08 看板,实验遵循 pr14 的"重复 + CI + 效应量",绝不给"测得全对"的承诺。