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 bb7e7979ae
commit 49695c38f3
12 changed files with 429 additions and 74 deletions

View File

@ -450,7 +450,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
},
},
},
}, "")
}, nil)
if !strings.Contains(out, "关联") {
t.Errorf("返回值应告知模型媒体已关联: %q", out)
}
@ -481,7 +481,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
},
},
}, "")
}, nil)
if strings.Contains(out, "失败") {
t.Errorf("普通提交不该失败: %q", out)
}
@ -505,7 +505,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
},
},
},
}, "")
}, nil)
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
if len(res.Relations) == 0 {
t.Fatal("召回为空")
@ -597,7 +597,7 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) {
},
},
},
}, "")
}, nil)
if strings.Contains(out, "失败") {
t.Errorf("无媒体存储时提交不该失败: %q", out)
}

View File

@ -88,7 +88,7 @@ func TestLightProfile_MemoryFaceWiring(t *testing.T) {
got := a.executeMemoryTool(agentAPI.ToolCall{
ID: "c1", Name: "memory_recall",
Arguments: map[string]interface{}{"query_intent": "主记忆实体,子独有实体"},
}, "")
}, nil)
if !strings.Contains(got, "主记忆实体") {
t.Fatalf("子应看得到主记忆:%s", got)
}
@ -132,7 +132,7 @@ func TestLightProfile_OrganizeToolsAbsentAndRefused(t *testing.T) {
Arguments: map[string]interface{}{
"name": "任意", "source": "a", "target": "b", "criteria": map[string]interface{}{},
},
}, "")
}, nil)
if !strings.Contains(got, "轻量内核") {
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
}

View File

