为什么讲这个案例:它把测试从"有没有 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)要求:

$$\forall x,\; f(T(x)) \;\cong\; T'\big(f(x)\big)$$

这里 ≅ 不必是"完全相等",可以是"语义等价""包含""偏序""分布一致"等——只要它是可判定的关系即可。对非确定性输出,把 f 视作分布,判据写成"重复采样后关系的成立率 ≥ 阈值":

$$\Pr_{x'\sim f(T(x)),\,y\sim f(x)}\big[\,x' \cong T'(y)\,\big] \;\ge\; \tau$$

2.2 给翻译 / 摘要 / 问答设计 MR

下面是三类常见任务的"输入等价改写 → 语义等价输出"直觉。关键是把模糊的"应该差不多"落成可判定的关系:

  • 翻译:把中文原文里的一句无关插入语删掉(T=删除与目标语义无关的修饰),译文主干应仍可覆盖原意(T'=蕴含),且来回翻译(中→英→中)与原文的语义相似度不低于阈值。
  • 摘要:原文中的核心实体集合应是摘要实体的超集(摘要不应凭空捏造人名/数字)——"原文 ⊇ 摘要要素"。给原文追加一段与主题无关的噪声,摘要主干应基本保持稳定。
  • 问答:把问句做等价改写("X 的成立日期" ↔ "X 什么时候成立"),两个答案应语义一致;给上下文补充"无关文档",正确答案不应被带偏(不变性)。
Source 用例(原始输入) Follow-up 用例(等价改写后) 输入 x f(x)输出 y₁ T(x) f(T(x))输出 y₂ 施加输出侧期望关系 T′ 判定:y₂ ≅ T′(y₁) ? 成立 → 通过;违反 → 记录为一次 MR 违反(F 码) 无需知道 y₁ 的"绝对正确值",只需断言两路输出之间的"关系不变"。
图 2-1 · 蜕变测试:以"关系不变式"取代"黄金值断言"

动手练习:给"中文→英文翻译"函数设计 3 条 MR,其中至少 1 条必须是能被真实模型违反的(如对含否定的句子做改写后语义翻转)。让读者亲手找到"模型在哪条关系上不蜕变",比背定义有效得多。


3. 属性测试(Hypothesis)

属性测试(Property-based Testing)与蜕变测试互补:它随机生成大量输入,断言某个"对一切输入都该成立"的属性恒真。Hypothesis 库还会在发现反例后自动收缩(shrinking)到最小失败用例,极大降低排障成本。对 AI 组件,常用属性包括:输出长度上界、禁止出现敏感串、必须能解析成合法 schema、幂等性、含某必答字段等。

Python
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,并强制只输出可解析结果(维度分 + 判定 + 理由),避免自由发挥:

Text · Judge Prompt
你是严格的评审。仅依据下方 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-PreferenceJudge 偏爱自家模型风格/同族输出换不同家族 Judge、盲评、屏蔽模型签名
分数锚定总分被首维度评分带偏各维度独立打分、打乱维度顺序

Judge 局限性清单(写进实验报告):① 有位置/冗长/自我偏好三类已知偏差;② 评分非确定,需固定温度=0 并重复;③ 对超出其知识的事实正确性不可靠(只能判"是否忠于给定上下文");④ 与被评模型同族时偏差最大;⑤ 分数是序数量表,不宜直接当"百分比正确率"对外宣称。


6. Judge 可信度 = 与人工标注的一致性

既然 Judge 本身是"被测对象",就不能盲信它的分。可信度的操作性定义是:Judge 的判定与人工标注的一致性(呼应 pr09 的标注一致性方法)。做法是抽样一批输出,请两名人工背靠背双标,再与 Judge 的判定比较,计算 Cohen's Kappa 与一致率。

$$\kappa=\frac{p_o-p_e}{1-p_e}$$

其中 p_o 为观察到的一致比例,p_e 为随机情况下期望的一致比例(由双方各类别边缘分布相乘求和得到)。Kappa 扣除了"瞎猜也能蒙对"的部分,比裸一致率更可信。经验参考:κ≥0.6 尚可、0.4–0.6 中等、<0.4 说明 Judge 与人类判断脱节。

Python
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。

Python · conftest 采集
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)
Python · test_ai.py
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-JUDGEJudge 判 fail/review 越阈值通过率verdict==pass 占比;须同时报人-Judge Kappa 以示可信
F-DISAGREEJudge 与人工判定不一致Judge-人工分歧率judge!=human_label 的抽检样本占比(对齐 §6)
$$\text{MR 违反率}= \frac{\sum \mathbb{1}[\,\text{fail\_code}=\text{F-MR}\,]}{\text{MR 断言总数}},\qquad \text{分歧率}= \frac{\sum \mathbb{1}[\,\text{judge}\neq \text{human}\,]}{|\text{抽检集}|}$$
Python · 看板聚合
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 的实证方法)。

$$d=\frac{\bar{x}_1-\bar{x}_2}{s_{\text{pooled}}},\qquad s_{\text{pooled}}=\sqrt{\frac{s_1^2+s_2^2}{2}}$$

例如比较 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 看板
pr09Kappa 与失败编码:§6 的人-人 / 人-Judge Kappa 与 §8 的 F 码 taxonomy 沿用 pr09 方法
pr14实证方法:§9 的重复采样、置信区间、Cohen's d 直接对接 pr14 的实验设计
pr03traces.csv schema:沿用 pr03 起的轨迹采集 schema,本案例新增 test_type / mr_id / judge_score / human_label 四列
被测模型pr11 · fp16/int8 蜕变测试 MR 属性测试 PBT LLM-as-a-Judge traces.csvtest_type / mr_id… pr08 看板 F 码 · Kappapr09 / pr14
图 7-2 · 三类判据汇入 traces.csv,再分流出 pr08 看板与 pr09/pr14 的失败编码与实证分析

一页纸总结:AI 组件难测源于非确定 + 无唯一真值 → 用蜕变关系断言"跨输入关系不变"、用属性测试断言"对一切输入恒真的性质"、用 LLM-as-a-Judge 兜语义质量 → 但 Judge 有位置/冗长/自我偏好偏差,须以人-Judge Cohen's Kappa 度量其可信度、不达标即改 rubric 或换 Judge → 三者缝进 pytest 写入 traces.csv,失败映射到 F 码接入 pr08 看板,实验遵循 pr14 的"重复 + CI + 效应量",绝不给"测得全对"的承诺。