mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-03 15:53:56 +00:00
feat(memory): 场景从「声明」改为「涌现」——场面指纹自己长成场景
上一版场景是声明/派生的:调用方写 scene="chan:qq",或由通道机械派生。 那不是涌现,是贴标签——标签谁定、怎么定全靠人。按「像人一样:干了什么事, 后续类似场面自动唤起对应记忆」的要求重做。 机制(全部取自运行时可观察量,无需模型配合、无需人工标注): - **场面指纹 Situation**:每轮采集 `chan:xx / peer:xx / peer_group:xx / tool:xx / topic:xx / part:xx`。权重按种类:通道与对象最强(1.0), 工具次之(0.8),话题是软信号(0.4),时段最弱(0.2)。 - **归属判定用加权 Jaccard**(不是字符串相等):共享特征权重和 / 并集权重和。 加权是必须的——`chan:qq` 与 `topic:排班` 的证据力差 2.5 倍,不加权会让 一次偶然的话题重合把两个不同场面并成一个。 - **涌现**:同类指纹重复到 minSceneEvidence=2 次才长出场景 (首次只登记 situation_evidence 足迹)。一次性的交互不是「场面」, 给它建场景会让库被一次性事件撑满、之后每次路过都召回一堆只发生过一次的事。 - **强化**:场景每次重现 strength+1、并入新特征。 - **唤起**:RecallBySituation 按**相似度**取回(阈值 0.35,比归属阈值 0.5 低 ——想不起来是损失,多想起一条只是多几行上下文),与措辞无关。 - **遗忘**:DecaySceneRefs 按半衰期让久未重现的关联淡出,低于 floor 直接删; 已接进 archive 心跳(半衰期 30 天,比「这个月没做过这类事」更久)。 三个必须讲清的边界: 1. 一轮只解析一次场景(TaskFrame 缓存)——多解析一次就多记一次强度, 「工具调得多」会被误读成「这个场面更常出现」。 2. 声明与涌现**并存**:声明是「我知道这是哪个场面」(插件注入点最清楚), 涌现是「这轮看起来像哪个场面」。两者都进召回。 3. 记忆挂载全自动:memory_commit 没写 scene 时落到本轮涌现场景, 模型不需要知道场景这回事。 验证:go build/vet 干净,go test -count=1 ./... 全绿。 新增用例(核心证据): - TestSceneEmergesFromRepetition:首次不建场景 → 第 2 次同类场面长出场景 → 同场面**不同话题**仍并入同一场景 → 换通道的场面自己长出独立场景(共 2 个)→ 强度随重现增长、特征多条。全程没有任何人声明过场景键。 - TestSceneRecallsBySituationNotWording:场面里写下的规则,换措辞后仍被 自动唤起(含原句),无关场面不唤起。 - TestSceneRefDecay:一个半衰期权重减半、第二个半衰期低于 floor 被清掉, 仍在重现的场景不受影响。 - TestSituationFeaturesFor:指纹维度齐全、归一化、数值 group_id 转换、nil 安全。
This commit is contained in:
@ -172,6 +172,19 @@ func (a *Agent) archiveColdDocs() {
|
||||
}
|
||||
}
|
||||
|
||||
// 场景记忆的「用进废退」:久未重现的关联按半衰期淡出。
|
||||
//
|
||||
// 不做衰减的后果不是"多记一点",而是**注入预算被一次性巧合吃光**——
|
||||
// 场景是每轮都要注入的常驻内容,关联只增不减时,越老的库注入越糊。
|
||||
// 半衰期取 30 天:比"这个月没做过这类事"更久,避免把季节性的事误删。
|
||||
if a.memory != nil {
|
||||
if n, err := a.memory.DecaySceneRefs(30*24*time.Hour, 0.05); err != nil {
|
||||
log.Printf("[agent] scene decay error: %v", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("[agent] 场景关联衰减:清理 %d 条长期未重现的引用", n)
|
||||
}
|
||||
}
|
||||
|
||||
if a.docStore != nil {
|
||||
a.docStore.Reindex()
|
||||
}
|
||||
|
||||
@ -450,7 +450,7 @@ func TestToolMemoryCommit_BindsMedia(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, "")
|
||||
if !strings.Contains(out, "关联") {
|
||||
t.Errorf("返回值应告知模型媒体已关联: %q", out)
|
||||
}
|
||||
@ -481,7 +481,7 @@ func TestToolMemoryCommit_WithoutMedia(t *testing.T) {
|
||||
map[string]interface{}{"subject": "甲方", "relation": "签署", "object": "合同"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, "")
|
||||
if strings.Contains(out, "失败") {
|
||||
t.Errorf("普通提交不该失败: %q", out)
|
||||
}
|
||||
@ -505,7 +505,7 @@ func TestToolMemoryCommit_CarriesSentenceText(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, "")
|
||||
res, _ := a.memory.Recall([]string{"李四"}, nil, 2, "")
|
||||
if len(res.Relations) == 0 {
|
||||
t.Fatal("召回为空")
|
||||
@ -597,7 +597,7 @@ func TestTools_NilMediaStoreDegrades(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}, "")
|
||||
if strings.Contains(out, "失败") {
|
||||
t.Errorf("无媒体存储时提交不该失败: %q", out)
|
||||
}
|
||||
|
||||
@ -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": "主记忆实体,子独有实体"},
|
||||
})
|
||||
}, "")
|
||||
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{}{},
|
||||
},
|
||||
})
|
||||
}, "")
|
||||
if !strings.Contains(got, "轻量内核") {
|
||||
t.Fatalf("%s 在轻量内核里必须明确报不支持,实际 %q", tool, got)
|
||||
}
|
||||
|
||||
@ -2,6 +2,8 @@ package core
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
@ -118,3 +120,131 @@ func (a *Agent) pruneByQuery(query string) int {
|
||||
}
|
||||
return a.context.Prune(query, topK, a.docStore)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 场面指纹:场景**涌现**的原料
|
||||
//
|
||||
// 场景不是谁声明的,而是从交互流里长出来的。长出来的原料就是每轮可观察的
|
||||
// 场面指纹——在哪个通道、跟谁、在做什么、聊什么、什么时段。全部取自运行时
|
||||
// 已有量,不需要模型配合,也不需要人工标注。
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
// situationFeaturesFor 采集一轮交互的场面指纹。
|
||||
//
|
||||
// 特征权重由种类决定(见 memory.SituationFeature.Weight):通道与对象是
|
||||
// 「同一个场面」最强的同一性信号,工具是行为信号,话题是软信号。
|
||||
func situationFeaturesFor(evt *agentIO.InputEvent, cleanInput, tool string) []memory.SituationFeature {
|
||||
var feats []memory.SituationFeature
|
||||
if evt != nil {
|
||||
if evt.Source != "" {
|
||||
feats = append(feats, memory.SituationFeature{Kind: "chan", Value: evt.Source})
|
||||
}
|
||||
// 对话对象:插件在 payload 里给的群/用户标识(有则用,无则退化为仅有通道)
|
||||
for _, k := range []string{"peer", "peer_id", "group_id", "user_id", "chat_id"} {
|
||||
if v, ok := evt.Payload[k]; ok {
|
||||
if s := payloadString(v); s != "" {
|
||||
// 群与私聊要能区分:同一 id 在两种场景下不是同一个对象
|
||||
kind := "peer"
|
||||
if k == "group_id" {
|
||||
kind = "peer_group"
|
||||
}
|
||||
feats = append(feats, memory.SituationFeature{Kind: kind, Value: s})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 时段:弱信号。人的记忆确实带时间气味(「早上那件事」),
|
||||
// 但它不该主导场面判定,所以权重最低。
|
||||
feats = append(feats, memory.SituationFeature{Kind: "part", Value: partOfDay(time.Now())})
|
||||
}
|
||||
if tool != "" {
|
||||
feats = append(feats, memory.SituationFeature{Kind: "tool", Value: tool})
|
||||
}
|
||||
// 话题:取清洗后输入的内容词做软特征(最多 3 个)。
|
||||
if cleanInput != "" {
|
||||
for i, kw := range memory.ExtractKeywords(memory.CleanText(cleanInput)) {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
feats = append(feats, memory.SituationFeature{Kind: "topic", Value: kw})
|
||||
}
|
||||
}
|
||||
return feats
|
||||
}
|
||||
|
||||
// payloadString 从 payload 值里取字符串(可能是 string / float64 / json.Number)。
|
||||
func payloadString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case float64:
|
||||
if t == float64(int64(t)) {
|
||||
return strconv.FormatInt(int64(t), 10)
|
||||
}
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// partOfDay 把时刻归成时段(场面指纹里最弱的一维)。
|
||||
func partOfDay(t time.Time) string {
|
||||
switch h := t.Hour(); {
|
||||
case h < 6:
|
||||
return "night"
|
||||
case h < 12:
|
||||
return "morning"
|
||||
case h < 18:
|
||||
return "afternoon"
|
||||
default:
|
||||
return "evening"
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTurnScene 解析本轮所属的**涌现场景**,一轮只解析一次。
|
||||
//
|
||||
// 与「声明场景」(sceneKeysFor:注入点 > 通道 > 工具)的关系:两者并存且都被
|
||||
// 用于召回。声明是"我知道这是哪个场面",涌现是"这轮看起来像哪个场面"——
|
||||
// 后者不需要任何人知道场景这回事。
|
||||
//
|
||||
// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记
|
||||
// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。
|
||||
func (a *Agent) resolveTurnScene(f *TaskFrame, tool string) string {
|
||||
if a == nil || a.memory == nil {
|
||||
return ""
|
||||
}
|
||||
if f == nil {
|
||||
return a.emergentSceneFor(nil, "", tool)
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
sig := memory.NewSituation(feats...)
|
||||
if sig.Empty() {
|
||||
return ""
|
||||
}
|
||||
key, created, err := a.memory.EnterScene(sig)
|
||||
if err != nil {
|
||||
log.Printf("[agent] scene enter failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
if created {
|
||||
log.Printf("[agent] 场景涌现: %q(由场面指纹 %v 长出)", key, sig.Keys())
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
|
||||
)
|
||||
|
||||
// TestSceneKeysFor 钉住当前场景的推导优先级:
|
||||
@ -51,3 +53,52 @@ func TestSceneKeysFor(t *testing.T) {
|
||||
t.Errorf("无 scene 声明时应只有通道场景: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSituationFeaturesFor 钉住指纹来源:全部是运行时可观察量,
|
||||
// 不需要模型配合也不需要人工标注。
|
||||
func TestSituationFeaturesFor(t *testing.T) {
|
||||
evt := &agentIO.InputEvent{
|
||||
Source: "QQ",
|
||||
Payload: map[string]interface{}{"group_id": float64(1027993713)},
|
||||
}
|
||||
feats := situationFeaturesFor(evt, "帮我看看排班表", "qq_get_message")
|
||||
kinds := map[string]int{}
|
||||
for _, f := range feats {
|
||||
kinds[f.Kind]++
|
||||
}
|
||||
if kinds["chan"] != 1 || kinds["peer_group"] != 1 || kinds["tool"] != 1 || kinds["part"] != 1 {
|
||||
t.Fatalf("必备维度缺失: %+v", feats)
|
||||
}
|
||||
if kinds["topic"] == 0 {
|
||||
t.Errorf("话题软特征缺失: %+v", feats)
|
||||
}
|
||||
if kinds["topic"] > 3 {
|
||||
t.Errorf("话题最多 3 个,得到 %d", kinds["topic"])
|
||||
}
|
||||
|
||||
sig := memory.NewSituation(feats...)
|
||||
keys := sig.Keys()
|
||||
// 归一化 + 数值 id 的转换
|
||||
found := false
|
||||
for _, k := range keys {
|
||||
if k == "chan:qq" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("通道特征未归一化: %v", keys)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if strings.Contains(k, "peer_group:1027993713") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("数值型 group_id 未转成特征: %v", keys)
|
||||
}
|
||||
|
||||
// 无事件时不 panic,且只有工具特征时也成立
|
||||
if feats := situationFeaturesFor(nil, "", "memory_recall"); len(feats) != 1 {
|
||||
t.Errorf("仅工具场景应有 1 个特征: %+v", feats)
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,6 +111,12 @@ type TaskFrame struct {
|
||||
CurResult string
|
||||
Resp *agentAPI.CompletionResponse
|
||||
|
||||
// Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明),
|
||||
// sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景
|
||||
// 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。
|
||||
Scene string
|
||||
sceneDone bool
|
||||
|
||||
// 游标与终态
|
||||
Step Step
|
||||
Response string
|
||||
@ -716,7 +722,7 @@ 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)
|
||||
result := a.executeToolCall(f.CurTool, f.OutputChannel, f.Scene)
|
||||
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))
|
||||
@ -757,6 +763,9 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
|
||||
// 与这一步工具本身(如 tool:qq_get_message)。带上工具场景,
|
||||
// 才能让「凡是要回 QQ 消息」这类规则在该步被取回。
|
||||
scenes := sceneKeysFor(f.Evt, tc.Name)
|
||||
if s := a.resolveTurnScene(f, tc.Name); s != "" {
|
||||
scenes = append(scenes, s)
|
||||
}
|
||||
recallText = a.memoryPass(query, "tool:"+tc.Name, needPrune, needRecall, scenes).RecallText
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import (
|
||||
"gitcode.com/JianFeeeee/HomeAgent/internal/memory/text"
|
||||
)
|
||||
|
||||
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret string) {
|
||||
func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string, turnScene ...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) (ret strin
|
||||
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
done <- a.executeToolCallInner(tc, channel)
|
||||
done <- a.executeToolCallInner(tc, channel, firstOr(turnScene))
|
||||
}()
|
||||
|
||||
select {
|
||||
@ -43,12 +43,21 @@ func (a *Agent) executeToolCall(tc agentAPI.ToolCall, channel string) (ret strin
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) string {
|
||||
// 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 {
|
||||
switch {
|
||||
case tc.Name == "persona_set":
|
||||
return a.executePersonaTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "memory_"):
|
||||
return a.executeMemoryTool(tc)
|
||||
return a.executeMemoryTool(tc, scene)
|
||||
case strings.HasPrefix(tc.Name, "social_"):
|
||||
return a.executeSocialTool(tc)
|
||||
case strings.HasPrefix(tc.Name, "knowledge_"):
|
||||
@ -123,7 +132,7 @@ func (a *Agent) executeToolCallInner(tc agentAPI.ToolCall, channel string) strin
|
||||
return fmt.Sprintf("%v", result)
|
||||
}
|
||||
|
||||
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScene string) string {
|
||||
g := a.graphMem()
|
||||
if g == nil {
|
||||
if tc.Name == "memory_document_query" {
|
||||
@ -214,6 +223,11 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall) string {
|
||||
// 两条路都为空则这条记忆不参与场景召回——不做猜测:猜错的场景会把
|
||||
// 无关记忆钉死,之后每次进入该场面都会被注入,比漏标更难发现。
|
||||
batchScene := getString(tc.Arguments, "scene")
|
||||
// 没有显式声明时,落到本轮**涌现**出来的场景上:模型不需要知道场景
|
||||
// 这回事,记忆也会因为「是在什么场面里写下的」而自动获得唤起入口。
|
||||
if batchScene == "" {
|
||||
batchScene = turnScene
|
||||
}
|
||||
var triples []memory.Triple
|
||||
for _, td := range triplesData {
|
||||
if m, ok := td.(map[string]interface{}); ok {
|
||||
|
||||
@ -63,6 +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)
|
||||
}
|
||||
return a.recallText(query, trigger, maxTokens, scenes)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user