代码语言

知识点思维导图

34 个知识节点

记忆系统(05) - Mem0 长期记忆:抽取、更新、召回与治理

读完后,你应能完成以下任务:

  • 绘制“记忆系统(05) - Mem0 长期记忆:抽取、更新、召回与治理 / Mem0 解决的不是保存消息”的关键对象与数据流,解释“直接保存聊天只能回答“用户说过什么”,不能稳定回答“哪些信息值得跨会话复用、旧偏好如何覆盖、相关记忆何时注入”。”,并用源码位置、日志或 Trace 标注证据。
  • 为“记忆系统(05) - Mem0 长期记忆:抽取、更新、召回与治理 / 最小可运行示例”设计正常与异常输入,验证“不同版本和托管/开源配置的返回结构可能变化,应用层应封装接口并做契约测试,不要让业务代码到处依赖供应商字段。”,输出首个偏差位置与回归测试结果。
  • 实现“记忆系统(05) - Mem0 长期记忆:抽取、更新、召回与治理 / 记忆记录需要哪些字段”的最小代码或配置,检验““用户是 Java 工程师”与“用户可能偏好 Java”不是同一种证据。”,输出命令、结果与 Diff,并说明不适用边界。

更新日期:2026/08/11

一、Mem0 解决的不是保存消息

直接保存聊天只能回答“用户说过什么”,不能稳定回答“哪些信息值得跨会话复用、旧偏好如何覆盖、相关记忆何时注入”。Mem0 在应用与存储之间增加记忆处理层:从对话抽取候选事实,判断新增/更新/删除,再按用户、Agent 或运行维度检索。

短期会话与长期记忆仍要分开:

  • Redis/LangGraph Checkpointer 保存当前线程状态和最近消息。
  • Mem0 保存跨会话可复用的明确事实、偏好和经验。
  • 业务主库保存订单、权限、审批等权威事实。
  • 审计系统保存谁在何时写入、读取或删除了哪些记忆。

二、最小可运行示例

不同版本和托管/开源配置的返回结构可能变化,应用层应封装接口并做契约测试,不要让业务代码到处依赖供应商字段。

三、记忆记录需要哪些字段

即便底层库允许只传文本,生产系统也应在业务层维护:

字段 用途
memory_id 稳定更新、删除、审计
tenant_id/user_id 权限边界
namespace/type 区分偏好、事实、任务经验
text 原子、可验证的记忆内容
source 原始线程、消息或业务记录
confidence 明确陈述与模型推断的差异
created_at/valid_until 新鲜度和过期判断
status/superseded_by 处理冲突和覆盖
embedding_version 模型升级与重建

“用户是 Java 工程师”与“用户可能偏好 Java”不是同一种证据。模型推断默认不应写成长期事实。

四、更新与冲突不能交给 Prompt 猜

当用户先说“默认中文”,后来明确说“以后默认英文”,系统应把旧偏好标记为被新记录覆盖。不要保留两条 active 记录,再让模型自行决定。

推荐规则:

  1. 同一命名空间和属性键只允许一条当前值。
  2. 用户明确表达高于模型推断,权威业务记录高于对话陈述。
  3. 新记录覆盖旧记录时保留审计关系,但旧记录不再参与召回。
  4. 时间敏感事实必须有 valid_until 或定期验证任务。
  5. 删除请求同时清理向量、缓存、导出和备份策略覆盖的副本。

五、多路召回如何设计

长期记忆可组合:

  • 语义召回:查找含义相近的偏好和经历。
  • 精确属性召回:按 namespace + key 获取当前值。
  • 时间召回:找最近事件或指定时间范围。
  • 关系召回:按项目、组织、联系人等关联实体过滤。

召回分数应同时考虑相关性、可信度、新鲜度和权限。最终注入的条目要少而明确,并带“这是用户记忆,不是系统指令”的边界,防止曾经保存的恶意文本变成长期 Prompt Injection。

六、把 Mem0 接进 Agent 的正确位置

