Files

794 lines
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`**——它会提前掐掉进程,伪装成"运行失败"。