Files
HomeAgent/docs/zh/scene-memory-fix-plan.md
JianFeeeee 758ec11832 fix(memory): 图整备覆盖 scenes + 证据桶按桶清(R3/R5)
R3:图整理心跳只查 entities/relations,scenes 完全没有整备路径。
Recall(nil,nil,1,"") 的全量路径只 SELECT 这两张表
(graph.go:609/631),于是同一场面的双胞胎键从建库起无人发现:
auto:chan:qq+part:morning 累积到 strength=271 / 6 features / 0 refs,
孪生的 auto:chan:qq_part:morning 持有 210 refs 却 0 features
(聚类只读 scene_features,所以它永远不被看见)。

新增 GraphDB.DedupeScenes:归一化后同名的场景合成一个——强度相加、
特征取并集(权重取大)、引用全部重定向,存活者保留 id 最小行,
跨 origin 也合。接到 mergeLoop 尾部。

为什么不塞进 detectEntityMerge 的双重循环:
- 实体是全库两两 bigram + LLM 裁决(1 万实体实测 5000 万次配对、
  ~224GB 瞬时分配每轮,是独立问题);
- 场景的判重口径是**归一化后是否同名**——同名即同一场面,键相同
  本身就是证据,不需要 LLM 裁决。而「像不像」是 EnterScene 聚类的
  职责,不是这里的事。
只做同键合并、不做相似度合并:把 chan:qq 与 chan:webui 合并是危险
的,去重不是「把像的一律合并」。

生产库副本实测(sqlite3 备份式复制到 /tmp,未碰生产):
65 个场景 → 合并 8 组 → 57 个;
auto:chan:qq_part:morning 的 refs/rel/ent 一条没丢,strength 1 → 272。

R5:createSceneLocked 新场景成立时执行的是
`DELETE FROM situation_evidence`(全表清),而证据表是多通道共用的
计数桶。后果不是「多清一点」:qq 的场景一长出来,就把 mc/webui/cli
尚未攒够 minSceneEvidence=2 的证据抹掉,它们的计数被反复清零,
于是**永远**攒不到 2 次。判据实测:6 个通道各来 3 次,只长出 2 个场景。
改为 `DELETE ... WHERE label = ?`,只清本指纹那个桶。

判据:scene_dedupe_test.go 8 例(含「不同场面不得被合并」与幂等)、
scene_evidence_test.go 3 例。均先红后绿。修 R5 时差点栽:桶键是
sig.Label(2) 本身、不带 auto: 前缀(base 才是带前缀的场景键),
第一版删错对象会「一条没删却看起来通过」,用探针实测真实桶键后改正。

记忆 8 包 + agent/core 全绿,8 包齐全、无 FAIL/panic/race。
2026-09-26 20:16:11 +08:00

