长期记忆:Agent 怎么“记住“用户

系列「企业级 AI Agent 实现拆解」第十篇。上一篇讲了知识库检索,这篇看记忆系统怎么让 Agent 越用越聪明。


为什么需要记忆

对话结束,进程可能重启,下次对话是全新的 session。如果没有记忆,用户每次都要重新告诉 Agent:"我是开发者,用中文回答,不要用太多术语"------这很烦。

企业场景里问题更大:某个工程师问了 100 次数据库性能问题,Agent 应该知道这个人关注什么方向,而不是每次都从零开始。

记忆系统(Memory BC)把有价值的信息从对话中提炼出来,持久化,下次对话时召回,注入 system prompt。就像你有个笔记本,每次聊天结束把关键信息记下来,下次见面时翻一翻。


命名空间:层级化的记忆分区

记忆不是一锅粥,需要按用途分区。DeepFlux 用 namespace 元组(层级路径)来组织:

go 复制代码
// domain/model/memory.go
type Memory struct {
    namespace []string // 层级路径
    // ...
}

四层命名空间:

namespace 路径 含义 举例
["user", "uid123"] 用户全局偏好 "用中文回答"、"我是开发者"
["user", "uid123", "project", "pid456"] 项目级事实 "这个项目用 PostgreSQL"
["agent", "erp-assistant"] Agent 共享知识 "本 Agent 专注 ERP 系统"
["session", "sid789"] 会话临时记忆 本轮对话的摘要

用元组而不是字符串枚举的好处是天然层级 ------不需要改 schema 就能加新层级。比如将来加 "team" 层级,只要写 ["user", uid, "team", tid] 即可。

你可以把它想象成文件路径:/user/张三/ 是张三的私人文件夹,/user/张三/project/ERP/ 是他在 ERP 项目里的专属信息,/agent/客服机器人/ 是所有用户共享的机器人知识。


四种记忆类型

每条记忆都有一个 kind 标签,告诉系统这条记忆的"性质":

类型 说明 举例
fact 客观事实 "我住北京"、"团队有 5 个人"
preference 个人偏好 "回答用中文"、"少用术语"
skill 学到的做事方法 "排查 OOM 先看 limit 设置"
summary 长会话压缩 "讨论了 K8s 集群迁移方案"

类型不同,存储的 namespace 也不同------preferenceskill 跨项目有效,存在用户全局命名空间;fact 如果带 projectID 则收窄到项目 scope,防止跨项目污染。


衰减:自动淘汰低价值记忆

不能无限积累记忆。存储有限,注入 prompt 的空间也有限。衰减机制决定哪些记忆该留。

域模型公式(用于淘汰排序):

go 复制代码
// domain/model/memory.go
func (m *Memory) DecayScore(now time.Time) float64 {
    if m.pinned {
        return 1e9  // 钉住的记忆永远不淘汰
    }
    ageDays := now.Sub(m.createdAt).Hours() / 24
    if ageDays < 1 { ageDays = 1 }
    return float64(m.confidence) * float64(m.hitCount+1) / ageDays
}

三个因子:

  • confidence(置信度):自动提炼的默认 0.7,用户手动编辑后升为 1.0
  • hitCount(命中次数):被检索命中越多,说明越有用
  • age_in_days(年龄):越旧的记忆 score 越低

Cron 衰减任务(独立于域模型公式):

go 复制代码
// infrastructure/decay/job.go --- 每天 0.95 系数衰减
const defaultThreshold = 30 * 24 * time.Hour // 30 天未访问才触发

func applyDecay(ctx context.Context, db *sql.DB, threshold time.Duration) {
    db.ExecContext(ctx, `
        UPDATE memories
        SET    importance = GREATEST(1, ROUND(importance * 0.95)),
               updated_at = now()                       -- 衰减后刷新,避免反复命中
        WHERE  pinned     = false
          AND  source     = 'auto'                      -- 只衰减自动提炼的
          AND  updated_at < now() - make_interval(secs => $1) -- 30 天没被碰过
          AND  deleted_at IS NULL                       -- 跳过已软删
    `)
}

两套机制配合:Cron 每天把长期不活跃的记忆 importance 打九五折(最低降到 1);当需要淘汰时,EvictByPolicy 按 importance * (hit_count + 1) / ageDays 排序,删掉尾部------这正是域模型 DecayScore 公式的 SQL 对应(importance = confidence × 100,仅差常数倍,不影响排序)。pinned 的记忆永远不参与衰减和淘汰。

就像你的书架:长期不翻的书慢慢积灰(0.95 衰减),积灰太久的书会被清理掉(EvictByPolicy),但"钉"在架子上的参考书永远不会被清理。


用户画像:结构化的 Profile

除了 Memory(自然语言短句),还有 Profile(结构化画像):

go 复制代码
// domain/model/memory.go
type Profile struct {
    TenantID  string
    UserID    string
    Fields    map[string]string // {"language": "zh-CN", "role": "developer"}
    UpdatedAt time.Time
}

两者互补:

特性 Profile Memory
数据形式 key-value 键值对 自然语言短句
注入方式 总是注入 system prompt 头部 向量检索 topK 后注入
检索方式 按 userID 直接查 语义相似度匹配
适用场景 稳定的结构化信息 动态的上下文相关内容

Profile 存的是"用户说中文、是开发者"这种稳定信息;Memory 存的是"上次讨论了数据库迁移方案"这种动态信息。


记忆提炼:会话结束后异步进行

每次 session 结束后,memory BC 异步从对话历史里提炼新记忆。整个过程是 fire-and-forget------失败不影响主流程。

javascript 复制代码
会话消息 → LLM 提炼 → 8 类 JSON → 向量去重 → 写库

