Agent 的长期记忆如何优雅实现?深入拆解 Mem0 的记忆框架实现
Agent 的长期记忆如何优雅实现?深入拆解 Mem0 的记忆框架实现
你用 AI 用久了,早晚会遇到一个挺荒诞的瞬间。
它可以在几十秒内读完一本书,可以一次处理几十万甚至上百万 Token,可以分析你的代码仓库、工作文档和会议记录。
但你上周才告诉它自己不吃香菜,这周换个会话,它又兴致勃勃地给你推荐了一家香菜爱好者天堂。
不是哥们,你不是刚认识我啊。。。
更长的上下文窗口,似乎没有自然带来更持久的记忆。模型变得越来越能读,却不一定越来越能记。
这也是我最近拆 Mem0 源码时,脑子里一直盘旋的一个问题。
大模型的长期记忆,到底应该怎么做?
很多人下意识的答案,是把聊天记录保存下来,下次再塞回 Prompt。再高级一点,给聊天记录做摘要,或者扔进向量数据库,需要的时候搜索一下。
这些办法当然都有用,但它们仍然没有碰到最难的那一层。
真正困难的不是把过去存下来,而是判断什么值得记、怎样记、什么时候该想起来、信息变化后如何保留历史,以及哪些东西应该逐渐淡出。
记忆不是一个无限扩容的聊天记录文件夹。
它是一套生命周期。
Mem0 真正想做的,就是这一层。
一. 上下文很长,不代表 AI 真的有记忆
我们平时在 ChatGPT、Claude 里感受到的连续对话,很容易让人误以为模型一直记得前面发生过什么。
其实吧,大模型的 API 调用天然是无状态的。
每次生成时,模型只知道应用这一次提交给它的内容。聊天产品之所以能承接上一句话,往往是因为应用把之前的消息重新放进了请求。
所以,上下文窗口更像一张工作台。
工作台越大,这一轮能铺开的材料越多。但会话结束后,桌上的东西是否被整理、归档,以及下次需要时能否准确找到,是另一套问题。
1.1 把全部历史重新塞进去,会发生什么
最直接的记忆方案,是每次请求都附上完整聊天历史。
短对话里,这个方法简单、准确,原始措辞和前后顺序也都保留着。很多产品刚开始做时,用它完全没问题。
可一旦用户持续使用几周、几个月,麻烦就来了。
聊天历史会线性增长,每轮都要为以前的内容重复付 Token 成本。上下文越长,网络传输、模型推理和首字延迟也会变得更重。
而且历史记录里绝大部分内容,和当前问题可能根本没关系。
问候、确认、工具输出、失败重试、助手对用户的复述,全都混在一起。模型不是没有看到关键信息,而是需要从一大堆噪声里再次把它认出来。
这就像每次问同事一个问题,都先把过去一年所有聊天记录打印出来放到他桌上。
信息很完整。
但人已经快被纸埋了。
1.2 摘要也不是万能解法
另一个常见方案,是定期把聊天历史压缩成摘要。
摘要确实能控制上下文长度,但它是一种有损压缩。摘要模型认为不重要的细节,一旦被删掉,后面很难恢复。
用户说自己喜欢悬疑小说,这件事可能会被保留。可他喜欢的是东野圭吾,最近刚看完《白夜行》,尤其喜欢里面的叙事结构,这些更容易支撑未来个性化的细节,可能已经被压成了一句泛泛的「用户喜欢阅读」。
这种记忆看起来没错。
但几乎没什么用。
1.3 普通向量 RAG 也只解决了一半
那把聊天记录切块,放进向量数据库呢?
它可以缓解全量重放的问题,但原始聊天并不是天然适合检索的知识单元。
「我最近已经不喝那个了」这句话单独拿出来,几乎没有任何意义。它依赖前面的几十轮对话,才能知道「那个」指的是咖啡还是牛奶。
聊天里还会存在大量语义重复。用户表达一次,助手复述一次,随后又总结一次。向量数据库可能把三个版本一起搜出来,白白消耗上下文。
更麻烦的是,个人信息会变化。
用户以前住在北京,现在搬到了杭州。旧地址不是虚假信息,它只是属于过去。直接覆盖会丢失历史,全部保留又可能让模型回答错当前状态。
到了这里,Mem0 想解决的问题才真正出现。
它解决的不是如何保存更多聊天,而是如何把持续增长的对话,加工成可复用、可检索、可演化的长期记忆。
二. Mem0 不是模型,它夹在应用和模型之间
Mem0 对自己的定位很克制,它是一层 Memory Layer。
它不替代大模型生成回答,也不会凭空让 Claude、GPT 或 Gemini 获得永久记忆。它做的是两件事。
一次发生在模型回答之前,搜索当前问题需要的记忆。
另一次发生在交互之后,从新对话中提取值得长期保存的信息。
sequenceDiagram
participant U as 用户
participant A as AI 应用
participant M as Mem0
participant L as 大模型
U->>A: 提交新问题
A->>M: search 查询相关记忆
M-->>A: 返回少量候选记忆
A->>L: 当前问题加候选记忆
L-->>A: 生成回答
A->>M: add 保存本轮对话
A-->>U: 返回回答
这张图里有一个特别重要的细节。
Mem0 只负责返回记忆,最终把哪几条放进 Prompt,仍然由应用决定。
所以它不是一个魔法黑盒,更像一套在模型外部运行的记忆基础设施。
2.1 发送给 Mem0 的是消息,保存下来的却是记忆
假设用户说了这样一句话。
最近出差坐过道太折腾了,以后订票尽量给我选靠窗的位置。
如果原样保存,未来搜索「用户有什么出行偏好」时,确实有机会找到它。但这句话仍然带着聊天语气和临时情绪。
Mem0 默认会让 LLM 把它整理成一条脱离原对话也能理解的事实。
用户在出行时偏好靠窗座位,因为坐过道会让其感到折腾。
这个过程在项目里叫 extraction,也可以理解成记忆蒸馏。
它会去掉问候和对话机械动作,补全代词指向,保留名字、日期、数字、动机和情绪,再把不同主题拆成不同记忆。
如果应用已经在上层完成了结构化,或者就是想原样保存普通文本,也可以设置 infer=False。对普通文本来说,这条路径会跳过记忆提取 LLM,直接把用户或助手消息写进向量存储。
这更像是在告诉 Mem0,「内容我已经整理好了,你只管原样存下来」。好处是没有额外的提取成本,也不会因为事实蒸馏而丢失措辞。代价是自动去重、指代补全和事实整理都需要应用自己负责。
多模态输入是个例外。视觉消息会在进入 infer 分支前先做解析,启用视觉模型时仍可能调用 LLM,并把图片转换成文字描述。本文后面讨论的 infer=False 都限定为普通文本场景。
2.2 记忆必须有边界
Mem0 用 user_id、agent_id 和 run_id 区分记忆属于谁。
user_id 通常承载跨会话的用户偏好和经历。agent_id 可以保存某个 Agent 学到的信息、配置和完成过的动作。run_id 更适合一次任务、一个会话或一段短期工作流。
搜索时至少需要其中一个作用域。
这并不是 API 设计上的小讲究。
长期记忆的第一个生产问题,往往不是召回率,而是绝不能把 Alice 的记忆返回给 Bob。
三. 写入一条记忆,比存下一句话复杂得多
从这里开始,我们要真正进入源码了,有两个名字需要先对齐。
Mem0 OSS 中的 OSS 是 Open Source Software,也就是可以自己部署、修改,并自行选择 LLM 和 Vector Store 的开源版本,对应 Python 里的 Memory。
这篇文章只讨论这套开源实现。接下来出现的 Mem0 和 OSS,都指开源版 Memory。
当前 Python OSS 的记忆写入主入口是 Memory.add()。
真正有意思的部分,是默认开启推断之后的 _add_to_vector_store()。这一段代码把一次看似简单的「记住它」,拆成了多个阶段。
flowchart TD
A[新消息进入 add] --> B[校验 user agent run 作用域]
B --> C[读取最近 10 条原始消息]
B --> D[拼接完整本轮消息]
D --> E[对完整消息生成 Query Embedding]
E --> F[向量检索最相关的 10 条旧记忆]
C --> G[构造 V3 提取 Prompt]
D --> G
F --> G
D -. 不自动截断或分块 .-> X[上限取决于 Embedding 与提取 LLM]
G --> H[一次 LLM 调用提取新记忆]
H --> I[容错解析 JSON 并读取 text]
I --> J[批量生成记忆 Embedding]
J --> K[精确 Hash 去重]
K --> L[批量写入主向量集合]
L --> M[写入 SQLite History]
M --> N[抽取实体并更新 Entity Store]
N --> O[保存最近消息窗口]
这个流程里,最值得拆的其实不是向量数据库,而是 LLM 调用之前送进去的上下文。
3.1 最近消息负责理解「它是谁」
当前实现会从 SQLite 里取出同一作用域最近 10 条消息。
它们主要用来解决指代问题。
这里的 10 条是 message,不是 10 轮对话。Mem0 只保存应用传给 add() 的内容,不会自己读取 Agent 回复。按照常见的 retrieve、generate、store 流程,应用会在 Agent 回答后,把本轮用户消息和最终回复一起提交,所以这个窗口通常大约覆盖最近 5 轮。
System Prompt、内部思考过程、大段工具输出和失败重试通常不应该放进来。真正改变外部状态的工具结果,可以整理成一条简洁的最终结论再保存。
用户说「Poppy 昨天做了体检」,旧消息可能告诉模型 Poppy 是用户的金毛犬。用户说「还是换回原来那家吧」,最近上下文可能告诉模型原来那家指的是哪家酒店。
这部分不是完整聊天历史,而是一个很小的滚动窗口。每条历史消息还会被截断,避免提取 Prompt 无限增长。
现在再回头看 infer=False,它不会把原始内容放进这个最近消息窗口。后续提取仍然可能通过向量搜索找到这条内容,但不能把它当作最近对话来还原「那个」「原来那家」之类指代。
Mem0 在这里做了一个挺实用的切分。
短期指代交给最近消息,长期背景交给相关记忆。
3.2 相关旧记忆先帮 LLM 判断「这是不是新信息」
系统会把当前新消息做一次 Embedding,然后在同一作用域中搜索最相关的 10 条已有记忆,再把它们和本轮对话一起交给提取 LLM。
第一层去重就发生在这次 LLM 调用里。
V3 Prompt 明确要求,如果新消息表达的事实与某条旧记忆语义相同,而且没有增加新的细节或背景,就不要再次提取。
例如系统已经保存「用户偏好靠窗座位」,用户后来又说「我坐飞机还是一直选靠窗」,这轮通常不需要新增记忆。但如果用户说「我现在改坐过道,因为最近腿受伤了,需要更方便起身」,这里出现了新的偏好、时间状态和变化原因,就应该保存成一条新记忆。
同一次 LLM 输出内部也要去重。如果用户说了一次,助手又复述一次,Prompt 要求只保留信息更完整的版本。
第二层去重发生在 LLM 输出之后。
代码会对每条新记忆的完整文本计算 MD5,并与那 10 条旧记忆的 Hash 以及本批次已经处理过的 Hash 比较。文本完全相同就跳过写入。
所以 Mem0 实际用了两道过滤。LLM 负责识别表达不同但含义相同的内容,更灵活,也可能判断错。MD5 只识别一模一样的文本,能力很窄,但结果稳定。
旧记忆在这里仍然只能作为判断参照,不能成为新事实的来源。如果旧记忆写着用户最喜欢 Olive Garden,而新消息只说「昨晚吃得不错」,提取器不能擅自写成「用户昨晚在 Olive Garden 吃得不错」。
这两层去重也不是全局的。它们只会对比语义搜索找回的 10 条旧记忆。如果真正的重复信息没有进入这 10 条,它就无法参与这一轮判断。
Mem0 控制了 Prompt 大小,也接受了去重不可能百分之百完整的现实。
3.3 提示词才是写入质量的核心
很多人聊记忆系统时,注意力会自然落到向量数据库。
但在生成 Embedding 之前,Mem0 还要先让 LLM 决定「这段对话里到底有什么值得记住」。负责约束这次判断的系统提示词,在代码里叫 ADDITIVE_EXTRACTION_PROMPT。
它不是用户输入的 Prompt,而是一份写给记忆提取 LLM 的系统级工作说明。调用时,Mem0 会把它作为 system message,再把本轮消息、最近消息、相关旧记忆和日期等上下文组装成另一份输入,要求 LLM 按固定 JSON 格式输出新记忆。
我把这份提示词从头读完后,自己的感受是,检索质量的上限,很多时候在生成 Embedding 之前就已经决定了。
这份提取规则有数百行,反复强调几件事。
它要求同时阅读用户和助手消息。用户的偏好、经历和计划要记,助手真正提供的新建议、完成的动作和研究结果也要记。
它会过滤「好的」「没问题」「这个问题很棒」之类没有信息量的内容,也会避免把助手对用户的复述保存两次。
它特别重视用户请求里顺带透露的事实。
用户问「我刚开始看《夜莺》,能推荐几本类似的书吗」,临时任务是推荐书,但长期有价值的信息是用户开始读《夜莺》,并且可能偏好这一类作品。
更夸张的是,它对细节保留有近乎执拗的要求。
书名、电影名、产品型号、人数、页数、时间、职位前缀,全都不能被泛化掉。Ferrari 488 GTB 不能被压成「跑车」,assistant manager 不能被压成「manager」。
因为未来真正能把记忆搜出来的,恰恰经常是这些具体信息。
一条「用户喜欢电影」的记忆很安全,也几乎没有个性化价值。
3.4 它不再追求极端原子化
早期做知识库时,我们经常强调一个 Chunk 只表达一个原子事实。
Mem0 V3 的 Prompt 反而要求记忆做到 contextually rich。
它希望一条记忆包含事实和周围最关键的原因,而不是把它切成一地碎片。
例如下面这种写法就不够好。
用户喜欢燕麦奶拿铁。
更好的版本是这样。
用户因为出现杏仁过敏,从杏仁奶拿铁换成了燕麦奶拿铁。
第二条稍微长一点,却同时保留了新偏好、旧偏好和变化原因。以后问「现在喝什么」「以前喝什么」「为什么更换」,它都有机会派上用场。
3.5 生产里,生成回答和提取记忆是两次不同调用
讲到这里,很容易产生一个误会,Mem0 V3 不是让一次 LLM 调用同时完成回答和记忆提取。
正常的 AI 应用会先调用回答模型,生成给用户看的内容。回答完成后,应用再调用 memory.add(),Mem0 内部使用提取 LLM,从本轮用户消息和 Assistant 回复中整理长期记忆。
sequenceDiagram
participant U as 用户
participant A as AI 应用
participant L1 as 回答 LLM
participant M as Mem0
participant E as Embedding 模型
participant V as Vector Store
participant L2 as 提取 LLM
U->>A: 发送问题
A->>L1: 生成回答
L1-->>A: 返回完整回复
A-->>U: 展示回复
A->>M: add 用户消息和最终回复
Note over M,L2: 当前消息不自动截断或分块
M->>E: 对完整本轮消息生成 Embedding
E-->>M: 返回 Query Embedding
M->>V: 检索 10 条相关旧记忆
V-->>M: 返回旧记忆
M->>L2: 完整本轮消息加上下文提取记忆
L2-->>M: 返回 ADD-only 记忆
所以从一次完整对话来看,当前常见链路仍然有两次模型调用。第一次服务用户,第二次服务记忆系统。第二次通常可以放到异步任务中,在回答已经返回后执行,避免增加用户等待时间,但模型费用依然存在。
这里还有一个生产中很现实的问题,Assistant 回复可能非常长。
当前 OSS 不会自动把本轮新消息分块或截断。用户提问和完整回复会先被拼接起来,用于搜索相关旧记忆,随后又会完整进入提取 Prompt。只有从 SQLite 读取的历史消息会被截断到每条 300 个字符。
如果回复仍在 Embedding 模型和提取 LLM 的输入限制内,Mem0 会读取全文,再把它蒸馏成几条短记忆。真正写入主 Vector Store 的是提取结果,不是整篇回答。
如果回复太长,就可能超过 Embedding 或 LLM 的输入上限,导致 add() 失败。即使没有超限,大段解释、示例和推荐也可能让提取器生成太多 Assistant 记忆,造成额外成本和记忆噪声。
生产中更稳妥的分工,是把完整回答保存在聊天记录或文档系统里,只把未来可能复用的结论交给 Mem0。
| 场景 | 适合传给 add() 的内容 |
|---|---|
| 普通短回答 | 本轮用户消息和最终 Assistant 回复 |
| 很长的解释型回答 | 用户消息加一份简短的结论或执行摘要 |
| 工具真正改变了外部状态 | 订票成功、日程已创建等最终结果 |
| 问候、失败重试、纯展示内容 | 可以直接跳过,不写长期记忆 |
如果想省掉第二次模型调用,也可以让回答 LLM 一次返回两个字段,一个是展示给用户的 answer,另一个是准备保存的 memory_candidates,然后用 infer=False 直接写入。
代价是 Mem0 不再执行语义去重、最近上下文辅助和实体索引。一次调用更省钱,但应用需要自己保证这些候选已经足够干净。
这里要区分两种计数。当前 V3 的 add() 内部只有一次提取调用,加上前面的回答调用,一轮对话通常是两次 LLM。旧版 add() 内部还有一次记忆操作判断,所以完整链路可能达到三次。
这就引出了 V3 最关键的设计转向。
四. 最反直觉的改动,Mem0 不再自动修改旧记忆了
如果你看过 Mem0 较早版本的介绍,可能会记得它有四种自动操作。
ADD、UPDATE、DELETE 和 NONE。
旧算法通常需要两次 LLM 调用。第一次从对话中提取候选事实,第二次拿候选事实和已有记忆比较,再决定新增、更新、删除还是保持不变。
听起来很聪明。
用户以前喜欢咖啡,现在不喜欢了,那就更新旧记忆。用户明确推翻一条信息,那就删掉。
但这里藏着一个很危险的假设。
它假设相互冲突的信息里,只有一条是真的。
现实经常不是这样。
「用户住在北京」和「用户住在杭州」可能分别描述不同时间。旧地址没有错,它只是已经过去了。用户以前喜欢跑步,受伤后暂时停止,现在又重新开始,这也不是简单的真假切换。
如果提取阶段过早覆盖,历史轨迹和变化原因就一起消失了。
所以当前 V3 做了一个非常大胆的简化。
只 ADD。
flowchart LR
subgraph V2[旧版两阶段流程]
A1[新对话] --> A2[LLM 提取候选事实]
A2 --> A3[LLM 比较已有记忆]
A3 --> A4[ADD]
A3 --> A5[UPDATE]
A3 --> A6[DELETE]
A3 --> A7[NONE]
end
subgraph V3[当前单阶段流程]
B1[新对话加相关旧记忆] --> B2[一次 LLM 提取]
B2 --> B3[只产生 ADD]
B3 --> B4[新旧事实共同保留]
end
当前系统提示词开头写得很直接,提取器的唯一操作就是 ADD。
仓库里仍然保留着旧的更新判断 Prompt,add() 的部分说明也还写着自动添加、更新和删除。只看这些残留,很容易误判当前行为。
但运行链路和测试已经很明确,V3 自动提取只会返回 ADD。UPDATE 和 DELETE 仍然存在,但必须由应用显式调用 update()、delete() 或 delete_all()。
4.1 少一次 LLM 调用,直接减少一次模型请求
少一次 LLM 调用,能够直接确认的收益是成本和延迟。
少掉的是旧版第二阶段的「记忆操作判断」。旧版先调用一次 LLM,从对话中提取候选事实;随后再调用一次 LLM,把候选事实与相关旧记忆放在一起,逐条决定应该 ADD、UPDATE、DELETE 还是 NONE。
V3 只保留一次提取调用。新消息、最近上下文和相关旧记忆会一起交给 LLM,模型直接输出值得新增的 ADD-only 记忆,不再执行第二次操作判断。
从调用链上可以直接确认,每批写入少了一次远程模型请求。实际能省多少时间和费用,取决于所选模型、Provider、网络和 Prompt 长度,开源版没有一个对所有配置都成立的固定数字。
ADD-only 更可靠的判断来自设计逻辑。LLM 漏提一条信息很糟糕,但错误删除一条正确历史通常更难恢复。它宁可留下少量冗余,也不愿让模型在证据不足时改写过去。
先保留,再判断。
4.2 代价也非常真实
新旧事实都保留,记忆数量一定会持续增长。
用户搬过三次家,系统里可能有三条地址相关记忆。查询「用户住在哪里」时,如果检索只看语义相似度,三条都很像,旧地址甚至可能排在最前面。
当前 OSS 的混合评分没有显式加入新近度,也没有自动维护哪条事实代表当前状态。
所以 ADD-only 不是免费午餐。
它把一部分复杂度从写入端挪到了读取端和应用自己的治理逻辑。当前开源版不会自动标记哪条旧事实已经过时,如果业务只想读取当前状态,就需要自己维护版本、显式更新或删除。
五. 记得住不等于想得起来
写入解决什么值得保留,搜索解决此刻应该想起什么。
这两个问题看着像一回事,其实完全不同。
假设系统里已经积累了几万条记忆,用户问「Alice 在 Orion 项目里负责什么」。这里同时包含实体 Alice、精确名称 Orion,以及一个语义问题负责什么。
只靠一个向量相似度,很难把所有信号都处理好。
所以 Mem0 V3 把搜索改成了多信号融合。
5.1 语义搜索负责理解大概在问什么
查询会先被转换成 Embedding,主向量存储返回语义上最接近的候选。
这是整个检索的底座。
用户问「他平时怎么锻炼」,系统有机会找到「用户每周跑步约四十公里,并且每周进行三次力量训练」。两段文本没有共享太多字面词汇,但表达的是同一主题。
当前代码会过量召回候选,内部数量取 top_k 的四倍和 60 中更大的值。这样后面的关键词和实体信号才有重新排序的空间。
5.2 BM25 给精确词命中的候选加分
向量搜索擅长判断两段话的意思像不像,却不一定重视一个具体字符串。
假设记忆里保存着「用户的工单编号是 INC-8472,使用 ThinkPad X1 Carbon Gen 12」。用户直接搜索 INC-8472 时,真正重要的是这个编号有没有原样出现,而不是两段文本在语义上是否相近。
BM25 就负责补这类信号。候选记忆里越集中地出现查询中的关键词,分数越高。
Mem0 还会先做词形还原。所谓词形还原,就是把时态、单复数等不同形式还原成同一个基础词。例如 attended 和 attending 都可以还原成 attend。否则对主要依赖字面词匹配的 BM25 来说,它们很可能会被当成三个不同的词。
当前 OSS 的词形还原主要依赖英文 spaCy 模型,所以这项增强更偏向英文。中文没有英语式的时态变化,还涉及分词、简称和实体识别等不同问题,不能直接套用同一套效果预期。
BM25 具体怎么执行,并不是 Mem0 自己全部包办的,而是交给配置的 Vector Store。
Mem0 默认使用 Qdrant。Qdrant 是一个独立的开源向量数据库,可以理解成专门保存向量并执行相似度搜索的存储系统。它不是 Mem0 的内部模块,只是 Mem0 默认集成的存储后端。直接使用默认配置时,qdrant-client 会以本地模式把数据放在 /tmp/qdrant;生产环境也可以连接单独部署的 Qdrant 服务。
使用 Qdrant 时,Mem0 会在保存语义向量的同时,再保存一份用于 BM25 的稀疏向量,并通过 fastembed 把文本转换成这种关键词表示。没有安装 fastembed 时,Qdrant 路径的 BM25 会被关闭。其他 Vector Store 可能使用自己的全文检索,也可能完全不支持关键词搜索。
所以同样调用 Memory.search(),换一个 Vector Store 后 API 看起来没变,实际参与排序的信号可能已经少了一路。
等关键词分数拿回来后,还有一个问题。向量相似度和 BM25 分数不是同一种尺度,不能直接相加。代码会用 Sigmoid 把 BM25 压到一个稳定范围,再和语义分数融合。查询越长,归一化参数也会跟着调整,避免单纯因为词更多就天然拿到高分。
5.3 Entity Store 把同一个对象的记忆串在一起
语义和关键词之外,还有一类查询很常见。
用户可能直接问「Alice 最近在负责什么」。系统里关于 Alice 的记忆分散在不同时间,内容分别在讲入职、项目、会议和旅行。它们的主题并不相同,唯一稳定的线索就是 Alice 这个人。
Mem0 会把 Alice 这类具体对象称为实体。人名、公司名、地点、产品和项目名都可以是实体。
实体主要由 spaCy 提取。spaCy 是一个开源的自然语言处理库,可以从文本中识别人名、组织、地点等信息。spaCy 本身提供中文模型,但当前 Mem0 OSS 没有使用它,代码把实体提取模型固定成了英文的 en_core_web_sm。所以这里描述的是英文场景下的主要能力,不能直接推断中文实体也有相同效果。
在 spaCy 的识别结果之外,Mem0 还补了一些规则,用来寻找引号中的名称、主题短语和代码里的标识符。
写入记忆时,Mem0 会把提取出的实体单独保存,并记录每个实体出现在哪些记忆里。这个独立索引就是 Entity Store。
假设系统里有两条记忆。
Alice 加入了 Acme,负责企业客户。
Alice 后来接手 Orion 项目的 Q1 路线图。
Mem0 会记下 Alice 同时关联这两条记忆,Acme 关联第一条,Orion 关联第二条。以后查询中再次出现 Alice,与她关联的两条记忆都会获得额外加分。
flowchart LR
M1[记忆 Alice 加入 Acme] --> A[实体 Alice]
M1 --> C[实体 Acme]
M2[记忆 Alice 负责 Orion] --> A
M2 --> O[实体 Orion]
Q[查询 Alice 最近负责什么] --> A
A --> B[给 M1 和 M2 加分]
如果一个实体关联了成千上万条记忆,它提供的区分度就会降低。「Python」可能到处出现,「Orion」却只属于某个具体项目。当前代码会降低高频实体的权重,避免一个过于常见的名字压过其他信号。
这套机制只会在默认的 infer=True 路径中建立。infer=False 写入原文后会直接返回,所以原样保存的内容可以参加语义搜索,却不会自动进入 Entity Store。
这里也要避免一个误解。Entity Store 不是知识图谱。
它知道 Alice 出现在哪些记忆里,却不知道 Alice manages Bob 中的 manages 是什么关系,也不会沿着关系进行多跳推理。它做的事情更朴素,找到查询里的具体对象,再给提到这个对象的记忆加分。
5.4 三路信号最后怎么汇合
到这里,当前开源版的三路检索信号就齐了。
语义搜索负责判断意思像不像,BM25 负责精确词是否出现,Entity Store 负责查询在谈哪个具体对象。
flowchart TD
A[查询进入] --> B[语义搜索生成候选集]
A --> C[计算关键词分数]
A --> D[查找相关实体]
B --> E[候选记忆]
C --> F[关键词加分表]
D --> G[实体加分表]
E --> H[融合三路分数]
F --> H
G --> H
H --> I[截断到 Top K]
I --> J[可选 Reranker]
J --> K[返回最终结果]
图里最关键的是,候选集只由语义搜索产生。BM25 和 Entity Store 提供的是加分表,只能改变已有候选的顺序,不能把语义搜索完全没找到的记忆重新捞进来。
5.5 最终分数并不是简单相加
当前 OSS 的混合评分大致可以写成下面这个形式。
$$
finalScore = \frac{semantic + bm25 + entityBoost}{maxPossibleScore}
$$
哪种信号存在,分母就加入对应的最大可能值,再把结果限制在 0 到 1 之间。
这里还需要解释 threshold。它是调用 search() 时设置的最低相关度门槛,当前默认值是 0.1。低于门槛的结果不会返回。
容易误解的地方是,这个门槛检查的不是三路信号融合后的最终分数,而是融合前的语义分数。
假设一条记忆的语义分数是 0.08,低于默认门槛 0.1。即使它因为包含完整工单号而拿到很高的 BM25 分数,代码也会先把它丢掉,根本不会走到后面的分数融合。只有语义分数先通过门槛,关键词和实体加分才有机会影响排序。
如果调用方传入 threshold=0.0,就相当于关闭这层语义分过滤。
结合上一节可以看到,语义分数在这里有两道决定权。它既决定一条记忆能不能进入候选集,又决定候选能不能通过门槛。因此当前 OSS 更准确的描述,是 semantic-gated hybrid ranking,也就是由语义搜索控制入口的混合排序。
语义召回控制入场名单,关键词和实体信号负责调整座次。
5.6 Reranker 对候选记忆做第二轮细读
前面的三路信号都在追求一件事,快速从大量记忆中筛出一小批候选。
速度快的代价,是判断比较粗。Embedding 会把 Query 和每条记忆分别编码成向量,记忆向量可以提前算好并反复使用。搜索时只需要比较向量距离,几万甚至更多记忆也能很快筛选。
但两个向量之间的距离,很难理解所有细微关系。
假设用户问「我现在更喜欢靠窗还是过道座位」。候选里一条写着「用户过去偏好过道座位」,另一条写着「用户腿伤恢复后,从过道改回了靠窗座位」。两条都包含座位偏好,语义分数可能很接近。真正回答问题,需要模型同时理解「现在」「过去」和「改回」之间的关系。
Reranker 就是第二轮更精细的评审。它不再面对整个记忆库,只拿第一轮已经找出的少量候选,把原始 Query 和每条候选记忆放在一起重新打分,然后按照新分数排序。
flowchart LR
A[Query] --> B[第一轮快速检索]
B --> C[少量候选记忆]
C --> D[Query 与候选逐条配对]
D --> E[Reranker 重新评分]
E --> F[按新分数排序]
这种「放在一起读」的模型通常叫 Cross-Encoder。与可以提前保存记忆向量的 Embedding 不同,Cross-Encoder 每次查询都要重新计算 Query 和候选的组合,所以更准确,也更慢。
Mem0 OSS 支持几类 Reranker。
| 类型 | Provider | 特点 |
|---|---|---|
| 本地 Cross-Encoder | Sentence Transformer、HuggingFace | 数据不离开本机,但需要下载模型并消耗 CPU 或 GPU |
| 远程 Rerank API | Cohere、Zero Entropy | 接入简单、效果稳定,但会增加网络延迟和 API 费用,候选文本也会发送给第三方 |
| 通用 LLM 评分 | LLM Reranker | 可以用 Prompt 定义相关性,理解能力强,但当前实现会逐条调用 LLM,成本最高 |
使用时需要两步。创建 Memory 时先配置一个 Reranker,再在查询时显式传入 rerank=True。只设置查询参数却没有配置 Provider,不会产生效果。当前默认值也是 False,因为并不是每次查询都值得承担这笔成本。
Reranker 更适合这些场景,候选文本非常相似、第一名是否正确很关键,或者 Query 带有否定、条件和状态变化。查询一个精确工单号时,BM25 往往已经够用,再加 Reranker 可能只是增加延迟。
它还有一个非常重要的边界。
当前 OSS 会先完成混合评分并截断到 top_k,然后才调用 Reranker。假设 top_k=20,真正相关的记忆排在初筛第 21 名,Reranker 根本看不到它。
所以 Reranker 改善的是排序精度,也就是已经找出的候选谁应该排在前面。它不能提高召回率,第一轮完全没有找到的记忆,第二轮无法凭空找回来。
重排完成后,结果会增加 rerank_score,原来的混合 score 仍然保留。调用失败时,Mem0 会记录 warning 并返回原始排序,不让整个搜索请求因为第二轮模型不可用而失败。
六. 三套存储,组成了一段完整记忆
当前 OSS Python 代码实际涉及三个独立的持久化面。主 Vector Store 同时保存 Embedding、记忆文本和 payload。SQLite 保存 ADD、UPDATE、DELETE 历史以及最近消息窗口。Entity Store 则是同一种 Vector Store Provider 创建的第二个集合。
flowchart LR
M[Memory 主流程] --> V[主向量集合]
M --> S[SQLite]
M --> E[Entity 向量集合]
V --> V1[记忆文本]
V --> V2[Embedding]
V --> V3[身份与 metadata]
S --> S1[History 审计]
S --> S2[最近消息窗口]
E --> E1[实体文本与向量]
E --> E2[关联 memory IDs]
这套设计把不同访问模式拆开了。
语义搜索交给向量库,历史审计交给 SQLite,实体匹配交给独立集合。替换主 Vector Store 时,History 的格式不需要跟着变化。
但它也带来一个生产上的老问题。
三个存储之间没有事务。
主向量、History 和 Entity Store 中任何一步都可能部分失败,系统不会统一回滚。更麻烦的是,主向量批量写入失败后,逐条重试仍然可能失败,但对应记录还可能继续进入 History、Entity Store 和返回结果。
所以极端情况下,接口告诉你一条记忆已经 ADD,History 里也有记录,主向量集合里却找不到它。实体增强失败通常只记录 warning,反方向的不一致同样可能发生。
这是可用性优先于强一致性的选择。
对于个性化推荐,也许可以接受。对于医疗过敏、交易状态和权限控制,就不能把它当成唯一事实源。
6.1 可插拔很爽,但能力并不完全等价
当前 Factory 注册了大量 Provider,LLM、Embedding、Vector Store 和 Reranker 都能替换。OpenAI、Anthropic、Gemini、Ollama、Qdrant、Pinecone、Milvus、PGVector、Redis 等都在支持范围里。
这也是 OSS 最有吸引力的地方。
你可以把模型和数据放在自己的基础设施里,也可以根据成本和延迟组合不同 Provider。
可插拔不代表完全等价。
有的 Vector Store 支持 BM25,有的只能退化到语义搜索。有的支持复杂逻辑过滤,有的只实现简单相等。有的批量搜索是真批量,有的只是在循环里逐个调用。
Embedding 维度和 Vector Store 维度也缺少统一的中央校验,配置不匹配时,往往要等到插入或搜索才报错。
所以 Mem0 把组件接口统一了,但没有办法消除所有后端语义差异。
七. Mem0 和普通 RAG,到底差在哪里
聊到这里,Mem0 看起来用了很多 RAG 熟悉的技术。
Embedding、向量搜索、BM25、Reranker,一个都不少。
那它为什么不只是另一个 RAG 框架?
我自己的理解是,它们关注的数据对象和生命周期不同。
| 方案 | 写入方式 | 读取方式 | 更适合解决 |
|---|---|---|---|
| 全量聊天历史 | 保存原始消息 | 每轮全部重放 | 短会话连续性 |
| 对话摘要 | 周期性压缩 | 注入最新摘要 | 控制上下文长度 |
| 普通向量 RAG | 文档切块与 Embedding | 相似度检索 | 稳定知识库问答 |
| Mem0 | 从交互中提取动态事实 | 多信号检索与作用域过滤 | 跨会话个性化与 Agent 状态 |
传统 RAG 主要回答,哪份资料能帮助解决当前问题。
Mem0 更关心,这个用户、这个 Agent、这次任务过去发生过什么,此刻应该想起哪一部分。
知识库里的产品手册通常不会每天改变。
用户却会搬家、换工作、改变偏好、完成计划、推翻自己三个月前的决定。
所以个人记忆天然需要时间、身份、历史版本和删除治理。它不是单纯的文档检索。
当然,边界也没有那么绝对。Mem0 的 Prompt 现在甚至会从用户分享的文档和结构化材料中提取事实,RAG 也可以加入用户画像和时间过滤。
两者正在互相靠近。
真正的区别不在某个算法名词,而在系统有没有把记忆从写入、读取、演化到治理整套串起来。
八. 一次完整对话,Mem0 到底经历了什么
前面拆了很多模块,现在把它们重新放回一次完整对话里。
假设 Alice 问 AI,「我下次坐飞机应该选什么座位」。在生成回答之前,应用先用这句话调用 search(),并通过 user_id 把搜索范围限定在 Alice 的记忆里。
语义搜索负责找出与出行和座位相关的候选,BM25 给精确词命中的候选加分,Entity Store 则补充与 Alice 或具体项目相关的实体信号。三路分数融合后,通过阈值过滤并截断到 Top K。如果启用了 Reranker,候选还会再经过一轮细读和重新排序。
应用把最终选中的少量记忆放进 Prompt,回答 LLM 才能知道 Alice 过去偏好靠窗,或者最近因为腿伤改成了过道。
回答生成后,应用再把本轮用户消息和最终回复交给 add()。Mem0 会用完整本轮消息生成 Embedding,搜索 10 条相关旧记忆,同时读取最近 10 条消息帮助理解指代。提取 LLM 随后把这些内容整理成 ADD-only 记忆。
新记忆经过语义去重、MD5 精确去重和 Embedding 后,写入主 Vector Store。History 记录这次 ADD,实体抽取结果进入 Entity Store,最近消息则保存到 SQLite,供下一轮提取使用。
flowchart TD
U[用户提出新问题] --> S[按 user_id 调用 search]
S --> V[语义搜索产生候选]
S --> B[BM25 关键词加分]
S --> E[Entity Store 实体加分]
V --> F[融合排序]
B --> F
E --> F
F --> R[可选 Reranker]
R --> P[应用把少量记忆放进 Prompt]
P --> L1[回答 LLM 生成回复]
L1 --> A[向用户返回回答]
L1 --> ADD[add 用户消息和最终回复]
ADD --> Q[完整本轮消息生成 Embedding]
Q --> O[搜索 10 条相关旧记忆]
ADD --> K[读取最近 10 条消息]
O --> L2[提取 LLM]
K --> L2
ADD --> L2
L2 --> D[语义去重与 MD5 去重]
D --> VS[写入主 Vector Store]
D --> H[写入 History]
D --> ES[更新 Entity Store]
D --> M[保存最近消息]
VS -. 下一轮搜索 .-> S
ES -. 下一轮实体加分 .-> S
整条链路可以压缩成四个记忆点。
| 记忆点 | 需要记住什么 |
|---|---|
| 先读后写 | 回答前 search(),回答后 add() |
| 写入不是存原文 | 默认由提取 LLM 把对话蒸馏成长期事实 |
| 检索由语义控制入口 | BM25、实体和 Reranker 主要负责调整已有候选的顺序 |
| ADD-only 保留历史 | 当前状态、清理和删除仍然需要应用治理 |
九. Mem0 仍然绕不开的几个问题
一个项目真正有价值,不代表它没有问题。
坦率的讲,我反而觉得 Mem0 最值得研究的地方,就是它把很多记忆系统绕不开的难题直接暴露了出来。
9.1 LLM 提取天然是有损的
默认写入依赖 LLM。
模型可能漏掉事实、理解错否定、混淆人物,也可能把一句临时表达过度总结成长期偏好。
Prompt 写得再详细,也只能降低风险,不能让提取变成确定性数据库操作。
所以对于关键事实,应用应该保留来源和审计能力,而不是只保存提取结果。
9.2 去重不是一个已经解决的问题
当前 OSS 除了让 LLM 根据相关旧记忆做语义去重,还会用 MD5 比较文本是否完全相同。
Hash 可以挡住一模一样的文本,却挡不住同义改写。更何况它只比较这一轮召回的相关旧记忆,不是给整个用户记忆库建立全局唯一约束。
9.3 Prompt 设计和落库还没有完全对齐
读到这里,我们已经知道 OSS 使用 Entity Store 给相关记忆加分。再看源码里另一个细节,就比较容易理解了。
V3 Prompt 会要求 LLM 在输出中附带 linked_memory_ids,表达一条新记忆与哪些旧记忆相关。但当前 OSS 主链路实际只读取 text 和 attributed_to,没有把这组 memory-to-memory links 写入主记忆。
所以,当前 OSS 搜索真正使用的关联,来自本地实体抽取和 Entity Store,而不是 LLM 返回的记忆关系。这不影响基础的新增和语义搜索,但如果应用期待得到一条可以追踪的记忆演化链,当前开源实现还没有完整接住 Prompt 已经设计出来的这部分数据。
9.4 ADD-only 会让存储不断膨胀
只追加可以保护历史,也会让检索候选越来越多。
OSS 支持给记忆设置 expiration_date。过期内容会从 search() 和 get_all() 的普通结果中隐藏,但按 ID 调用 get() 仍然可以取到,而且底层记录不会自动删除。真正要降低存储量,仍然需要显式调用删除接口,或者在应用侧实现清理与归档。
如果不做治理,长期记忆最终很可能从「帮助模型想起来」变成「给模型制造更多相似答案」。
9.5 中文和多语言能力需要重新评测
当前 OSS 实体抽取与 BM25 词形还原主要依赖英文 en_core_web_sm。
没有 spaCy 时,系统会优雅降级,不影响语义搜索。但中文实体、人名、复合词和分词效果,不能照搬英文评测结论。
如果业务主要是中文,至少应该用自己的真实查询集重新测召回率,而不是看英文 Benchmark 下结论。
9.6 记忆系统不应该替代业务数据库
用户喜欢靠窗,可以放进 Mem0。
用户当前账户余额、是否有退款权限、订单是否支付成功,不应该只存在 Mem0。
前者允许模糊召回和软排序,后者需要确定性、一致性和严格审计。
同样的道理,医疗过敏、合规状态和访问权限,可以把记忆作为辅助上下文,但最终判断必须回到权威数据源。
9.7 隐私不是装完 SDK 就自动解决
Mem0 的设计目标就是以后能把信息搜出来。
所以密码、密钥、未脱敏身份信息一旦被写入,就不该指望系统替你自动忘掉。默认 OSS 配置还会调用外部 LLM 和 Embedding 服务,查询和记忆可能经过第三方 Provider。
选择 OSS 只能让你有能力控制数据边界,不代表默认配置已经完成了隐私治理。
该不该记,永远应该在怎么记之前。
十. 总结,记忆不是保存过去,而是在当下选择过去
回到最开始那个问题。
为什么一个能读百万字的大模型,还是记不住你不吃香菜?
因为能读多少,和应该记住什么,本来就是两件事。
上下文窗口解决的是一次推理能看到多少材料。长期记忆解决的是跨过一天、一个月甚至一年以后,系统还能否找到真正有用的那一小部分过去。
拆完当前 OSS 源码后,Mem0 做的事情其实可以概括得很简单。它在写入时从对话中提取长期事实,在读取时用语义、关键词和实体信号选出少量相关记忆,再把这些记忆交回应用和回答模型。
它没有让大模型本身获得永久记忆,而是在模型外面建立了一套可以持续写入和按需读取的外部记忆系统。
Mem0 OSS 当前的设计也越来越像一种人工记忆过程。
Extraction 决定记住什么,语义、关键词和实体信号共同决定想起什么,History 留下修改痕迹,显式的 update 和 delete 把最终治理权交回应用。
它当然还远远不等于人的记忆。
但这个方向很有意思。
过去我们总想着给模型装一个更大的硬盘,把所有东西都存下来。现在真正困难的部分逐渐显现了,智能不只需要保存,还需要选择、更新、联想、遗忘和承认不知道。
所以我现在越来越觉得,一套好的 AI 记忆系统,不应该追求记得越多越好。
它应该做到一件更难的事。
在此刻,想起正确的那一件。





