Files
HomeAgent/internal/agent/core/memorypass.go
JianFeeeee d4f9a12db0 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 安全。
2026-09-15 09:27:39 +08:00

251 lines
8.8 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. 同一 query:prune 与 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"
}
}
// 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
}