ai agent --- mem0 外挂记忆系统

0 阅读12分钟

一.mem0是什么?

1.概念

Mem0 是为 AI 应用和 Agent 提供的智能记忆层,负责从对话中识别、保存、更新、隔离和召回有价值的上下文,使无状态模型能够跨会话保持连续性。

Mem0(读作 “mem-zero”)是给 AI 助手/Agent 加的一层持久化语义记忆层**。

它解决的核心问题是:AI 是无状态的,每次对话都从零开始,而 Mem0 让它能跨会话记住用户偏好、项目决策、学习经验等。

核心能力:

  • 三层记忆作用域:user_id(用户级)、agent_id(Agent 级)、run_id(会话级)
  • 自动事实提取:丢一段对话进去,LLM 自动从中抽取关键事实
  • 多信号检索:语义搜索 + BM25 关键词 + 知识图谱,三路融合后去重,重排。
  • 实体链接:自动提取实体(人名、项目名等),跨记忆关联
  • 时序推理:时间感知检索,区分"当前状态"和"过去事件"

在 aiAgent 里面 mem0 角色

image.png

使用 Mem0 之前:

用户提问后,系统并行调用 Milvus 进行语义检索、BM25 进行关键词检索,并访问知识图谱进行关系检索;三路分别返回结果后,业务层需要完成去重、排序、加权融合和上下文整理,再将筛选后的内容注入提示词,交给大模型生成回答。

使用 Mem0 之后:

用户提问进入应用后,由Mem0统一管理相关记忆的召回、过滤、排序和整理;应用取得整理后的相关记忆后,再交给大模型。也就是说mem0将原来的向量检索,知识图谱,关键词搜索,去重,重排等一系列复杂的事情全部封装好了你只需要将mem0返回的结果交给大模型处理就好了。

上图非常像RAG的处理流程图,但是这个处理的是会话上下文。RAG 负责“企业文档知识”,他的搜索依旧需要原来的解决方案,不能使用mem0做简化,因为mem0的优势是用户记忆,会话记忆,agent的配置记忆。

image.png

  • 用户记忆 (User Memory)

绑定userld后,存的是使用人的:姓名、城市、偏好、习惯等。 换会话、换Agent也能读到,适合个性化和跨场景复用。

  • 会话记忆(Session Memory)

绑定userld+runld,存的是当前这次对话里的任务上下文:这次要做什么、讨论到哪- 步、临时目标等。 会话结束后可以清理,不会和别的会话混在一起。

  • Agent 记忆 (Agent Memory)

绑定agent_id,agent_id是Agent 上下文的隔离与归属标识,存的是Agent自己的设定:角色、语气、回答方式、专业领域等。

同一个用户换不同Agent,各自保持独立人格和风格。

就是说换个大模型以后,我设定的机器人是男的还是女的,声音,回答方式,你的角色是旅游博主等等,都不会随agent的切换而改变。

二.mem0的使用

mem0有2个比较容易混淆的类,如下所示:

image.png

MemoryClient 把数据发给 Mem0 托管服务;Memory 在你自己的基础设施中运行。

MemoryClient的创建方式

使用MemoryClient创建的memory,他的数据是存放在app.mem0.ai/dashboard/r… 里面的。mem0是第三方数据管理平台。 它不但帮我们托管数据,还帮我完成会话向量检索,知识图谱搜索,关键词搜索。

安装

pnpm install mem0ai

获取MEM0_API_KEY,登录:app.mem0.ai/login 先要注册用户,才能获取。

image.png

image.png

import MemoryClient from "mem0ai";
import "dotenv/config"; 

const client = new MemoryClient({
  apiKey: process.env.MEM0_API_KEY,
});

使用Memory创建的memory,他的数据是存放在自己的数据库里面。他会和milvus,BM 25,知识图谱配合完成 完成会话向量检索,知识图谱搜索,关键词搜索,去重,重排等繁琐的事情。

Memory的创建方式

import { Memory } from "mem0ai/oss";