在线顺序应是:鉴权 → 检索当前用户记忆 → 应用冲突/敏感过滤 → Token 预算选择 → 注入背景区 → 回答。记忆不能改变系统权限、工具白名单或审批要求。

七、验收与治理

  • 写入精确率:抽取出的长期记忆中,真正值得保存的比例。
  • 召回命中率:标注需要的记忆是否进入 Top K。
  • 过期/冲突注入率:目标应接近零。
  • 跨用户泄漏率:必须为零。
  • 删除完整率与传播时延:用户删除后各存储副本是否清理。
  • 行为提升:启用记忆后,任务成功率是否提高,而不是只有“更个性化”的主观感觉。

UI 至少要允许用户查看、修改和删除记忆。隐式抽取敏感信息并永久保存,即使技术上可行,也不是合格产品。

八、常见错误

  • 把每轮摘要都写进长期库,造成重复、冲突和召回污染。
  • 只存向量,不保存来源、状态和 Embedding 版本。
  • 用昵称作为 user_id,不同租户同名用户相互污染。
  • 用户删除后只删主记录,向量索引和缓存仍可召回。
  • 把召回记忆拼进 system prompt,让历史恶意内容获得更高权限。

九、总结

  • Mem0 解决的不是保存消息:直接保存聊天只能回答“用户说过什么”,不能稳定回答“哪些信息值得跨会话复用、旧偏好如何覆盖、相关记忆何时注入”。
  • 最小可运行示例:不同版本和托管/开源配置的返回结构可能变化,应用层应封装接口并做契约测试,不要让业务代码到处依赖供应商字段。
  • 记忆记录需要哪些字段:| tenant_id/user_id | 权限边界 |
  • 更新与冲突不能交给 Prompt 猜:同一命名空间和属性键只允许一条当前值。 -> 用户明确表达高于模型推断,权威业务记录高于对话陈述。 -> 新记录覆盖旧记录时保留审计关系,但旧记录不再参与召回。 -> 时间敏感事实必须有 valid_until 或定期验证任务。
  • 多路召回如何设计:时间召回:找最近事件或指定时间范围。
  • 把 Mem0 接进 Agent 的正确位置:在线顺序应是:鉴权 → 检索当前用户记忆 → 应用冲突/敏感过滤 → Token 预算选择 → 注入背景区 → 回答。

十、动手实践:Mem0 记忆生命周期交互

这个浏览器实验用确定性规则模拟 Mem0 的关键生命周期,而不是把聊天记录原样保存。点击“下一轮对话”可依次看到 ADD → UPDATE → NOOP → DELETE,再用检索框观察精确属性、语义关键词和新鲜度三路得分。

10.1 页面运行

在文章中的实验区点击“运行”,然后逐轮执行。HTML 沙盒不联网、不读取站点 Cookie,也不需要 API Key。

10.2 本地查看

直接用浏览器打开 sandbox.html 即可。

10.3 与真实 Mem0 的边界

  • 真实 Mem0 通常用 LLM 抽取候选记忆,并使用 Embedding、向量库或图存储完成检索。
  • 本实验用可读规则代替 LLM 和 Embedding,只为了让写入决策、冲突覆盖、删除和召回排序可复现。
  • 生产环境仍需租户隔离、敏感信息过滤、审计、删除传播和 API 契约测试。

10.4 实现源码与运行边界

下方 sandbox.html 可直接在文章中运行;其余文件保留真实本地项目结构,用于理解接口、部署和测试。

