Mem0 深度拆解:一次 add 和 search 背后的记忆流水线
从事实提取到候选召回与评分,沿着源码追踪一条记忆,解释 ADD-only、去重边界,以及“关键词命中却搜不到”的原因。
博客本文目录
Memory Agent 深度拆解 · 第 1 / 8 篇。查看系列目录。
接入长期记忆,表面上只要两步:回答前查一次,回答后存一次。
但如果用户说“我现在住上海”,下次助手依然推荐杭州的餐厅,问题究竟出在哪里?没提取出来、没写进去、没检索到,还是找到了却排在后面?
Mem0 值得拆,因为它把这些环节放进了一条可以跟踪的流水线。看懂 add() 和 search(),比记住一个跑分更有用。
本文依据 2026 年 9 月 22 日读取的 Python OSS 源码,固定提交为 a39a802bbc93。只讨论这份实现及公开说明,未做部署或性能复测。
1. 先看对象:一条记忆到底存了什么?
在当前实现中,事实文本进入向量存储的 payload,同时附带标识、文本哈希、时间和作用域等信息。实体匹配还会维护另一套实体记录,用于关联记忆。源码:Memory
可以把一条记录想成下面的结构。它是帮助理解的示意,不是完整返回协议:
id 这条记忆的标识
data 提取后的事实文本
user_id 属于哪个用户
agent_id/run_id 可选的 Agent 或运行范围
hash 文本去重线索
created_at 系统创建时间
attributed_to 可选的事实归属
expiration_date 可选的到期日期
这里有两个不同层次的身份:记忆记录的 ID,和它属于谁。前者用来定位、编辑记录,后者决定检索范围。应用不能让用户随意提交别人的 user_id,再指望向量库替自己完成权限判断。
还有一个单独的历史与消息存储层,用于记录操作历史和近期消息。它与向量存储并不是同一个对象。调试时只查向量库,会漏掉提取所使用的上下文。源码:历史与消息存储
2. 一次 add:先给模型上下文,再让它提取
正常的 infer=True 路径,大致经过六步:
校验作用域 → 获取近期消息与相关旧记忆 → LLM 提取 → 批量 embedding → 去重与持久化 → 实体关联。
一个容易被忽略的细节是,提取模型并非只看到本次传入的句子。实现会读取同一作用域下的近期消息,并检索相关旧记忆,为本轮提取补足上下文。
这能帮助理解“还是上次那个项目”“现在改成周五”等省略表达,也能减少重复提取。但它同时意味着:记忆提取质量受前置检索影响。旧事实没有进入候选上下文,模型未必知道它已经存在。
当前路径使用一次 LLM 调用输出新事实,然后批量生成向量。批量 embedding 失败时,会尝试逐条处理;一些无法生成向量的记录会被跳过。进入持久化的记录也会经历批量写入与逐条回退。源码:_add_to_vector_store
因此,“单次提取调用”不等于“整个请求只调用一次外部服务”,更不等于只产生一份模型费用。旧记忆查询、文本 embedding、实体 embedding,都应纳入写入成本。
3. 为什么当前算法改为 ADD-only?
旧路线会提取候选事实,再判断应该新增、修改还是删除。当前自动提取采用 ADD-only,保留新事实,让新旧记录并存。官方迁移说明
“住杭州”和“7 月搬到上海”因此可以同时存在。我的理解是,这减少了写入时做破坏性覆盖的需要,但把更多判断留给了后续检索与回答。
这里的“只新增”描述自动提取策略,不表示管理接口从此不能修改、删除。
去重也需要讲清楚边界:这段代码使用文本哈希,比较当前批次和本次检索到的旧记录。它能拦截这些范围内的完全相同文本,但不能据此声称“所有历史记忆都实现了全局语义去重”。“喜欢喝不加糖的咖啡”和“咖啡不要糖”是否合并,还取决于提取模型与候选上下文。
4. 混合检索里,谁有资格参与排名?
读取路径会处理查询词、生成查询向量、获取语义结果、请求关键词结果,再计算实体加分。听起来像三路结果汇合,但源码有一个关键约束:交给评分器的候选记录,来自语义检索结果。
当前 Python 路径先超额取回 max(top_k × 4, 60) 条语义候选。关键词分数和实体分数按 ID 为这些候选加分,并不是把所有关键词命中的记录自动加入最终候选池。源码:检索入口
这件事直接影响排障:
假设一条故障记忆含有罕见错误码,关键词精确命中,但它根本没进入语义候选池。仅提高关键词得分,并不能让它凭空进入最终结果。
所以,检索没命中时先问“有没有入围”,再问“排名为什么低”。这是两个问题。
5. 分数公式透露了什么?
评分函数先用阈值过滤语义分数,再将语义、归一化的关键词分数和实体加分相加,除以当前活跃信号的最大可能总分。BM25 原始分数经 sigmoid 归一化,参数会随查询词数变化。源码:评分函数
用一组假设数据说明:语义分数为 0.6,关键词分数为 0.8,实体加分为 0.5,三种信号都有时,组合分数是 1.9 / 2.5 = 0.76。这只是公式演示,不是实测结果。
更关键的是先后顺序。若语义分数已经低于阈值,即使关键词或实体很匹配,也会先被排除。
explain=True 能返回各部分分数。实际排障时,我会先留存这些解释字段,而不是只看一个最终 score。模型、分词或存储后端一换,信号分布也可能变化,旧阈值没有天然可比性。
6. 时间能力与图能力,要逐项看实现
当前 OSS 的 add() 不接受非空 timestamp,search() 不接受非空 reference_date,会将这些参数标为 Platform 能力。OSS 支持的到期过滤,也不能直接等同于完整的历史时间推理。源码:接口参数检查
因此,把时间写进 metadata,和拥有理解“去年搬家之前”的检索算法,是两回事。自建应用需要明确:时间由文本表达、业务字段过滤,还是额外的检索逻辑来处理。
图记忆也一样。当前 OSS 已移除外部图存储集成,实体辅助匹配并不意味着可以遍历任意关系图。托管平台的图能力和基准成绩,不应自动归属于开源包。官方仓库与能力说明
7. 成功返回之后,还需要确认什么?
读源码时,我会特别关注失败路径。
这里的 LLM 调用异常会抛出错误;一些输出解析失败则会变成空提取结果。批量写入、历史写入也有各自回退逻辑。它们说明这是一条多阶段流水线,不能把返回体简单理解成跨多个存储的原子事务证明。
这是基于控制流的风险判断,本文没有故障注入结果,不能把它写成“已经复现某个生产故障”。真正的验证方法是主动制造超时、单条写入失败和重试,再核对返回记录、向量库、历史表与下一次查询。
我会为接入准备四组样本:重复表达、罕见错误码、新旧事实并存、两个用户的相反偏好。分别观察提取结果、候选池、解释分数和最终答案。只测最终答案,很难知道是哪一层在帮忙,又是哪一层在掩盖错误。
8. 适合谁,什么时候需要补一层?
对已有对话产品,Mem0 提供了比较清晰的事实记忆接入面。你可以围绕 add/search 建立观测,逐步改善提取提示、作用域、召回与上下文预算。
如果需求高度依赖关系遍历、严格的历史状态查询,或对每条记忆的事务一致性有特别要求,就要继续评估现有 OSS 路径能覆盖多少,哪些需要应用承担。
我从 Mem0 学到的最实用的一点是:记忆效果可以拆成一串可检查的中间结果。 当助手再次忘记用户住在哪里,不必先换模型。先看看那条事实究竟在哪一步消失了。
系列阅读
下一篇:Graphiti 深度拆解。