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

5.2 KiB
Raw Blame History

场景式记忆修复 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

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 失焦。单独开条目。