NM2.1 · 之后做了什么
三处改动各自的测量依据、把端到端正确率锁死在 50% 的写入缺陷如何定位、75% 未知泄漏如何修到 0%,以及它接上运行时连续失败两次的完整记录。
94.14% 路由器 Top-1(v2 为 41.12%)
发布页上的数字来自 v2。这一页讲**之后又做了什么**:三处改动各自凭什么、一个把端到端正确率 锁死在 50% 的写入缺陷怎么定位的、以及一个 75% 泄漏的失败怎么修到 0%——又怎么在接上运行时之后失败两次。 所有数字都来自磁盘上的报告文件,每张表都标了出处;**没有一条是估算或外推**。
先读这一句
这一页里**离线实验最好的数字(未知拒答 100.00%)不等于运行时可用**。它接上运行时之后 连续失败两次,最终被回退。把失败过程一起写出来,是因为它比成功更能说明这套机制到底卡在哪。
净变化一览
出处 · V2_dpskw\NM2_VS_NM2_1.md(该文件自述:所有数字均从磁盘上的评分卡 / JSON 读取,不是手工抄录)
| 轴 | v2(原版) | NM2.1 | 净变化 |
|---|---|---|---|
| 路由器 Top-1 | 41.12% | 94.14% | +53.02 pp |
| 路由器 Recall@3 | 46.73% | 96.48% | +49.75 pp |
| 未知拒答率 | 0.00% | 100.00% | +100.00 pp |
| 未知问题泄漏(同形候选) | 75.00% | 0.00% | −75.00 pp |
| 一次写 20 条不同属性后存活 | 12 / 20 | 20 / 20 | +8 条 |
| 零字面重叠改写正确率 | 43.75% | 68.75% | +25.00 pp |
| hop 欠预测率 | 4.68% | 0.00% | −4.68 pp |
| 参数量 / 每条记录地址字节 | 2,037,774 / 512 | 2,037,774 / 512 | 不变 |
「未知拒答率」与「未知泄漏」是同一件事的两面:前者是问库里没有的属性时**正确说不知道**的比例, 后者是**照样编一个答案**的比例。v2 在这两轴上是 0% 和 75%,也就是几乎必然硬答。
一 · 路由器工件本身的检索与排序
出处 · router_scorecard_final.json(判定见 router_verdict_final.json)· 冻结 v6 评测集 21,920 条 / 10 类别
| 指标 | v2 · V2-128 deployed(v3) | NM2.1 · REPLAY-128 v7 final |
|---|---|---|
| Top-1 正确率 | 41.12% | 94.14% |
| Recall@1 | 37.03% | 88.58% |
| Recall@3 | 46.73% | 96.48% |
| Recall@5 | 48.91% | 97.15% |
| MRR | 47.69% | 95.77% |
| nDCG@3 | 44.22% | 95.37% |
| 多跳证据全中(Top-3) | 43.43% | 96.22% |
| 多跳证据全中(仅多正例) | 37.15% | 95.45% |
| hop 正确率 | 73.23% | 100.00% |
| hop 欠预测率 | 4.68% | 0.00% |
二 · 拒答与仲裁策略轴
出处 · router_scorecard_final.json(门槛 0.50)+ threshold_sweep_check.json(0.30–0.80 全门槛扫描)
| 指标 | v2 | NM2.1 |
|---|---|---|
| need F1 | 89.58% | 100.00% |
| need 召回 | 99.82% | 100.00% |
| need 精确率 | 81.24% | 100.00% |
| 未知拒答率 | 0.00% | 100.00% |
| 已知问题被误拒率 | 0.18% | 0.00% |
| 未知问题被误读率 | 100.00% | 0.00% |
| 仲裁准确率 | 81.12% | 100.00% |
门槛扫描是这里真正的证据。0.30 / 0.40 / 0.50 / 0.60 / 0.70 / 0.80 六个门槛逐一复核: NM2.1 **全门槛通过**;而原版在**每一个**门槛上未知拒答率都是 0.00%,且门槛越高误拒越差 (0.00% → 1.32%)。一个能在整条门槛轴上都成立的结论,和一个只在某个阈值上成立的点,可信度不是一回事。
三 · 端到端整体记忆能力(同一份运行时,只有模型包不同)
出处 · nm2_battery_comparison_final.json · A 110 用例 / B 16 / C 48 / D 重启持久化
| 指标 | 原版 NM2 | NM2.1(仅换路由器) | NM2.1 最终 |
|---|---|---|---|
| A 总体正确率 | 89.09% | 88.18% | 88.18% |
| A 可回答正确率 | 100.00% | 100.00% | 100.00% |
| A 未知拒答率 | 60.00% | 56.67% | 56.67% |
| A 已知问题被误拒率 | 0.00% | 0.00% | 0.00% |
| B 零字面重叠改写回答正确率 | 43.75% | 68.75% | 68.75% |
| B 答成别的属性 | 18.75% | 18.75% | 18.75% |
| B 触发读取 | 56.25% | 93.75% | 93.75% |
| C 可回答正确率(24 同形候选) | 65.00% | 65.00% | 70.00% |
| C 答成别的属性 | 35.00% | 35.00% | 27.50% |
| C 未知泄漏率 | 75.00% | 75.00% | 0.00% |
| C 活跃记录 min / max | 23 / 24 | 23 / 24 | 23 / 24 |
| D 重启后召回 / 作答 / 清理 | 通过 | 通过 | 通过 |
运行间波动必须自己讲出来:B 段只有 16 个用例,1 个用例 = 6.25 pp。所以 B 的 68.75% 与另一次测得的 75.00% 属于同一水平的运行间波动,**不作为增益主张**。A / C / D 的结论在多次运行中一致。
四 · 零字面重叠改写的泛化
出处 · replay_check_zov.json(路由器级)与上表 B / C 段(端到端)· 数据集 300 条、24 个未见过的问法
| 指标 | v2 权重 | NM2.1 权重 |
|---|---|---|
| 路由器 Top-1(250 条可回答) | 18.40% | 59.60% |
| 路由器 Recall@3 | 36.00% | 83.20% |
| 路由器 MRR | 35.07% | 73.06% |
| 端到端改写正确率(B 段) | 43.75% | 68.75% |
| 端到端未知泄漏(C 段) | 75.00% | 0.00% |
零重叠评测集有 24 条**同句式、不同属性**的候选,随机猜中的概率是 4.17%。 所以 59.60% 大约是随机水平的 14 倍。
关键设计判断:改写与**无关**属性的字符重叠被刻意保留(命中错误候选只会得到错误答案, 让任务更难,不提供捷径);真正必须为 0 的是与**自身**目标的重叠。最初把二者混为一谈时, 24 个属性里有 7 个因为「起床时间」的「时」、「办公城市」的「公」这类高频字被判为「无可用的改写」。
五 · 代价:几乎没有
出处 · router_scorecard_final.json、router_latency_bench_prod.json(7 轮交错取中位数)
| 指标 | v2 | NM2.1 | 说明 |
|---|---|---|---|
| 参数量 | 2,037,774 | 2,037,774 | 未增加 |
| 每条记录地址字节 | 512 | 512 | 存储几何未变 |
| 单查询延迟中位数(GPU) | 0.9549 ms | 0.9169 ms | 略快 |
| 批量 QPS(batch=64) | 66,037 | 69,378 | +5.1% |
| 批量 QPS(batch=256) | 207,287 | 242,759 | +17.1% |
| 模型包大小 | 8.88 GB / 22 文件 | 8.88 GB / 23 文件 | 多出属性头 ~0.25 MB |
交错基准(7 轮,消除顺序效应):单查询中位数 1.4203 ms → 1.4242 ms,差 +0.28%。 同一轮里原版自身离散度就有 4.85% —— 也就是说这个差值小于测量噪声。 零重叠那一轮里 batch=64 的读数低 9.2%,但该轮离散度是 17.4%,且同架构同参数同 FLOPs 下 batch=256 反而更快, 方向不一致,判为测量噪声而非回归;这条也写在这里,因为把噪声当结论是这个领域最常见的错误。
六 · 工程鲁棒性
| 项目 | v2 | NM2.1 |
|---|---|---|
| 一次写 20 条不同属性事实后存活 | 12 / 20(8 条查询前被误删) | 20 / 20 |
| 端到端(写入修复前后,16 用例) | 37.50% | 68.75% |
| 替换兼容性 | 基线 | DROP-IN OK(16 / 16 键) |
| 单元测试 | — | 52 项通过 |
| 未知问题泄漏(同形候选) | 75.00% | 0.00% |
七 · 三处改动,每处都有测量依据
改动 1 · 记录排序混合权重取 0.5
memory_record_router_blend 控制「打包的词面重排器」与「学习出的路由器」各占多少。
调这个之前先测了剂量曲线 —— 而且**此前的同进程测量因顺序污染已作废**,下面这版是独立进程测的。
改动 2 · 把加法先验参数化,然后测出「不该动」
假设是「加法先验压过了学习打分」。把它暴露成可调再测,结果**调小只会更差**:
这是一个有价值的负面结论:它否定了原来的假设。项目里这类结论和增益一样被保留下来, 因为它们决定了后面不再往哪个方向投入。
改动 3 · 覆盖门 —— 以及为了让它真正生效必须一起修的两处
覆盖门本身很简单:先判断「这个提问在问哪个属性」,再查库里有没有这个属性。但单独加门是不生效的, 有两处**必须同时修**,否则门会被静默绕过:
_build_text_prefix 短路
门拒绝之后如果继续往下回落,会走**旧版 16 槽注入路径**,把记忆又塞回去。 实测:不修这一处,泄漏只从 75.00% 降到 62.50%。
闭包要动态解析 bank
reset_memory() 每次都会新建 memory_os_v2;闭包捕获了旧引用的话,
门会永远看到一个空 bank,于是**静默旁路** —— 实测诊断输出 applicable:0 / bypassed:1。
门必须自门控
只有当库的属性集合填充了头部词表的 ≥ 90% 时才让门生效。 这条阈值是必需的:放宽成「子集即可」会让门在通用套件上误判, 把 A 段可回答正确率从 100.00% 压到 92.50%、已知误拒升到 2.50%;收紧到 90% 后 A 段完全恢复, C 段泄漏仍为 0.00%。
八 · 写入路径:为什么 20 条事实会互相销毁
出处 · V2_dpskw\WRITE_PATH_FIX.md(根因由栈追踪确证,非推断)
症状
一次写入 20 条不同属性的事实后,库里有 20 条记录但只剩 12 条 active, 8 条在查询前就被 retract。16 个关键零重叠用例里有 8 个的目标记录已经不存在 —— 因此端到端正确率存在 50% 的硬上限。**任何排序器都救不回一条已经被删掉的记录。**
16 次 retract 的调用栈完全一致:
eval_end_to_end_memory.py:106 write_fact
-> qwen_integration.py:3807 forward
-> qwen_integration.py:2195 _write_text_memory <-- retract 调用点
-> memory_os_v2.py:2578 MemoryOSV2.retract_record
-> memory_os_v2.py:1959 PagedMemoryBankV2.retract (status = retracted)
status_transition_summary == {"active->retracted": 16},无 superseded、无 quarantined,
20 次 bank.write 全部返回 inserted —— 写入路径的唯一销毁机制就是 2195 行的 retract。
授权条件 confirmed_update 在 20 次写入中为真 5 次,且全部只走「学习重排器
learned_best >= 0.95」这一条:
| 写入序号 | 事实 | learned_best | 结果 |
|---|---|---|---|
| 2 | 出生城市 | 0.998959 | confirmed_update = True |
| 3 | 办公城市 | 0.992638 | confirmed_update = True |
| 9 | 工位楼层 | 0.964987 | confirmed_update = True |
| 12 | 手机尾号 | 0.998516 | confirmed_update = True |
| 15 | 办公楼层 | 0.997487 | confirmed_update = True |
随后 2187–2197 的循环退掉 8 条:3 条走 record.slot_index == slot,5 条走
score >= 0.95 and shared >= 2;2196 行的 break(仅当 score < 0.98 才停)
让写 #12 一次级联退掉 3 条、写 #15 退掉 2 条。被退掉的正是写 1/2/3/4/9/10/11/14 —— 16 条关键事实中的 8 条。
辅助事实 1
0 / 20
exact_slots.numel() > 0(token 完全相同)一次都没命中 —— 精确重复路径与此无关。
辅助事实 2
{2,3,5,6,7}
shared >= 2 毫无区分力:19 个候选的 shared 全落在这个区间,因为每条事实都含「我的 / 是」模板词元。
辅助事实 3
0.9985
190 个不相关属性对里有 10 个 ≥ 0.95,最高 0.9985(「常住城市」vs「出生城市」)。调阈值不可行:假阳性高达 0.9989,没有任何标量切点能分开「不相关属性」与「真更新」。
根因:retire 一条已存在记录的授权完全来自学习打分加一个固定 0.95 阈值,
没有任何「新文本与在位记录共享 (entity, attribute)」的结构性校验。
修复(两处,都是结构性的)
confirmed_update 的两条打分类分支改为结构优先
仅当候选文本的 entity::attribute 已经在库的
active_by_conflict 账本里存在(即确实是同一条事实的更新)才允许由打分授权;
exact_slots 这条结构安全的分支保持不变,以保留「重复写入幂等」的既有语义。
retract 循环加冲突键守卫
if record.conflict_key() != candidate_conflict_key: continue ——
循环只能退掉描述同一属性的记录,不再因「共享热槽」或「词面重叠高」销毁无关事实。
语义不受损:真·同属性更新本来就已经由 PagedMemoryBankV2.write 通过
active_by_conflict 做版本化(旧版本保留为 superseded、version+1),
修复只是移除了那条额外的、无监督的销毁路径。
四级验证
| 级 | 检验 | 修复前 | 修复后 |
|---|---|---|---|
| ① | status_transition_summary | {"active->retracted": 16} | {} |
| ① | retraction_calls / status_counts | 16 次 / {retracted: 8, active: 12} | [] / {active: 20} |
| ② | 正对照:同属性改值 | — | 旧值 superseded + 新值 active,其余 19 属性不受影响,0 retract |
| ③ | 重排器缺席对照 | 会踩死代码分支 | 20 条全 active、0 retract |
| ④ | target_record_active | 8 / 16 | 16 / 16 |
| ④ | target_record_retracted_or_absent | 8 | 0 |
| ④ | active_records_min / max | 12 / 12 | 20 / 20 |
| ④ | probe 口径回答正确率 | 25.00%(4 / 16) | 43.75%(7 / 16) |
端到端效果(官方 harness,16 个零重叠用例):
| 路由器 | 修复前 | 修复后 | 答成别的属性 |
|---|---|---|---|
| deployed(原版 NM2) | 25.00% | 50.00%(+25.00 pp) | 18.75% → 12.50% |
| V2-128-v6 | 37.50% | 68.75%(+31.25 pp) | 25.00% → 18.75% |
| REPLAY-128 | 37.50% | 68.75%(+31.25 pp) | 25.00% → 18.75% |
一个诚实的补充:这 16 个用例里 93.75% 的读取仍走旧版 16 槽路径而非 V2 库记录, 地址命中 0 / 16。写入路径的修复解除了 50% 的硬上限,但「V2 库记录真正参与读取」这一项仍未解决。
九 · 未知拒答:从 75% 泄漏到 100% 拒答
出处 · V2_dpskw\ABSTENTION_BREAKTHROUGH.md
这是整个项目最差的一个数字:问库里不存在的属性时,75% 的情况下模型照样编一个答案。 下面按「先排除一整类无效修法 → 再给出有效机制 → 最后如实记录接上运行时的两次失败」来写。
9.1 先排除掉一整类无效修法(有测量支撑)
最直觉的修法是「检索分数不够高就拒答」。用只在训练集拟合、评测集算 AUC 的口径, 测了四种打分器的分数几何(top1 / margin / top-k 均值 / 离散度 / 熵 / 近邻数,共 9 个特征):
| 打分器 | 最佳单特征 AUC | 拟合头 AUC(评测集) |
|---|---|---|
| cosine(冻结键余弦) | 0.6257(margin) | 0.6086 |
打包 text_retriever | 0.6076(margin) | 0.4752 |
| 50/50 混合 | 0.5446 | 0.5062 |
| 训练过的路由器 | 0.5711 | 0.5130 |
操作点同样不可用:混合打分器想标出 22% 的未知要误拒 28.40% 的可回答问题; cosine 想标出 46% 要误拒 30.00%。 结论:75% 的泄漏不可能靠分数阈值或分数上的分类头修好。 这不是调参空间不够,而是这个信号根本不存在 —— 24 条同句式候选对任何提问都「一样像」。
9.2 有效的机制:先判「问的是哪个属性」,再查库里有没有
换一个问题问就成立了:不是「有没有候选匹配得好」,而是「这个提问在问哪个属性,这个属性在库里吗」。
| 封闭词表 | 值 |
|---|---|
| 可回答 episode 中「预测属性在候选集内」 | 100.00%(250 条) |
| 未知 episode 中「预测属性在候选集内」 | 0.00%(50 条) |
| 未知拒答率 | 100.00% |
| 已知问题被误拒率 | 0.00% |
| 混淆矩阵 | tp=50 fn=0 fp=0 tn=250 |
开放集:真实用户会问模型没见过的属性。只靠最大概率调阈值不够 —— 实测没有拒绝选项时,从未见过的属性 100.00% 会被认成某个存在的属性。 所以改成显式训练一个 NONE 类:词表取 12 个属性,另取 6 个词表外属性的真实提问作为 NONE 的负样本, 最后用第三组、与两者都不相交的 6 个全新属性做测试。
运行时规则只有三步,全部可审计、无阈值调参:① 属性分类头读提问的冻结键 → 输出某个属性或 NONE; ② 若为 NONE → 拒绝;③ 若为某属性 → 查库的活跃属性集合是否包含它,不含 → 拒绝。
9.3 接上运行时:连续失败两次(如实记录)
| 指标 | NM2.1(无门) | + 覆盖门(第一轮) | + 门 + 短路(第二轮) |
|---|---|---|---|
| A 可回答正确率 | 100.00% | 6.25% | 0.00% |
| A 已知问题被误拒率 | 0.00% | 7.50% | 7.50% |
| A 未知拒答率 | 56.67% | 43.33% | — |
| C 未知泄漏率 | 75.00% | 62.50% | 0.00% |
| C 可回答正确率 | 65.00% | 65.00% | 65.00% |
第一轮:门把几乎全部可回答问题都拒掉了(100.00% → 6.25%),而它本该修好的 C 段泄漏只从 75.00% 降到 62.50%。
第二轮定位到真正的机制缺口 —— read() 返回空记录后不会停止,_build_text_prefix
会继续落到旧版 16 槽注入路径把无关记忆又塞回去。加了短路之后,C 段泄漏真的到了 0.00%、可回答正确率无损,
但 A 段崩到 0.00%。
A 段崩掉的原因已测定:段事实里 94.03% 是「这是普通对话噪声…不需要长期保存」, 只有 5.97%(63 / 1056)能解析出属性;更关键的是 A 段的属性空间完全不在属性头训练的 24 个属性之内, 头对 A 的提问只能输出那 24 个之一 → 必然不在库里 → 门把一切都拒掉。 也就是说:这个机制目前是「域内可用、跨域不可用」。
结论:已把 NM2.1 的 memory_coverage_gate 改回 false,恢复为已验证的可用状态。
属性头工件保留在 checkpoints/memory_attribute_head/,未随包启用。
正确的最终形态是开放词表的属性匹配(把提问与库里真实存在的属性名做匹配,词表随写入动态变化),
而不是 24 类闭集分类头。
这一节最该记住的一句
离线 100.00% / 0.00% 是真实的,但它只证明了机制在「裸提问键 + 24 个可解析属性」这个条件下成立, 并不等于接上运行时就能用。这一点必须在任何对外表述里讲清楚。
十 · 关键负面结论:路由器排序不影响端到端答案
出处 · V2_dpskw\ZERO_OVERLAP_FINDINGS.md §5
NM2.1 的路由器在零重叠集合上 Top-1 提升了 41.20 pp,但在
eval_router_critical_e2e.py(16 个零重叠用例)上,REPLAY-128 与 V2-128-v6 的结果
逐位相同(默认 Top-K:37.50% / 37.50%,答成别的属性 25.00% / 25.00%,平均选中记录 5.88 / 5.88)。
于是做了一个判决性实验:保留 need_memory / hop_controller / head_gate
全部原权重,只把排序通路(query_projection、key_projection、pair_scorer)
替换为同形状随机权重。结果在默认 Top-K 与 Top-K=1 下都完全不变。
memory_os_v2.py:1671:路由器分数被逐位置覆盖
带 semantic_key 的记录,其 _score_candidates() 分数会被
self.record_scorer 覆盖。实测残差与 text_retriever 匹配 18 / 18,
与 memory_router_v2 匹配 0 / 18。
「部署目录没有 text_retriever.pt 所以走余弦兜底」是错的
重排器被烘焙进合并包(safetensors 内有 6 个
dynamic_memory.text_retriever.* 张量,1,573,377 参数),_text_retriever_ready
实测为 True,余弦兜底分支根本不执行。
路由器在此配置下唯一实际生效的杠杆是 need_memory 门
部署权重下门开启率 68.75%(11 / 16),而三份随机初始化的路由器只有 37.5%(6 / 16)。判决性探针只随机化了排序通路、保留了门控头,所以「端到端零变化」 是由构造保证的,不是巧合。
结论分两层,不要混为一谈:① 路由器记分卡上 22 / 22 的统治性和 +41.20 pp 的零重叠提升,
都是路由器工件真实的增益;② 但在这套运行时里,记录级顺序由打包的 text_retriever 决定,
路由器分数被覆盖,且相当比例的读取根本不走 V2 库、目标记录还会被写入路径删除。
所以路由器增益不会传递到最终答案。
十一 · 仍未解决(不粉饰)
| 短板 | 现状 | 说明 |
|---|---|---|
| 跨域未知拒答 | 未解决 | 覆盖头是 24 类闭集,只在其词表被库填充 ≥ 90% 时生效;开放词表的属性匹配实测仅 49.20% Top-1 / AUC 0.6560(零训练),不足 |
| 答成别的属性 | 27.50% | 同形候选间排序仍不理想;已排除先验重加权(更差)与单纯替换打分器(更差) |
| A 段未知拒答率 | 56.67% | 未见改善 |
| 规模验证 | 未做 | 仅 24 属性 / 300 条评测;生产需上千属性、上万改写问法 |
| V2 库记录真正参与读取 | 未解决 | 16 个关键用例里 93.75% 仍走旧版 16 槽路径,地址命中 0 / 16 |
| 通用能力回归套件 | 未跑 | eval_general_capability.py 依赖的 comprehensive_general.jsonl 不存在 |
十二 · 复现
$env:PYTHONPATH='H:\Memory'; $env:PYTHONIOENCODING='utf-8'; cd H:\Memory\V2_dpskw
# 整体电池(A/B/C/D 四段)
pwsh -NoProfile -File .\run_nm2_battery.ps1 -Package qwen3_5_4b_natural_memory_v2_1 `
-Label 'NM2.1 最终' -Tag 'nm2_1_final' -PerCategory 10 -OverlapCases 48 -Blends '0.5'
# 三代对照表
& 'C:\Users\Administrator\miniconda3\envs\LLM\python.exe' -m V2_dpskw.compare_nm2_batteries `
--tag "原版NM2=nm2_orig" --tag "NM2.1=nm2_1" --tag "NM2.1最终=nm2_1_final"
# 门的适用性诊断(确认 applicable/bypassed,避免静默旁路)
& 'C:\Users\Administrator\miniconda3\envs\LLM\python.exe' -m V2_dpskw.diagnose_coverage_gate `
--package qwen3_5_4b_natural_memory_v2_1