sandbox.html

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Mem0 记忆生命周期</title>
    <style>
      :root {
        color-scheme: light dark;
        --background: #f7f8f7;
        --surface: #ffffff;
        --text: #17201d;
        --muted: #63706b;
        --border: #d8dedb;
        --accent: #0f766e;
        --accent-soft: #dff4ef;
        --warning: #b45309;
        --danger: #b42318;
        --info: #2563a6;
      }

      * {
        box-sizing: border-box;
      }

      body {
        margin: 0;
        background: var(--background);
        color: var(--text);
        font: 14px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        letter-spacing: 0;
      }

      button,
      input {
        font: inherit;
        letter-spacing: 0;
      }

      button {
        min-height: 34px;
        border: 1px solid var(--border);
        border-radius: 6px;
        background: var(--surface);
        color: var(--text);
        cursor: pointer;
        padding: 6px 12px;
      }

      button.primary {
        border-color: var(--accent);
        background: var(--accent);
        color: #ffffff;
      }

      button:disabled {
        cursor: not-allowed;
        opacity: 0.55;
      }

      main {
        min-height: 100vh;
        padding: 16px;
      }

      .toolbar {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 12px;
        border-bottom: 1px solid var(--border);
        padding-bottom: 12px;
      }

      .title {
        margin: 0;
        font-size: 17px;
        font-weight: 700;
      }

      .subtitle {
        margin: 2px 0 0;
        color: var(--muted);
        font-size: 12px;
      }

      .actions {
        display: flex;
        gap: 8px;
      }

      .grid {
        display: grid;
        grid-template-columns: minmax(0, 1fr) minmax(280px, 0.9fr);
        gap: 16px;
        padding-top: 14px;
      }

      .section-title {
        display: flex;
        align-items: center;
        justify-content: space-between;
        min-height: 28px;
        margin: 0 0 8px;
        font-size: 13px;
      }

      .counter {
        color: var(--muted);
        font-size: 12px;
        font-weight: 400;
      }

      .conversation,
      .memory-list,
      .recall-list {
        display: grid;
        gap: 8px;
      }

      .message,
      .memory,
      .recall {
        border: 1px solid var(--border);
        border-radius: 6px;
        background: var(--surface);
        padding: 9px 10px;
      }

      .message-label,
      .memory-meta,
      .event-meta,
      .recall-meta {
        color: var(--muted);
        font-size: 11px;
      }

      .message-label {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 8px;
      }

      .message-text,
      .memory-text {
        margin-top: 3px;
      }

      .message {
        padding: 6px 8px;
      }

      .message .message-text {
        margin-top: 1px;
      }

      .message .event-meta {
        line-height: 1.25;
      }

      .badge {
        display: inline-block;
        border-radius: 4px;
        padding: 1px 5px;
        font-size: 10px;
        font-weight: 700;
      }

      .badge.add {
        background: var(--accent-soft);
        color: var(--accent);
      }

      .badge.update,
      .badge.noop {
        background: #fff3d6;
        color: var(--warning);
      }

      .badge.delete {
        background: #fee9e7;
        color: var(--danger);
      }

      .search {
        display: grid;
        grid-template-columns: minmax(0, 1fr) auto;
        gap: 8px;
        margin: 14px 0 8px;
      }

      .search input {
        min-width: 0;
        min-height: 34px;
        border: 1px solid var(--border);
        border-radius: 6px;
        background: var(--surface);
        color: var(--text);
        padding: 6px 9px;
      }

      .empty {
        border: 1px dashed var(--border);
        border-radius: 6px;
        color: var(--muted);
        padding: 14px;
        text-align: center;
      }

      @media (prefers-color-scheme: dark) {
        :root {
          --background: #111513;
          --surface: #171c19;
          --text: #edf2ef;
          --muted: #9aa8a1;
          --border: #303a35;
          --accent: #42b8a4;
          --accent-soft: #163b34;
          --warning: #f2b75d;
          --danger: #ff8b82;
          --info: #78afe9;
        }

        button.primary {
          color: #071d18;
        }

        .badge.update,
        .badge.noop {
          background: #3c2d12;
        }

        .badge.delete {
          background: #41201e;
        }
      }

      @media (max-width: 720px) {
        main {
          padding: 12px;
        }

        .toolbar {
          align-items: flex-start;
          flex-direction: column;
        }

        .grid {
          grid-template-columns: 1fr;
        }
      }
    </style>
  </head>
  <body>
    <main>
      <header class="toolbar">
        <div>
          <h1 class="title">Mem0 记忆生命周期</h1>
          <p class="subtitle">离线机制模拟:候选抽取 → 冲突决策 → 存储 → 多路召回</p>
        </div>
        <div class="actions">
          <button id="reset-button" type="button">重置</button>
          <button id="next-button" class="primary" type="button">下一轮对话</button>
        </div>
      </header>

      <div class="grid">
        <section aria-labelledby="conversation-title">
          <h2 id="conversation-title" class="section-title">
            对话与写入决策
            <span id="step-counter" class="counter">0 / 4</span>
          </h2>
          <div id="conversation" class="conversation"></div>
        </section>

        <section aria-labelledby="memory-title">
          <h2 id="memory-title" class="section-title">
            Active memories
            <span id="memory-counter" class="counter">0 条</span>
          </h2>
          <div id="memory-list" class="memory-list"></div>
          <div class="search">
            <input id="recall-query" type="text" value="回答偏好" aria-label="记忆召回问题" />
            <button id="recall-button" type="button">召回</button>
          </div>
          <div id="recall-list" class="recall-list"></div>
        </section>
      </div>
    </main>

    <script>
      // 依次输入记忆处理层的教学对话。
      const conversationSteps = [
        {
          message: "以后技术问题默认用中文,并先给结论。", // 用户本轮明确表达。
          action: "ADD", // 当前候选产生的生命周期动作。
          key: "answer_style", // 用于冲突合并的原子属性键。
          value: "中文,先给结论", // 当前属性的新值。
          reason: "明确、稳定且可跨会话复用的偏好。" // 写入决策的可审计理由。
        },
        {
          message: "以后默认用英文回答。", // 用户用新陈述覆盖旧偏好。
          action: "UPDATE", // 同属性冲突需要更新而非新增。
          key: "answer_style", // 与旧记录相同的原子属性键。
          value: "英文,先给结论", // 合并仍有效约束后的新值。
          reason: "同一属性出现更新,旧记录标记为 superseded。" // 冲突处理理由。
        },
        {
          message: "对,还是默认用英文回答。", // 与当前记录语义相同的重复陈述。
          action: "NOOP", // 重复事实不创建新向量。
          key: "answer_style", // 用来查重的原子属性键。
          value: "英文,先给结论", // 与当前值相同的规范化结果。
          reason: "规范化后与 active 记录一致,避免重复污染。" // 去重理由。
        },
        {
          message: "忘掉我的回答语言偏好。", // 用户提出明确删除请求。
          action: "DELETE", // 主记录、索引和缓存都应传播删除。
          key: "answer_style", // 等待删除的原子属性键。
          value: "", // 删除动作没有新值。
          reason: "用户删除优先,记录退出召回并进入审计。" // 删除决策理由。
        }
      ];

      // 当前仍可参与召回的记忆记录。
      let activeMemories = [];
      // 已经处理到的对话步骤下标。
      let currentStepIndex = 0;
      // 对话消息列表的 DOM 容器。
      const conversationElement = document.querySelector("#conversation");
      // Active memory 列表的 DOM 容器。
      const memoryListElement = document.querySelector("#memory-list");
      // 召回结果列表的 DOM 容器。
      const recallListElement = document.querySelector("#recall-list");
      // 当前实验进度文本节点。
      const stepCounterElement = document.querySelector("#step-counter");
      // 当前有效记忆数量文本节点。
      const memoryCounterElement = document.querySelector("#memory-counter");
      // 执行下一轮对话的按钮。
      const nextButtonElement = document.querySelector("#next-button");
      // 重置全部教学状态的按钮。
      const resetButtonElement = document.querySelector("#reset-button");
      // 提交多路召回的按钮。
      const recallButtonElement = document.querySelector("#recall-button");
      // 用户输入召回问题的文本框。
      const recallQueryElement = document.querySelector("#recall-query");

      /**
       * 对动态文本执行 HTML 转义。
       * @param {string} value 等待写入页面的非可信文本。
       * @returns {string} 可安全放入模板的文本。
       */
      function escapeHtml(value) {
        // 浏览器原生文本节点用于完成可靠转义。
        const temporaryElement = document.createElement("div");
        temporaryElement.textContent = value;
        return temporaryElement.innerHTML;
      }

      /**
       * 应用一条记忆生命周期动作。
       * @param {{message: string, action: string, key: string, value: string, reason: string}} step 当前对话步骤。
       */
      function applyMemoryAction(step) {
        // 与当前候选拥有相同属性键的有效记忆下标。
        const existingIndex = activeMemories.findIndex((memory) => memory.key === step.key);
        if (step.action === "ADD") {
          activeMemories.push({
            id: "mem-001", // 教学记录的稳定主键。
            key: step.key, // 当前记忆的原子属性键。
            text: step.value, // 经过规范化的记忆文本。
            confidence: 1, // 明确用户陈述使用最高可信度。
            updatedAt: currentStepIndex + 1 // 确定性新鲜度序号。
          });
        } else if (step.action === "UPDATE" && existingIndex >= 0) {
          activeMemories[existingIndex] = {
            ...activeMemories[existingIndex],
            text: step.value, // 新偏好覆盖旧记录正文。
            updatedAt: currentStepIndex + 1 // 更新后刷新新鲜度序号。
          };
        } else if (step.action === "DELETE" && existingIndex >= 0) {
          activeMemories.splice(existingIndex, 1);
        }

      }

      /** 渲染已经处理的用户对话。 */
      function renderConversation() {
        // 当前已经执行的对话步骤切片。
        const visibleSteps = conversationSteps.slice(0, currentStepIndex);
        conversationElement.innerHTML = visibleSteps.length
          ? visibleSteps
              .map(
                (step, index) => `
                  <article class="message">
                    <div class="message-label">
                      <span>user · turn ${index + 1}</span>
                      <span class="badge ${step.action.toLowerCase()}">${step.action}</span>
                    </div>
                    <div class="message-text">${escapeHtml(step.message)}</div>
                    <div class="event-meta">${escapeHtml(step.reason)}</div>
                  </article>`
              )
              .join("")
          : '<div class="empty">点击“下一轮对话”开始观察记忆变化。</div>';
      }

      /** 渲染当前仍可参与召回的记忆。 */
      function renderMemories() {
        memoryCounterElement.textContent = `${activeMemories.length} 条`;
        memoryListElement.innerHTML = activeMemories.length
          ? activeMemories
              .map(
                (memory) => `
                  <article class="memory">
                    <div class="memory-meta">${escapeHtml(memory.id)} · ${escapeHtml(memory.key)} · confidence ${memory.confidence.toFixed(2)}</div>
                    <div class="memory-text">${escapeHtml(memory.text)}</div>
                  </article>`
              )
              .join("")
          : '<div class="empty">当前没有 active memory。</div>';
      }

      /**
       * 对一条记忆计算三路召回分数。
       * @param {{key: string, text: string, confidence: number, updatedAt: number}} memory 当前有效记忆。
       * @param {string} query 用户检索问题。
       * @returns {{exact: number, semantic: number, recency: number, total: number}} 分路和总分。
       */
      function scoreMemory(memory, query) {
        // 查询是否直接命中回答偏好属性。
        const exactScore = query.includes("回答") || query.includes("偏好") ? 1 : 0;
        // 查询和记忆共享的教学语义词数量。
        const sharedTerms = ["中文", "英文", "结论", "回答"].filter(
          (term) => query.includes(term) && memory.text.includes(term)
        ).length;
        // 归一化后的教学语义分数。
        const semanticScore = Math.min(1, sharedTerms / 2);
        // 越晚更新的记录拥有越高的新鲜度分数。
        const recencyScore = memory.updatedAt / conversationSteps.length;
        // 精确属性、语义和新鲜度的加权总分。
        const totalScore = exactScore * 0.45 + semanticScore * 0.4 + recencyScore * 0.15;
        return { exact: exactScore, semantic: semanticScore, recency: recencyScore, total: totalScore };
      }

      /** 根据输入问题执行多路召回并显示分数。 */
      function recallMemories() {
        // 去除首尾空白后的召回问题。
        const query = recallQueryElement.value.trim();
        // 带分路评分并按总分降序排列的候选。
        const scoredMemories = activeMemories
          .map((memory) => ({ memory, scores: scoreMemory(memory, query) }))
          .filter((candidate) => candidate.scores.total > 0)
          .sort((left, right) => right.scores.total - left.scores.total);
        recallListElement.innerHTML = scoredMemories.length
          ? scoredMemories
              .map(
                ({ memory, scores }) => `
                  <article class="recall">
                    <div class="recall-meta">total ${scores.total.toFixed(2)} · exact ${scores.exact.toFixed(2)} · semantic ${scores.semantic.toFixed(2)} · recency ${scores.recency.toFixed(2)}</div>
                    <div>${escapeHtml(memory.text)}</div>
                  </article>`
              )
              .join("")
          : '<div class="empty">没有可召回的 active memory。</div>';
      }

      /** 同步页面全部视图和按钮状态。 */
      function render() {
        stepCounterElement.textContent = `${currentStepIndex} / ${conversationSteps.length}`;
        nextButtonElement.disabled = currentStepIndex >= conversationSteps.length;
        nextButtonElement.textContent = currentStepIndex >= conversationSteps.length ? "演示完成" : "下一轮对话";
        renderConversation();
        renderMemories();
        recallMemories();
      }

      /** 执行下一条预设对话及其记忆动作。 */
      function runNextStep() {
        if (currentStepIndex >= conversationSteps.length) {
          return;
        }
        // 当前等待处理的教学步骤。
        const step = conversationSteps[currentStepIndex];
        applyMemoryAction(step);
        currentStepIndex += 1;
        render();
      }

      /** 清空全部状态并回到第一轮之前。 */
      function resetDemo() {
        activeMemories = [];
        currentStepIndex = 0;
        render();
      }

      nextButtonElement.addEventListener("click", runNextStep);
      resetButtonElement.addEventListener("click", resetDemo);
      recallButtonElement.addEventListener("click", recallMemories);
      recallQueryElement.addEventListener("keydown", (event) => {
        if (event.key === "Enter") {
          recallMemories();
        }
      });
      render();
    </script>
  </body>