const memory = new Memory({
  llm: {
    provider: "openai",
    config: {
      apiKey: process.env.OPENAI_API_KEY,
      model: "gpt-5-mini",
    },
  },
  embedder: {
    provider: "openai",
    config: {
      apiKey: process.env.OPENAI_API_KEY,
      model: "text-embedding-3-small",
    },
  },
  vectorStore: {
    provider: "qdrant",
    config: {
      host: "localhost",
      port: 6333,
      collectionName: "memories",
    },
  },
});

MemoryClient和Memory的区别

image.png

如何选择MemoryClient和Memory

  1. 数据必须完全留在企业内部:选 Memory
  2. 希望快速上线、降低基础设施负担:选 MemoryClient
  3. 需要 Graph Memory、Webhooks 等平台能力:选 MemoryClient
  4. 需要深度改造记忆链路:选 Memory

有数据私有化和平台运维能力的企业,更可能选择 Memory;重视交付速度、成本和托管能力的企业,更可能选择 MemoryClient。

数据必须留在自有基础设施,并且企业具备模型与数据库运维能力时,选 Memory;其余大多数项目,尤其是产品验证和一般业务系统,优先选 MemoryClient。

MemoryClient 的优势是把基础设施和运维责任交给 Mem0 托管平台,因此交付速度更快、基础设施效率更高

MemoryClient使用案例

import MemoryClient from "mem0ai";
import "dotenv/config"; 

const client = new MemoryClient({
  apiKey: process.env.MEM0_API_KEY,
});

const userId = "user-123";

async function main() {
  /* ========== 1. 新增记忆 ========== */
  const messages = [
    {
      role: "user",
      content: "我喜欢弹钢琴,我每周都要弹三次,每次一小时,我很喜欢这个过程。",
    },
    {
      role: "assistant",
      content: "好的,我已经记住你的喜好。",
    },
  ];

  const addResult = await client.add(messages, {
    userId,
    metadata: {
      category: "travel_preference",
      source: "chat",
    },
  });

  console.log("新增结果:", JSON.stringify(addResult));

  /* ========== 2. 查询相关记忆 ========== */
  const searchResult = await client.search("推荐北京餐厅", {
    filters: {
      user_id: userId,
    },
    topK: 10,
  });

  console.log("搜索结果:", JSON.stringify(searchResult, null, 2));

  /* ========== 3. 查询当前用户的所有记忆 ========== */
  const allMemories = await client.getAll({
    filters: {
      user_id: userId,
    },
  });

  console.log("用户全部记忆:", JSON.stringify(allMemories, null, 2));

  /* ========== 4. 根据 ID 查询单条记忆 ========== */
  const memoryId = "f6ae414a-aa19-4859-8853-a5ad576caeba";
  const singleMemory = await client.get(memoryId);

  console.log("单条记忆:", JSON.stringify(singleMemory, null, 2));

  /* ========== 5. 修改记忆 ========== */
  // const updateResult = await client.update(memoryId, {
  //   text: "用户偏好川菜,计划下周三前往北京出差,预算人均200元。",
  //   metadata: {
  //     category: "travel_preference",
  //     updated: true,
  //   },
  // });

  // console.log("修改结果:", JSON.stringify(updateResult, null, 2));

  /* ========== 6. 删除单条记忆 ========== */
  // const deleteResult = await client.delete(memoryId);

  // console.log("删除结果:", JSON.stringify(deleteResult, null, 2));

  // /* ========== 7. 删除当前用户的所有记忆 ========== */
  // // 注意:该操作会删除指定用户下的全部记忆,生产环境必须谨慎使用
  // const deleteAllResult = await client.deleteAll({
  //   userId,
  // });

  // console.log("删除用户全部记忆:", JSON.stringify(deleteAllResult, null, 2));
}

main().catch((error) => {
  console.error("Mem0 操作失败:", error);
});

查看 mem0 的数据

image.png

image.png

语义检索,我每周都要干嘛?他回复我每周喜欢弹钢琴,一周三次。