第一步:LLM 提炼

把对话历史发给专门的"记忆提炼 prompt",LLM 返回 8 个分类的结构化 JSON:

json 复制代码
{
  "profile":     ["用户是后端开发者"],
  "preferences": ["偏好中文回答"],
  "entities":    ["项目用 PostgreSQL"],
  "events":      ["上周做了数据库迁移"],
  "cases":       ["排查过 OOM 问题"],
  "patterns":    ["习惯先看日志再查指标"],
  "tools":       ["常用 kubectl 和 psql"],
  "skills":      ["会用 EXPLAIN ANALYZE 优化查询"]
}

每个分类映射到对应的 MemoryKind:profile/entities/events/tools → fact,preferences → preference,cases/patterns/skills → skill

第二步:向量去重

每条候选记忆先调 Embedder 生成向量,然后在该用户的 namespace 里查相似记忆。如果 cosine 相似度 ≥ 0.85,说明已有差不多的记忆了,跳过------防止重复积累。

go 复制代码
// application/command/extract_session.go
const dupSimilarityThreshold = float32(0.85)

func (h *ExtractSessionHandler) saveIfUnique(ctx context.Context, m *model.Memory) error {
    vecs, _ := h.emb.Embed(ctx, []string{m.Content()})
    similar, _ := h.repo.SearchSimilar(ctx, m.TenantID(), scopes, vecs[0], 3, dupSimilarityThreshold)
    if len(similar) > 0 {
        return nil  // 已有足够相似的记忆 → 跳过
    }
    return h.repo.Save(ctx, m)
}

你可以把去重想象成"笔记查重":记新笔记前先翻翻旧笔记,如果已经记过差不多的事情(相似度 ≥ 85%),就不重复记了。


Recall:怎么把记忆注入给 Agent

Agent 每次对话时,通过 Recall 查询把相关记忆注入 system prompt:

go 复制代码
// application/query/recall.go
func (h *RecallHandler) Handle(ctx context.Context, in RecallInput) (*RecallResult, error) {
    // 1. Profile 总是拿(不需要检索)
    prof, _ := h.profile.Load(ctx, in.TenantID, in.UserID)

    // 2. 多命名空间并集检索
    scopes := []domain.NSQuery{
        {Namespace: []string{"user", in.UserID}},           // 用户全局
    }
    if in.ProjectID != "" {
        scopes = append(scopes, domain.NSQuery{
            Namespace: []string{"user", in.UserID, "project", in.ProjectID}, // 项目级
        })
    }
    if in.AgentConfig != "" {
        scopes = append(scopes, domain.NSQuery{
            Namespace: []string{"agent", in.AgentConfig},   // Agent 共享知识
        })
    }
    if in.SessionID != "" {
        scopes = append(scopes, domain.NSQuery{
            Namespace: []string{"session", in.SessionID},   // 会话临时记忆
        })
    }

    // 3. 把用户问题 embed 成向量,在各 scope 里做语义检索
    vecs, _ := h.emb.Embed(ctx, []string{in.Query})
    mems, _ := h.mems.Recall(ctx, in.TenantID, scopes, vecs[0], in.TopK)

    // 4. 命中的记忆异步 Touch + 落库(fire-and-forget,不阻塞响应)
    go func() {
        for _, m := range mems {
            m.Touch()                // hitCount + 1
            _ = h.mems.Save(ctx, m)  // 持久化热度 → 下次淘汰更不易被清
        }
    }()

    return &RecallResult{Memories: mems, Profile: prof}, nil
}

检索同时从多个命名空间查(用户 + 项目 + Agent + 会话),取并集。命中的记忆在后台 goroutine 里异步 Touch(hitCount + 1)并落库------不阻塞本次响应;hitCount 写回 memories.hit_count 列后,淘汰排序 importance * (hit_count + 1) / age 会让常用记忆分数更高,不容易被淘汰------形成正反馈循环。


小结

记忆系统的关键设计:

  1. 层级命名空间 :用元组 ["user",uid] / ["user",uid,"project",pid] / ["agent",cfg] / ["session",sid] 分区,天然层级可扩展
  2. 四种记忆类型:fact / preference / skill / summary,不同类型存不同 namespace
  3. 双套衰减:Cron 每天 0.95 打折 + EvictByPolicy 按 score 淘汰尾部,pinned 永不淘汰
  4. Profile + Memory 互补:结构化键值对(总是注入)+ 自然语言短句(语义检索 topK 注入)
  5. 提炼带去重:LLM 8 分类提取 + 向量相似度 ≥ 0.85 跳过,防止重复积累
  6. 正反馈循环:命中的记忆 hitCount 增长 → 衰减分变高 → 更不容易被淘汰

下一篇:多租户隔离 ------ 一条 RLS 策略怎么防数据串

相关推荐
leeyi2 小时前
工具调用:Agent 的手和眼
llm·agent
leeyi2 小时前
多 LLM Provider:不改一行业务代码换模型
llm·agent
leeyi2 小时前
SSE 实时推流 —— Token 怎么一个个蹦出来
后端·agent
凌奕2 小时前
微信小程序接入微信 AI:让用户"说一句话"就能下单
微信·微信小程序·agent
leeyi2 小时前
Hook 系统:插件化安全护栏怎么设计
llm·agent
Nicander2 小时前
去除中文写作AI味的Skill:write-like-human-zh
agent
leeyi2 小时前
ReAct 循环的 50 行 Go 实现,逐行拆解
后端·agent
leeyi2 小时前
HITL:让人类随时叫停 AI,并且能优雅地继续
后端·agent
hixiong1232 小时前
C# Tokenizers.DotNet测试工具
开发语言·人工智能·llm