feat(memory): P2 读路径打分 + 桌面端记忆面板(看/改/删)

读路径(Generative Agents 公式的 Recency+Importance 两项,Relevance 待 P3):
- memory.Get 改为 rankProfiles:Score=0.4·Recency+0.6·Importance,按分降序、截断 top-30。
  Recency=0.98^天 指数衰减;未评分行用兜底 importance=5(不被不公平遗忘)。纯函数 + 单测。
- 新增 Store.List(结构化、不截断)+ memory_list 工具。

桌面端记忆面板:
- gateway GET /memory(列表) + DELETE /memory?key=(软删),受保护组。
- api listMemory/deleteMemory;MemoryView 右侧从占位 → 真列表:按分排序展示
  key/value/重要度/最近时间,可内联编辑(PUT)与删除(软删);左侧登记后自动刷新。

实测:PUT 两条 → GET 返回带 importance/last_seen 的有序列表(较新者靠前)→ DELETE 一条
(软删)→ 再 GET 已消失。rank/recency/兜底 importance 单测过;前端 tsc+构建通过。

至此记忆:召回(打分) + 历史 + 自动对账(P1) + 用户可管控(面板) 闭环。Relevance(Milvus) 留 P3。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-22 14:48:11 +08:00
parent 1674252d81
commit 0edfc948ba
9 changed files with 316 additions and 14 deletions
+64 -3
View File
@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"log"
"math"
"sort"
"strings"
"time"
@@ -108,7 +109,17 @@ func migrateLegacyProfile(db *gorm.DB) {
log.Printf("[memory] 已回灌 %d 条偏好(新雪花 id)", len(saved))
}
// Get 返回某用户的画像,渲染为可直接注入 prompt 的多行文本(按 key 排序,稳定输出)。
// 读路径打分参数(Generative Agents 公式的 Recency + Importance 两项;Relevance 待接 Milvus)。
const (
memTopN = 30 // 注入上限(截断,控 context)
wRecency = 0.4 // 最近性权重
wImportance = 0.6 // 重要度权重
recencyDecayPerDay = 0.98 // 每天衰减因子(指数)
defaultImportance = 5.0 // 旧/未评分行的兜底重要度(避免被不公平遗忘)
)
// Get 返回某用户画像,渲染为可注入 prompt 的多行文本。
// 按 Score = wRecency·Recency + wImportance·Importance 降序,截断 top-N(控 context + 自然遗忘)。
func (s *Store) Get(ctx context.Context, userID string) (string, error) {
if s.db == nil || userID == "" {
return "", nil
@@ -117,14 +128,64 @@ func (s *Store) Get(ctx context.Context, userID string) (string, error) {
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
return "", err
}
sort.Slice(rows, func(i, j int) bool { return rows[i].Key < rows[j].Key })
ranked := rankProfiles(rows, time.Now(), memTopN)
var b strings.Builder
for _, r := range rows {
for _, r := range ranked {
fmt.Fprintf(&b, "- %s%s\n", r.Key, r.Value)
}
return strings.TrimRight(b.String(), "\n"), nil
}
// List 返回某用户全部 active 偏好(结构化,供管理面板查看/编辑),按 Score 降序、不截断。
func (s *Store) List(ctx context.Context, userID string) ([]Profile, error) {
if s.db == nil || userID == "" {
return nil, nil
}
var rows []Profile
if err := s.db.WithContext(ctx).Where("user_id = ?", userID).Find(&rows).Error; err != nil {
return nil, err
}
return rankProfiles(rows, time.Now(), 0), nil
}
// rankProfiles 纯函数:按 Score 降序排序(同分 key 升序稳定),截断 top-NtopN<=0 不截断)。
func rankProfiles(rows []Profile, now time.Time, topN int) []Profile {
out := make([]Profile, len(rows))
copy(out, rows)
sort.SliceStable(out, func(i, j int) bool {
si, sj := profileScore(out[i], now), profileScore(out[j], now)
if si != sj {
return si > sj
}
return out[i].Key < out[j].Key
})
if topN > 0 && len(out) > topN {
out = out[:topN]
}
return out
}
// profileScore 计算一条偏好的 Recency+Importance 综合分(各归一到 [0,1])。
func profileScore(p Profile, now time.Time) float64 {
imp := p.Importance
if imp <= 0 {
imp = defaultImportance
}
return wRecency*recencyScore(now, p.LastSeenAt) + wImportance*(imp/10)
}
// recencyScore 指数衰减的最近性分:last_seen 越久越低;未记时间视为新鲜(1)。
func recencyScore(now, last time.Time) float64 {
if last.IsZero() {
return 1.0
}
days := now.Sub(last).Hours() / 24
if days < 0 {
days = 0
}
return math.Pow(recencyDecayPerDay, days)
}
// Upsert 写入/更新一条画像偏好((user_id,key) 冲突即覆盖 value/importance,保留原 id
// 置 last_seen=now 作"印证")。importance<=0 时不覆盖旧值(NOOP 印证场景只 bump 时间)。
func (s *Store) Upsert(ctx context.Context, userID, key, value string, importance float64) error {