224 lines
11 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 是病因。** 顺序不能反。
### R4 场景身份只有「输入通道」一个可靠维度
现网 65 个场景键的维度分布(实测):
```
auto:chan(涌现·通道主导) 36
chan: (输入通道) 26
tool: (工具) 3
peer 主导 0
topic 主导 0
```
两个独立原因叠加:
1. **采集侧恒空**:`situationFeaturesFor` 从 `evt.Payload` 找
`peer/peer_id/group_id/user_id/chat_id`,而全仓**没有任何插件在 InjectInput
时填这些键**(`group_id` 只出现在 `output.go:145` 的发送侧帮助文本里)。
⇒ 日志中 `peer` 特征出现次数为 **0**。
2. **排序上被挤掉**:`chan` 与 `peer` 权重同为 1.0,而 `NewSituation` 按权重
降序**稳定**排序,`chan` 先 append 就永远在前 ⇒ 即使采集到 peer,
`Label(2)` 也轮不到它。
后果:「跟谁对话」这个本该最强的身份信号(权重与 chan 并列)**根本进不了
场景身份**。同一件事在 QQ 和 Telegram 上会落进不同场景而无法共享。
**R4 与 R1/R2 是不同层面的问题**:R1/R2 让键构造正确,R4 决定键**能表达
什么**。R1 修完后 `auto:chan:qq` 会取代 `auto:chan:qq+part:morning`,
但它依然只认通道——所以 R4 不修,修复效果只到「正确的单一维度」。
### R5 `situation_evidence` 全表清空
`createSceneLocked` 新场景一成立就 `DELETE FROM situation_evidence`
(不只删本指纹的足迹)。多场景并发轮次下,A 场景的建立会连带清掉 B 尚未
攒够 `minSceneEvidence=2` 的证据 ⇒ 门槛判定被别的场景的建立随机打断。
### R6 通道侧没有「是否参与场面识别」的声明项(SDK 缺口)
`ChannelDef` 的记忆相关声明已有三件套,语义各管一轴:
```
NoMemory 进不进记忆计算
ContextPolicy 裁不裁上下文(破坏性,默认关)
RecallPolicy 召不召回记忆(只读,默认开)
```
**唯独没有「这条通道是否参与场面识别」。** 现状是**无条件参与**:
`situationFeaturesFor` 里只要 `evt.Source != ""` 就塞一个 `chan` 特征,
没有可关的开关 ⇒ 现网 `chan:system` / `chan:kernel` / `chan:timer` 这类
**纯内部信噪通道也在参与场面聚类**。
穷举确认不是查漏:编译使用的就是 `third_party/homeagent-sdk`(go.mod replace),
`plugin.go` 中 `scene` 出现 0 次,SDK 自身 git 历史 `-S'Scene' -- sdk/` 为空。
已有但未被使用的另一个口子:`Payload["scene"]`(`memorypass.go:37`)允许插件
在单次注入时声明场景键(string/[]string/[]interface{} 三形态,来自 `d98bf51`)。
现网 **0 个插件使用**,24 个 declared 场景全是 `ChannelScene(evt.Source)` 派生。
### R7 渠道本身可以覆盖多个场景
`payload["scene"]` 传 `chan:qq/peer:group_1` 这类**层级键**时,
`RecallByScene` 的 `(key = ? OR key LIKE ? || '/%')`(`scene.go:351`)
支持前缀召回——这层能力已存在,但因 R6 无人使用而闲置。
---
## 步骤
### 步骤 1:修 R1 + R2(源头,不碰存量)✅ 已完成
- [x] `Label` 只取权重 ≥ `labelFeatureWeight`(0.5) 的主导特征,结果过 `NormalizeSceneKey`
- [x] `createSceneLocked` 的 `base` 再做一次防御性归一化;label 为空时拒建无名场景
- [x] 冲突后缀 `#N` → `.N`(`'#'` 会被归一化成 `'_'`,是第四处双胞胎来源)
- [x] 判据:`scene_key_test.go` 6 例,先红后绿(4 红 1 绿 → 全绿)
- 提交:`1fa9ef6`
### 步骤 2:SDK 补 `ScenePolicy` 声明项(公开接口,可动)
按 `ContextPolicy` / `RecallPolicy` 的既有风格补齐(同一文件、同一形状):
- [ ] 常量:`ScenePolicyAuto = "auto"` / `ScenePolicyNone = "none"`
- [ ] 校验:`ValidScenePolicy(policy string) bool`(空串等价默认)
- [ ] `ChannelDef.ScenePolicy string` + json tag `scene_policy,omitempty`
- [ ] `InjectOptions.ScenePolicy string`(单次注入可覆盖通道默认)
- [ ] 内核接线:
- `internal/agent/io/channel.go:349-357` 的 payload 搬运加一条 `scene_policy`
- `situationFeaturesFor` 读到 `none` 时**不产任何特征**(连 `part` 也不产——
一个不参与场面识别的通道不该留下时段噪声)
- 声明路 `sceneKeysFor` 同样受 `none` 约束
- [ ] 判据:`none` 通道连续 5 次交互,`scenes` 表行数不变
- [x] **存量标注:不做**(用户裁定 2026-09-26:「所有都默认开启,因为多写无影响,
少写会缺场景」)。实测支持:8 个 0-refs 通道合计 70 strength、0 条记忆,
召回返回空;且 declared 场景**不进**相似度空间
(`loadEmergentScenesLocked` 只取 `origin='emergent'`),
故多写对聚类零影响。声明项作为「插件将来确实需要时」的闸门保留。
### 步骤 3:修 R3(让图整理覆盖全库)
- [ ] 给 `mergeLoop` 加**独立的场景去重路径**,不塞进实体那个 O(n²) 双重循环
(理由:实体 1 万行 × bigram + LLM 裁决,实测 5000 万次配对/轮;
场景表小且**已有现成的 `situationSimilarity` 加权 Jaccard**,语义更准)
- [ ] 键归一化后相同 ⇒ 合并 refs/features/strength,**不经 LLM**(键相同已证明同一场面)
- [ ] 判据:构造两个 `+`/`_` 孪生键,跑一次 mergeLoop 后期望合成一个
### 步骤 4:清理现网垃圾场景,让它重新生成
用户明确要求:**直接清理,重新生成**(不做保守迁移)。
- [ ] `sqlite3 .backup` 备份(**禁用 cp**,WAL 模式会拷出不一致快照)
- [ ] 删 `origin='emergent'` 的全部场景 + 其 `scene_features`/`scene_refs`
- [ ] 同步清 `situation_evidence`
- [ ] 重启 homed,等 ≥2 次同类交互让场景重新涌现
- [ ] 复验:新场景键**不含 `+`/`#`**、有 features **且**有 refs
> 清理**不影响记忆本体**:868 条 active 关系与 1179 个实体都在
> `relations`/`entities` 表,与 `scenes` 无外键依赖。
> 兜底不冷场:`chan:qq` 声明场景(strength=281、108 条关系)全程保留,
> 涌现重建期间它继续承担 QQ 场景召回。
### 步骤 5:修 R5(证据桶别全表清)
- [ ] `DELETE FROM situation_evidence` 改为按本指纹的桶标签删
- [ ] 判据:预置两个桶的证据各 1 次;建一个场景后断言另一个桶的证据还在
### 步骤 6:修 R4 的「覆盖面」部分(让 peer / 语义场景进得来)
前置:先只读调查各插件 InjectInput 时手上有什么,产出结论表再动。
`payload["scene"]` 的口子已存在(R6),插件声明比内核猜 peer 更直接。
- [ ] 调查:qq / mail-bridge / a2a / acp / webui / cli … 各自可声明什么
- [ ] 排序侧:`chan` 与 `peer` 权重同为 1.0 而 `chan` 恒在前(稳定排序),
即使采集到 peer 也进不了 `Label(2)` ⇒ 需要 peer 优先或提权
- [ ] 判据:构造「同一 chan、不同 peer」的两轮,期望落进**不同**场景
### 步骤 7:验证与收口
- [ ] `go build ./...` + 全仓 `go test ./...`
- [ ] `go test ./internal/memory/... ./internal/agent/core/...` 全绿
- [ ] SDK 接口冻结检查:`git diff main -- third_party/homeagent-sdk/sdk/` 的变化
**已获用户授权**(开发阶段),但需在提交信息里写明「纯追加、omitempty、
老插件行为不变」
- [ ] 现网观察:场景命中后能查到 refs(非 0)
- [ ] 现网观察:场景键前缀分布不再 100% 锚在 chan(R4 未做则保持挂账)
- [ ] `git_release_check.sh` 无新增红项(SDK 冻结项变化属预期)
## 不做的事(防反复挂账)
- **不**把 `scenes` 塞进 `memory_merge` 工具:该工具语义是"实体删除 + 关系重定向",
场景合并需要"特征并集 + refs 重定向 + strength 相加",是另一套操作,硬塞会让工具语义变危险。
- **不**改 `validGraphNodeKind`:它只管 `memory_block_edges` 端点校验,与 `scenes` 无关。
- **不**给实体那个 O(n²) 循环做优化:属独立问题(已实测:1 万实体→~224GB 瞬时分配/轮),
混进本次修复会让 diff 失焦。单独开条目。
- **不**在 R6 里动 `ValidContextPolicy` / `ValidRecallPolicy` 的既有语义:
新增项是纯追加,不借机改旧行为。