image.png

这个东西的好处是,他帮我们做好了会话上下文的图谱检索,关键词搜索,语义检索,就不需要我们手动再做。不过这个东西是需要掏钱的。每天都有一些额度你可以使用。

利用redis和mem0存储会话

利用redis做短期记忆,压缩记忆本次会话都说了些什么东西。利用mem0做长期记忆,记录用户信息,agent喜好等。

具体实现代码如下:

代码里面用RedisMessageStore类来装redis的存信息,取信息,清空信息的方法。

Mem0MemoryStore类用来存mem0存取数据的方法。

invokeWithMemory是用来加载对话的,不管是进入长期会话还是进入短期会话,都是靠大模型自己来判断加入的。

    1. 从 Redis 加载当前会话历史
    1. 从 Mem0 检索用户长期记忆和会话记忆
    1. 将 Mem0 记忆转换为 SystemMessage
    1. 调用 Agent
    1. 将新对话写入 Redis
    1. 判断哪些内容需要写入 Mem0
    1. 返回模型结果和统计信息

invokeWithMemory 负责“取记忆—组上下文—调用模型—存会话—筛选长期事实—写回 Mem0”的完整闭环,是整个应用把 Redis 短期会话与 Mem0 长期记忆连接起来的核心方法。

/**
 * Redis 记这轮聊天,Mem0 记值得长期留着的事。
 *
 * 用户层 = 换天聊还认得你;会话层 = 只管当前这个聊天窗口。
 *
 * docker compose up -d redis 后 pnpm agent
 * :clear 清 Redis | :clear-mem0 清 Mem0 | exit/:q 退出
 */
import "dotenv/config";
import Redis from "ioredis";
import * as readline from "node:readline/promises";
import { stdin, stdout } from "node:process";
import { z } from "zod";
import { MemoryClient } from "mem0ai";
import { ChatOpenAI } from "@langchain/openai";
import {
  SystemMessage,
  SystemMessageChunk,
  HumanMessage,
  mapChatMessagesToStoredMessages,
  mapStoredMessagesToChatMessages,
} from "@langchain/core/messages";
import { createAgent, summarizationMiddleware } from "langchain";

const REDIS_HOST = process.env.REDIS_HOST ?? "localhost";
const REDIS_PORT = Number(process.env.REDIS_PORT ?? 6379);
const REDIS_DB = Number(process.env.REDIS_DB ?? 0);
const MEMORY_TTL = Number(process.env.MEMORY_TTL_SECONDS ?? 1800);
const KEY_PREFIX = process.env.MEMORY_KEY_PREFIX ?? "agent:short_memory";

const USER_ID = process.env.MEM0_USER_ID ?? "demo_user_001";
const SESSION_ID = "session_002";
const MEM0_TOP_K = Number(process.env.MEM0_TOP_K ?? 5);

const memorySchema = z.object({
  write_user: z
    .boolean()
    .describe(
      "写入用户层:换一个新会话仍应保留的长期事实(身份、居住地、长期爱好、饮食禁忌、持久偏好)。不含仅本轮任务。",
    ),
  write_session: z
    .boolean()
    .describe(
      "写入会话层:仅当前会话/thread 有效的任务、大纲、进度、待办、临时决策(如「这次先写…」「数据部分明天补」)。",
    ),
  reason: z.string().describe("分类理由,一句话"),
});

const CLASSIFIER_PROMPT = `你是记忆分层分类器。判断本轮对话是否有「新事实」需写入 Mem0,并分到正确层级。

## user 层(跨会话长期)
- 用户身份与画像:姓名、职业、居住地、长期爱好
- 长期偏好与约束:饮食过敏、回答风格、常用技术栈
- 持续数周以上的个人背景(非单次任务)

## session 层(仅当前会话)
- 当前正在做的任务、目标、文档大纲、方案草稿
- 本会话内的进度、决策、待办、临时约定
- 用户明确用「这次」「本轮」「当前会话」描述的工作上下文

## 均不写入
- 寒暄、致谢、纯确认
- 助手生成的通用内容(攻略、示例代码、建议清单),用户未明确采纳为新事实
- 无信息增量的复述

## 决策原则
1. 「这次我们先写 Q1 总结」「当前在排查 XX」→ 优先 session,不要标成 user
2. user 与 session 可同时为 true(如同时说职业+当前任务),但勿把纯会话任务只标 user
3. 一次性请求(如「帮我做旅行攻略」)且未产生需跨轮记住的约定 → 均为 false`;

