AI 智能体 Memory 记忆模块

0 阅读5分钟

大家好,我是双越。wangEditor 作者,前百度 滴滴 资深前端工程师,慕课网金牌讲师,PMP,前端面试派 作者。

我正致力于两个项目的开发和升级,感兴趣的可以私信我,加入项目小组。

  • 划水AI Node 全栈 AIGC 知识库,包括 AI 写作、多人协同编辑。复杂业务,真实上线。
  • 智语 AI Agent 智能体项目。一个智能面试官,可以优化简历、模拟面试、解答题目等。

本文介绍 AI 智能体 Memory 记忆模块的架构和流程。

为何需要记忆?

LLM 本身是无状态的,每次调用都是全新的。Agent 要维持连贯对话、记住用户偏好、跨会话积累知识,就必须在外部管理记忆。记忆模块本质上是在回答一个问题: "哪些信息应该被放进下一次 LLM 调用的 context 里?"

注意,memory 不是 data ,不是数据库里存储的所有聊天记录。这俩概念一定要区分开。

短期记忆(Short-term Memory)

短期记忆就是当前 session 会话(不会垮 session)的对话历史,直接拼进 prompt 的 messages 数组里。

核心挑战是 Context Window 有限,通常 8k~128k tokens,必须管理。一般有几种方法:

  • sliding window 滑动窗口,即每次都保留最后 x 条数据,这样容易丢失早期信息
  • Token 精确裁剪,按照模型限制的 token 数量裁切,这样也会丢失早期信息
  • Summarization 摘要压缩,当对话太长时,不是粗暴丢弃,而是把老消息压缩成摘要保留语义。这是更智能的做法:
class SummaryMemory {
  constructor(client, summaryThreshold = 10) {
    this.client = client; // Anthropic client
    this.summaryThreshold = summaryThreshold;
    this.summary = ""; // 历史摘要
    this.recentMessages = []; // 近期完整消息
  }

  async add(role, content) {
    this.recentMessages.push({ role, content });

    if (this.recentMessages.length >= this.summaryThreshold) {
      await this._compress();
    }
  }

  async _compress() {
    const historyText = this.recentMessages
      .map((m) => `${m.role}: ${m.content}`)
      .join("\n");

    const prompt = this.summary
      ? `已有摘要:${this.summary}\n\n新增对话:\n${historyText}\n\n请更新并合并为一段新摘要,保留关键信息。`
      : `请将以下对话压缩为简洁摘要,保留关键事实和用户意图:\n\n${historyText}`;

    const res = await this.client.messages.create({
      model: "claude-opus-4-6",
      max_tokens: 500,
      messages: [{ role: "user", content: prompt }],
    });

    this.summary = res.content[0].text;
    this.recentMessages = []; // 清空,等待新消息积累
  }

  getMessages() {
    const messages = [];
    // 把摘要作为 system-level 的上下文注入
    if (this.summary) {
      messages.push({
        role: "user",
        content: `[对话历史摘要]: ${this.summary}`,
      });
      messages.push({
        role: "assistant",
        content: "好的,我已了解之前的对话背景。",
      });
    }
    return [...messages, ...this.recentMessages];
  }
}

长期记忆(Long-term Memory)

长期记忆跨越会话存在,需要持久化存储。分两个层次:用户画像(结构化)和语义记忆(向量化)。

用户画像

把用户的偏好、基本信息等结构化数据存 DB,用 PostgreSQL / MongoDB 均可。每次对话开始时读取注入 system prompt:

// 用 PostgreSQL / MongoDB 均可,这里示意结构
class UserProfileMemory {
  constructor(db) {
    this.db = db;
  }

  async updateProfile(userId, newFacts) {
    // newFacts 来自 LLM 对对话的信息抽取
    await this.db.collection("profiles").updateOne(
      { userId },
      { $set: { ...newFacts, updatedAt: new Date() } },
      { upsert: true }
    );
  }

  async getSystemPrompt(userId) {
    const profile = await this.db.collection("profiles").findOne({ userId });
    if (!profile) return "";

    return `
用户基本信息:
- 姓名:${profile.name || "未知"}
- 职业:${profile.occupation || "未知"}
- 偏好语言:${profile.preferredLang || "中文"}
- 已知背景:${profile.background || "无"}
    `.trim();
  }

