# 度量缺陷修复:为什么之前报的数字是错的 > 日期:2026-09-15 | 全部数字为**百分比** | 语料:`data/realistic_v2/eval.jsonl`(200 条,8 类) ## 0. 一句话结论 之前上报的「总体 66.50% / 可回答 76.00% / 未知拒答 0.00%」**不是模型的真实水平**, 而是**三个度量/语料缺陷**叠出来的假象。修正后同一份权重、同一份语料的真实水平是 **总体 82.00% / 可回答 86.29% / 未知拒答 52.00% / 已知被误拒 2.29%**。 更重要的副产品:**「多跳只有 24%、是最大瓶颈」这个判断本身是错的** —— 多跳真实是 **100.00%**。 --- ## 1. 缺陷一:评分器对空格敏感(影响最大) **原实现**(`eval_end_to_end_memory.score_case`): ```python accepted = [value for value in case["acceptable"] if value and value.lower() in lowered] ``` **症状**:期望锚点是 `值班人-5259`,模型回答 `值班人 -5259`(**只多一个空格**)→ 判错。 **规模**:逐条核对,**19 条多跳用例**全部栽在这一个空格上。 以基线跑分(`rv2_new_e2e`,同一份权重、同一份语料)逐级展开: | 评分器状态 | 总体 | 可回答 | 未知拒答 | |---|---:|---:|---:| | 原始(两个缺陷都在) | 63.50% | 72.57% | 0.00% | | 只修拒答检测 | 70.00% | — | 52.00% | | **两个都修** | **79.50%** | **83.43%** | **52.00%** | `rescore_e2e` 的 legacy 列 = 「旧空白逻辑 + 新拒答检测」(70.00%), 修正列 = 两个都修(79.50%)。因此两个缺陷各自的贡献是: | 缺陷 | 总体贡献 | 单项最大影响 | |---|---:|---| | 拒答检测只认关键词 | **+6.50pp** | 未知拒答 0.00% → 52.00% | | 评分器对空格敏感 | **+9.50pp** | 多跳 24.00% → 100.00% | 多跳一项单独看: | | 旧评分 | 修正后 | |---|---:|---:| | multi_hop | 24.00% | **100.00%** | **核对方法**:`audit_score_flips.py` 逐条打印锚点与回复,19 条 flip 全部是 「原始锚点不存在、去掉空白后在同一位置命中」,没有一条是「本来不该算对」。 **修法**:`eval_scoring.squash()` 做 NFKC 归一 + 去掉所有空白(含全角空格)后再做包含判定。 锚点在本项目里是标识符/版本号/时间/人名,**空白不携带语义**。 --- ## 2. 缺陷二:拒答检测只认固定关键词 **原实现**:`ABSTENTION_MARKERS = ("不知道", "没有记录", "无法确认", "未找到", "不清楚", "没有相关信息", "不知道。")` **症状**:模型实际在 25 条未知属性用例里拒答了 13 条,措辞是 > 「关于您的 weekly 会议安排,当前长期记忆中**未包含相关信息**,无法回答。」 > 「关于报警触发条件,当前记忆中没有相关记录。」 —— **一条都不在关键词表里**。于是「未知拒答率」被报成 **0.00%**。 **规模**: | | 旧检测 | 修正后 | |---|---:|---:| | unknown_attribute 拒答 | 0.00% | **52.00%**(13/25) | **核对方法**:`audit_refusal_both_ways.py` 把两个方向都打出来 —— 13 条判为拒答的逐条都是真拒答;7 条「可回答但被判误拒」的逐条都是真的「我答不出来」; 另有 2 条虽然带拒答字样但**同时命中了正确锚点**,不计入误拒(`wrongly_abstained` 要求未命中锚点)。 **修法**:`eval_scoring.is_refusal()` 改为**模式集**(`未包含|未找到|未记录|无法回答|证据不足| 记忆中没有…|未知。` 等),并把 `refused` 单独记进每行结果,便于离线复核。 --- ## 3. 缺陷三:`update_conflict` 是用例本身不成立(25/25) **这是语料/评测装置的缺陷,不是模型缺陷。** 生成器写的是: ```python candidates = [old_fact, new_fact] + [frames[0].format(s="我", v=_value(rng)) for _ in range(3)] ``` 后 3 条干扰项用的是 **`old_fact` 同一个句式**,也就是**同一个属性**、随机值。 而评测装置当年的写入顺序是 `positives 在前`,于是: > **期望答案被最先写进库,随后又被 4 条同属性事实覆盖。** `analyze_update_conflict.py` 用生成器自己的句式模板做属性归组后确认: | 检查 | 结果 | |---|---:| | 该属性被写入的事实条数 | 5 条(25/25 用例) | | **最新一条 `= 期望答案`** | **0/25** | | 模型答了「最新那条」 | 9/25 | | 模型答了「期望答案」 | 10/25 | | 两者都不是 | 6/25 | 也就是说:**这个类别在构造上要求模型答一个库里已经被改掉的值**。 模型「跟着最新值走」反而是更接近产品正确的行为,却一律判错。 **修法(两处,都必要)**: 1. **写入顺序按时间**:`--write-order answer-last`(默认)。干扰项先写、**答题事实最后写**, 期望答案成为最新证据 —— 这也符合真实会话(旧事实先来)。 → `update_conflict` **44.00% → 64.00%**。 2. **答题事实必须预留槽位**:改成 answer-last 后我第一次直接写成 `(distractors + positives)[:facts]`,而 `noise_context` 是 **1 条答案 + 8 条干扰**、 槽位只有 6 → **答案被整个截掉**,该类别从 72.00% 崩到 **0.00%**。 改成 `distractors[:facts - len(positives)] + positives` 后恢复。 → 已加单元测试锁死(`EvidenceWriteOrderTest`)。 --- ## 4. 修正后的完整状态 三份跑的**同一 200 条语料**、**同一份权重**,用修正后的评分器重新打分(`rescored_e2e.py`, 从存盘回复离线重算,不需要重跑 4B 模型): | 配置 | 总体 | 可回答(175) | 未知拒答(25) | 已知被误拒 | |---|---:|---:|---:|---:| | ① 原始(answer-first,含 supersede 修复前) | 79.50% | 83.43% | 52.00% | 2.86% | | ② + 补丁式更新修复(answer-first) | 80.00% | 84.00% | 52.00% | 4.00% | | ③ **+ 时间序写入 + 预留槽位(当前)** | **82.00%** | **86.29%** | **52.00%** | **2.29%** | 分类型(当前配置): | 类别 | 正确率 | 错几条 | |---|---:|---:| | alias_paraphrase | 100.00% | 0 | | multi_hop | 100.00% | 0 | | near_miss | 100.00% | 0 | | multi_entity | 96.00% | 1 | | long_fact | 72.00% | 7 | | noise_context | 72.00% | 7 | | update_conflict | 64.00% | 9 | | unknown_attribute | **52.00%** | **12** | **真正剩下的短板只有 4 项、共 36 条**:未知属性 12、更新冲突 9、长事实 7、噪声上下文 7。 --- ## 5. 一个判决性的负面结论:margin 门(省得以后再试) 看到「未知拒答」是短板后,我按正确顺序先量了最简单的信号,**没有直接动手改运行时**: - 记 `cos(查询, 最好记录) - cos(查询, 次好记录)` 为 margin; - 训练/评测**属性族不相交**,阈值只在训练族上拟合、再套到评测族。 | 信号 | 训练 AUC | 评测 AUC | |---|---:|---:| | 最大余弦 | — | 0.6741 | | **margin** | 0.8348 | **0.8629** | AUC 看起来能跨属性族泛化(0.86),**但它不能用来做这件事**: 把评测集的 25 条未知属性用例拆成「模型已拒答的 13 条」与「模型编造的 12 条」后 —— - 已拒答的 margin:0.000 ~ 0.107 - **编造的 margin:0.003 ~ 0.082** **两者完全交织**。取阈值 0.020 时:抓住 4/12 条编造,却把 6/13 条**本来已经拒答**的再判一次, 并新增 6/75 条误拒。净效果是「未知拒答 52%→68%、误拒 2.29%→10.29%」,**得不偿失**。 原因:那 0.86 的 AUC 来自**跨类别**的记录集形状差异,而不是「这一条到底有没有对应记录」的判别力。 **结论:margin 门不接入运行时。** --- ## 6. 工具与纪律(本轮新增,可复用) | 文件 | 作用 | |---|---| | `eval_scoring.py` | 唯一评分真源(空白无关 + 模式化拒答),可离线重算 | | `rescore_e2e.py` | **从存盘回复离线重算**,不用重跑 4B 模型即可用新评分器比较历史跑分 | | `verify_pairing.py` | 用存盘的 `matched` 字段**独立证明**「运行行 ↔ 语料用例」配对正确 | | `audit_score_flips.py` | 逐条打印每个判定翻转的锚点与回复,证明翻转是空白造成的 | | `audit_refusal_both_ways.py` | 拒答检测两个方向复核(漏检 + 过检) | | `analyze_update_conflict.py` | 用生成器句式证明类别是否自相矛盾 | | `probe_answerability_cosine.py` / `fit_answerability_threshold.py` | 先量信号再改代码 | | `probe_margin_vs_fabrications.py` | 判定门是否真能抓「编造」而非「重复拒答」 | **两条本轮踩到、值得记住的坑**: 1. **配对错位**:运行结果按「类别字典序」排,语料按文件序存。 直接 `zip(语料, 运行结果)` 会**整行错配**,而且同一类别里查询重复 → 检查不出来, 表现是「凭空多出一个 0.94 的 AUC」和「模型拒答数从 13 变成 2」。 我因此得出过一次错误的乐观结论,靠 `verify_pairing.py` 的独立证人抓回来。 **以后一律用 `rescore_e2e.load_corpus` 的排序加载器。** 2. **`re.split` 的捕获组会混进结果列表**:`re.split(r"\{[sv]\}", ...)` 里的 `[sv]` 是捕获组, 返回值会插入 `'s'`/`'v'`,拼出来的正则全错(表现为「属性归组恒为 0」)。 另外**先 `re.escape` 再按占位符切分会失败**(`{s}` 已变成 `\{s\}`),必须**先切分、再逐段 escape**。 --- ## 7. 下一步(按证据排序) 1. **未知属性 12 条编造** —— 这是用户最在意的轴,且已证明「打分几何 + margin」都抓不住。 剩下唯一有希望的方向是**让门学会「问的属性在不在库里」这件事本身**: 已有 `make_answerability_data.py` 造好的 **6400 条平衡监督样本** (400/类别 × 8,正负各半,**训练族与评测族属性不相交**),下一步是训练这个门。 2. **更新冲突 9 条** —— 时间序写入已把类别变成可解,接下来查「同一属性多值时读取端是否按新鲜度仲裁」。 3. **长事实 / 噪声上下文各 7 条** —— 长事实的判别线索是**确定性措辞**(「最后确认…按这个执行」 vs「会上也提过一嘴…没有正式确认过」),噪声上下文的失败里有一半是**该答却拒答**。