const summaryPrompt = `你是对话摘要助手。用中文简洁总结:话题、会话内进度/报错/待办。
用户级长期偏好由外部记忆维护,摘要勿重复堆砌。不要编造。

待摘要的对话:
{messages}

摘要:`;

/** Mem0 注入的 SystemMessage 不写回 Redis */
function messagesForRedis(messages) {
  return messages.filter(
    (m) => !SystemMessage.isInstance(m) && !SystemMessageChunk.isInstance(m),
  );
}

class RedisMessageStore {
  constructor({ redis, keyPrefix, ttlSeconds }) {
    this.redis = redis;
    this.keyPrefix = keyPrefix;
    this.ttlSeconds = ttlSeconds;
  }

  messagesKey(sessionId) {
    return `${this.keyPrefix}:${sessionId}:messages`;
  }

  async loadMessages(sessionId) {
    const raw = await this.redis.get(this.messagesKey(sessionId));
    if (!raw) return [];
    return mapStoredMessagesToChatMessages(JSON.parse(raw));
  }

  async saveMessages(sessionId, messages) {
    const payload = JSON.stringify(mapChatMessagesToStoredMessages(messages));
    await this.redis.set(this.messagesKey(sessionId), payload, "EX", this.ttlSeconds);
  }

  async clear(sessionId) {
    await this.redis.del(this.messagesKey(sessionId));
  }

  async ttl(sessionId) {
    return this.redis.ttl(this.messagesKey(sessionId));
  }
}

class Mem0MemoryStore {
  constructor({ client, userId, sessionId, topK, classifier }) {
    this.client = client;
    this.userId = userId;
    this.sessionId = sessionId;
    this.topK = topK;
    this.classifier = classifier;
  }

  async search(query) {
    const [userRes, sessionRes] = await Promise.all([
      this.client.search(query, {
        filters: { user_id: this.userId },
        topK: this.topK,
      }),
      this.client.search(query, {
        filters: {
          AND: [{ user_id: this.userId }, { run_id: this.sessionId }],
        },
        topK: this.topK,
      }),
    ]);
    return {
      user: userRes.results ?? [],
      session: sessionRes.results ?? [],
    };
  }

  buildSystemMessage({ user, session }) {
    const blocks = [];
    if (user.length) {
      blocks.push(`【用户长期记忆】\n${user.map((m) => `- ${m.memory}`).join("\n")}`);
    }
    if (session.length) {
      blocks.push(`【当前会话记忆】\n${session.map((m) => `- ${m.memory}`).join("\n")}`);
    }
    if (!blocks.length) return null;
    return new SystemMessage(`${blocks.join("\n\n")}\n\n请结合以上记忆回答,勿编造。`);
  }

  async classifyAndPersist(userText, assistantText) {
    const turn = [
      { role: "user", content: userText },
      { role: "assistant", content: assistantText },
    ];

    const { write_user, write_session, reason } = await this.classifier.invoke([
      new SystemMessage(CLASSIFIER_PROMPT),
      new HumanMessage(`用户:${userText}\n助手:${assistantText}`),
    ]);

    const written = [];
    if (write_user) {
      await this.client.add(turn, { userId: this.userId });
      written.push("user");
    }
    if (write_session) {
      await this.client.add(turn, { userId: this.userId, runId: this.sessionId });
      written.push("session");
    }
    return { written, reason };
  }

  async clear() {
    await this.client.deleteAll({ userId: this.userId });
    await this.client.deleteAll({ userId: this.userId, runId: this.sessionId });
  }
}

