Files
HomeAgent/internal/agent/core/memorypass.go
JianFeeeee 49695c38f3 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 ./... 全绿。
2026-09-15 09:37:29 +08:00

261 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package core
import (
"log"
"strconv"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
)
// sceneKeysFor 推导本轮输入的**当前场景**。
//
// 场景是“这场面正在发生”的机器可读描述,用于把带条件的记忆(规则/约定)
// 取回来。优先级:
// 1. 注入点显式声明payload.scene——插件最清楚自己在什么场面里
// 2. 通道evt.Source → chan:qq
// 3. 工具tool:qq_get_message——工具输出触发的召回只知道这一步
//
// 多个场景是**并列命中**(取回任一场景的记忆),不是交集:
// 「在 QQ 上」与「刚取回消息正文」是两个都能独立成立的触发条件。
func sceneKeysFor(evt *agentIO.InputEvent, toolName string) []string {
var keys []string
seen := make(map[string]bool)
add := func(k string) {
// 显式声明的场景键来自插件,大小写/空白/标点都不可控;归一化后再去重,
// 否则「chan:QQ」与「chan:qq」会变成两个场景各自只召回一半记忆。
k = memory.NormalizeSceneKey(k)
if k == "" || seen[k] {
return
}
seen[k] = true
keys = append(keys, k)
}
if evt != nil && evt.Payload != nil {
switch v := evt.Payload["scene"].(type) {
case string:
add(v)
case []string:
for _, s := range v {
add(s)
}
case []interface{}:
for _, item := range v {
if s, ok := item.(string); ok {
add(s)
}
}
}
}
if evt != nil {
add(memory.ChannelScene(evt.Source))
}
if toolName != "" {
add(memory.ToolScene(toolName))
}
return keys
}
// memoryPassOut 是一次记忆操作(取进来 / 踢出去)的结果。
type memoryPassOut struct {
// Archived 是被归档进文档记忆的低相关 L0 事件数prune 的输出)。
Archived int
// RecallText 是可注入 prompt 的记忆索引文本recall 的输出,空串表示无)。
RecallText string
}
// memoryPass 是「取进来(召回)」与「踢出去(裁剪)」的**唯一入口**。
//
// prune 与 recall 是两根正交的声明轴(默认值刻意相反:裁剪是破坏性的、
// 默认关;召回是只读增量、默认开),但两者都建立在**同一份清洗后的 query**
// 之上。调用点只负责解析声明,这里统一做三件各写一遍就会写歪的事:
//
// 1. 同一 queryprune 与 recall 用同一个查询向量来源,避免「裁错事件、
// 召回错记忆」——原始内容里的 ANSI/base64/JSON 噪声会把相关性打分带偏。
// 2. 同一次预算:召回文本按 token 预算截断只做一次(见 recallText
// 3. 同一条审计trigger据什么触发了哪种操作都落一条日志
// 否则又是一个「幕后发生、查不出是谁」的机制(对齐 prune 的设计初衷)。
//
// 边界prune 与 recall 目前仍走**各自的相关性空间**prune 用 L0 事件的
// 稠密/词向量给已有事件打分recall 用图 + TF-IDF 实体索引)。真正的
// 「一次打分」要先统一打分空间(后续步骤);这里统一的是**入口、query、
// 预算与审计**——这已是「一个过程」的可审计外壳,剩下的差在打分空间。
func (a *Agent) memoryPass(query, trigger string, prune, recall bool, scenes []string) memoryPassOut {
var out memoryPassOut
if a == nil || (!prune && !recall) {
return out
}
if prune {
out.Archived = a.pruneByQuery(query)
}
if recall && query != "" {
out.RecallText = a.recallTextFor(query, trigger, scenes)
}
if out.Archived > 0 || out.RecallText != "" {
log.Printf("[agent] memory pass (%s): archived=%d recalled=%d chars",
trigger, out.Archived, len(out.RecallText))
}
return out
}
// pruneByQuery 按相关性把低相关 L0 事件归档进文档记忆,返回归档数。
//
// **不做声明判定**——声明已由调用方pruneOnInput / stepToolAfter解析
// 这里只负责执行。放在 memoryPass 内部是为了让裁剪与召回共享入口。
func (a *Agent) pruneByQuery(query string) int {
if a == nil || a.context == nil {
return 0
}
// **动态上下文**是父 agent 专属能力:轻量内核(驻留子)用传统上下文,
// 不做按相关度的裁剪与向 doc 记忆的归档(子也没有 doc 记忆)。
if a.isLightKernel() {
return 0
}
topK := a.maxContextSize - 1
if topK < 1 {
topK = 1
}
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"
}
}
// resolveTurnScenes 解析本轮的场景集合,**同时走主动与被动两条路**
//
// 主动(声明):注入点/通道/工具声明了"这是哪个场面" → 场景存在化并喂入
// 本轮指纹(声明场景因此慢慢学会自己认自己)
// 被动(涌现):场面指纹聚类 → 同类指纹重复出现时自己长出场景
//
// 返回结果的 Primary 用于**写**(优先细粒度的涌现场景,首次交互退到声明场景
// 兜底Keys 用于**读**(两条路的并集,去重)。
//
// 解析会**写库**(场景强化/长出),所以必须一轮一次:多调一次就多给场景记
// 一次强度,"工具调得多"会被误读成"这个场面更常出现"。
func (a *Agent) resolveTurnScenes(f *TaskFrame, tool string) memory.TurnScene {
var out memory.TurnScene
if a == nil || a.memory == nil {
return out
}
if f != nil && f.sceneDone {
return f.turnScene
}
declared := sceneKeysFor(evtOf(f), tool)
feats := situationFeaturesFor(evtOf(f), cleanInputOf(f), tool)
sig := memory.NewSituation(feats...)
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 ""
}
return f.CleanInput
}