Files
HomeAgent/docs/zh/scene-memory-fix-plan.md
JianFeeeee 1fa9ef68a4 fix(memory): 场景键归一化 + 排除最弱维度,修双胞胎与空转
现网实测:scenes 表 65 行里有 6 组是同一场面的双胞胎键,最严重的
auto:chan:qq+part:morning 累积到 strength=270、6 个 features、
0 条记忆,日志里被"命中"179 次;孪生的 auto:chan:qq_part:morning
则持有 201 条记忆却有 0 个 features(不参与聚类)。两套特征体系
各活各的,谁也发现不了谁。

病因:键构造不唯一。
- Label() 直接用 "+" 拼接且不过 NormalizeSceneKey,而
  EnsureScene / effectiveScenes / RecallByScene 三处都过了归一化。
  "+" 会被 normalizeSceneSegment 归一成 "_",于是两个字符串都合法,
  key UNIQUE 约束拦不住。
- Label(2) 会把权重仅 0.2 的 part(时段)挤进场景身份。实测
  morning 场景吞掉 evening 指纹:共享 chan:qq 权重 1.0、并集含
  part 0.2×2,相似度 1.0/1.4 = 0.714 > joinSceneThreshold 0.5。
  这本就是加权 Jaccard 的正常行为,但键名不该写进时段。
- createSceneLocked 的 "#N" 冲突后缀同样会被归一成 "_",
  造成 auto:chan:mc:event+topic:mc#2 在库、而写侧归一化后去找
  auto:chan:mc:event_topic:mc_2 —— 又一对匹配不上的双胞胎。

修法:
- Label 只取权重 ≥ 0.5 的主导特征,结果过 NormalizeSceneKey;
  全部特征都弱于门槛时退回最强的一批(宁可名字信息量低,也不能
  没有名字 —— 没名字就没有键,场景根本长不出来)。
- createSceneLocked 对 base 再做一次防御性归一化,并在 label 为空
  时拒建无名场景。
- 冲突后缀由 "#" 改为 "."('#' 会被归一化,'.' 是白名单字符)。

判据:新增 scene_key_test.go 6 例,参照物在生产代码之外("同一场面
⇒ 同一个键"这条不变量 + 直查 scenes 表复算行数)。先跑红确认判据
在跑(4 红 1 绿,失败的正是键唯一性/时段污染/证据桶),再改实现。
其中 TestWrittenRefReachableFromItsScene 一开始就是绿的——写侧到读侧
那条路本身是通的,坏的只是键的构造。

记忆 8 包 + agent/core 全绿。
2026-09-26 19:56:28 +08:00

117 lines
5.2 KiB
Markdown
Raw 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.

# 场景式记忆修复 plan
> 起点:2026-09-26 生产库实测(`/home/newqqagent/memory/graph.db`)。
> 现象:65 个场景中 6 组是同一场面的双胞胎键;主力场景 `auto:chan:qq+part:morning`
> strength=270、6 个 features、**0 条记忆**,日志里被"命中"179 次。
> 分支:`feature/scene-writeback`(从 main 拉出,工作树干净)。
---
## 根因(三条,逐条修)
### R1 建键不过归一化(病因)
`internal/memory/scene_emerge.go:329`
```go
base := "auto:" + sig.Label(2) // Label 用 "+" 拼接,未归一化
```
而同一层的另外三条路**都**过了归一化:
| 位置 | 是否归一化 |
|---|---|
| `EnsureScene`(声明建键)`scene_emerge.go:555` | ✅ |
| `effectiveScenes`(写侧挂 ref)`graph.go:1397` | ✅ |
| `RecallByScene`(读侧召回)`scene.go:328` | ✅ |
| **`createSceneLocked`(涌现建键)`scene_emerge.go:329`** | ❌ |
`normalizeSceneSegment` 把 `+` 归一成 `_`,所以涌现键天生带 `+`、其余三方说 `_`。
`scenes.key` 虽有 `UNIQUE` 约束,但**两个不同字符串都合法**,拦不住。
实测 6 组双胞胎(`REPLACE(key,'+','_')` 后重名):
```
auto:chan:qq+part:morning ↔ auto:chan:qq_part:morning (270/0 refs)
auto:chan:mc:event+topic:mc ↔ auto:chan:mc:event_topic:mc
auto:chan:mc:system+topic:mc ↔ auto:chan:mc:system_topic:mc
auto:chan:mc-resident+topic:mc ↔ auto:chan:mc-resident_topic:mc
auto:chan:homeagent-mail-bridge+... ↔ auto:chan:homeagent-mail-bridge_...
auto:chan:system+topic:任务 ↔ auto:chan:system_topic:任务
```
### R2 `part`(时段,权重 0.2)污染主键与门槛
`Label(2)` 取权重最高的 2 个特征。特征顺序是
`chan(1.0) → peer(1.0) → tool(0.8) → topic(0.4) → part(0.2)`,
只有当前 2 个强特征不足时 `part` 才会进键——于是出现
`auto:chan:qq+part:morning` 这种"时段成了场景身份"的名字。
**更糟的是 `recordSituationEvidenceLocked` 也用 `Label(2)` 做桶**(`scene_emerge.go:380`),
所以 `part` 不只影响名字,还决定 `minSceneEvidence=2` 这个门槛在哪个桶里计数。
实测证据(键名与实际指纹自相矛盾):
```
场景键: auto:chan:qq+part:morning strength=270
实际指纹: [chan:qq part:evening] ← 19:15 命中,evening 却并入 morning
```
这不是 bug 触发,是加权 Jaccard 正常工作:共享 `chan:qq`(1.0)、
并集含 `part`(0.2×2),相似度 1.0/1.4 = 0.714 > `joinSceneThreshold` 0.5。
### R3 图整理心跳漏扫 `scenes`
`detectEntityMerge` 唯一遍历入口 `Recall(nil,nil,1,"")`,其全量路径只查
`entities`(`graph.go:609`) + `relations`(`graph.go:631`),`scenes` 不在其中。
所以 R1/R2 造成的双胞胎从 9-15 起无人发现,23 号空转到 strength=270。
**R3 是"没被发现"的原因,R1/R2 是病因。** 顺序不能反。
---
## 步骤
### 步骤 1:修 R1 + R2(源头,不碰存量)
- [ ] `Label` 改名语义:**只取权重 ≥ 阈值的主导特征**,且结果过 `NormalizeSceneKey`
- [ ] `createSceneLocked` 的 `base` 用归一化后的 label
- [ ] `recordSituationEvidenceLocked` 的桶键用同一个归一化 label(R2 的另一半)
- [ ] 判据:先写**红**测试——同一指纹两次建键必须落同一行(现状会落两行)
### 步骤 2:修 R3(让图整理覆盖全库)
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准)
- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
### 步骤 3:清理现网垃圾场景,让它重新生成
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
(**保留 `origin='declared'`**:那些是插件声明的,不是垃圾)
- [ ] 同步清 `situation_evidence`
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
- [ ] 复验:新场景键**不含 `+`**、有 features **且**有 refs
### 步骤 4:验证与收口
- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
- [ ] `go build ./...` + 全仓 `go test ./...`
- [ ] 现网观察:日志中场景命中后能查到 refs(非 0)
- [ ] `git_release_check.sh` 无新增红项
---
## 不做的事(防反复挂账)
- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向",
场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。
- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
混进本次修复会让 diff 失焦。单独开条目。