feat(memory): P1 长期记忆升级 —— 异步攒批 Consolidate + 软删 + importance/last_seen

把"逐轮盲写抽取"升级为 Mem0 式对账(方案见 memory_industry_analysis.md 落地节):

mcp-go:
- Profile 加 Importance(1~10, poignancy) + LastSeenAt(为 Generative Agents 读路径
  Score=w1·Relevance+w2·Recency+w3·Importance 铺路)。
- Upsert 收 importance + 每次置 last_seen(印证);新增 Delete(软删,BaseModel.DeletedAt
  已具备,失效不物删可审计)+ Touch;memory_upsert 透传 importance、新增 memory_delete 工具。

dispatcher:
- extractMemory → consolidateMemory:一次 LLM 调用同时做 抽取+对账,输出
  [{op:ADD|UPDATE|DELETE|NOOP,key,value,importance}];ADD/UPDATE→upsert、DELETE→软删;
  sanitizeOps 防幻删(DELETE 须命中已有)/夹 importance[1,10]/同key保末个/丢 NOOP。
- 攒批:每 3 轮(per-session 计数)才 consolidate 一次,省成本,对齐 ChatGPT 周期整理。
  从根上解决 exact-key 盲写的记忆腐烂。

验证:parseOps/sanitizeOps/parseProfile 纯逻辑单测;store 集成测试(真 PG)覆盖
importance/last_seen 写入 + 软删(live 0 / 物理 1);dispatcher -race 全过。
(注:完整多轮 LLM consolidate 未做实跑,属构造性验证 + 沿用已证 pool.Chat 模式。)

P2 待做:读路径按 Score(Recency+Importance) 排序/衰减/截断 + 桌面端记忆面板。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-22 14:27:16 +08:00
parent b06c768f11
commit 1674252d81
8 changed files with 352 additions and 98 deletions
+22 -7
View File
@@ -59,8 +59,8 @@ func TestProfileStore_Integration(t *testing.T) {
}
}
// Upsert 两次同 (user_id,key) → 覆盖,不新增;id 稳定。
if err := s.Upsert(ctx, "u1", "城市", "北京"); err != nil {
// Upsert 两次同 (user_id,key) → 覆盖,不新增;id 稳定importance/last_seen 写入
if err := s.Upsert(ctx, "u1", "城市", "北京", 5); err != nil {
t.Fatal(err)
}
var first Profile
@@ -68,7 +68,10 @@ func TestProfileStore_Integration(t *testing.T) {
if first.ID == "" || first.CreatedAt.IsZero() {
t.Error("行应有雪花 id 与创建时间")
}
if err := s.Upsert(ctx, "u1", "城市", "上海"); err != nil {
if first.Importance != 5 || first.LastSeenAt.IsZero() {
t.Errorf("应写入 importance 与 last_seen: imp=%v last=%v", first.Importance, first.LastSeenAt)
}
if err := s.Upsert(ctx, "u1", "城市", "上海", 8); err != nil {
t.Fatal(err)
}
var cnt int64
@@ -78,12 +81,24 @@ func TestProfileStore_Integration(t *testing.T) {
}
var after Profile
raw.Where("user_id = ? AND key = ?", "u1", "城市").First(&after)
if after.Value != "上海" || after.ID != first.ID {
t.Errorf("应覆盖 value 且保留 id: value=%s id=%s/%s", after.Value, after.ID, first.ID)
if after.Value != "上海" || after.ID != first.ID || after.Importance != 8 {
t.Errorf("应覆盖 value/importance 且保留 id: value=%s imp=%v id=%s/%s", after.Value, after.Importance, after.ID, first.ID)
}
// Get 渲染多行(按 key 排序)。
_ = s.Upsert(ctx, "u1", "爱好", "围棋")
// Delete 软删:行打 deleted_at,正常查询不返回,但物理行还在(可审计)。
_ = s.Upsert(ctx, "u1", "临时", "可删", 1)
if err := s.Delete(ctx, "u1", "临时"); err != nil {
t.Fatal(err)
}
var liveCnt, rawCnt int64
raw.Model(&Profile{}).Where("user_id = ? AND key = ?", "u1", "临时").Count(&liveCnt)
raw.Unscoped().Model(&Profile{}).Where("user_id = ? AND key = ?", "u1", "临时").Count(&rawCnt)
if liveCnt != 0 || rawCnt != 1 {
t.Errorf("软删后正常查询应 0、物理行应 1live=%d raw=%d", liveCnt, rawCnt)
}
// Get 渲染多行(按 key 排序),软删的不出现。
_ = s.Upsert(ctx, "u1", "爱好", "围棋", 6)
got, _ := s.Get(ctx, "u1")
if got == "" || got != "- 城市:上海\n- 爱好:围棋" {
t.Errorf("Get 渲染不符: %q", got)