mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-01 15:02:53 +00:00
feat(memory): 场景双通道——主动声明与被动涌现并存,且互不吞噬
按「声明式的也要支持,相当于主动被动两条路」落实。此前两者只是恰好并存,
没有边界,实测会互相吃掉(下面的坑就是)。
- Triple.Scenes []string(多值):一轮写下的记忆**两条路都挂**。
只挂一条会丢东西——只挂声明则细粒度唤起丢失,只挂涌现则首次交互
(场景还没长出来)没有兜底。单值 Scene 保留兼容。
- TurnScene:Primary 用于写(优先涌现场景,首次退到声明场景兜底),
Keys 是两条路的并集,用于召回(声明+涌动的场景一起进 RecallByScene)。
- EnterSceneWithHint:主动路 EnsureScene(声明即建场景,不等第二次),
被动路 EnterScene(指纹聚类)。写侧由 executeToolCall 把本轮场景集合
传给 memory_commit,模型不需要知道"场景"这回事。
踩到并修掉的坑(两条路互相吞噬):
最初让声明场景也吸收**整轮指纹**,于是 chan:qq 的相似度永远是 1.0,
把后续所有同类轮次全部吃掉 → 被动路再也长不出更细的场面,
实测 turn2.Emergent=true 但 Primary 仍是 chan:qq、没有 auto: 场景。
修法:给场景加 origin(declared/emergent):
- 被动聚类只认 origin='emergent' 的场景(声明场景不进相似度空间);
- 声明场景的特征**只从键自身解析**(chan:qq/peer:group_1 → {chan:qq, peer:group_1}),
白名单 kind(chan/peer/peer_group/tool/topic/part),不猜——
「老大2026-09-04_12:27_qq私聊图片」里的 12:27 也是 kind:value 形态,
放进特征空间就是往相似度里灌垃圾(有测试钉住)。
- 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),机制各归各。
- memgc -scene-stats 增加 [declared|emergent] 与 strength/features 两栏,
可直接观察两条路各自在长什么。
新增/改写用例:
- TestDeclaredAndEmergentBothLearn:首次交互兜底到声明场景 → 第 2 轮长出
细粒度涌现场景且**优先用于写入** → 声明场景不进相似度空间(防止压死被动路)
但仍走声明键取回 → 两条路都进召回集合 → 声明键特征解析与白名单。
- TestEffectiveScenes:多值+单值合并去重保序。
go build/vet 干净,go test -count=1 ./... 全绿。
This commit is contained in:
@ -59,6 +59,14 @@ type Triple struct {
|
||||
// 与这条关系都会被挂到这个场景上,场景重现时按场景召回。
|
||||
// 约定见 memory.NormalizeSceneKey:`chan:qq`、`chan:qq/peer:group_123`。
|
||||
Scene string `json:"scene,omitempty"`
|
||||
// Scenes 是同一批多场景挂载(可空):主动**声明**的场景(如 chan:qq)
|
||||
// 与被动**涌现**出来的场景(如 auto:chan:qq+peer_group:1027)可以同时挂。
|
||||
//
|
||||
// 为什么要两条都挂:声明路是"我知道这是哪个场面",稳定、可读、可兜底;
|
||||
// 涌现路是"这轮看起来像哪个场面",细粒度、不需要任何人声明。
|
||||
// 只挂一条的代价:只挂声明则细粒度唤起丢失,只挂涌现则首次交互(场景
|
||||
// 还没长出来)没有兜底。
|
||||
Scenes []string `json:"scenes,omitempty"`
|
||||
}
|
||||
|
||||
type GraphDB struct {
|
||||
@ -193,6 +201,11 @@ func (g *GraphDB) initSchema() error {
|
||||
-- strength 是场景被重现的次数:场景不是被声明出来的,是被反复遇到
|
||||
-- 长出来的(见 EnterScene / minSceneEvidence)。
|
||||
strength INTEGER DEFAULT 1,
|
||||
-- origin 区分两条路:'declared' 是主动声明出来的(键由人/插件给,
|
||||
-- 召走精确+前缀匹配),'emergent' 是被动涌现的(召走相似度)。
|
||||
-- 两者刻意**分开**参与匹配:若让声明场景也吸收整轮指纹,
|
||||
-- 它会在相似度上压过一切,被动路就再也长不出更细的场面了。
|
||||
origin TEXT NOT NULL DEFAULT 'emergent',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
@ -256,6 +269,12 @@ func (g *GraphDB) initSchema() error {
|
||||
tx.Exec(`ALTER TABLE memory_blocks ADD COLUMN scene TEXT DEFAULT ''`)
|
||||
// 迁移6:旧 scenes 表加 strength 列(涌现侧的强度计数)
|
||||
tx.Exec(`ALTER TABLE scenes ADD COLUMN strength INTEGER DEFAULT 1`)
|
||||
// 迁移7:旧 scenes 表加 origin。既有行都是声明/存量引导来的(建表时还没有
|
||||
// 涌现机制),标成 declared;新建的涌现场景在 createSceneLocked 里写 emergent。
|
||||
if !columnExists(tx, "scenes", "origin") {
|
||||
tx.Exec(`ALTER TABLE scenes ADD COLUMN origin TEXT NOT NULL DEFAULT 'emergent'`)
|
||||
tx.Exec(`UPDATE scenes SET origin = 'declared'`)
|
||||
}
|
||||
// 迁移5:场景引用加 ref_text(块/文档的 id 是字符串)。
|
||||
//
|
||||
// 不能只 `ALTER TABLE ADD COLUMN`:REF_TEXT 同时参与唯一约束
|
||||
@ -489,12 +508,14 @@ func (g *GraphDB) commit(triples []Triple, sessionID string, turnID int, trackSe
|
||||
}
|
||||
}
|
||||
|
||||
// 场景引用:写完关系立即把「关系 + 两端实体」挂到这个场景上。
|
||||
// 同一事务内完成,避免出现「关系写进去了但场景引用丢了」——
|
||||
// 那会让这条记忆在后来的场景里永远召不回来,且无声无息。
|
||||
if t.Scene != "" && relID != 0 {
|
||||
if err := tagSceneTx(tx, t.Scene, relID, []int64{sourceID, targetID}, confidence); err != nil {
|
||||
return nil, 0, 0, err
|
||||
// 场景引用:写完关系立即把「关系 + 两端实体」挂到**每个**场景上
|
||||
// (主动声明的 + 被动涌现的)。同一事务内完成,避免出现「关系写进去了
|
||||
// 但场景引用丢了」——那会让这条记忆在后来的场景里永远召不回来,且无声无息。
|
||||
if relID != 0 {
|
||||
for _, sc := range effectiveScenes(t) {
|
||||
if err := tagSceneTx(tx, sc, relID, []int64{sourceID, targetID}, confidence); err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1317,3 +1338,28 @@ func columnExists(tx *sql.Tx, table, column string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// effectiveScenes 合并一条三元组的场景键(Scenes 多值 + Scene 单值),去重且保序。
|
||||
//
|
||||
// 单值 Scene 保留是为了兼容既有调用方与 memory_commit 的模型参数;
|
||||
// 多值 Scenes 是"声明 + 涌现"两条路同时挂载的载体。
|
||||
func effectiveScenes(t Triple) []string {
|
||||
if len(t.Scenes) == 0 && t.Scene == "" {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]bool, len(t.Scenes)+1)
|
||||
out := make([]string, 0, len(t.Scenes)+1)
|
||||
add := func(k string) {
|
||||
k = NormalizeSceneKey(k)
|
||||
if k == "" || seen[k] {
|
||||
return
|
||||
}
|
||||
seen[k] = true
|
||||
out = append(out, k)
|
||||
}
|
||||
for _, k := range t.Scenes {
|
||||
add(k)
|
||||
}
|
||||
add(t.Scene)
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user