mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 12:53:35 +00:00
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 全绿。
This commit is contained in:
116
docs/zh/scene-memory-fix-plan.md
Normal file
116
docs/zh/scene-memory-fix-plan.md
Normal file
@ -0,0 +1,116 @@
|
||||
# 场景式记忆修复 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 失焦。单独开条目。
|
||||
Reference in New Issue
Block a user