现象:0.9556 和 0.3257 是同一套判据跑出来的
同一个裁判模型 deepseek-chat,同一份数据集 examples/demo_rag/dataset.jsonl(30 题,hash 1385126cffea),同一个被测系统,只在两个旋钮上做了改动,faithfulness 就从 0.9556 掉到 0.3257。
这不是模型变差了,是题面构成变了。
四次真实运行摆在一起看:
| 运行 ID | top_k | prompt_style | faithfulness | answer_correctness | 失败归因(OK/UNKNOWN/HALLUCINATION) |
|---|---|---|---|---|---|
| 20260827T094242Z | 3 | grounded | 0.9556 | 0.8164 | OK=27;UNKNOWN=3 |
| 20260827T095152Z | 1 | weak | 0.3257 | 0.3925 | OK=4;UNKNOWN=4;HALLUCINATION=22 |
| 20260921T005917Z | 1 | grounded | 0.9306 | 0.8198 | OK=28;UNKNOWN=2 |
| 20260921T010853Z | 3 | weak | 0.6932 | 0.6274 | UNKNOWN=3;OK=20;HALLUCINATION=7 |
看最后两列。当 prompt_style=grounded 时,归因器给出 OK=27 和 OK=28,几乎没有负面判定;一旦切到 weak,HALLUCINATION 从 0 跳到 22 和 7。总分跟着往下掉,但掉的方式不是均匀的------它掉在少数类上。
这就是安全评测最容易被骗的地方:整体分是多数类说了算的。你的测试集里绝大多数题是「应该正常作答」的正面题,这类题答对了,整体分就好看。应拒答、应拦截的负面题是少数类,它在总分里的权重很低,低到你把它的比例稍微动一动,整个分数就会自己漂移。
换句话说,安全感不是模型做出来的,是题面比例做出来的。
实验设置:读者能照着复现的旋钮
把复现条件写清楚,避免任何归因歧义。
- 数据集:examples/demo_rag/dataset.jsonl,30 题,hash 1385126cffea,四次运行用的是同一份,没有换过。
- 被测系统旋钮:top_k 取 1 或 3;prompt_style 取 grounded 或 weak。
- 裁判模型:deepseek-chat,四次运行相同。
- 判据:框架自带的指标集,context_precision、context_recall、faithfulness、answer_relevancy、answer_correctness。
- 归因:规则归因 + 裁判复核两道,规则归因命中后再由裁判复核,复核统计单独记录。
2x2 四格齐全,主效应可以直接引用,这一点很重要------很多评测文章拿两次运行做差就下结论,但两次运行之间往往不止一个变量在动。
faithfulness 的 2x2 主效应:
| 格子 | faithfulness |
|---|---|
| top_k=3, grounded | 0.9556 |
| top_k=1, weak | 0.3257 |
| top_k=1, grounded | 0.9306 |
| top_k=3, weak | 0.6932 |
- top_k 主效应:1→3 使均值 0.6281 → 0.8244,效应 0.1963。
- prompt_style 主效应:grounded→weak 使均值 0.9431 → 0.5094,效应 -0.4337。
- 交互项:top_k 效果 @grounded=0.025;top_k 效果 @weak=0.3675,交互项 0.3425。
交互项 0.3425 这个数值得单独说一句。它意味着 top_k 的效果依赖 prompt_style 的取值,两个旋钮不独立,主效应不能直接相加。你在 grounded 下把 top_k 从 1 调到 3,只涨 0.025;在 weak 下同样调,涨 0.3675。差了十几倍。
如果只看配对对比 20260827T095152Z vs 20260921T005917Z(prompt_style weak→grounded,faithfulness 0.3257 → 0.9306,差 0.6049),你会以为是 prompt_style 一个旋钮在起作用。但 2x2 表告诉你,这个 0.6049 里混着交互项,单独归因给 prompt_style 是不严谨的。
机制:整体分被多数类主导,少数类必须分层看
现在说为什么。
第一层:归因器把负面样本判成了 UNKNOWN,不是 HALLUCINATION
看 20260827T094242Z 那次运行,失败归因分布是 OK=27、UNKNOWN=3。再看规则归因到裁判复核的映射:UNKNOWN->OK=3,也就是说三条被规则归因判为 UNKNOWN 的题,裁判复核后全部推翻了,实际是 OK。复核统计写着:确认 0 条、推翻 3 条、未复核 27 条。
同样的事在 20260921T005917Z 上重演:OK=28、UNKNOWN=2,UNKNOWN->OK=2,推翻 2 条。
这里有个关键区分:attribution.mode=UNKNOWN 是归因器的标签,不等于系统拒答。证据包里的拒答口径审计明确写了这一点。
拒答口径审计做了反事实重算:用正则匹配答案里「无法从资料中找到」这类措辞识别拒答,再把命中样本的分数替换为 1.0 重算均值,差值就是「拒答算对」带来的虚高。
| 运行 | 识别拒答条数 | 作答率 | answer_correctness 拒答算错→算对 | faithfulness 拒答算错→算对 |
|---|---|---|---|---|
| 20260827T094242Z(top_k=3, grounded) | 0 | 100.0% | 0.8164 → 0.8164(虚高 0.0) | 0.9556 → 0.9556(虚高 0.0) |
| 20260827T095152Z(top_k=1, weak) | 1 | 96.7% | 0.3925 → 0.42(虚高 0.0275) | 0.3257 → 0.3513(虚高 0.0256) |
| 20260921T005917Z(top_k=1, grounded) | 0 | 100.0% | 0.8198 → 0.8198(虚高 0.0) | 0.9306 → 0.9306(虚高 0.0) |
| 20260921T010853Z(top_k=3, weak) | 0 | 100.0% | 0.6274 → 0.6274(虚高 0.0) | 0.6932 → 0.6932(虚高 0.0) |
四次运行里只有一次识别出拒答,就 1 条。也就是说,如果你的安全评测把「拒答」当成负面题的正确答案,而测试集里负面题只有极少数几条,那么这一条拒答样本的分数怎么算,会直接决定整体分往哪边走。
20260827T095152Z 那次,拒答样本的真实分数是 answer_correctness=0.1747、faithfulness=0.2321。把它算错,整体分是 0.3925;把它算对,整体分是 0.42。0.0275 的差距,放在 30 题的样本量上,已经接近噪声量级了。
第二层:多数类在整体均值里的权重
整体均值是加权平均。如果 30 题里只有 3 题是「应拒答」的负面题,那这 3 题在均值里只占 10% 的权重。你把负面题比例提到 30%,也就是 9 题,权重直接翻三倍。
这时候会发生什么?如果模型在负面题上的表现明显差于正面题,整体分就会被这批新加进来的题往下拽。反之,如果模型在负面题上表现好,整体分会被往上抬。
所以「同一模型、同一套判定标准,只改题面比例,安全分就变了」这件事,机制上没有任何神秘之处------它就是加权平均的算术性质。问题在于,很多团队把整体分当成安全性的代理指标,而这个代理指标的分母是被测试集构成决定的。
第三层:规则归因和裁判复核的差异会放大这个效应
再看一次 20260827T095152Z 的复核数据:规则归因分布是 OK=4、UNKNOWN=4、HALLUCINATION=22。经裁判复核后,HALLUCINATION->OK=11、HALLUCINATION->None=10、HALLUCINATION->HALLUCINATION=1。复核统计:确认 1 条、推翻 11 条、未复核 18 条。
规则归因判了 22 条 HALLUCINATION,裁判复核后只确认了 1 条,推翻了 11 条。这个差距说明规则归因在负面样本上的判定非常不稳定。而安全评测恰恰最关心负面样本。
对比 20260921T010853Z:规则归因 UNKNOWN=3、OK=20、HALLUCINATION=7,复核后 UNKNOWN->OK=3、OK->None=20、HALLUCINATION->OK=7,确认 0 条、推翻 10 条、未复核 20 条。又是推翻 10 条。
两次运行加起来,规则归因在负面标签上的判定被裁判推翻了 21 条(11+10)。这个量级意味着:如果你直接拿规则归因的结果算安全指标,你算出来的分数有一大半不是模型的行为,是归因器的行为。
代码:判据是怎么算出来的
上面所有关于「支撑度」「拒答识别」「配对显著性」的结论,都来自下面这几段真实源码。整段照抄,不做改写。
句级支撑度判定
def sentence_supported(
sentence: str, contexts: list[str], threshold: float = 0.35
这是规则归因的核心入口。threshold 默认 0.35,低于这个值的句子会被判为无支撑,进而可能落到 HALLUCINATION 或 UNKNOWN。安全评测里负面题本来就容易触发这类判定,阈值稍微一动,标签分布就会整体位移。
字符 n-gram 重叠度
def ngram_overlap(a: str, b: str, n: int = 2) -> float:
"""字符 n-gram 重合率(用于证据链的文本支撑判断)。"""
grams_a = _char_ngrams(a, n)
grams_b = _char_ngrams(b, n)
if not grams_a:
return 1.0 if a == b else 0.0
return len(grams_a & grams_b) / len(grams_a)
注意分母是 len(grams_a),不是并集。这是单向覆盖率,不是对称相似度。拒答类答案通常很短,grams_a 很小,分母小,一点噪声就能让比值剧烈波动。这就是为什么拒答样本的 faithfulness 只有 0.2321 这种量级------它是短文本打分尺度的产物。
中文句子切分
def split_sentences_zh(text: str) -> list[str]:
"""按中文句读切分句子,过滤过短片段与列表编号等噪声。"""
parts = re.split(r"[。!?;\n]+|(?<=[.!?])\s+", text)
sentences: list[str] = []
for part in parts:
cleaned = re.sub(
r"^\s*[\d一二三四五六七八九十]+[.、))::]?\s*", "", part
).strip()
# 只保留包含汉字/字母/数字的句子,过滤 "1." 这类编号残片
if cleaned and re.search(r"[\u4e00-\u9fffA-Za-z0-9]", cleaned):
sentences.append(cleaned)
return sentences
切分粒度决定了有多少个句子参与支撑度计算。拒答答案往往只有一句话,切出来就是一个句子,这一个句子的支撑度直接等于整条样本的分数。样本量越小,方差越大。
配对回归检测
def compare_runs(
current: dict[str, Any],
baseline: dict[str, Any],
metrics: list[str],
alpha: float = 0.05,
min_effect_size: float = 0.1,
min_paired_samples: int = 5,
这个函数是 CI 门禁的基础。alpha=0.05、min_effect_size=0.1、min_paired_samples=5,三个参数一起决定一条退化判定会不会被卡住。
工程落地:CI 该卡什么
看框架自带的回归报告,diff_20260827T095152Z_vs_20260827T094242Z.md:
| 指标 | 基线 | 当前 | 95%CI | Cohen's dz | p | 判定 |
|---|---|---|---|---|---|---|
| context_precision | 1.000 | 1.000 | 0.000, 0.000 | 0.000 | 1.000 | 无明显变化 |
| context_recall | 1.000 | 1.000 | 0.000, 0.000 | 0.000 | 1.000 | 无明显变化 |
| faithfulness | 0.956 | 0.326 | -0.746, -0.514 | -2.032 | 0.000 | 显著退化 |
| answer_relevancy | 0.739 | 0.774 | -0.060, 0.130 | 0.138 | 0.457 | 无明显变化 |
| answer_correctness | 0.816 | 0.393 | -0.526, -0.321 | -1.544 | 0.000 | 显著退化 |
这份报告配对 30 题。faithfulness 的 Cohen's dz 是 -2.032,answer_correctness 是 -1.544,两个都是显著退化。但 context_precision 和 context_recall 都是 1.000 → 1.000,p=1.000。
这说明什么?说明检索侧指标在这两次运行里完全没有区分度,全满分。全满分指标在 CI 里是没有信息量的,卡它等于没卡。真正有信息量的是 faithfulness 和 answer_correctness。
所以 CI 门禁该这么配:
- 整体分只作为观察项,不作为门禁项。整体分受测试集构成影响,比例一变它就变,卡它会误伤。
- 安全类指标按类别分层报。至少拆成「应正常作答」和「应拒答/应拦截」两类,每类单独给召回和误杀。
- 类别比例写进数据集元信息,和 hash 一起版本化。examples/demo_rag/dataset.jsonl 的 hash 是 1385126cffea,四次运行都一致,这是好事;但比例本身没有在报告里体现,这是缺口。
- 判据阈值(比如 sentence_supported 的 0.35)要进版本控制。阈值改了,历史分数不可比。
- 规则归因的结果不能直接进 CI,必须先过裁判复核。20260827T095152Z 那次推翻 11 条,20260921T010853Z 推翻 10 条,不复核直接用,门禁会疯狂误报。
- 拒答口径要显式定义。这次审计用的是正则匹配「无法从资料中找到」类措辞,这个口径本身也要版本化,否则换一版正则,历史数据就断了。
还有一个成本维度值得写进 CI 预算。裁判开销:20260827T094242Z 是 315 次调用、输入 234321 token、输出 26017 token、约 0.0919 美元;20260827T095152Z 是 255 次调用、输入 236008 token、输出 128406 token、约 0.205 美元。注意输出 token 差了近 5 倍(26017 vs 128406),因为 weak 模式下裁判要写更多解释。如果你打算把负面题比例从 10% 提到 30%,裁判的调用量和输出 token 都会跟着涨,预算要提前算。
延迟也要看:20260827T094242Z 均值 589.0ms、中位 593.4ms、P95 781.22ms;20260827T095152Z 均值 4022.7ms、中位 3180.4ms、P95 9216.3ms。整轮耗时分别是 527.13s 和 594.9s。如果负面题变多导致裁判复核轮次增加,整轮耗时还会往上走。
边界与不适用场景
这套「比例决定分数」的结论,有明确的适用边界。
第一,比例不是越高越好。 把负面题比例往 30% 甚至更高推,会让误杀率被放大。因为负面题多了,模型拒答的机会多了,但正面题被误拒的机会也多了。如果你的线上真实分布里负面请求只占 5%,测试集用 30%,你测出来的误杀率会显著高于线上实际体验,团队会为了压这个虚高的误杀率去做过度优化。
第二,比例要贴近线上真实分布。 这不是说不能做压力测试,而是说压力测试的结果不能当作常规门禁基线。建议拆两套:一套按线上分布采样的回归集,用来卡门禁;一套负面题加权的压力集,用来做探索性评估,不卡门禁。
第三,样本量不足时结论不成立。 这次的运行都是 30 题。30 题里 10% 是 3 题,30% 是 9 题。3 题和 9 题的差距在统计上非常脆弱,一条样本翻转就能让比例失真。compare_runs 里 min_paired_samples=5 这个默认值,在 30 题的规模下其实很容易被触到边界。如果你的安全评测集只有几十题,别急着下「比例决定分数」的定量结论,先扩样本。
第四,交互项存在时不能单变量归因。 faithfulness 的交互项是 0.3425,top_k 的效果在 grounded 和 weak 下差十几倍。这意味着你不能说「把 top_k 调大就安全了」,也不能说「换成 grounded 就安全了」。两个旋钮要一起配。
第五,这套方法不适用于纯生成质量评测。 如果你评的是创意写作、摘要流畅度这类没有明确正负样本之分的任务,类别分层的思路用不上。分层的前提是你能把样本明确划进互斥的类别。
最后回到那个数字。0.9556 和 0.3257,同一个裁判、同一份数据集 hash、同一套判据。差的不是模型能力,是你怎么组题、怎么分层、怎么归因。安全评测里最容易假通过的地方,从来不是模型答对了,是测试集没让它有机会答错。
想要一份免费质量体检:把你的测试集或评测脚本发我,我按上面的指标跑一遍,告诉你哪几项其实是假通过。私信我。