@ -205,46 +205,56 @@ func partOfDay(t time.Time) string {
}
}
// resolveTurnScene 解析本轮所属的**涌现场景**,一轮只解析一次。
// resolveTurnScenes 解析本轮的场景集合,**同时走主动与被动两条路**
//
// 与「声明场景」sceneKeysFor注入点 > 通道 > 工具)的关系:两者并存且都被
// 用于召回。声明是"我知道这是哪个场面",涌现是"这轮看起来像哪个场面"——
// 后者不需要任何人知道场景这回事。
// 主动(声明):注入点/通道/工具声明了"这是哪个场面" → 场景存在化并喂入
// 本轮指纹(声明场景因此慢慢学会自己认自己)
// 被动(涌现):场面指纹聚类 → 同类指纹重复出现时自己长出场景
//
// 返回结果的 Primary 用于**写**(优先细粒度的涌现场景,首次交互退到声明场景
// 兜底Keys 用于**读**(两条路的并集,去重)。
//
// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记
// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。
func (a *Agent) resolveTurnScene(f *TaskFrame, tool string) string {
func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene {
var out memory.TurnScene
if a == nil || a.memory == nil {
return ""
return out
}
if f == nil {
return a.emergentSceneFor(nil, "", tool)
if f != nil && f.sceneDone {
return f.turnScene
}
if f.sceneDone {
return f.Scene
}
f.Scene = a.emergentSceneFor(f.Evt, f.CleanInput, tool)
f.sceneDone = true
return f.Scene
}
// emergentSceneFor 采集指纹并交给图库做「归属或长出」。
func (a *Agent) emergentSceneFor(evt *agentIO.InputEvent, cleanInput, tool string) string {
feats := situationFeaturesFor(evt, cleanInput, tool)
if len(feats) == 0 {
return ""
}
declared := sceneKeysFor(evtOf(f), tool)
feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
sig := memory.NewSituation(feats...)
if sig.Empty() {
return ""
}
key, created, err := a.memory.EnterScene(sig)
turn, err := a.memory.EnterSceneWithHint(sig, declared)
if err != nil {
log.Printf("[agent] scene enter failed: %v", err)
// 出错时至少把声明场景交给召回,不让整条召回链一起失效
turn = memory.TurnScene{Keys: declared}
if len(declared) > 0 {
turn.Primary = declared[0]
}
}
if turn.Emergent {
log.Printf("[agent] 场景涌现/命中: %q指纹 %v", turn.Primary, sig.Keys())
} else if len(turn.DeclaredCreated) > 0 {
log.Printf("[agent] 声明场景成立: %v指纹 %v", turn.DeclaredCreated, sig.Keys())
}
if f != nil {
f.turnScene = turn
f.Scene = turn.Primary
f.sceneDone = true
}
return turn
}
// cleanInputOf 安全取出清洗后输入f 为 nil 时为空)。
func cleanInputOf(f *TaskFrame) string {
if f == nil {
return ""
}
if created {
log.Printf("[agent] 场景涌现: %q由场面指纹 %v 长出)", key, sig.Keys())
}
return key
return f.CleanInput
}

View File

@ -102,3 +102,20 @@ func TestSituationFeaturesFor(t *testing.T) {
t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
}
}
// TestWritePathAttachesBothPaths 钉住写侧的「两条路都挂」:
// 显式声明优先;否则挂本轮声明的 + 涌现的场景集合。
func TestTurnSceneKeysBothPaths(t *testing.T) {
// 声明的通道场景与工具场景都在,涌现键(若有)追加在后
evt := &agentIO.InputEvent{Source: "qq", Payload: map[string]interface{}{"scene": "chan:qq/peer:group_1"}}
got := sceneKeysFor(evt, "qq_get_message")
want := []string{"chan:qq/peer:group_1", "chan:qq", "tool:qq_get_message"}
if len(got) != len(want) {
t.Fatalf("声明侧场景数不对: %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("声明侧[%d] = %q want %q", i, got[i], want[i])
}
}
}

View File

@ -30,6 +30,7 @@ import (
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
@ -115,6 +116,7 @@ type TaskFrame struct {
// sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景
// 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。
Scene string
turnScene memory.TurnScene
sceneDone bool
// 游标与终态
@ -722,7 +724,10 @@ func denialResultText(ctx *sdk.StageContext, toolName string) string {
// stepToolExec 执行工具。**临界区**:见设计文档 §4.3。
func (a *Agent) stepToolExec(f *TaskFrame) stepOutcome {
result := a.executeToolCall(f.CurTool, f.OutputChannel, f.Scene)
// 执行工具前先解析本轮场景:写侧要用它给记忆自动挂场景(主动+被动两条路),
// 而工具步不一定走到下面的召回分支,所以不能等那里再解析。
turn := a.resolveTurnScenes(f, f.CurTool.Name)
result := a.executeToolCall(f.CurTool, f.OutputChannel, turn.Keys...)
f.CurResult = result
f.ToolResults = append(f.ToolResults, ToolResultItem{Name: f.CurTool.Name, Output: result})
log.Printf("[agent] tool %s result: %s", f.CurTool.Name, truncateStr(result, 100))
@ -762,9 +767,11 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
// 工具路召回的场景有两个来源:本轮输入的场面(如 chan:qq
// 与这一步工具本身(如 tool:qq_get_message。带上工具场景
// 才能让「凡是要回 QQ 消息」这类规则在该步被取回。
// 召回用两条路的并集:声明场景(注入点/通道/工具)+ 涌现场景
scenes := sceneKeysFor(f.Evt, tc.Name)
if s := a.resolveTurnScene(f, tc.Name); s != "" {
scenes = append(scenes, s)
turn := a.resolveTurnScenes(f, tc.Name)
for _, k := range turn.Keys {
scenes = append(scenes, k)
}
recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText
}

View File

@ -13,7 +13,7 @@ import (
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
)
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScene ...string) (ret string) {
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScenes ...string) (ret string) {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
@ -31,7 +31,7 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScene
done := make(chan string, 1)
go func() {
done <- a.executeToolCallInner(tc, channel, firstOr(turnScene))
done <- a.executeToolCallInner(tc, channel, turnScenes)
}()
select {
@ -43,21 +43,12 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScene
}
}
// firstOr 取可选参数的首个值(工具执行路径只有调用方知道本轮场景,
// 用变参是为了不让「不关心场景」的调用点spawn/测试)被迫传空串)。
func firstOr(v []string) string {
if len(v) == 0 {
return ""
}
return v[0]
}
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, scene string) string {
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, turnScenes []string) string {
switch {
case tc.Name == "persona_set":
return a.executePersonaTool(tc)
case strings.HasPrefix(tc.Name, "memory_"):
return a.executeMemoryTool(tc, scene)
return a.executeMemoryTool(tc, turnScenes)
case strings.HasPrefix(tc.Name, "social_"):
return a.executeSocialTool(tc)
case strings.HasPrefix(tc.Name, "knowledge_"):
@ -132,7 +123,7 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string, scene
return fmt.Sprintf("%v", result)
}
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string {
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) string {
g := a.graphMem()
if g == nil {
if tc.Name == "memory_document_query" {
@ -223,10 +214,14 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string
// 两条路都为空则这条记忆不参与场景召回——不做猜测:猜错的场景会把
// 无关记忆钉死,之后每次进入该场面都会被注入,比漏标更难发现。
batchScene := getString(tc.Arguments, "scene")
// 没有显式声明时,落到本轮**涌现**出来的场景上:模型不需要知道场景
// 这回事,记忆也会因为「是在什么场面里写下的」而自动获得唤起入口。
// 写侧的场景是**两条路都挂**
// 显式声明(模型在参数里点名)优先;
// 否则挂本轮解析出的场景集合——主动声明的 + 被动涌现的。
// 只挂一条会丢东西:只挂声明则细粒度唤起丢失,只挂涌现则首次交互
// (场景还没长出来)没有兜底。
var batchScenes []string
if batchScene == "" {
batchScene = turnScene
batchScenes = turnScenes
}
var triples []memory.Triple
for _, td := range triplesData {
@ -241,6 +236,9 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string
if t.Scene == "" {
t.Scene = batchScene
}
if len(t.Scenes) == 0 {
t.Scenes = batchScenes
}
// 模型显式关联的媒体:结构化字段随三元组一起提交,
// 由 commitTriplesWithMedia 变成 L3 一等块并与句子建边——
// 不再把 marker 写进句子文本。

View File

@ -63,9 +63,10 @@ func (a *Agent) buildTaskMemoryContext(f *TaskFrame, input string, maxTokens int
if f.Evt != nil && f.Evt.Source != "" {
trigger = "input:" + f.Evt.Source
}
// 涌现场景:不是谁声明的,而是这轮的场面指纹与既有场景簇匹配出来的
if s := a.resolveTurnScene(f, ""); s != "" {
scenes = append(scenes, s)
// 场景集合 = 声明(主动)+ 涌现(被动)两条路的并集
turn := a.resolveTurnScenes(f, "")
for _, k := range turn.Keys {
scenes = append(scenes, k)
}
return a.recallText(query, trigger, maxTokens, scenes)
}