  // 让 LLM 从对话中自动抽取用户信息
  async extractAndSave(userId, conversation, client) {
    const res = await client.messages.create({
      model: "claude-opus-4-6",
      max_tokens: 300,
      messages: [{
        role: "user",
        content: `从以下对话中抽取用户的个人信息和偏好,以 JSON 格式返回(只返回 JSON):
对话:${conversation}
可抽取字段:name, occupation, background, preferredLang, interests 等`
      }]
    });

    try {
      const facts = JSON.parse(res.content[0].text);
      await this.updateProfile(userId, facts);
    } catch (e) {
      console.log("抽取失败,跳过");
    }
  }
}

语义记忆

这是长期记忆的核心。把历史对话、知识片段 embedding 成向量存储,对话时用当前 query 做相似度检索,把最相关的记忆片段注入 context。

写入:文本 → Embedding API → 向量 → 存入 VectorDB(附带原文metadata)
读取:当前query → Embedding → 相似度搜索 → 取 Top-K 原文 → 注入 prompt

这里需要存储的可不是所有的对话记录,而是值得被记住的信息,这个概念就很模糊,而且不知道怎么判断哪些值得被记忆。

在实际执行时,会在每轮对话结束时,让 LLM 总结本轮对话的概述,然后记录在 Vector DB ,算是这一轮的语义记忆。这是一个比较好实现的解决方案。

每轮对话结束
    ↓
直接存(或先压缩成摘要再存)
    ↓
检索时用相似度阈值过滤
    ↓
只有真正相关的才会被召回

另外,如何识别一轮对话结束?并不是浏览器关闭了就是对话结束,浏览器不关闭对话也可能自动技术(用户长久无响应)。实际更常用的触发时机有两种:

方式一:Redis TTL 过期时触发 会话 30 分钟无活动自动过期,在 key 过期的回调里执行总结和存储。Redis 有 keyspace notification 机制可以监听过期事件。缺点是需要额外配置,稍微复杂一点。

方式二:每隔 N 轮自动触发(更常用) 不依赖退出事件,每累积 10 轮对话就自动总结一次存入 Vector DB,滚动进行。

10 轮结束 → 总结前 10 轮 → 存 Vector DB
第 20 轮结束 → 总结 11-20 轮 → 存 Vector DB
...

这样即使用户直接关掉页面,已经发生的对话也不会丢失,最多丢最近不足 N 轮的部分。

短期记忆 + 长期记忆 结合

在生产中,两者结合使用:

每次对话请求
    │
    ├─ 1. 读取用户画像          → 注入 system prompt 头部
    ├─ 2. 向量召回相关历史记忆   → 注入 system prompt 中部
    ├─ 3. 取近期对话窗口         → 作为 messages 数组
    │
    └─ 调用 LLM → 返回结果
                     │
                     ├─ 存入向量DB(长期记忆写入)
                     └─ 更新对话窗口(短期记忆更新)

组装示例

async function buildContext(userId, currentMessage) {
  const [profile, recalled, recentMsgs] = await Promise.all([
    profileMemory.getSystemPrompt(userId),
    recallMemory(userId, currentMessage, 3),
    shortTermMemory.getMessages(),
  ]);

  const recalledText = recalled
    .map(r => `[记忆] ${r.content}`)
    .join("\n");

  const system = [profile, recalledText].filter(Boolean).join("\n\n");

  return { system, messages: recentMsgs };
}

详细流程图

LLM 永远是无状态的,它"记得你"这个感觉,完全是每次请求前你在外部把记忆拼进 payload 造成的。流程图里的 ③ 构建 Context 就是这个核心步骤。

读取是并行的,写入是异步的。三路读取用 Promise.all 同时发起,压缩到 ~30ms;写回不阻塞用户响应,在 setImmediate 或消息队列里处理。

Redis 的 TTL 是短期记忆的"自然死亡"机制,每次用户发消息都 RESET TTL,30 分钟无活动自动销毁,不需要写任何清理代码。

摘要压缩是有损的,它牺牲细节换取空间,所以重要的信息(用户偏好、关键事实)应该在写回时单独抽取存进 PostgreSQL,而不是只靠摘要保留。

image.png

根据流程图提取的时序图:

image.png

最后

以上就是 Agent 记忆模块的基础知识和流程,有其他问题欢迎继续留言补充~