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:
JianFeeeee
2026-09-15 09:37:29 +08:00
parent 0bb3191284
commit 246306a989
12 changed files with 429 additions and 74 deletions

View File

@ -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
}

View File

@ -123,8 +123,10 @@ type SceneStat struct {
Entities int `json:"entities"`
// Strength 是该场景被重现(强化)的次数;Features 是它长出的特征数。
// 两者一起说明「这个场景是不是真的在涌现」,而不是被一次性写出来的。
Strength int `json:"strength"`
Features int `json:"features"`
Strength int `json:"strength"`
Features int `json:"features"`
// Origin 是这条场景来自哪条路:declared(主动声明)或 emergent(被动涌现)。
Origin string `json:"origin"`
UpdatedAt time.Time `json:"updated_at"`
}
@ -477,6 +479,7 @@ func (g *GraphDB) SceneStats() ([]SceneStat, error) {
SUM(CASE WHEN sr.kind = 'entity' THEN 1 ELSE 0 END),
COALESCE(s.strength, 1),
(SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id),
COALESCE(s.origin, 'emergent'),
s.updated_at
FROM scenes s LEFT JOIN scene_refs sr ON sr.scene_id = s.id
GROUP BY s.id ORDER BY COUNT(sr.id) DESC, s.key`)
@ -489,7 +492,7 @@ func (g *GraphDB) SceneStats() ([]SceneStat, error) {
for rows.Next() {
var st SceneStat
var rels, ents sql.NullInt64
if err := rows.Scan(&st.Key, &st.Refs, &rels, &ents, &st.Strength, &st.Features, &st.UpdatedAt); err != nil {
if err := rows.Scan(&st.Key, &st.Refs, &rels, &ents, &st.Strength, &st.Features, &st.Origin, &st.UpdatedAt); err != nil {
return nil, err
}
st.Relations = int(rels.Int64)

View File

@ -1,6 +1,7 @@
package memory
import (
"database/sql"
"fmt"
"sort"
"strings"
@ -238,9 +239,14 @@ func (g *GraphDB) EnterScene(sig Situation) (string, bool, error) {
return key, true, nil
}
// loadEmergentScenesLocked 读入全部场景及其特征权重。
// loadEmergentScenesLocked 读入参与**被动聚类**的场景(origin='emergent')及其特征权重。
//
// 为什么不带上声明场景:声明场景若也进相似度空间,它一旦吸收了整轮指纹就会
// 以接近 1.0 的相似度吃掉后续所有同类轮次,被动路再也长不出更细的场面。
// 声明路的泛化靠**层级键前缀**(chan:qq 覆盖 chan:qq/peer:x),各有各的机制。
func (g *GraphDB) loadEmergentScenesLocked() ([]emergentScene, error) {
rows, err := g.db.Query(`SELECT id, key, COALESCE(strength, 1) FROM scenes`)
rows, err := g.db.Query(`SELECT id, key, COALESCE(strength, 1) FROM scenes
WHERE COALESCE(origin, 'emergent') = 'emergent'`)
if err != nil {
return nil, err
}
@ -338,7 +344,7 @@ func (g *GraphDB) createSceneLocked(sig Situation) (string, error) {
key = fmt.Sprintf("%s#%d", base, i)
}
res, err := tx.Exec(`INSERT INTO scenes (key, strength) VALUES (?, 1)`, key)
res, err := tx.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'emergent')`, key)
if err != nil {
return "", err
}
@ -467,7 +473,7 @@ func (g *GraphDB) EmergentScenes() ([]SceneStat, error) {
`SELECT s.key, COALESCE(s.strength,1),
(SELECT COUNT(*) FROM scene_refs sr WHERE sr.scene_id = s.id),
(SELECT COUNT(*) FROM scene_features f WHERE f.scene_id = s.id),
s.updated_at
COALESCE(s.origin, 'emergent'), s.updated_at
FROM scenes s ORDER BY COALESCE(s.strength,1) DESC, s.updated_at DESC`)
if err != nil {
return nil, err
@ -476,10 +482,158 @@ func (g *GraphDB) EmergentScenes() ([]SceneStat, error) {
var out []SceneStat
for rows.Next() {
var st SceneStat
if err := rows.Scan(&st.Key, &st.Strength, &st.Refs, &st.Features, &st.UpdatedAt); err != nil {
if err := rows.Scan(&st.Key, &st.Strength, &st.Refs, &st.Features, &st.Origin, &st.UpdatedAt); err != nil {
return nil, err
}
out = append(out, st)
}
return out, rows.Err()
}
// TurnScene 是一轮交互解析出来的场景集合。
type TurnScene struct {
// Primary 是本轮写记忆时的**首选**场景:优先用涌现出来的(细粒度、
// 与措辞无关),没有(首次出现、场景还没长出来)时退到第一个声明场景。
Primary string
// Keys 是声明场景 + 涌现场景的全集(去重保序),供召回并集使用。
Keys []string
// Emergent 标记 Primary 是否来自涌现。
Emergent bool
// DeclaredCreated 是本次**新建**的声明场景(此前不存在,因声明而成立)。
DeclaredCreated []string
}
// EnterSceneWithHint 同时走**主动声明**与**被动涌现**两条路。
//
// 主动路(declaredKeys 非空):确保这些场景存在,并把本轮的场面指纹喂给它,
// 强度 +1。这样声明出来的场景会**慢慢学会自己认自己**——同一个场面以后
// 即使没人声明,也能被 RecallBySituation 按相似度命中。
// 声明即建场景,不等第二次:人明确说了"这是哪个场面",就不该再等它自己涌现。
//
// 被动路(始终执行):EnterScene 的聚类,指纹重复到 minSceneEvidence 次时
// 自己长出场景。首次交互这里返回空,此时 Primary 落到声明场景兜底——
// 这正是"只挂一条会丢东西"的那一半。
func (g *GraphDB) EnterSceneWithHint(sig Situation, declaredKeys []string) (TurnScene, error) {
out := TurnScene{}
// 主动路:声明场景存在化 + 学特征 + 强化
seen := make(map[string]bool)
for _, raw := range declaredKeys {
key := NormalizeSceneKey(raw)
if key == "" || seen[key] {
continue
}
seen[key] = true
learned, err := g.EnsureScene(key, sig)
if err != nil {
return out, err
}
if learned {
out.DeclaredCreated = append(out.DeclaredCreated, key)
}
out.Keys = append(out.Keys, key)
}
if len(out.Keys) > 0 {
out.Primary = out.Keys[0]
}
// 被动路:指纹聚类(可能返回已聚合的场景、也可能首次为空)
if !sig.Empty() {
key, created, err := g.EnterScene(sig)
if err != nil {
return out, err
}
if key != "" {
if !seen[key] {
out.Keys = append(out.Keys, key)
}
out.Primary = key
out.Emergent = true
} else if created {
out.Emergent = true
}
}
return out, nil
}
// EnsureScene 让一个**声明出来的**场景存在(不存在则建),并给它记一次强度。
//
// 特征只从**键自身**解析(`chan:qq/peer:group_1` → {chan:qq, peer:group_1}),
// 不吸收本轮的整轮指纹。这条边界很关键:声明场景若吸收整轮指纹,它会在相似度
// 上压过一切,被动聚类再也长不出更细的场面(实测过,见 loadEmergentScenesLocked)。
// 声明路的泛化靠层级键前缀,不需要靠学指纹。
func (g *GraphDB) EnsureScene(key string, _ Situation) (bool, error) {
key = NormalizeSceneKey(key)
if key == "" {
return false, nil
}
g.mu.Lock()
defer g.mu.Unlock()
var sceneID int64
err := g.db.QueryRow(`SELECT id FROM scenes WHERE key = ?`, key).Scan(&sceneID)
created := false
if err == sql.ErrNoRows {
res, ierr := g.db.Exec(`INSERT INTO scenes (key, strength, origin) VALUES (?, 1, 'declared')`, key)
if ierr != nil {
return false, ierr
}
sceneID, _ = res.LastInsertId()
created = true
} else if err != nil {
return false, err
}
if _, err := g.db.Exec(
`UPDATE scenes SET strength = COALESCE(strength,1) + 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
sceneID); err != nil {
return created, err
}
for _, f := range FeaturesFromSceneKey(key) {
if _, err := g.db.Exec(
`INSERT INTO scene_features (scene_id, feature, weight) VALUES (?, ?, ?)
ON CONFLICT(scene_id, feature) DO UPDATE SET weight = MAX(weight, excluded.weight)`,
sceneID, f.Key(), f.Weight()); err != nil {
return created, err
}
}
return created, nil
}
// FeaturesFromSceneKey 从场景键解析它自身蕴含的场面特征。
//
// chan:qq → {chan:qq}
// chan:qq/peer:group_1027 → {chan:qq, peer:group_1027}
// 老大2026-09-04_12:27_qq私聊图片 → {}(无 kind:value 结构,不猜)
//
// 只有 `kind:value` 形态的层才算特征——猜不出结构的键宁可留空,
// 也不要往特征空间里灌进会污染相似度的东西。
func FeaturesFromSceneKey(key string) []SituationFeature {
parts := strings.Split(NormalizeSceneKey(key), "/")
var out []SituationFeature
for _, p := range parts {
i := strings.Index(p, ":")
if i <= 0 || i == len(p)-1 {
continue
}
kind, val := p[:i], p[i+1:]
// 白名单,不猜:`老大2026-09-04_12:27_qq私聊图片` 里的 `12:27` 也是
// `kind:value` 形态,放进特征空间就是往相似度里灌垃圾。
if !knownFeatureKinds[kind] {
continue
}
feat := SituationFeature{Kind: kind, Value: val}
if feat.Key() == "" {
continue
}
out = append(out, feat)
}
return out
}
// knownFeatureKinds 是允许进入场面指纹的特征种类(新的种类在这里登记,
// 并在 SituationFeature.Weight 里给权重)。
var knownFeatureKinds = map[string]bool{
"chan": true, "peer": true, "peer_group": true,
"tool": true, "topic": true, "part": true,
}

View File

@ -2,6 +2,7 @@ package memory
import (
"os"
"strings"
"testing"
"time"
)
@ -201,3 +202,121 @@ func TestSceneRefDecay(t *testing.T) {
t.Errorf("刚用过的场景不该被衰减掉: %+v", left.Relations)
}
}
// TestDeclaredAndEmergentBothLearn 钉住「主动 + 被动两条路」的相互长进:
// - 主动:声明即建场景(不等第二次涌现),并把指纹喂给它
// - 被动:指纹聚类自己长出场景;声明场景学会特征后**即使没人再声明**
// 也能被相似度命中
// - 第一次交互(涌现场景还没长出来)由声明场景兜底
func TestDeclaredAndEmergentBothLearn(t *testing.T) {
g := newTestGraph(t)
defer os.Remove(g.dbPath)
defer g.Close()
// 第 1 轮:声明了 chan:qq。此刻还没有涌现场景 → Primary 必须兜底到声明场景
turn, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "排班"), []string{"chan:qq"})
if err != nil {
t.Fatalf("EnterSceneWithHint: %v", err)
}
if turn.Primary != "chan:qq" {
t.Fatalf("首次交互应兜底到声明场景,得到 %q", turn.Primary)
}
if turn.Emergent {
t.Error("首次交互不该有涌现场景")
}
if len(turn.DeclaredCreated) != 1 || turn.DeclaredCreated[0] != "chan:qq" {
t.Errorf("声明即建场景(不等第二次涌现): %+v", turn.DeclaredCreated)
}
// 第 2 轮同类场面:涌现场景长出来,且它比声明场景**更优先**用于写入
turn2, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "排班表"), []string{"chan:qq"})
if err != nil {
t.Fatalf("EnterSceneWithHint: %v", err)
}
if !turn2.Emergent || !strings.HasPrefix(turn2.Primary, "auto:") {
t.Fatalf("第 2 轮应涌现出细粒度场景并优先: %+v", turn2)
}
if len(turn2.Keys) < 2 {
t.Fatalf("两条路都要进召回集合: %+v", turn2.Keys)
}
// 在声明场景里写上一条(模拟首次交互时写的记忆)
if _, _, err := g.Commit([]Triple{{
Subject: "群规", Relation: "禁止", Object: "Markdown排版", Confidence: 1.0,
Scenes: []string{"chan:qq"},
}}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
// 在涌现场景里写上一条
if _, _, err := g.Commit([]Triple{{
Subject: "排班表", Relation: "格式", Object: "纯文本", Confidence: 1.0,
Scenes: []string{turn2.Primary},
}}, "main", 0); err != nil {
t.Fatalf("commit: %v", err)
}
// **没人声明**的同场面提问:被动路按相似度命中涌现场景;
// 声明场景不在相似度空间里(否则它会吃掉被动路),靠声明/前缀路命中。
sig := mkSig("qq", "group_1027", "qq_get_message", "统计")
r, err := g.RecallBySituation(sig, 8)
if err != nil {
t.Fatalf("RecallBySituation: %v", err)
}
got := map[string]bool{}
for _, rel := range r.Relations {
got[rel.TargetName] = true
}
if !got["纯文本"] {
t.Errorf("涌现场景应被被动命中: %+v", r.Relations)
}
if got["Markdown排版"] {
t.Errorf("声明场景不该进相似度空间(会压死被动路): %+v", r.Relations)
}
// 声明场景走声明键,照样取回
byKey, err := g.RecallByScene([]string{"chan:qq"}, 8)
if err != nil {
t.Fatalf("RecallByScene: %v", err)
}
if len(byKey.Relations) != 1 || byKey.Relations[0].TargetName != "Markdown排版" {
t.Errorf("声明路应取回声明场景的记忆: %+v", byKey.Relations)
}
// 而完整的一轮(声明+涌现)两条路都进召回集合
full, err := g.EnterSceneWithHint(mkSig("qq", "group_1027", "qq_get_message", "统计"), []string{"chan:qq"})
if err != nil {
t.Fatalf("EnterSceneWithHint: %v", err)
}
if len(full.Keys) < 2 {
t.Errorf("两条路都应进召回集合: %+v", full.Keys)
}
// 声明键从自身解析特征(不含整轮指纹)
feats := FeaturesFromSceneKey("chan:qq/peer:group_1027")
if len(feats) != 2 || feats[0].Key() != "chan:qq" || feats[1].Key() != "peer:group_1027" {
t.Errorf("声明键特征解析失败: %+v", feats)
}
if len(FeaturesFromSceneKey("老大2026-09-04_12:27_qq私聊图片")) != 0 {
t.Error("无 kind:value 结构的键不该硬猜特征")
}
// 声明场景不该被覆盖成涌现键:两者的身份各自保留
if _, err := g.EnsureScene("chan:qq", Situation{}); err != nil {
t.Fatalf("EnsureScene: %v", err)
}
var n int
if err := g.db.QueryRow(`SELECT COUNT(*) FROM scenes WHERE key = 'chan:qq'`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("声明场景应保持独立存在,得到 %d", n)
}
}
// TestEffectiveScenes 覆盖「单值声明 + 多值」合并去重。
func TestEffectiveScenes(t *testing.T) {
got := effectiveScenes(Triple{Scene: "chan:qq", Scenes: []string{"auto:a", "chan:qq", ""}})
if len(got) != 2 || got[0] != "auto:a" || got[1] != "chan:qq" {
t.Errorf("合并去重保序失败: %v", got)
}
if effectiveScenes(Triple{}) != nil {
t.Error("无场景应返回 nil")
}
}