</html>

学完自测

选择所有正确答案;提交后逐项核对判断依据。

1在“Mem0 长期记忆:抽取、更新、召回与治理”中,需要同时满足“Mem0 解决的不是保存消息”与“最小可运行示例”。给定正文约束“直接保存聊天只能回答“用户说过什么”,不能稳定回答“哪些信息值得跨会话复用、旧偏好如何覆盖、相关记忆何时注入”。”,哪些判断保持了原有处理机制?多选
2“Mem0 长期记忆:抽取、更新、召回与治理”出现偏差:“在“Mem0 长期记忆:抽取、更新、召回与治理 / 记忆记录需要哪些字段”中,即使不满足““用户是 Java 工程师”与“用户可能偏好 Java”不是同一种证据”,结果与副作用仍会保持不变。”已成为实际行为。围绕“记忆记录需要哪些字段”与“更新与冲突不能交给 Prompt 猜”,哪些判断能定位被改变的职责或边界?多选
3评审“Mem0 长期记忆:抽取、更新、召回与治理”方案时,验收条件包含“最终注入的条目要少而明确,并带“这是用户记忆,不是系统指令”的边界,防止曾经保存的恶意文本变成长期 Prompt Injection。”。关于“多路召回如何设计”与“把 Mem0 接进 Agent 的正确位置”的哪些决策符合正文机制?多选