async function invokeWithMemory(agent, redisStore, mem0Store, sessionId, userText) {
  const history = await redisStore.loadMessages(sessionId);
  console.log(`  ↳ Redis 加载 ${history.length} 条历史`);

  const mem = await mem0Store.search(userText);
  if (mem.user.length) console.log(`  ↳ Mem0 用户层 ${mem.user.length} 条`);
  if (mem.session.length) console.log(`  ↳ Mem0 会话层 ${mem.session.length} 条`);

  const memoryMsg = mem0Store.buildSystemMessage(mem);
  const invokeMessages = [
    ...(memoryMsg ? [memoryMsg] : []),
    ...history,
    new HumanMessage(userText),
  ];

  const result = await agent.invoke(
    { messages: invokeMessages },
    { recursionLimit: 30 },
  );

  const redisMessages = messagesForRedis(result.messages);
  const dropped = result.messages.length - redisMessages.length;
  await redisStore.saveMessages(sessionId, redisMessages);
  const ttl = await redisStore.ttl(sessionId);
  console.log(
    `  ↳ Redis 写回 ${redisMessages.length} 条` +
      (dropped ? `(过滤 ${dropped} 条 SystemMessage)` : "") +
      ` (TTL ${ttl}s)`,
  );

  const assistantText = String(result.messages.at(-1)?.content ?? "");
  const { written, reason } = await mem0Store.classifyAndPersist(userText, assistantText);
  console.log(`  ↳ 分类: ${reason}`);
  console.log(written.length ? `  ↳ Mem0 写入: ${written.join(", ")}` : "  ↳ Mem0 未写入");

  return { messages: result.messages, redisMessages, assistantText };
}

if (!process.env.MEM0_API_KEY || !process.env.OPENAI_API_KEY) {
  console.error("需要 MEM0_API_KEY 与 OPENAI_API_KEY");
  process.exit(1);
}

const redis = new Redis({ host: REDIS_HOST, port: REDIS_PORT, db: REDIS_DB });
const mem0 = new MemoryClient({ apiKey: process.env.MEM0_API_KEY });

redis.on("connect", () => console.log("✅ Redis 已连接"));
redis.on("error", (err) => console.error("❌ Redis 错误:", err.message));

try {
  await redis.ping();
} catch {
  console.error("Redis 未连接,请先执行: docker compose up -d redis");
  process.exit(1);
}

const redisStore = new RedisMessageStore({
  redis,
  keyPrefix: KEY_PREFIX,
  ttlSeconds: MEMORY_TTL,
});

const llmOpts = {
  apiKey: process.env.OPENAI_API_KEY,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  temperature: 0,
};

const model = new ChatOpenAI({ model: process.env.MODEL_NAME, ...llmOpts });

const classifier = new ChatOpenAI({
  model: process.env.MODEL_NAME,
  ...llmOpts,
}).withStructuredOutput(memorySchema);

const mem0Store = new Mem0MemoryStore({
  client: mem0,
  userId: USER_ID,
  sessionId: SESSION_ID,
  topK: MEM0_TOP_K,
  classifier,
});

const agent = createAgent({
  model,
  tools: [],
  systemPrompt:
    "你是会话助手。结合系统消息中的长期/会话记忆回答,中文简短。有对话摘要则据此继续。",
  middleware: [
    summarizationMiddleware({
      model,
      summaryPrompt,
      trigger: { messages: 8 },
      keep: { messages: 4 },
    }),
  ],
});

console.log(`用户 ${USER_ID} | 会话 ${SESSION_ID}`);
console.log("输入 exit / quit / :q 退出;:clear 清空 Redis;:clear-mem0 清空 Mem0\n");

const rl = readline.createInterface({ input: stdin, output: stdout });
let prevCount = (await redisStore.loadMessages(SESSION_ID)).length;

