Initial commit: Natural Memory Agent Lab:模型服务 + TypeScript agent + 记忆能力场景评测(冲突更新、跨会话回忆、遗忘撤回、多跳、未知拒答)

This commit is contained in:
WpyQwq
2026-09-19 12:09:13 +08:00
commit eb9bd87a4d
108 changed files with 6452 additions and 0 deletions
+793
View File
@@ -0,0 +1,793 @@
# NM2 Agent Lab — 验证结果与发现
> 本文件记录**实跑出来的数字**,不写未验证的推测。测量对象:`qwen3_5_4b_natural_memory_v2_1`(NM2.1)。
> 最后更新:2026-09-13。
## 1. 环境与产物
| 项 | 值 |
|---|---|
| 模型包 | `H:\Memory\V2_dpskw\qwen3_5_4b_natural_memory_v2_1`(`package_label: NM2.1`) |
| 路由器 | 已换为 `router_replay_v7_v2_128/memory_router_v2.pt`(sha256 `69f8295e…`,128 维 / 8 头,512 字节/记录) |
| 新增子系统 | **coverage gate**:`memory_attribute_head.pt`(24 个属性词表)+ `memory_coverage_gate: true` |
| 模型 server | `agent_lab/model_server/server.py`,OpenAI 兼容 + 15 个 NM2 端点,运行在 `LLM` conda 环境 |
| Agent 侧 | `agent_lab/agent/`(Bun + Pi `[email protected]` + `[email protected]`),独立 node_modules |
| 自检 | `agent_lab/model_server/selftest.py` |
## 2. 控制面自检:29/29 通过
`python agent_lab/model_server/selftest.py --base-url http://127.0.0.1:8766/v1`
覆盖并通过的项目:
- `health` / `nm2/state` / `nm2/config` / `nm2/trace` 可用;
- **coverage gate 已加载并绑定到实时记忆库**(`configured=True loaded=True bound=True`,词表 24 个属性);
- `reset` 清空;空库上 `probe` 正确拒答(`stop=below_read_threshold`);
- 可信写入(`action=inserted`);`probe` 命中写入的记录(`need=True stop=evidence_found`);
- `probe` 返回完整路由决策(`hop`、`score_margin`、`hop_trace`、`coverage`);
- **版本化纠正**:新值 active、旧值 superseded(实测 `active=1 superseded=1`);
- 撤回把记录移出 active;
- `audit` 健康(`healthy=true issues=[]`);
- **KV 压缩**:预算内不动(1800→1800);把 `kv_budget_tokens` 压到 1024 后超预算分块并归档;
- **用户隔离**:`session save` → `reset`(active=0)→ `session load` 恢复该用户记忆;
- 配置读写往返 + **非法旋钮被明确拒绝**(`unknown knob` / `out of range`);
- **无记忆对照组**:`memory_mode=off` 时 `probe` 不召回(`memory_disabled`);
- **OpenAI 聊天补全 + 原生 tool calling**:`finish=tool_calls`、`tool_calls=['nm2_read']`;
- 每轮 NM2 诊断随补全返回(前缀 token 数、路由耗时、路由决策、策略概率、压缩报告、coverage 判定)。
## 3. 验证中发现并修复的 4 个真缺陷
| # | 位置 | 缺陷 | 后果 | 状态 |
|---|---|---|---|---|
| 1 | 模型 `qwen_integration.setup_attribute_coverage` | 加载了 attribute head,却**没有绑定到已构造的记忆库**(bank 在模型初始化时就建好,那时 head 还是 None) | **NM2.1 的核心拒答机制被加载但从不被调用**(`configured=True loaded=True bound=False`) | 已修(加载后重绑定)并验证 |
| 2 | 模型 `memory_os_v2.read`(coverage 判定) | 拒答检查**硬编码实体名** `f"user::{attribute}"`,而同一文件的 `coverage()` 用 `key.split("::",1)[1]`(实体任意)——自相矛盾 | 只要库里的实体不是字面量 `user`,gate 一旦激活就**拒绝一切问题**,包括它确实持有的属性(实测误拒 100%) | 已修(按属性名跨实体匹配)并验证 |
| 3 | 我的 server `probe` | 未处理 `memory_mode=off` | 「无记忆对照组」在探针端点上仍会召回 | 已修(返回 `memory_disabled`)并验证 |
| 4 | 我的 server `update_config` | 旋钮写入 `memory_config`,但 KV 预算在 `KVBudgetManagerV2` **dataclass 构造时固化** | 旋钮报成功、压缩却永不触发(`hot_limit` 始终 32768) | 已修(镜像到运行时预算器)并验证 |
## 4. Agent 侧验证中发现并修复的 5 个 harness 缺陷
| # | 缺陷 | 实证 | 修复 |
|---|---|---|---|
| 1 | `nm2_write` 强制要求 `text` | 模型按最自然的方式给 `entity/attribute/value` → 被拒 `"text is required"` → `activeRecords=0` | 缺 `text` 时由结构化字段合成记录文本 |
| 2 | 工具动作枚举过窄 | 模型调 `nm2_admin{action:"search"}`(search 在 `nm2_read` 里)→ schema 拒绝 → 模型**自己编造 record_id** → `/nm2/correct` 500 | 别名放开 + server 把未知 record id 映射为可读 404 |
| 3 | 工具调用无熔断 | 一次交互发出 **81 次工具调用、耗时 421 秒**,最后上下文退化成 `'assistant\nuser\nuser...'` echo | `beforeToolCall` 预算(6 次)+ 重复调用熔断(2 次)+ **`agent.abort()` 硬中止**(`terminate` 单独不够,模型会另开批次) |
| 4 | `nm2_write action=correct` 无恢复路径 | 自动遗忘在生成前撤回记录 → 模型的 `correct` 命中 `only active records can be edited` → 服务器包成 **500** → 模型编造假工具返回并死循环 → 断言 1/3 | 失败时**自动降级为结构化写入**;服务器把该冲突改为 **409 + 恢复提示**(详见 §6.1) |
| 5 | `memory_mode=off` 不是真正的对照组 | 关掉的只是自动路径,显式 `nm2_write` 仍能写 → 对照组建出记忆并拿到 3/3 | 模式下所有写入端点 409 拒绝(详见 §6.5) |
## 5. 端到端 A/B 结果(`runs/suite7`,同模型/同提示/同工具,仅记忆层开关不同)
**对照组已核验为空**:每一条 OFF 轨迹末尾 `activeRecords=0`,
且 `memory_mode=off` 时所有写入端点返回 409(见 §6.5)。
| 场景 | 记忆开启 | 记忆关闭(对照) | 说明 |
|---|---|---|---|
| `cross_session_recall` | **3/3 = 100%** | **0/3 = 0%** | 会话 1 写入 → 会话 2/3(全新上下文)询问 |
| `conflict_update` | **3/3 = 100%** | 1/3 = 33% | 改写旧值后跨会话问新值 |
| `multi_hop_chain` | **2/2 = 100%** | 1/2 = 50% | 项目→负责人→工号 两跳 |
| `unknown_abstention` | **3/3 = 100%** | 2/3 = 67% | 库里没有的事实必须拒答 |
| `forget_retraction` | 1/2 = 50% ⚠️ | **2/2 = 100%** | **记忆组更差**——见 §6.6,是真实缺陷 |
| **合计** | **12/13 = 92%** | **6/13 = 46%** | |
记忆层在 5 个场景里赢 4 个,且差距最大的一条(跨会话)是 100% vs 0%。
唯一输的一条是**遗忘**,原因是记忆层把用户要求忘记的事实留了个"兄弟记录"(§6.6),
这条要看的是**记忆层自己的缺陷**,不是 agent 的。
### 决定性的那一条
`cross_session_recall` 记忆组跨**全新会话**准确复述两条事实(`user;项目代号;蓝鲸-47`、`部署区域是 ap-southeast-3。`),
路由显示 `need=True stop=evidence_found` 并命中同一条记录;对照组在同一问题下**编造 `'我的项目代号是:Project Phoenix'`**,
第三个会话甚至去 `find / -type d -name "deployment"` 满盘搜索。对照组虽然持有文件便签工具、也确实把事实写进了便签,
但**它没有回头去读**——这正是模型内记忆要解决的问题。
### 每轮耗时(同一套场景,秒/轮)
| 场景 | ON | OFF |
|---|---|---|
| cross_session_recall | **4.77** | 5.51 |
| conflict_update | **8.09** | 10.64 |
| unknown_abstention | **9.90** | 14.23 |
| multi_hop_chain | **13.79** | 33.50 |
| forget_retraction | **18.11** | 23.02 |
记忆组在全部 5 个场景都更快——因为答案来自注入的证据,而不是让模型反复试 shell 命令。
## 6. 已定论的问题
### 6.1 `conflict_update` 记忆组"写入不生效"——已修复(1/3 → 3/3)
(此前记录的"quarantine 未被提升"是**错误推测**,已作废。)
### 6.1 根因:自动遗忘在生成之前就撤回了记录,而工具层没有恢复路径
用**静态复现**(只跑 ON 组、跑完立刻查库、中间不做任何 reset)稳定复现 1/3,然后逐层定位:
1. `/nm2/correct` 对一条**确实处于 active** 的记录单独调用时完全正常:
写 `日语` → `correct` → 返回 `version=1` 的新记录、旧记录 `superseded`。**控制面本身没有缺陷。**
2. 差别在于场景里那条记录是**自动记忆路径**写入的(`source: "automatic"`),
而第 1 轮用户说的是「更正一下:我的默认语言改成 葡萄牙语,**日语作废**」。
3. 模型服务每轮的顺序是 `_write_turn(...)`(server.py:487)**→ 生成(:513)**。
而 `_write_turn` 内部包含**自动遗忘**逻辑(qwen_integration.py:2166):
`auto_forget_probability >= auto_forget_threshold`(实测该轮为 **0.985 ≥ 0.9**)时,
按本轮文本 `_forget_text_memory_by_key(...)` 把 `日语` 记录**撤回**。
4. **这一步是 NM2 的正确行为**——用户说了「作废」。
但它在**模型生成之前**执行,于是模型这一轮手里那个上一轮学到的 `record_id` 已经变成 `retracted`。
5. 接下来模型做的是最自然的事:`nm2_write{action:"correct", record_id:<已撤回>}` →
`only active records can be edited` → 而服务器把它包成 **HTTP 500**,且错误文本不含任何恢复指引。
6. 4B 模型拿到 500 后**不会自己恢复**:先编造一段假的工具返回(伪造 `record_id: "mem_abc123"` 与 JSON),
然后反复 `nm2_admin search` 直到被硬熔断中止 → 新值从未落库 → 会话 2 读到空前缀(`prefix_tokens=0`)→ 答 `中文`。
### 6.2 修复(两处,均已实测验证)
| 位置 | 修复 |
|---|---|
| `agent_lab/agent/src/tools.ts` | `action=correct` 带 `record_id` 失败时,**自动降级为结构化写入**(entity/attribute/value)并把 `recovered_from` 写进工具返回;`action=correct` 不带 `record_id` 时也直接走结构化写入(NM2 本来就会按 `entity::attribute` 自动版本化,强制要 id 只是给模型挖坑)。另把缺省 `entity` 补成 `user`,与记忆层自己写的记录对齐 `entity::attribute` 冲突键。 |
| `agent_lab/model_server/server.py` | 领域冲突不再包成 500:`only active records can be edited` → **409 `record_not_active`**,并附 `hint` 指明恢复调用;其余 `ValueError` → 400。 |
### 6.3 修复后的实测(`runs/conflict_fixed`)
```
turn0 写 日语 → active=1
turn1 “改成葡萄牙语,日语作废”
├ 自动遗忘撤回 日语(正确行为)
├ nm2_write{correct, record_id=日语} → 透明降级为结构化写入 → 新记录 active=1
└ nm2_write{retract, 日语} → 幂等
最终:active=1(葡萄牙语) retracted=1(日语)
s2(全新会话)问“我的默认语言是什么?” → 葡萄牙语 ✓
断言 3/3 (100%),每轮 22.31s → 8.64s
```
### 6.4 尚未定论
模型在工具报错后**不会自行恢复**、倾向重复同一调用(已用硬熔断 + 让工具不再报错两条路兜住,
但根因仍在模型侧)。下面 §6.5–§6.7 是后续定位出来的三个新问题。
### 6.5 【方法论缺陷,已修复】`memory_mode=off` 曾经不是对照组
修改系统提示词、让 agent 主动使用记忆工具之后,**对照组在 `cross_session_recall` 拿到 3/3、`activeRecords=2`**。
原因:`memory_mode=off` 只关掉了**自动路径**(证据前缀注入 + 自动写入),
而 agent **显式调用** `nm2_write` / `nm2_read` 依然能写能读 —— 于是"无记忆"那一组悄悄又有了记忆。
证据:OFF 组轨迹末尾 `activeRecords=2/1/2`,本应恒为 0。
**修复**:`memory_mode != "on"` 时,所有**写入类**端点(`write`/`correct`/`retract`/`approve`/`compact`/`session`/`memory`)
一律 409 拒绝;`read_only` 同样拒绝写入;模式切换与 `reset` 不受影响。
读路径保持开放——写入既已全堵,库必然为空,留着读接口正好**让诊断证明这一点**。
修复后核验:`off` 与 `read_only` 下 `nm2/write` 与 `nm2/correct` 均返回 409;
`probe` 返回 `stop_reason=memory_disabled`;`records` 返回 0;
`suite7` 每条 OFF 轨迹 `activeRecords=0`。**控制面自检仍 29/29。**
> 教训:**"关掉记忆"必须在每个端点上成立**。只关自动路径、留着显式工具,
> 得到的不是对照组,而是"另一种用记忆的方式"。
### 6.6 【已修复 2026-09-15】遗忘从"按 key"改成"按轮次"
**原缺陷(实测)**:`forget_retraction` 记忆组不如对照组(1/2 vs 2/2),失败方式是**泄露**:
```
turn1 用户:记一下:我的紧急联系人是 王工,电话分机 7781。
→ 落库两条 active 记录:紧急联系人=王工、电话分机=7781
turn2 用户:请遗忘我的紧急联系人信息,不要再保留和使用它。
→ 自动遗忘只撤回了 key 命中的那一条(紧急联系人)
→ 库:active=1(电话分机=7781 仍在) retracted=1
turn3 用户:我的紧急联系人分机是多少?如果已经不保留这条信息,就直接说明。
→ 路由把仍 active 的电话分机记录注入了上下文
→ 模型回答:"您的紧急联系人分机是 7781。" ← 泄露
```
**机制**:撤回是**按 key/文本相似度**匹配的(`_forget_text_memory_by_key` /
`_forget_text_memory_by_metadata`),**同一句话/同一轮写入的其余记录不在匹配范围内**。
**修复(已实施,改动在 `V2_dpskw`)**:给记录加 `origin`(该记录来自的那一轮用户话的短哈希),
撤回时先按原逻辑命中目标记录,再 `retract_origin(target.origin)` 把**同一轮写入的全部记录**一起撤回。
| 文件 | 改动 |
|---|---|
| `V2_dpskw/memory_os_v2.py` | `MemoryRecordV2.origin` 字段;`write(origin=)`;`edit_record` 继承 origin;新增 `PagedMemoryBankV2.retract_origin()` 与 `MemoryOSV2.retract_origin()`;`memory_record_to_dict` 暴露 origin |
| `V2_dpskw/qwen_integration.py` | 新增模块级 `memory_origin(text)`;自动写入带 `origin=`;两个遗忘路径命中后追加 `retract_origin()` |
| `V2_dpskw/tiered_memory_store_v2.py` | **关键**:SQLite 表有独立列清单,加字段不会自动持久化 → 补 `origin` 列(schema / `_record_values` / INSERT / `load_records` / 隔离区 payload),并对**已有库**做 `PRAGMA table_info` 检测 + `ALTER TABLE ADD COLUMN` 原地迁移 |
| `agent_lab/model_server/server.py` | 每轮记住 `_turn_origin`,显式 `nm2_write` 带上它——否则 agent 逐字段写出的记录与自动记录分属不同组,实测的那个泄露就修不掉 |
| `V2_dpskw/tests/test_memory_origin_forget.py` | 新增 9 个 CPU 回归测试 |
**验证(CPU,不占显卡)**:新增 9/9 通过,覆盖:同轮兄弟记录一起撤回、**别的轮次不受影响**、
`origin` 为空的历史记录永不匹配(`retract_origin("")` 是 no-op)、版本化继承 origin、
`asdict` 往返 + 缺 `origin` 的旧 payload 仍可加载、**经 SQLite 存储往返后 origin 不丢**、
**旧库(无 origin 列)打开时被原地迁移**、`MemoryOSV2` 门面转发。
**既有测试套件 61/61 通过**(`python -m unittest discover -s V2_dpskw/tests -t .`)。
**尚未做(需要显卡,等你的作业跑完)**:端到端复测 `forget_retraction` 两个组,
确认泄露消失且其它场景不回归。
**已知残留风险(如实报告)**:轮次粒度是**策略选择**——若同一轮里除了被撤回的事实,
还说了**无关**的事,那件无关的事也会一起被撤回(例如"我的编辑器是 VSCode,紧急联系人是王工"
之后说"忘掉紧急联系人",两条都会没)。要收紧的话,可以只在"同一轮 + 属性/取值相关"时成组,
抓手是记录里已有的 token/`input_sha1` 证据;本次先做整轮,因为用户说的"忘掉这条信息"
在实测样例里指的正是整句话。
**尝试过但无效的做法(记录以免重蹈)**:在系统提示词里写清
"search → 逐条 retract → 再 search 核验"的多步协议。
结果 4B 模型**卡在第一步**:连发 13 次 `nm2_read action=search` 直到预算熔断,**一次 retract 都没发**,
该场景从 1/2 掉到 **0/2**。已回退。
**结论:4B 模型执行不了多步协议,这个洞必须由记忆层自己补。**
### 6.7 另一处已修复的 harness 缺陷:agent 不去读记忆,改用 shell 满盘搜
修复前的证据(`runs/suite5`):
- `multi_hop_chain`:路由**已经**把全部 4 条记录(含两跳所需的双方)注入上下文(`prefix_tokens=324`),
库中 `active=4`,**答案所需的证据就在提示词里**——模型却去执行 `run_shell find / -name "*星尘*"`。
- `forget_retraction`:路由正确返回 `stop=below_read_threshold`(确实没有可召回的东西),
模型却去 `grep` 文件系统找"紧急联系人"。
根因是 harness:原默认系统提示词**一个字都没提 NM2**(只有 "You are a test agent operating a small workspace")。
已改为 NM2 感知的提示词:说明事实存在记忆层、`【长期记忆证据】` 块是权威的、
**回答此类问题前先 `nm2_read action=probe`**、**不要用 find/grep 去找关于用户的事实**、
没有证据就明说没有。两臂使用**完全相同**的提示词,所以 A/B 仍然成立。
效果:`multi_hop_chain` 记忆组 1/2 → **2/2**,`cross_session_recall` 的 ON 组每轮耗时降到 4.77s。
### 6.8 【真实缺陷,已修复】更正只传 value 时静默失效:模型继续答旧值
**发现方式**:在 NM2.0 上用「我叫Wpy → 不对我叫王五」试 TUI 时,干净进程里被问「我叫什么名字」,
路由弃权、未注入证据,模型却答出**已被取代的旧值 `Wpy`**。逐层排查后定位到与路由无关:
**根因**:模型读的是记录的**存储文本**(`record.text` 与它的 token 缓存),不是结构化字段。
而自动层写入时把文本存成**按当时取值预先渲染好的证据卡片**:
```
【长期记忆证据】
事实:我的名字是 Wpy
已确认值:Wpy ← 说 Wpy
可直接复述的关键短语:user;名字;Wpy ← 又说一遍
```
`/nm2/correct` 只传 `value=王五` 时,`edit_record` 更新的是 `value` 字段、**保留旧 text,并继承旧 token_ids**。
于是卡片里「已确认值」三处全是旧值,模型照着念 → 答 Wpy。
**接口全程报告成功**(`corrected:true`、版本升到 v1、旧记录 superseded、审计 `healthy`),
**只有答案是旧的**,从外部完全看不出来。
**为什么以前没暴露**:项目自带的测试与 Agent 工具**都同时传 `text`**(`text="用户喜欢绿色"` + `value="绿色"`),
所以这条路径从没被测到。我查过全部脚本,`edit_record` 只被 `natural_memory_service` 与测试调用,
**训练/评测脚本不碰它**,因此既有评测数字不受影响。
**修复(按"少外部参数、让模型自己负责"的原则,不加任何开关、不给调用方加义务)**:
| 文件 | 改动 |
|---|---|
| `V2_dpskw/qwen_integration.py` | 新增 `memory_fact_sentence()`(从卡片里取出原句)与 `compose_corrected_evidence()`:**由改后的字段重建整张卡片**;并把原句里的旧值就地替换(保留自然措辞,与模型自己走 Agent 路径时写的「我的名字是 王五」一致)。无字段变更、或记录缺结构化身份时返回 `None`,保持原行为。 |
| `V2_dpskw/natural_memory_service.py` | 只传字段的更正:先重建卡片,再照常编码 token(token 缓存随之更新)。 |
| `agent_lab/model_server/server.py` | 同一处理,保证两个入口行为一致。 |
**核心思路**:记录**不可能再自相矛盾**——同一事实原来存了两遍(字段一遍、烤进 text 一遍),改一边就会漂;
现在改字段必然重建它自己的卡片。这加的是**不变量**,不是控制参数。
**验证**:
- CPU 测试 **88/88 通过**(新增 5 项:旧值在卡片各处消失且措辞自然、旧值不在句中时的回退、无字段变更返回 None、缺身份返回 None、Agent 写的普通句子记录)。
- **端到端最小复现(NM2.0)**:自动记住「我的名字是 Wpy」→ 只传 `value=王五` 更正 →
两种问法(`我的名字是什么` / `我叫什么名字`)都命中更正后的记录并答 **王五**;修复前答 **Wpy**。
**顺带观察(未改)**:修复后回答形如 `王五;user;名字;王五`——这是项目前缀里
「可直接复述的关键短语」+「回答要求:…并原样复述上面的关键短语」的设计所致,属既有行为。
如果它影响你的评测判分口径,可以再看。
### 6.9 外部参数面收敛:判断权还给模型(2026-09-15,按用户方针)
用户方针原话:「**我希望更多配置由模型决定,外部不要传入那么多参数(必要的保留)**」。
判据:**凡是模型自己已经学过的判断,就不该由调用方传数字**。据此收敛**面向 Agent 的参数面**:
| 原来外传的参数 | 处置 | 理由 |
|---|---|---|
| `nm2_write.importance` / `confidence` | **删除** | 检查点里本来就有学到的 importance 头(`AutomaticMemoryPolicyV2.importance`,从该轮隐状态打分)。让模型手填 `importance: 0.9`,等于把「这轮值不值得记」变成外部常数。 |
| `nm2_admin.config_set`:`read_threshold` / `min_read_margin` / `require_evidence` | **删除** | 路由器已有 `need_memory` 头与 margin/confidence 输出,阈值只是覆盖它。 |
| 同上:`auto_memory_threshold` / `auto_forget_threshold` | **删除** | 自动策略是学到的(`auto_memory_probability` / `auto_forget_probability`)。 |
| 同上:`max_hops` | **删除** | 路由器已有 `hop_controller` 头。 |
| 同上:`coverage_gate` / `coverage_vocabulary_fraction` | **删除** | 覆盖门由学到的属性头驱动。 |
| 同上:`top_k_pages` / `top_k_records` / `hot_pages` / `write_threshold` | **删除**(Agent 面) | 检索与分层预算属记忆层自己的判断。 |
| `kv_budget_tokens` / `kv_keep_recent_tokens` / `context_chunk_tokens` / `gpu_cache_records` / `gpu_cache_tokens` | **保留** | 机器硬约束,模型无从知晓 —— 属「必要」。 |
| `nm2_read.limit` / `status` | **保留** | 只是显示条数与过滤,不改变记忆行为。 |
| 服务端 `write_record` 私自补的 `importance=0.9 / confidence=0.99` | **删除该默认值** | 调用方没给就落到记忆层自己的门槛,而不是服务端编一个常数。 |
被拒绝时会**明确报错**(而不是静默丢弃):模型若以为自己改动了路由阈值,会一直那么以为。
**注意**:HTTP 的 `/v1/nm2/config` **仍保留全部旋钮**——那是你研究脚本在用的(门槛扫描等)。
收敛的是 **Agent 能看到、能传的参数**,不是把研究工具砍掉。
**实测(NM2.0)**:不带 importance/confidence 的写入 → `inserted`、`importance=0.5`(层默认)、可召回(score 8.661);
Agent 侧两次写入均为 `importance=0.5` 且召回正常(命中 `user|默认语言 = 葡萄牙语` score 8.640)。
类型检查通过、CPU 测试 **88/88**。
**仍未做(需你定)**:显式写入拿不到该轮隐状态,所以「让 importance 头打分」的理想路径目前只有自动层走得通;
要让显式写入也由模型打分,得把写入挪进当轮生成路径,属较大改动。
**本次试跑顺带发现的同族新问题**:同一句「我叫Wpy,默认语言是 葡萄牙语。」下,Agent 写的属性是 `默认语言`,
自动层抽出的却是 **`语言`**——键不同(`user::语言` vs `user::默认语言`)所以**不会互相取代**,
库里于是留下**同一事实的两条生效记录**。后果与 §6.6/§6.8 同族:将来只更正其中一个名字,另一个仍会带旧值被检索。
可能的修法是把「同轮吸收」推广为:**同一轮里模型自己写下的结构化记录,取代该轮的自动记录(不论自动记录有无身份)**。
代价:若一轮说了两件事而模型只写了一件,被取代的原始记录不再被检索(仍留在库里可审计)。
**属语义变更,等你点头再动。**
### 6.10 生产级实测(NM2.0,真实任务):**当前不可日用** —— 诚实结论(2026-09-15)
用户要求:「给这个模型手动做完整测试,**包括真生产任务**,这不是一个表在相框里的勺子,而是摆在抽屉里的——**每天都要用**」。
于是拿这台机器上**有客观基准可对**的真活来试,不用合成场景。基准先自己量好,避免用模型输出去验证模型。
**基准**:`H:\Memory\V2_dpskw` 下 `.py` 文件 **126** 个(含子目录);C 盘可用 **34.45 GB**;
`WRITE_PATH_FIX.md` 存在、4023 字符。
#### 第一轮(修复前)
| 任务 | 结果 | 判定 |
|---|---|---|
| 统计 .py 文件数 | 一次 `Get-ChildItem -Recurse -Filter *.py \| Measure-Object` → **126** | ✅ 对,5.4 秒 |
| C 盘可用空间 | 先编 `Get-CDisk`(不存在);再写 `Where-Object {$_.DriveLetter -eq 'C:'}` → **成功但零输出**;随后**同一调用重发 10+ 次**,62 秒,助手文本退化成 `assistant\nassistant…` | ❌ 彻底失败 |
| 读 `WRITE_PATH_FIX.md` 说根因 | 内容**答对了**(「`confirmed_update` 依赖学习打分 0.95、缺 `(entity,attribute)` 结构校验 → 误删」),但把上一条失败命令又重发了 5 次,99 秒,尾部乱码 | ⚠️ 内容对、过程崩 |
| 「昨天买的股票涨了多少」 | 「我没有你购买的股票信息,无法回答」 | ✅ 诚实拒答(但耗时 50 秒,因上下文已被污染) |
**根因查实(不是推断)**:`Get-Volume` 的 `DriveLetter` 是**字符** `'C'`,
`'C' -eq 'C:'` 永远为假 → **命令成功、零输出**。实测确认:`-eq 'C:'` 输出长度 0,`-eq 'C'` 正常输出。
模型分不清"成功没数据"和"还没查到",于是死循环;一次静默失败**污染整段会话**。
#### 第二轮(我加了三处补丁之后)
补丁:① `run_shell` 零输出时明确解释「命令成功但没打印任何东西,多半是过滤条件没匹配上,**不要再发同一条**」;
② 熔断改为**同步** `agent.abort()`;③ 熔断后替换掉被污染的助手消息(会话卫生)。
| 任务 | 结果 | 判定 |
|---|---|---|
| C 盘可用空间 | **答「100 GB」——编造**(真值 34.45 GB) | ❌ **更糟** |
| 统计 .py 文件数 | 外层又套 `powershell -NoProfile -Command "…$_…"`,`$_` 被外层吃掉(`.DeviceID : The term ... is not recognized`),死循环 → 中止 | ❌ |
| 再问 C 盘 | 又编「100 GB」,并把**前几轮对话原文整段复读** | ❌ |
**补丁不足,如实记录**:③ 的会话卫生**显然没生效**(下一轮仍在复读旧对话,需查 `agent.state.messages` 赋值是否真被 Pi 采纳);
② 同步 abort 生效了(日志出现 `Operation aborted`),但仍不足以救回回合。
并且暴露一个**新的、更危险的失败模式:拿不到值时编造具体数字**。
#### 日用结论(当前)
- **能用的**:单步、命令正确、结果非空的任务(教一遍、答对、5 秒级)。
- **不能用的**:任何需要"命令写错→自己发现→换写法"的任务;一次静默失败即毁掉整段会话;拿不到值时**会编造数字**。
- 对一个"每天都要用"的工具,这三条都是硬伤。
#### 第三轮(窄接口工具 + 编造检测,2026-09-15 目标轮次 2)
依据是前两轮反复撞同一面墙:**卡住真活的不是记忆,是模型手写命令行**。于是:
1. **新增窄接口工具**(`tools.ts`):`disk_space {path}` 与 `list_files {path, pattern?, recursive?}`,
由 Node 直接实现(`fs.statfs` / `fs.readdir`),**模型只填路径、不拼命令**;提示词里明确要求这两类问题走它们。
`fs.statfs` 实测与 PowerShell 完全一致(34.62 GB)。
2. **编造检测**(`agent.ts` + `memory.ts` + `tui.ts`):问题在问数量、回答里有数字、而**本轮一次工具都没调**
→ 标为 `unverifiedQuantity`,TUI 打红色警告。这是**检查器不是修复**,目的是不让编造冒充测量结果。
| 任务(有客观基准) | 前两轮 | 本轮 |
|---|---|---|
| C 盘可用空间(真值 34.62 GB) | 编「100 GB」;再前一轮死循环 62 秒 | `disk_space{C:\}` → **34.64 GB** ✓ 一次调用 |
| `E:\deepseek\artifacts` 文件数(顶层真值 46) | 死循环、无答案、64.6 秒 | `list_files{path}` → **46** ✓(工具自报"仅顶层") |
| 跨会话记忆:新进程问「报告放哪」 | 召回成功但干活被 shell 挡住 | **召回成功**(命中 2 条 · 205 词元)+ 目录答对 ✓ |
**新暴露的失败(已两次独立复现,且可稳定重放)**:模型对"数量"类问题**会跳过工具直接给数字**——
问文件数答 **0**(真值 46)而**一次工具都没调**;上一轮问磁盘答 **100 GB**。
提示词里"不要猜任何没从工具拿到的数字"对它无效。现在这类回答会被明确标红,不再静默溜过。
**下一步(本轮未做)**:把"标记"升级为"强制"——问题问数量而本轮未调工具时,
不给它收尾的机会,追加一轮要求它先调 `disk_space`/`list_files` 再回答。
#### 第四轮(强制重问 + 多步链路,2026-09-15 目标轮次 3)
**做了**:把编造检查从「标记」升级为「强制」——问题问数量、回答有数字、本轮零工具调用时,
**不给它收尾**,追加一轮(`ENFORCE_TOOL_USE_NUDGE`,点明该用哪个工具)并要求只答核实到的结果;
被替换掉的首答留在记录里,TUI 提示「首答没有出处,已强制重问」。上限两轮,防死循环。
| 真实任务(有基准) | 结果 |
|---|---|
| 跨会话问「评测报告目录里有多少文件」(真值 46,**上一轮此处编造 0**) | **自己调 `list_files` → 答 46** ✓ 6.0 秒 |
| 同会话问同一件事 | 调 `list_files` → **46** ✓ 5.7 秒 |
| 「模型包有多少个参数」(工具答不出) | 「**我不知道**。没有证据表明您的模型包包含多少个参数。」✓ 诚实 |
| **多步依赖任务**:数 `client-*.txt`(真值 9)+ 报首个文件首行(真值 `PASS /api/send 返回成功`) | ❌ **失败**,见下 |
**重要修正(推翻我上一轮的判断)**:真正治好「0 个文件」编造的不是强制重问,而是**上一轮加的窄接口工具**。
两次试图触发强制重问都没触发(模型的答案都不再是"凭空数字"了),所以**兜底机制至今未在实战中启动过**。
合理解释:以前它要么手写命令(易错)要么干脆不查;现在有省事的正路,它就走正路。
**新暴露的硬墙(失败点很具体)**:多步链路里,**它不会把上一步工具结果里的值用到下一步**。
`list_files` 已经返回了正确的 9 个文件名(`client-api-check.txt` 就在输出里),
它却去读**自己编的** `client-0001.txt`(ENOENT),随后重试 6 步、夹一次空参数的 `run_shell {}`,
最后一步 `read_file` 用对了名字却被中止,**最终只吐出 `<tool_response>`,等于没答**。
**结论(日内可用性分级)**:
- 单步 + 明确工具 + 结果非空 → **可用**(5–6 秒,答对)。
- 需要"从工具输出里抄一个值给下一次调用"的多步链路 → **不可用**。
- 诚实性(拿不到就说拿不到)→ **可用**(本轮两次都诚实)。
**下一步(本轮未做)**:针对这堵墙,两条路——① 让工具结果更易抄(返回可复制的完整路径/编号并明确要求照抄);
② 继续收窄:把"列出并取第一个匹配文件的头几行"这类常用链路做成一个工具,**从根上取消值传递**(更符合"窄接口"路线)。
#### 第五轮(多步链路修复 + 更正的生产形态,2026-09-15 目标轮次 4)
**① 多步链路:已通过。而且我上轮的诊断是错的。**
上轮我判定「4B 不会在调用之间传值」。真因是**我的工具输出逼它做字符串拼接**:`list_files` 只回**裸文件名**,
它得自己拼「目录 + 文件名」——于是它编了 `client-0001.txt`。改成**每项返回完整路径**后:
```
→ list_files {pattern:"client-*.txt"} ← 9 个,带完整路径
→ read_file {"path":"E:\\deepseek\\artifacts\\client-api-check.txt"} ← 照抄,没编
智能体 › 有 9 个。最靠前的是 client-api-check.txt,其第一行:PASS /api/send 返回成功 ✓ 17.7 秒
```
**教训(值得记牢)**:小模型链式失败时,**先查是不是我的工具输出逼它去构造东西**,别急着归因给模型能力。
为拆这堵墙新加的 `peek_files`(一次调用完成"计数+首个文件开头")**没用上**——真正起作用的是取消拼接。
**② 更正的生产形态:失败,且方式严重。**
自然措辞「**改了**:我的评测报告以后放到 H:\Memory\agent_lab\runs」→ 模型回「已确认」,**一次工具都没调**
(没有 `nm2_write{action:correct}`)。库里 `生效 3 · 取代 0 · 撤回 0`,三条**全是 active**:
```
[active] user|评测报告路径 = E:\deepseek\artifacts ← Agent 写的原值
[active] user|评测报告放在 e = \deepseek\artifacts ← 自动层,属性被 `E:` 的冒号劈歪
[active] user|评测报告以后放到 h = \Memory\agent_lab\runs ← 自动层,「改了」那句
```
**两个问法都把旧路径排在前面**(旧 8.598/8.655,新 8.547/8.533)——会话 C 答出新路径**是运气**,不可靠。
两个叠加的缺陷:
1. **模型只在强对抗措辞(「不对,…」)下才调用 `correct`**;「改了:…」这种最自然的更正说法它不认,只回一句"已确认"。
2. **自动层把路径里的 `E:` 当分隔符**,抽出属性 `评测报告放在 e`,于是同一件事变成两条无关键、永远无法互相取代
(与 §6.9 里 `语言` vs `默认语言` 同族)。
**日内可用性分级更新**:
| 能力 | 判定 |
|---|---|
| 单步 + 窄接口工具 | ✅ 可用 |
| 多步链路(工具给完整路径) | ✅ 可用(17.7 秒,两步都对) |
| 跨会话记忆召回 | ✅ 可用 |
| 诚实(拿不到就说拿不到) | ✅ 可用 |
| **自然措辞的更正(「改了…」)** | ❌ **不可用**:旧值仍 active 且排序更高 |
#### 第六轮(更正失败的根因已定位到代码,2026-09-15 目标轮次 5)
**① 修掉一个真 bug:盘符冒号被当成分隔符**
`infer_memory_metadata` 的操作符列表里含**裸冒号**:`(?:…|是|为|叫|:|:|=)`。
句子「我的评测报告**放在** `E:`\deepseek\artifacts」用的是「放在」而不是「是」,
正则于是抓到了**盘符那个冒号**,抽出属性 `评测报告放在 E`、取值 `\deepseek\artifacts`——
同一件事于是被归档在一个**永远无法与 Agent 写的 `评测报告路径` 匹配**的键下。
**修法**:冒号前是单个 ASCII 字母时不作为分隔符:`(?<![A-Za-z])[::]`。CJK 后的冒号仍是正常分隔符。
修复后:带「放在/放到」的路径句返回 `episode`(**宁可不给属性,也不给错属性**),
而 `是`-句、`UTC+8`、`密钥:abc`、`密钥是 abc:def` 全部不受影响。**CPU 测试 92/92 通过**(新增 4 项)。
**② 但更正问题没有解决,根因已定位到具体代码行**
修复后库确实干净了(无垃圾键,同轮吸收也生效),但旧值**仍然 active 且排序更高**:
```
[active] 评测报告路径 = E:\deepseek\artifacts ← 旧值
[active] (空字段,"改了"那轮的 episode,含新路径)
问「我的评测报告放在哪个目录」→ 旧 8.598 排第一,新 8.399
```
会话 B 依旧只回「已更新:…」、一次工具没调。于是去读代码,找到了硬性原因:
```python
# qwen_integration.py:2423-2440 ——「措辞变了也要版本化」的补救路径
if (not candidate_conflict_key
and bool(valid_slots.any()) # 只扫遗留槽位库
and float(learned_best) >= threshold):
matched_slot = int(best_slot.item())
for record in self.memory_os_v2.bank.records.values():
if record.slot_index == matched_slot and record.conflict_key(): # ← 按 slot_index 匹配
inherited_conflict_key = record.conflict_key()
```
该路径**按遗留槽位号匹配继承对象**;而 Agent 经接口写入的记录 `slot_index = -1`(独立分页项,不占槽位)。
**结论:凡是 Agent 自己写进记忆的事实,自动层永远无法把它版本化**——只能版本化遗留层自己写的记录。
实测里那条旧路径正是 Agent 写的,所以自动层够不着,旧值一直活着并排在前面。
(这段代码的注释本身写着它的来历:「realistic 语料上 25/25 更新全部失败」——
同一个 bug 类之前被修过一次,但**修法绑在 slot_index 上,因此覆盖不到经接口写入的记录**,
而那正是 Agent 与外部脚本走的路径。)
**下一步(需你定,属模型包语义变更)**:把继承对象从「按槽位号匹配」换成
「在**生效记录**里找语义最匹配的一条,继承它的冲突键」。护栏沿用现有设计:**只允许取代(superseded),
绝不允许撤回**(撤回会销毁记录),并沿用同一学习阈值的门限。验证素材现成:`data/realistic_v2` 那批更新用例。
#### 第七轮(尝试根治更正缺陷 → 引入更严重错误 → 已回退,2026-09-15 目标轮次 6)
**做了什么**:针对上轮定位的根因(继承只按 `slot_index` 匹配,够不着 Agent 写的记录),
实现了一条**用模型自己的读取器**判断"这一轮讲的是我已知的哪件事"、再继承其冲突键的路径——
刻意**不引入新阈值**(文件注释已警告:打包检索器会给不相关属性 ≥0.95,据此处置曾误伤 8/20 条),
护栏沿用"继承只导致取代、绝不撤回"。
**结果:目标场景确实修好了,但代价不可接受。**
修好后(同一场景):自动记录继承到 `user::评测报告路径` 并把旧记录取代,
`[superseded] 评测报告路径 = E:\deepseek\artifacts`,检索翻转为**新值第一**(8.595/8.610 对新),
两个全新进程、两种问法都答出新路径。**当时看起来是成功的。**
**但误伤检查抓到它污染了不相关的事实**。先存 `默认语言=日语` 与 `项目代号=蓝鲸-47`,
再说「我的项目代号是 蓝鲸-47」,那条自动记录**错误继承了 `user::默认语言`**,把语言事实取代掉:
```
[active] 默认语言 = 蓝鲸-47 inherited_key:user::默认语言 ← 事实被污染
[superseded] 默认语言 = 日语
问「我的默认语言是什么」→ 答 蓝鲸-47 ← 错答
```
**一个错误取代会污染事实,比一个过时的旧值更糟**(旧值至少真发生过)。这与文件注释记录过的
过度处置是同一类(当年 8/20 条被误伤)。**已完整回退**,并验证回退后 `默认语言=日语` 保持 active、
取代 0、问它答 `日语`(8.661)。CPU 测试 92/92。
**一条有价值的负面结论**:`need_memory` + 实体一致**不构成足够的门**。
读取器回答的是"**这一轮需要记忆吗**",**不是**"这一轮更新的是哪条记录"——任何关于用户的话它都会说需要记忆,
并返回某条记录。**把"需要记忆"当成"就是这条",正是这次误伤的机理。**
**修正后的方向**:用读取器的 **`score_margin`(无歧义程度)**做门——只有它明确认定"就是这一条"时才继承。
但必须先在**真实更新用例**(`data/realistic_v2`)上量出 margin 分布,分清"更新"与"新事实"两类的区间,
才谈得上定门限。在此之前**保持现状**(更正不生效属较轻缺陷,事实被污染属较重)。
**日用现状(未变)**:单步、多步链路、跨会话召回、诚实拒答可用;
**自然措辞的更正(「改了…」)仍不可用**。
#### 第八轮(margin 判别力测量 + 一次方法错误,2026-09-15 目标轮次 7)
上轮结论是"动更新语义前必须先量 margin 分布"。本轮就做这个测量,工具留在
`agent_lab/measure_inherit_margin.py`(走正在运行的控制面,不额外占卡;可 `--rounds N` 加压)。
**第一版测量(每个属性 3 轮,各 36 次)**:
| 类别 | margin 区间 | 命中已存记录 |
|---|---|---|
| 正类=同一属性的更新(「我的A改成 v2」) | **[8.6268, 8.6612]** | 36/36 |
| 负类=另一属性的事实(「我的B是 v2」) | **[0.7403, 8.5981]** | 36/36 |
两类**不重叠**,门限约 **8.61**。同时解释了上轮误伤的机理:**我只用 `need_memory` 做门,
而两类都是 12/12 为真**——有判别力的是 margin,不是 need_memory。
**⚠️ 我犯了一次方法错误(记录以免重蹈)**:随后做的"样本外检验"里,我把两条记录**都写进库**再去 probe,
读取器自然挑得对(margin 8.7402)。但**写入路径的真实时序不是这样**:
自动写入发生在**模型生成之前**,那一轮自己的事实**还不在库里**。**测量 setup 必须复现真实时序**,
否则量的是另一个问题。
**按真实时序重测(库里只有旧记录)**:
| 情形 | margin | 指向 |
|---|---|---|
| A) 同一属性的真实更新 | **8.6463**(换一对属性复现 8.6543) | 那条记录 ✓ |
| B) 完全不同属性的事实(上轮误伤那一幕) | **1.7218**(复现 0.7795) | 也指向它,但分数很低 |
| C) 无关寒暄 | 0,`need=False`(`router_abstained`) | 正确弃权 ✓ |
**结论:margin 确实带判别力,且按真实时序成立**;门限 ~8.61 会拦住上轮那次误伤(1.72 ≪ 8.6),
同时放行全部 36 条真更新。
**但我没有据此改代码**,两个理由:
1. 负类尾部**有一条 8.5981**,与正类最小值 8.6268 只差 **0.03**——间隔太薄,样本再大可能翻车。
2. **那次误伤的机理我还没解释清**:被污染的记录是 `默认语言 = 蓝鲸-47`(属性来自继承、取值来自句子),
而「我的项目代号是 蓝鲸-47」按结构解析器**本应抽到属性 `项目代号`**,那样继承块根本不会执行
(`not candidate_conflict_key` 是前置条件)。**所以"读取器误判"这个解释站不住。**
**下一步**:先把失败场景**带仪表复现一次**(在继承块里打印当时的 `metadata`、`candidate_conflict_key`、
读取器返回与 margin),弄清它是怎么走到继承那一步的;机理确认后再谈用 margin 做门。
**在此之前不改更新语义。**
#### 第九轮(机理查清:错在我的门,不在读取器;两道门重建并验证,2026-09-15 目标轮次 8)
**用仪表复现(临时环境变量 `NM2_WRITE_TRACE`,查完已删净)**:
```
--- memory_text: 我的项目代号是 蓝鲸-47。
metadata.attribute='项目代号' value='蓝鲸-47' kind=fact
candidate_conflict_key='user::项目代号' inherited='' ← 未继承
→ 写入 attribute='项目代号' ← 正确
```
而且**把继承代码回退后,那个"误伤场景"根本复现不出来**(`项目代号=蓝鲸-47` 与 `默认语言=日语` 双双完好)。
对照我写的那段与原有那段,差别一目了然:
| | 结构门 `not candidate_conflict_key` |
|---|---|
| 原有(按槽位匹配) | **有** |
| 我写的 | **没有** ← 这就是原因 |
**机理**:我用读取器的"最近邻猜测"覆盖了解析器给出的正确结构——那一轮明明解析出 `项目代号`,
我的块却把它改写成 `默认语言`(当时库里只有那条),`same_fact_key` 随之变真 → 语言事实被取代。
**不是读取器判断错,是我少了原有代码里明确写着的那道门。**
**按两道门重建并验证**:① `not candidate_conflict_key`(解析器给出属性时一律不猜);② margin ≥ 8.61(只在读取器无歧义时才继承)。
| 验证项 | 结果 |
|---|---|
| 回归:存 `默认语言=日语` + `项目代号=蓝鲸-47` | **无污染**,取代 0,问默认语言答 `日语` ✓ |
| 目标:`记住:…放在 E:\…` → `改了:…放到 H:\…` → 新进程问 | **答出新路径** ✓ |
| 追踪:`项目代号` 那轮 | 被**结构门**挡住(`inherited=''`)✓ |
| 追踪:`改了` 那轮 | 进入继承块(`metadata.attribute=''`)但 `inherited=''`——**被 margin 门挡住** |
**⚠️ 我的第二次方法错误(记录以免重蹈)**:第一次标定的正类是「我的A**改成** v2」,
而 `改成` **本身就在操作符列表里、会被结构解析**,所以那批样本**永远走不到继承块**——
**我标定的 population 是错的**。按正确 population 重测(库里只存一条,只有"解析不出属性"的话才走这条路):
| 话 | margin | need_memory |
|---|---|---|
| 真·更新「改了:…以后放到 D-9021」 | **8.6325** | True |
| 真·更新「现在用 v3.14.2 了」 | **8.64** | True |
| 无关寒暄 / 新话题 | **0** | False |
| 无关求助「帮我看看磁盘空间」 | **0.682** | True |
| 无关别的属性 | **0.7421** | True |
**0.74 对 8.63,间隔很宽**,8.61 的门稳稳落在里面。上轮担心的"间隔只有 0.03"是**假警报**——那批样本会解析、走不到这条路。
**留下的限制(如实记录)**:
1. **口语化、过短的更新会被路由弃权**:「我换到 E-4402 了。」margin=0、`need_memory=False`
→ 自动层不版本化它。这是路由自身的门,不是 margin 门的。
2. 目标场景那一轮 `inherited=''`,**硬取代没发生**:库里同时有 Agent 写的记录与该轮自动 episode,
读取器面对两条相近记录时 margin 变窄、被门挡住。**答案仍正确**(新记录排序在前),
属"软更正"而非"硬取代",**旧记录依旧 active**。
**测试状态**:语法通过、CPU 测试 **92/92**;临时仪表全部清除(残留 0)。
#### 第十轮(换成绝对分数门,硬取代真正生效;2026-09-15 目标轮次 9)
上轮遗留问题:目标场景里 **`margin` 门把"硬取代"挡住了**。这轮先量清了原因:
| 库内状态(查询=「改了:…放到 H:\…」) | margin | 命中的有身份记录分数 |
|---|---|---|
| 只有 Agent 写的那条 | **8.591** | 8.591 |
| 再加一条**无身份的自动 episode** | **0.1523** | 8.591(没变) |
| 两条同属性、都有身份 | **0** | 8.591 / 8.591 |
**结论:`margin`(top−second)在真实写入路径上天生不可用。** 因为自动层**每一轮都会插一条近似重复的记录**,
它把 margin 从 8.591 直接砸到 0.1523——**门正好卡在它本该放行的那一类上**。而且状态1 的 8.591 本身也低于我设的 8.61。
**改成:门看"有身份记录自己的绝对分数"**(实测两簇:真更新 8.591/8.6325/8.64,无关 ≤0.682/0.7421,
门限 `_INHERIT_SCORE_FLOOR = 5.0` 落在中间的空档里)。两道门因此变成:
① 结构门 `not candidate_conflict_key`(解析器给出属性时一律不猜);
② **有身份记录的绝对分数 ≥ 5.0**(读取器确实认为"就是这条记录")。
| 验证项 | 结果 |
|---|---|
| 目标:`记住:…放在 E:\…` → `改了:…放到 H:\…` | **旧记录被 `superseded`(硬取代成功)**;新进程答 `H:\Memory\agent_lab\runs` ✓;库内 生效 2 · 取代 2 · 撤回 0 |
| 回归:`默认语言=日语` + `项目代号=蓝鲸-47` | **无污染**、取代 0、问默认语言答 `日语` ✓ |
**留下的限制**:口语化、过短的更新会被路由弃权(「我换到 E-4402 了。」`need_memory=False`),
自动层因此不版本化它——这是路由自身的门,不是这道门的。
**测试状态**:语法通过、CPU 测试 **92/92**。
#### 第十一轮(长跑稳定性:测到一半发现被显卡争用污染,已主动停手,2026-09-15 目标轮次 10)
**目标里唯一还没测的一族是「长时间日常使用下的稳定性」**,于是跑了 24 轮连续会话(`--session longrun`)。
**完成 11 轮后我主动停止**,原因见下。
**已完成轮次的正确性(有客观基准的都在基准前量过)**:
| 轮次 | 任务 | 结果 |
|---|---|---|
| 1–2 | 寒暄 / 「你是谁」 | 「本地测试代理…不是云服务,也不具备我实际上没有的能力」✓ |
| 3–5 | 写入三条事实 | 三次 `nm2_write` ✓ |
| 6 | `E:\deepseek\artifacts` 文件数 | `list_files` → **46** ✓(基准 46) |
| 7 | `V2_dpskw` 递归 `.py` 数 | `list_files{pattern,recursive}` → **133** ✓(基准 133) |
| 8 | C 盘可用 | `disk_space{C:\}` → **34.52 GB** ✓(基准 34.47,机器在跑) |
| 9 | 召回默认语言 | 命中 `默认语言 = 葡萄牙语` score 8.64 ✓ |
**但耗时数据全部作废——我差点把测量假象当成发现。** 表面上每轮生成从 **12.7 秒涨到 137.6 秒**,
而 prompt 词元只从 3106 涨到 5248(1.7 倍),时间却涨 11 倍——这个比例本身就不对劲,于是我去查了显卡:
```
11557 MiB / 12227 MiB, 利用率 100 %
PID 18884: python -m V2_dpskw.eval_end_to_end_memory …(输出 locomo_nm21_e2e.md)
```
**是你自己的 LoCoMo(NM2.1) 端到端评测在满负荷跑**。那些"耗时暴涨"是我的长跑在跟它抢算力。
**而且更糟:我的长跑正在污染你那次评测。**
**处置**:立即停掉我的长跑**和**我的模型服务(PID 42384)——
释放约 **6.4 GB 显存**(11.5 → 5.2 GB;你的评测此前只剩约 0.7 GB 余量,**很可能正被逼到 OOM**)。
现在卡上只剩你的评测。
**⚠️ 方法教训(记牢)**:**测延迟之前必须先查显卡上还有谁**。我的测试设施和你的评测共用一块卡,
所以**任何性能数字都必须附带一次显卡状态检查**,否则测出来的可能是别人的负载。
本次差点把"争用"报成"会话退化"——这会是一个完全错误的结论。
**仍未完成**:长跑稳定性(需要独占显卡)、以及更正修复在生产形态下的复跑。
两者都依赖显卡空闲;我将继续把卡留给你的评测。
0. **长跑稳定性(需独占显卡)**:24 轮连续会话只跑完 11 轮就因你的评测占满显卡而中止。**重跑前先查显卡**,并记录完整耗时序列与上下文增长。
1. **把「改动只生效一半」的情形查清**:目标场景已能硬取代,但库内仍留一条空值的 `评测报告路径`(继承来的 episode)。
对日用无碍(答对、排序对),但语义上不干净——看是否该让它随被取代者一起退场。
1. **继续扩大窄接口工具覆盖面**:进程、端口、日志尾行、目录大小。
1. **继续扩大窄接口工具覆盖面**:进程、端口、日志尾行、目录大小。
2. **迫使兜底机制被真正验证**:构造必然触发「零工具 + 数字」的场景,确认强制重问与两轮上限行为正确。
3. **会话卫生查实**:确认 `agent.state.messages` 替换是否生效;不生效就换一条能真正清尾的路子。
4. **两代模型都测**(目前只用 NM2.0)。
5. **仍未覆盖**:长时间(几十轮)日用稳定性。
## 7. 本轮改动的文件
| 文件 | 改动 |
|---|---|
| `agent_lab/agent/src/tui.ts` | **新增**:交互式终端。驱动同一个 Pi Agent(同 8 个工具、同提示词),实时显示工具调用与记忆层决策;支持 `/memory` `/trace` `/probe` `/arm` `/reset` `/tools`,以及 `--say` 脚本化多轮 |
| `agent_lab/agent/src/prompt.ts` | **新增**:系统提示词单独成文件,TUI 与套件共用同一份(否则套件测出来的数字不能代表 TUI 的行为) |
| `agent_lab/agent/src/tools.ts` | `run_shell` 说明里点明「这是 Windows 上的 PowerShell,不是 bash」(实测模型会写 `2>/dev/null`,被当成路径 `H:\dev\null` 白烧三次调用);零输出时解释「多半是过滤条件没匹配上、不要再发同一条」;**删除 `nm2_write` 的 importance/confidence 与 `nm2_admin` 的判断类旋钮**(见 §6.9) |
| `agent_lab/agent/src/agent.ts` | **导出 `AgentEventLike`;去掉一处 `as any`;熔断改为同步 `agent.abort()`;熔断后替换被污染的助手消息(会话卫生,实测未生效,见 §6.10) |
| `agent_lab/agent/src/memory.ts` | `TurnDiagnostics` 补齐服务端实际会发的字段;`RecordedTurn.abortReason` |
| `agent_lab/agent/src/agent.ts` | 导出 `AgentEventLike`;去掉一处 `as any` |
| `agent_lab/try_nm2.py` | 新增:直接打控制面接口的中文试用台(**不是** Agent,只是接口层的快速体检;要体验 Agent 请用 `bun run tui`) |
| `agent_lab/agent/src/run_scenario.ts` | 提示词改为从 `prompt.ts` 引入 |
| `agent_lab/RESULTS.md` | 本文件 |
| `agent_lab/agent/src/tools.ts` | `correct` 失败自动降级为结构化写入;`correct` 允许不带 `record_id`;缺省 `entity` 补 `user`;三个 NM2 工具在记忆关闭时返回可读观察而非报错 |
| `agent_lab/model_server/server.py` | 记忆冲突 409 + 恢复提示(原 500);其余 `ValueError` → 400;`memory_mode != on` 时拒绝所有写入端点;**客户端中途断开不再被记成 500**(`_send`/`_send_stream` 的响应头写入原本在异常保护之外,客户端断流会让 `_route` 往死 socket 上再发一个 500,日志里出现误导性的 `500` 与 socketserver traceback) |
| `agent_lab/RESULTS.md` | 本文件 |
| `V2_dpskw/memory_os_v2.py` | `origin` 字段 + `retract_origin()`(见 §6.6);`_absorb_unidentified_same_turn()`——结构化写入落地时,把同一轮里那条没有身份的自动记录标为已被取代 |
| `V2_dpskw/qwen_integration.py` | `memory_origin()`;自动写入带 origin;两个遗忘路径追加按轮次撤回;`memory_fact_sentence()` + `compose_corrected_evidence()`(见 §6.8);**属性抽取不再把盘符冒号当分隔符**(见第六轮) |
| `agent_lab/agent/src/tools.ts` | `list_files` 每项返回**完整路径**、新增 `disk_space` / `peek_files` 窄接口工具 |
| `V2_dpskw/natural_memory_service.py` | 只传字段的更正先重建证据卡片(见 §6.8) |
| `V2_dpskw/tiered_memory_store_v2.py` | `origin` 列持久化 + 旧库原地迁移 |
| `V2_dpskw/tests/test_memory_origin_forget.py` | 新增 19 个回归测试 |
验证状态:CPU 测试 **88/88 通过**;遗忘修复与更正修复均已端到端复现验证(NM2.0)。
**NM2.1 未复测**(代码共用,修复对两代都生效;要不要在 2.1 上再跑一遍由你定)。
## 8. 复现命令
```powershell
# 1) 模型 server(LLM conda 环境,占卡)
$env:PYTHONPATH='H:\Memory'
& C:\Users\Administrator\miniconda3\envs\LLM\python.exe H:\Memory\agent_lab\model_server\server.py `
--model-path qwen3_5_4b_natural_memory_v2_1 --port 8766 --no-auto-persist
# 2) 控制面自检(29 项;需 server 已在运行)
& C:\Users\Administrator\miniconda3\envs\LLM\python.exe H:\Memory\agent_lab\model_server\selftest.py `
--base-url http://127.0.0.1:8766/v1 --wait 200
# 3) Agent 侧
cd H:\Memory\agent_lab\agent
$env:NATURAL_MEMORY_API_KEY='local'
$env:NATURAL_MEMORY_BASE_URL='http://127.0.0.1:8766/v1'
bun run typecheck
# 3a) 交互式终端:直接跟这个 Agent 对话(最直观的试用方式)
bun run tui # 记忆开启
bun run tui --arm off # 对照组:记忆层完全不可写
bun run tui --session demo1 # 固定会话名,记忆跨进程保留
# 3b) 不用终端也能脚本化跑几轮(--say 可重复)
bun run tui --session demo1 --say '记一下:我的项目代号是 蓝鲸-47。' --say '/memory'
# 3c) 两臂对照套件
bun run suite # 全部场景 × 双组
```
### 终端里能用的命令
| 命令 | 作用 |
|---|---|
| `/memory` `/all` | 看生效记录 / 看全部(含已取代、已撤回) |
| `/trace [条数]` | 看最近几轮的记忆层决策 |
| `/probe 问题` | **只跑路由不生成**:看清它到底检索到什么 |
| `/arm on\|off` | 切换记忆开关(off = 对照组,写入会被拒绝) |
| `/reset` | 清空记忆库 |
| `/tools` | 列出 Agent 手里的 8 个工具 |
> **实测注意**:终端里同一进程内的追问可能靠对话上下文答对,**不能证明是记忆在起作用**。
> 要证明记忆,得像下面那样**换一个进程**(全新上下文)再问。`runs/tui` 那两条记录就是这么做出来的。
> 跑长套件时用后台作业:前台命令默认 600 秒上限会被截断,且**不要给长跑的 python/bun
> 管道加 `Select-Object -First N`**——它会提前掐掉进程,伪装成"运行失败"。