try {
  while (true) {
    const userText = (await rl.question("你: ")).trim();
    if (!userText) continue;

    if (["exit", "quit", ":q"].includes(userText.toLowerCase())) break;

    if (userText === ":clear") {
      await redisStore.clear(SESSION_ID);
      prevCount = 0;
      console.log("已清空 Redis 短期记忆\n");
      continue;
    }

    if (userText === ":clear-mem0") {
      await mem0Store.clear();
      console.log("已清空 Mem0 用户层与当前会话层\n");
      continue;
    }

    const { redisMessages, assistantText } = await invokeWithMemory(
      agent,
      redisStore,
      mem0Store,
      SESSION_ID,
      userText,
    );

    console.log("\n助手:", assistantText);
    console.log(`Redis 消息数: ${redisMessages.length}`);
    if (redisMessages.length < prevCount + 2) {
      console.log("  ⚡ 已触发压缩");
    }
    prevCount = redisMessages.length;
    console.log();
  }
} finally {
  rl.close();
}

await redis.quit();

/*
 * 测试对话(复制进终端,先来 :clear-mem0 和 :clear)
 *
 * 一、寒暄
 * 你好 / 在吗 / 谢谢
 * → 纯客套,Mem0 不用记。
 *
 * 二、自我介绍
 * 我叫小明,住在杭州,平时喜欢骑行和摄影。
 * 我对海鲜过敏,出差尽量别安排沿海城市。
 * → 换天聊还得知道的事,写 user 层。
 *
 * 三、这会儿在干嘛
 * 这次我们先写 Q1 季度总结,大纲分三块:项目复盘、数据指标、下季度计划。
 * 项目复盘里重点写 order-service 的 500 错误排查过程。
 * → 只管这次聊天的事,写 session 层。
 *
 * 四、长期背景 + 手头活
 * 我长期做后端开发,这次会话的任务是排查 payment-api 超时,先从 P99 日志看起。
 * 另外我之后技术回答都希望带代码示例,这个一直记住。
 * → 职业和当前任务可能两层都写,偏好那条走 user。
 *
 * 五、Redis 和 Mem0 各管啥
 * 刚才说的 payment-api,超时阈值先假设 3 秒。
 * 上一句我说的阈值是多少?
 * → 刚说过的话 Redis 兜得住,不用等 Mem0。
 *
 * 重启 agent(别清 mem0)再问:我是谁?有什么过敏?
 * → 新会话 Redis 是空的,user 层还能认出你。
 *
 * 六、聊多了会压缩(可选,连聊 8 轮以上)
 * 继续完善 Q1 总结 / 把第二段改短 / 加个标题……
 * → 终端会出现「已触发压缩」,老消息变摘要。
 *
 * 推荐顺序:清空 → 寒暄 → 自我介绍 → 当前任务 → 重启验 user → 清 mem0 验 session 没了
 */

三.总结

Mem0 是一层智能记忆系统,通过 user_id、run_id 和 agent_id 管理用户、会话与 Agent 上下文;MemoryClient 使用 Mem0 托管服务,Memory 在自有基础设施运行;企业文档知识仍由 RAG 负责。

  1. Mem0 解决 AI 无状态导致的上下文丢失问题,使应用和 Agent 能跨会话记住相关信息。
  2. 用户记忆绑定 user_id,保存长期有效的事实和偏好。
  3. 会话记忆绑定 run_id,保存当前任务、进度和临时决策。
  4. Agent 记忆绑定 agent_id,用于隔离不同 Agent 的上下文,而不是保存角色提示词。
  5. MemoryClient 使用托管平台,Memory 在自有基础设施运行。
  6. Mem0 负责用户和 Agent 记忆,RAG 负责企业文档知识,两者可以组合使用。
  7. 生产项目还需增加授权、脱敏、审计、删除、权限控制和数据留存策略。

Mem0 不是通用文档 RAG 的替代品,也不是 Agent 配置中心,而是位于业务应用与模型之间的智能记忆层。它负责事实抽取、记忆存储、更新、冲突处理、范围隔离、检索和结果整理;企业文档检索仍应由专门的 RAG 系统承担。