代码基线:文中「mem0」指 mem0ai/mem0,本地克隆锁定 commit 4debc58。所有 file:line 引用均以该克隆为准,路径前缀省略为仓库根。


引言

mem0 用两个 API(add / search)把记忆生命周期收敛成一个「加法系统」:写入侧是单次 LLM 调用、纯 ADD 提取(ADDITIVE_EXTRACTION_PROMPT 明确「Your sole operation is ADD」),靠哈希去重避免重复;检索侧用语义、关键词、实体三路信号加性融合,并让阈值在融合前拦截低分候选。它的简化姿态是「记忆只累积、不自动改写」——修订交给显式 update()/delete() API 与过期时间,新鲜度靠检索侧的时间过滤而非写入侧的覆盖写。

本文用 mem0 的真实代码回答一个问题:一个通用记忆层,在工程上到底把哪些事情做掉了,哪些事情留给了你? 路线:定位与能力边界(§1)→ 最小接口 add/search 与作用域治理(§2)→ v3 写入管线(§3)→ 存储与记忆对象(§4)→ 多信号检索(§5)→ 修订与边界(§6)→ 评测、对照与可迁移经验(§7)。


1. 从「记忆层」概念到 mem0:定位与能力边界

1.1 mem0 把「记忆层」做成什么形态

上一篇我们把「记忆层」定义为:位于 LLM 与外部存储之间、负责「记住 → 组织 → 召回」的基础设施。mem0 是这一概念最直白的开源实现之一——README 的 Introduction 把它定位为 AI 助手 / Agent 的记忆层(memory layer),核心卖点是三件套:给 LLM 追加记忆、检索相关记忆、管理记忆的持久化。

它提供的记忆是多级作用域的:记忆按 user_id(用户级)、agent_id(Agent 级)、run_id(会话/运行级)三套身份隔离,形成「User / Session / Agent」三种粒度。这一点在 add() 的签名上直接可见——三个 id 都是命名参数,至少传一个(mem0/memory/main.py:755-768)。

代码的入口非常收敛:一个 Memory 类(main.py:482)加一个 MemoryConfig(mem0/configs/base.py:29)。MemoryConfig 聚合五类配置:vector_store(向量库)、llm(提取用模型)、embedder(嵌入模型)、history_db_path(默认 ~/.mem0/history.db,SQLite 历史库)、reranker(可选重排器),外加两个行为开关:version(默认 "v1.1",见 base.py:50-53)与 custom_instructions(自定义提取指令,base.py:54-57)。

也就是说,一个 Agent 接上记忆层的「最小仪式」就是:

from mem0 import Memory
m = Memory.from_config(config_dict)   # 指定 vector_store / llm / embedder
m.add(messages, user_id="u1")          # 写入(add 接受顶层 user_id)
hits = m.search("他喜欢什么编程语言", filters={"user_id": "u1"})  # 检索(search 必须走 filters)

它在整个 Agent 架构中的位置(图 1):

图 1|记忆层在 Agent 架构中的位置

mem0 记忆层的四个下游组件
mem0 记忆层的四个下游组件

图注:四个下游组件各司其职——提取器负责「对话 → 事实」、嵌入器负责向量化、向量库负责检索、SQLite 负责可追溯历史;user_id / agent_id / run_id 三个作用域 id 贯穿写入与读取。图上没有画出的「Temporal Reasoning」等平台能力,就是第 1.2 节对照表里 ❌ 的那一行——它们不属于 OSS 记忆层,属于托管平台。

四个下游组件各司其职:提取器负责「对话 → 事实」、嵌入器负责向量化、向量库负责检索、SQLite 负责可追溯历史;user_id / agent_id / run_id 三个作用域 id 贯穿写入与读取。图上没有画出的「Temporal Reasoning」等平台能力,就是第 1.2 节对照表里 ❌ 的那一行——它们不属于 OSS 记忆层,属于托管平台。

1.2 能力边界先行:README 声称 vs OSS 实际

写实践文章最怕的坑,是把项目 README 的「能力宣言」当成「开源代码现状」。mem0 恰好是个典型案例:它的 README 在「New Memory Algorithm」一节(2026 年 4 月更新)列了五项新能力,但其中至少一项是托管平台专有的。先把对照表放在这里,后面每节再展开证据:

README 宣称(README.md 相关行)OSS 代码实际状态证据
Single-pass ADD-only extraction✅ OSS 已实现ADDITIVE_EXTRACTION_PROMPT(prompts.py:468)
Agent-generated facts are first-class✅ OSS 已实现_create_procedural_memory(main.py:1988)
Entity linking✅ OSS 已实现写入侧 Phase 7 实体链接(main.py:1081-1185)
Multi-signal retrieval✅ OSS 已实现_search_vector_store 三信号融合(main.py:1623)
Temporal Reasoning❌ 托管平台能力timestamp / reference_date 参数在 OSS 显式标注「Platform-only… Not supported in OSS」并抛错(main.py:782、:812-813、:1416、:1427-1428)

这张表是全文的「能力地图」:读完之后你会知道,OSS 版 mem0 能放心依赖的,是纯 ADD 提取 + 哈希去重 + 三信号检索 + 过期时间过滤;而时间感知检索这类「魔法」属于付费平台,自建时必须自己补。

为什么会出现这种落差?答案藏在上游代码的注释里:ADDITIVE_EXTRACTION_PROMPT 写着「Ported from platform/backend/shared/core/config/prompts.py」(prompts.py:465-466)——OSS 的提取管线是托管平台的下游移植,二者同源;但 README 是平台团队写的,自然以平台能力为全集。于是「同源代码 + 平台级文档」就成了落差的结构性来源。这对所有「开源 SDK + 托管平台」双轨项目都成立:文档描述的能力全集里,总有几项依赖专有基础设施,OSS 侧只能做到「方向性相似」。

1.3 本节小结

mem0 的形态一句话:一个 Memory 类,把「记忆层」收敛成写(add)与读(search)两个入口,外加作用域隔离与配置注入。 它不是一个通用数据库,也不是 RAG 框架——它的全部价值在于「把对话变成可检索事实」这一段管线。而这段管线中 README 声称与 OSS 实际之间的边界,就是接下来三节的正文。

四个核心证据文件,贯穿全文:

文件角色
mem0/memory/main.py同步 API 主实现:Memory.add、Memory.search、Memory.update/delete 及全部内部管线
mem0/configs/prompts.py提取指令:ADDITIVE_EXTRACTION_PROMPT 等
mem0/utils/scoring.py检索侧融合打分:BM25 归一化与 score_and_rank
mem0/memory/storage.pySQLite 历史与消息存储:SQLiteManager

2. 最小接口:add / search 与作用域治理

2.1 写入入口:add()

add() 的签名(main.py:755-768)非常短:

def add(self, messages, *, user_id=None, agent_id=None, run_id=None,
        metadata=None, timestamp=None, expiration_date=None,
        infer=True, memory_type=None, prompt=None):

要点拆开看:

  • messages:str、dict 或 list[dict] 三种形态都会被归一成消息列表(:834-846),每项是 {"role": ..., "content": ...}。
  • 作用域三选一:user_id / agent_id / run_id 至少传一个(docstring :772)。三者可以组合,比如「某个用户的某个 Agent」。
  • metadata:随记忆存入向量库 payload 的自定义字段,检索时可用高级算子过滤(见 2.2)。
  • expiration_date:YYYY-MM-DD 字符串(或 datetime),写入时归一化后塞进 metadata(:823-824),过期的记忆在检索/列表时默认隐藏。这是 OSS 里唯一的「时间」能力。
  • timestamp:⚠️ 传了就抛 ValueError(:812-813),错误信息来自 get_temporal_feature_error_message——「Platform-only temporal parameter. Not supported in OSS」。
  • infer:默认 True 走 LLM 提取;False 则把消息原文原样入库(第 3 节详述,两条写入路径并存)。
  • memory_type:目前只接受 "procedural_memory"(配合 agent_id 走程序性记忆通道,:826-832、:848-857)。
  • prompt:覆盖提取指令的临时参数(优先级高于 MemoryConfig.custom_instructions,:941)。

2.2 读取入口:search()

search() 的签名(main.py:1374-1385):

def search(self, query, *, top_k=20, filters=None, threshold=0.1,
           rerank=False, explain=False, reference_date=None, show_expired=False, **kwargs):
  • query:检索的自然语言查询。
  • top_k:默认 20,返回条数上限。
  • filters:作用域过滤的唯一入口——必须至少包含 user_id / agent_id / run_id 之一,否则直接抛 ValueError(:1452-1456)。此外还支持一套高级元数据过滤算子(docstring :1397-1412):精确匹配、eq/ne/in/nin/gt/gte/lt/lte/contains/icontains、通配 *,以及 AND/OR/NOT 逻辑组合。
  • threshold:最低得分门槛,默认 0.1(第 5 节会看到它「拦在融合前」的精确位置)。
  • reference_date:⚠️ 与 add 的 timestamp 对称,传了就抛错(:1427-1428),OSS 不支持时间点检索。
  • show_expired=True:把已过期的记忆也放进结果(默认隐藏)。
  • explain=True:每条结果附带 score_details(语义分 / BM25 分 / 实体加成 / 融合分),是调试检索质量的利器(:1721-1722)。

2.3 作用域治理的「写宽松、读严格」不对称

这是 mem0 API 设计里一个容易被忽略、但值得记下来的细节:add() 接受顶层实体参数,search() / get_all() 拒绝顶层参数、强制走 filters。

add() 里 user_id 是命名参数;而 search() / get_all() 中若有人传 search(query, user_id="u1"),会被 _reject_top_level_entity_params(:165-172)拦下并报错:

Top-level entity parameters {'user_id'} are not supported in search().
Use filters={'user_id': '...'} instead.

docstring 也把这条规则写得明明白白(:794-797)。为什么这么设计?一个合理的解释是:add() 的调用方通常只有一条写入路径,作用域直接给参数最顺手;而 search() 的 filters 需要承载作用域 + 元数据高级过滤两套语义,混用顶层参数会造成歧义(user_id 在 metadata 过滤里可能是某个字段名)。于是设计者选择「写入宽容、读取严格」,用报错把使用者逼到 filters 这一种形态上,保持读取侧语义单一。这是值得借鉴的 API 纪律。

2.4 两条写入路径:infer 开关

infer 是理解 mem0 行为的关键开关。_add_to_vector_store(:874)第一行就分叉:

  • infer=False(原始 ADD):不做任何 LLM 调用,把每条消息的 content 直接嵌入并入库(:875-909),role、actor_id(消息里的 name)一并记入 metadata,事件标记为 ADD。这条路径是「原文存档」,适合你只想让 Agent 记住原始对话、不想让 LLM 改写语义的场景(比如日志、法律/医疗原文)。
  • infer=True(默认,LLM 提取):进入 v3 提取管线,下节完整拆解。

图 2|两条写入路径:infer 开关分叉

infer=False 与 infer=True 两条写入路径
infer=False 与 infer=True 两条写入路径

图注:infer=False 是「原文存档」路径(适合日志、法律/医疗原文),不做 LLM 改写;infer=True 走第 3 节的 v3 提取管线。两条路径共用同一个向量库 + SQLite 双写。

一句话总结第 2 节:mem0 的最小接口是「写入一个函数、读取一个函数」,作用域靠三选一 id 表达、靠 filters 强制,infer 则给了你「提取入库还是原样入库」的选择权。 接下来进入全文最核心的一节——infer=True 时那条 v3 写入管线到底做了什么。


3. v3 写入管线:单次提取、纯 ADD、哈希去重

3.1 全景:一次 add() 调用发生了什么

add() 的 infer=True 分支最终落到 _add_to_vector_store(main.py:874),代码里赫然写着 # === V3 PHASED BATCH PIPELINE ===(:911)。这条管线把「一条对话变成一批记忆」拆成 8 个阶段,但全程只调用一次 LLM:

图 3|v3 写入管线:一次 add() 的八个阶段

mem0 v3:一次 add() 的 Phase 0–8
mem0 v3:一次 add() 的 Phase 0–8

图注:整条链路上只有 Phase 2 是 LLM 调用,没有任何 UPDATE / DELETE 分支——这就是「ADD-only」在代码里的样子。提取为空时直接从 Phase 2 短路到 Phase 8。

视觉要点

整条链路上只有 Phase 2 是 LLM 调用,没有任何 UPDATE / DELETE 分支——这就是「ADD-only」在代码里的样子。下面按阶段拆解。

3.2 Phase 0–1:上下文收集与已有记忆召回

Phase 0 上下文收集(:913-916):先按作用域取最近消息作提取上下文:

session_scope = _build_session_scope(filters)
last_messages = self.db.get_last_messages(session_scope, limit=10)

这里取的是 SQLite 里该作用域最近 10 条消息(第 4 节会看到,SQLite 恰好只保留每个作用域最近 10 条——limit=10 不是巧合,是环形缓冲的容量)。

Phase 1 已有记忆召回(:918-933):对这批消息做一次向量检索,top_k=10 召回相关旧记忆,供 LLM 去重参考。有个细节很值得讲:召回结果的 UUID 会被映射成整数 id 再传给 LLM:

# Map UUIDs to integers (anti-hallucination)
uuid_mapping = {}
for idx, mem in enumerate(existing_results):
    uuid_mapping[str(idx)] = mem.id
    existing_memories.append({"id": str(idx), "text": mem.payload.get("data", "")})

注释写的是「anti-hallucination」——LLM 在 JSON 输出里编造一个不存在的 UUID 是常见幻觉,把真实 uuid 藏起来、只暴露 "0"、"1"、"2" 这种短 id,就能让「引用旧记忆」这件事无懈可击:LLM 只能引用我们给它的序号。这是一个小而美的工程技巧。

3.3 Phase 2:单次 LLM 提取(ADD-only 的核心)

提取的系统提示词是 ADDITIVE_EXTRACTION_PROMPT(mem0/configs/prompts.py:468),开头第一句就立规矩:

You are a Memory Extractor — a precise, evidence-bound processor… Your sole operation is ADD: identify every piece of memorable information and produce self-contained, contextually rich factual statements.

几个关键设计点(均来自 prompts.py:468 起的 prompt 正文):

  1. 用户与助手消息都提取:用户消息提取个人事实、偏好、计划、经历;助手消息提取给过的推荐、制定的计划、查到的信息——但助手内容要「以用户视角」转述(如 "User was recommended X"),避免把"我(助手)说了什么"当成记忆主体。
  2. 20 条最近提取记忆是主要去重依据:prompt 里的「Recently Extracted Memories」明确写着 "up to 20"、 "This is your primary deduplication reference — do not re-extract information already captured here"。
  3. 显式列出不提取的类别:泛化的奉承("you seem passionate")、助手通用应答("Sure!")、助手对自身能力的元评论——这些被明确排除。
  4. Existing Memories 只用于去重与链接,不从中提取:如果新消息与旧记忆语义等价且无新增信息,跳过;若相关则把旧记忆 UUID 填进新记忆的 linked_memory_ids。
  5. Observation Date 时间锚:prompt 要求把所有相对时间("yesterday"、"last week")都落到对话发生日(Observation Date),并给了一句很好的理由:"User went to Paris last week is useless 6 months later. 'User went to Paris the week of May 15, 2023' is meaningful forever."

调用侧(main.py:935-964)有几个工程细节:

  • agent 作用域(有 agent_id 无 user_id)时,系统提示词追加 AGENT_CONTEXT_SUFFIX(:936-939),把提取引导到 Agent 语境。
  • 用户提示词由 generate_additive_extraction_prompt 组装(:943),把 Phase 0 的最近消息、Phase 1 的已召回记忆、custom_instructions 一起打包。
  • response_format={"type": "json_object"}(:956)强制 JSON 输出;解析时先剥代码块、容错 JSON 提取(:967-979)。
  • 提取失败 re-raise 而不是静默返回空(:950-964):代码注释交代了历史——旧实现 LLM 失败时返回 [],让上游分不清「LLM 挂了(429/5xx)」和「LLM 没提取出东西」,现在改成抛 LLMError,把失败决策权还给调用方。

3.4 Phase 3–5:批量嵌入与哈希去重

  • Phase 3 批量嵌入(:986-999):embed_batch(mem_texts, "add") 一次嵌入所有提取结果;批量失败则逐个嵌入回退,单条失败只告警不中断。
  • Phase 4/5 哈希去重(:1000-1034):这是「加法系统」能成立的保险丝。对每条提取结果计算 md5(text),与「已有记忆的 hash 集合」和「本批已见 hash 集合」比对,命中即跳过:
mem_hash = hashlib.md5(text.encode()).hexdigest()
if mem_hash in existing_hashes or mem_hash in seen_hashes:
    continue

注意去重键是提取后文本的 md5:只要 LLM 这次提取的句子和上次逐字相同,就绝不重复入库。这很巧妙——它绕开了「语义去重」的复杂度(那是 LLM 的活,prompt 里已经让模型去重了),只用精确哈希兜底,把「LLM 漏了去重」的重复挡在门外。代价是:同义不同文的两条记忆("喜欢喝美式" vs "偏好美式咖啡")不会被哈希拦住,会并存——这被接受,因为并存比丢信息更安全,且语义层面的重复交给检索排序去稀释。

3.5 Phase 6–8:持久化、实体链接与收尾

  • Phase 6 批量持久化(:1040-1079):构造 payload(data、text_lemmatized、hash、created_at/updated_at),vector_store.insert 批量插入,失败逐个回退;随后 batch_add_history 给每条记忆记一条 ADD 事件(:1059-1079)。
  • Phase 7 实体链接(:1081-1185,README「Entity linking」的 OSS 对应物):对新记忆批量提取实体(extract_entities_batch),去重后批量嵌入,到 entity_store 里 search_batch 找已存在实体(语义匹配阈值 0.95);命中就合并 linked_memory_ids,未命中就新建实体记录(:1140-1182)。这条写入侧的实体索引,正是第 5 节检索侧「实体加成」的数据来源——实体关系在写入时建好、检索时按查询实体加分。
  • Phase 8 收尾(:1187-1201):save_messages 存消息(供下次 Phase 0 用),返回 [{"id", "memory", "event": "ADD"}]。

3.6 观察点:docstring 与实现的脱节

add() 的 docstring 至今仍写着(main.py:785-787):

infer (bool, optional): If True (default), an LLM is used to extract key facts from 'messages' and decide whether to add, update, or delete related memories.

但 v3 实现是纯 additive——_add_to_vector_store 里没有任何 update/delete 分支。README「New Memory Algorithm」宣称的转向("no UPDATE/DELETE. Memories accumulate; nothing is overwritten")在代码里已经落地,但 docstring 滞后了。这不是 bug,而是文档欠账;但这是个通用提醒:读开源库 API 文档时,行为以代码为准。 顺带预告:自动管线不做改写,不代表不能改写——修订被挪到了显式 API(update/delete),第 6 节专门讲。


4. 存储与记忆对象:向量库 + SQLite 历史

一条记忆的双存储形态
一条记忆的双存储形态

4.1 一条记忆的最终形态:_create_memory

infer=False 路径与 Phase 6 最终都会调用 _create_memory(main.py:1956-1986),一条记忆的「出生证明」是这样的:

memory_id = str(uuid.uuid4())
new_metadata["data"] = data
new_metadata["hash"] = hashlib.md5(data.encode()).hexdigest()
new_metadata["text_lemmatized"] = lemmatize_for_bm25(data)
self.vector_store.insert(vectors=[embeddings], ids=[memory_id], payloads=[new_metadata])
self.db.add_history(memory_id, None, data, "ADD", ...)

逐项看:uuid4 作唯一 id;md5(data) 作去重指纹(与 Phase 4 同源);text_lemmatized 是词形还原后的文本(如 "likes" → "like"),专供 BM25 关键词检索;created_at/updated_at 自动打 UTC 时间戳。之后双写:向量库收正文与向量(承载检索),SQLite 收一条 ADD 事件(承载可追溯历史)。

4.2 双存储分工:「可检索」与「可追溯」分层

mem0 的存储不是单库,而是两层:

层载体存什么服务什么
检索层可插拔向量库(Qdrant / Chroma / FAISS…)记忆正文 + 向量 + payload metadata语义检索、关键词检索、实体检索
历史层SQLite(SQLiteManager,storage.py:11)history 表(事件流)+ messages 表(原始消息)记忆变更追溯、写入上下文、调试

SQLiteManager(storage.py:11)默认建两张表:history 记录每条记忆的事件流(ADD / UPDATE / DELETE,带 old_memory/new_memory 和 actor_id/role,见 add_history :150-191);messages 记录原始对话消息(save_messages :257)。Memory.history(memory_id)(main.py:1941-1954)可以直接把某条记忆的完整演变史拉出来——这是「可追溯」的直接 API。

4.3 一个容易被忽略的设计:messages 环形缓冲

save_messages 的收尾有一段注释很说明问题(storage.py:279-291):

# Evict old messages beyond the most recent 10 for this scope.

SQLite 的 messages 表每个作用域只保留最近 10 条消息

这跟 Phase 0 的 get_last_messages(limit=10) 正好对上:SQLite 不是消息仓库,而是「最近 10 条」的环形缓冲,只服务于提取时的上下文。想保留完整对话历史?那是你应用层的事,mem0 明确不做。这个「每个组件只做一件事」的分工,比把所有东西都塞进向量库要清醒得多。

4.4 程序性记忆:Agent 生成的事实

README 宣称「Agent-generated facts are first-class」——OSS 对应物是 _create_procedural_memory(main.py:1988)。当 add() 收到 agent_id + memory_type="procedural_memory" 时(入口分支 :848-857),走的是另一条通道:用 PROCEDURAL_MEMORY_SYSTEM_PROMPT(prompts.py:326)让 LLM 把一段对话提炼成「Agent 应当如何行动」的程序性记忆,memory_type="procedural_memory" 入库。这对应上一篇生命周期里的「程序性记忆」(procedural memory)——不是事实("用户喜欢 X"),而是行为规则("用户要求时应该 X")。它是与对话记忆平行的第二类记忆,在检索时同样参与打分。

4.5 本节小结

mem0 的记忆对象是「向量 + payload + SQLite 事件流」三位一体:向量库管检索、payload 管元数据、SQLite 管追溯与上下文。写入侧的"家底"到此讲完,接下来是另一半——记忆怎么被找回来,也就是 README 吹得最狠的「Multi-signal retrieval」。


5. 多信号检索:semantic + BM25 + entity 的加性融合

5.1 为什么只靠向量相似度不够

纯向量检索(semantic search)有两个已知短板:一是专名/缩写这类对语义嵌入不敏感的词,相似度会被稀释(搜 "BERT" 未必召回含 "BERT" 的记忆);二是精确词命中反而可能是强信号(用户搜 "密码重置" 时,含 "密码" 的记忆即便语义向量一般也该排前面)。混合检索(hybrid retrieval)的标准解法是关键词信号(BM25)兜底,而 mem0 在此基础上又加了第三路——实体加成。_search_vector_store(main.py:1623)把这三路信号并行打分、归一化后加性融合。

5.2 检索管线九步拆解

完整流程在 _search_vector_store(main.py:1623-1726),按顺序:

  1. 预处理查询(:1629-1630):查询词形还原(供 BM25)+ 实体提取(供实体加成)。
  2. 嵌入查询(:1633)。
  3. 语义召回 over-fetch(:1636-1639):internal_limit = max(limit * 4, 60)——先超量取候选(top_k=20 时取 80 条),为后续融合留出「陪跑池」。这是混合检索的标准姿势:召回宁可多、排序交给融合。
  4. 关键词召回(:1642-1644):vector_store.keyword_search,用词形还原后的查询。
  5. BM25 归一化(:1646-1654):原始 BM25 得分无界(0–20+),先用 logistic 压到 [0,1]:normalize_bm25 = 1 / (1 + exp(-steepness * (score - midpoint)))(scoring.py:43-54)。sigmoid 参数按查询长度自适应(get_bm25_params,scoring.py:16-40)——查询越长原始 BM25 越高,midpoint 就越大(3 词以内 (5.0, 0.7),15 词以上 (12.0, 0.5))。
  6. 实体加成(:1656-1659):_compute_entity_boosts(:1728-1808),见 5.3。
  7. 候选集过滤过期(:1661-1672):遍历语义候选,_payload_is_expired 剔除已过期记忆(除非 show_expired=True)。
  8. 融合排序(:1675-1682):score_and_rank(scoring.py:60)。
  9. 结果格式化(:1684-1726):把 payload 里的 user_id/agent_id/run_id/role/expiration_date 等提升为结果字段,其余 metadata 归入 metadata;explain=True 时附带 score_details。

5.3 融合公式与「阈值前置」

score_and_rank(scoring.py:60-139)是检索的灵魂,核心就三行:

semantic_score = result.get("score") or 0.0
if semantic_score < threshold:      # 阈值在融合前拦截
    continue
combined = min((semantic_score + bm25_score + entity_boost) / max_possible, 1.0)

三个关键设计:

  1. 加性融合 + 自适应分母:max_possible 按实际启用的信号动态取值(scoring.py:97-101)——仅语义 1.0;+BM25 2.0;+实体 2.5;语义+实体(无 BM25)1.5。这样无论几路信号参与,融合分都落在 [0,1],不会因为信号多就天然分数高。
  2. 阈值拦在融合前(scoring.py:110-112):semantic_score < threshold 直接跳过——关键词和实体只能锦上添花,不能把语义低分救活。这是防止"混合检索把无关结果抬进 top-k"的关键闸门。threshold 的默认值 0.1 很低,语义分过不了 0.1 的记忆本来就不该出现。
  3. explain 可观测:explain=True 时每条结果带 score_details(scoring.py:126-135):semantic_score / bm25_score / entity_boost / raw_score / max_possible_score / final_score / threshold——调阈值、查"为什么这条没进 top-k"全靠它。

一个具体的数字例子

假设一条候选记忆语义分 0.55,BM25 归一化 0.40,实体加成 0.15,三路信号齐活(max_possible=2.5)→ 融合分 min((0.55+0.40+0.15)/2.5, 1.0) = 0.44。若 threshold=0.5,语义分 0.55 过了门槛,得以参与融合;而另一条语义分 0.45 的记忆,哪怕 BM25 0.6、实体 0.4(加和远超 0.5),也会在融合前被直接扔掉——门槛管语义、加成管排序,职责分明。

图 4|检索管线:over-fetch 召回 → 三路打分 → 阈值前置 → 加性融合

semantic + BM25 + entity 的多信号检索
semantic + BM25 + entity 的多信号检索

图注:threshold 拦在融合之前——关键词和实体只能锦上添花,不能把语义低分救活;max_possible 按实际启用的信号动态取值,保证融合分落在 [0,1]。

5.4 实体加成:写入时建链、检索时加分

_compute_entity_boosts(main.py:1728-1808)的工作方式,正好消费第 3 节 Phase 7 写入的实体索引:

  1. 对查询实体去重,最多取 8 个(:1739-1746);
  2. 批量嵌入实体文本,到 entity_store 检索,top_k=500、相似度 ≥ 0.5 才采纳(:1768-1791);
  3. 命中实体的 linked_memory_ids 指向的记忆获得加成:boost = similarity ENTITY_BOOST_WEIGHT memory_count_weight(:1797-1798)。其中 ENTITY_BOOST_WEIGHT = 0.5(scoring.py:57),memory_count_weight 对链接了大量记忆的"热实体"降权(1/(1+0.001*(n-1)^2),:1797)——防止明星实体把一批记忆无差别抬分。

因为 similarity ≤ 1 且 memory_count_weight ≤ 1,单条实体加成天然封顶 0.5(docstring :1737 也写明 [0, 0.5])。这保证了实体信号只能做语义的补充,不能反客为主。多实体命中时取各实体的最大加成(:1803)。

5.5 本节小结

mem0 的检索是一个教科书级的混合检索管线:over-fetch 召回 → 三路并行打分 → 各自归一化 → 加性融合 → 阈值前置拦截 → top_k 裁剪。它回答了一个常见疑问:"向量相似度之外还要什么?"——要关键词兜底专名、要实体关系补强关联记忆,但用融合前阈值守住底线,让加成永远只是排序的微调。explain=True 让这一切可调试,这是自建记忆层时最该抄的作业之一。


6. 修订与边界:显式 API、过期时间与 OSS 功能落差

6.1 「自动管线 ADD-only」与「显式修订入口」并存

自动管线不改写记忆,不代表记忆不可改。v3 的准确画像是:写入自动、修订手动。三个显式 API 都在:

  • update(memory_id, text=..., metadata=..., expiration_date=...)(main.py:1810-1862):按 id 更新记忆正文/元数据/过期时间。注意 user_id/agent_id/run_id 在 update 里被明确忽略——作用域创建后不可变(docstring :1825-1826),避免"改 id 把记忆挪到别人的作用域"这类事故。expiration_date 传 None 表示清除过期。
  • delete(memory_id)(:1864-1883):按 id 删除;id 不存在抛 ValueError。删除会同步处理向量库与 history(事件 DELETE)。
  • delete_all(user_id=..., agent_id=..., run_id=...)(:1885-1939):按作用域批量清空。实现里有个细节(:1913-1927):因为多数向量库 list() 默认只返回 100 条,它循环分批删除并记录已见批次,批次重复即停止——防止 delete_all 静默删不干净。

对照第 3 节再看一遍:add() 的 docstring 说 LLM 会 "decide whether to add, update, or delete",但 v3 提取器只有 ADD 一种操作;真正的 update/delete 是人(或上层编排)调用的显式 API。README 的表述("Memories accumulate; nothing is overwritten")与代码一致——这就是 v3 的完整画像:加法管线 + 手动修订。

6.2 过期时间:写入侧标注、检索侧过滤

OSS 唯一的"时间"能力是过期(TTL),实现是两端配合:

  • 写入:expiration_date(YYYY-MM-DD)归一化后塞进 payload metadata(:823-824)。
  • 检索:候选集构建时 _payload_is_expired(:1665)比对当前日期,过期即剔除;show_expired=True 可显式包含(:1384)。
  • 语义:过期不是删除,是"默认藏起来"——get_all / search 都遵守;delete 才是真正清除。对"临时记忆"(如一场活动的提醒)这是够用的机制。

图 5|修订与过期:显式 API 与 TTL 两端配合

写入自动、修订手动:TTL 只是默认隐藏
写入自动、修订手动:TTL 只是默认隐藏

图注:「写入自动、修订手动」——自动管线只 ADD,改错/去重/清空走显式 API;过期(TTL)是写入侧标注、检索侧过滤,过期不是删除,而是"默认藏起来"。

6.3 README 与 OSS 的落差清单

把全文的"声称 vs 实际"收拢成一张表,就是最终的能力地图:

README 宣称OSS 代码状态
"Single-pass ADD-only extraction — one LLM call, no UPDATE/DELETE"ADDITIVE_EXTRACTION_PROMPT + Phase 2 单次调用✅ 一致
"Agent-generated facts are first-class"_create_procedural_memory(procedural memory 通道)✅ 一致
"Entity linking — entities extracted, embedded, and linked across memories"Phase 7(main.py:1081-1185)✅ 一致
"Multi-signal retrieval — semantic, BM25, entity scored in parallel and fused"_search_vector_store + score_and_rank✅ 一致
"Temporal Reasoning — time-aware retrieval that ranks the right dated instance"timestamp/reference_date 传入即抛错❌ 仅托管平台

结论很直接:README 是「托管平台能力 + OSS 能力」的并集。凡涉及"时间推理"(Temporal Reasoning)的宣称,OSS 用户都拿不到——代码甚至用抛错把边界焊死,防止误用。这也解释了为什么很多教程照 README 写出来的示例,在本地跑会报错:示例用的是托管平台参数。写实践文章、做技术选型时,把"README 能力"和"可运行能力"分开,是第一课。

6.4 本节小结

修订的完整图景是:自动管线只 ADD,修订交给显式 update/delete/delete_all,时间只提供过期过滤。这套设计的自洽之处在于:让提取 LLM 专注"记住新东西"这一件事,把"改/删"从自动路径彻底拿掉,避免 LLM 在写路径上做高风险改写;需要改错、去重、清空时,由上层用确定性代码显式操作。用加法代替改写,把修订变成显式动作——这是 mem0 最值得带走的设计哲学之一。


7. 评测、对照与可迁移经验

7.1 官方基准:看数字之前先看口径

README「New Memory Algorithm」给了一组基准(README.md:45-68):

基准旧算法新算法说明
LoCoMo71.492.5长期对话记忆
LongMemEval67.894.4其中 assistant memory recall 98.2
BEAM (1M)—64.1百万 token 级生产规模评测
BEAM (10M)—48.6千万 token 级

但口径必须读:README 在同一段明确写了(README.md:52-53)——所有数字跑在 "Mem0's managed platform, which includes proprietary optimizations not available in the open-source SDK; open-source users should expect directionally similar gains but not identical numbers"。翻译过来:这些是官方自测、含托管平台专有优化(很可能就包括 OSS 里被焊死的 Temporal Reasoning),OSS 结果方向性相似但不一致。所以本文不把这些数字当作 OSS 独立复现结果,也不拿它跟其它记忆方案排名——正确的读法是:v3 的加法架构 + 混合检索,在官方口径下比 v2 提升显著,方向可信、数值不可直接迁移。

7.2 系列内对照:三条记忆路线

回到系列框架,把三种已读过的记忆方案并排看(hello-agents 与 pi 的细节以系列前文描述为准,非本次代码核查范围):

维度hello-agents MemoryToolpi(会话树)mem0(本文)
存储结构工作记忆(短期、随上下文进出)会话树 + checkpoint 摘要折叠外部事实库(向量 + SQLite 事件流)
写入策略教学式注入(工具显式告知)会话过程中持续折叠摘要单次 LLM 提取,ADD-only + 哈希去重
检索方式随提示词携带(显式)沿树回溯父节点摘要semantic + BM25 + entity 三信号融合
修订方式直接覆写工作记忆折叠即覆写显式 update/delete + 过期时间

三条路线的差异根源在存储结构与写入哲学:hello-agents 把记忆当"上下文的一部分"(随会话进出,弱持久化);pi 把记忆当"会话树的摘要"(随对话生长,结构与对话绑定);mem0 把记忆当"独立的事实资产"(与对话解耦,靠 id 索引、靠作用域隔离)。选哪条,取决于你的 Agent 是"一次性长对话"(pi 占优)、"轻量多轮"(hello-agents 够用)还是"跨会话长期记忆"(mem0 的用武之地)。

怎么选?一个实用的判断顺序:先问记忆要不要跨会话长期存活——不要,hello-agents 的工作记忆足够;要,再看记忆形态是"跟着对话长"还是"跟着事实走"——前者 pi 的会话树天然契合,后者(跨会话反复查询的事实库)才轮到 mem0 这类外部记忆层。另一个参考维度是工程代价:pi 与 hello-agents 几乎不需要额外基础设施,mem0 要引入向量库、嵌入服务、提取 LLM 三条外部依赖;如果场景只有几千条记忆、又不想维护这些依赖,为这点量级引入整套混合检索可能并不划算。

7.3 可迁移模式(作者判断)

从 mem0 的代码里,有四条不依赖任何 LLM 服务的工程模式值得自建记忆层时直接抄(以下为作者基于代码阅读的判断,非官方背书):

  1. 最小 API 面:把记忆收敛成 add(messages, scope) / search(query, scope, ...) 两个入口 + 作用域过滤。API 少,心智负担小,作用域强制反而杜绝了"跨用户串记忆"的脏数据。
  2. 单次提取 + 哈希去重:用「加法 + 精确去重」替代「提取时改写」。LLM 在写路径上只做"记住新东西",改错/合并交给显式 API——既省 token,又把写路径的风险降到最低。
  3. 阈值前置的混合检索:语义分不过门槛,BM25/实体加分再多也不进结果。这比"先融合再截断"更能守住相关性底线,且 explain 输出让每个分数可审计。
  4. over-fetch + 融合 + top_k 裁剪:召回超量(4x 或至少 60)、排序精算、按需裁剪——召回与排序解耦,是任何混合检索的标准骨架。

7.4 边界与适用性

最后说清楚 mem0 的边界,避免误用:

  • 依赖外部服务:提取与嵌入都靠外部 LLM/embedding 服务,记忆质量 = 提取质量 + 嵌入质量。custom_instructions(base.py:54-57)和 prompt 参数(main.py:941)是你调提取行为的唯一抓手,值得花力气。
  • "原样存储"是特定场景的选项:infer=False 把消息原文入库,适合原文存档(日志、合规场景),但要接受它不做去重、不做提炼。
  • 时间能力止步于过期:想按"当时 vs 现在 vs 将来"做时间推理,OSS 给不了,得在应用层自己建模。
  • 哈希去重不拦同义:md5 只挡逐字重复,语义重复靠提取 prompt 的 20 条去重参考 + 检索排序稀释——高频场景下你仍可能看到"两条说得一样的话"。

结语

回到开篇的问题:一个通用记忆层,在工程上把哪些事情做掉了?——mem0 的答案清晰而克制:它替你做好了「对话 → 可检索事实」的加法管线(单次提取、哈希去重、实体建链)与「查询 → 排序结果」的混合检索(语义 + BM25 + 实体、阈值前置),并把这套能力收敛成 add / search 两个 API。 它不做的事同样明确:不自动改写(修订归显式 API)、不做时间推理(归托管平台)、不存完整历史(SQLite 只留最近 10 条消息)。这种「把边界焊死」的姿态,恰恰是它作为开源记忆层最值得学习的地方——知道一个组件不管什么,和知道它管什么,同等重要。

系列预告:下一篇将基于本文的检索管线,实测 explain=True 的 score_details 在不同 threshold 下的行为,把「阈值前置」的直觉量化成调参指南。


附录 A:证据索引(与正文对应的代码位置)

正文断言证据位置(20260810-mem0/ 克隆,commit 4debc58)
Memory 类入口、MemoryConfig(version 默认 v1.1)mem0/memory/main.py:482;mem0/configs/base.py:29、:50-53
add() 签名与作用域参数mem0/memory/main.py:755-768
timestamp / reference_date 平台限定、OSS 抛错mem0/memory/main.py:782、:812-813、:1416、:1427-1428
infer=False 原始 ADD 路径mem0/memory/main.py:875-909
v3 写入管线 Phase 0–8mem0/memory/main.py:911-1201
单次 LLM 提取(ADD-only prompt、agent suffix、JSON 强制、失败 re-raise)mem0/configs/prompts.py:468;mem0/memory/main.py:936-964
UUID→int 防幻觉映射mem0/memory/main.py:928-933
哈希去重(md5、existing+seen)mem0/memory/main.py:1000-1034
Phase 7 实体链接(写侧建链)mem0/memory/main.py:1081-1185
_create_memory(uuid4/hash/lemmatize/insert/history)mem0/memory/main.py:1956-1986
_create_procedural_memory(agent 事实通道)mem0/memory/main.py:1988、:848-857
SQLite 双表、消息环形缓冲(每 scope 10 条)mem0/memory/storage.py:11、:257、:279-291、:298
search() 签名、filters 强制作用域、元数据算子mem0/memory/main.py:1374-1412、:1452-1456
检索九步(over-fetch 4x/60、BM25、实体、过期、融合)mem0/memory/main.py:1623-1726
entity boost(≤8 实体、top_k=500、阈值 0.5、上限 0.5)mem0/memory/main.py:1728-1808;scoring.py:57
融合公式与阈值前置mem0/utils/scoring.py:60-139(:97-101、:110-112、:118-119)
BM25 归一化与参数自适应mem0/utils/scoring.py:16-54
显式 update / delete / delete_all 与过期过滤mem0/memory/main.py:1810、:1864、:1885、:1665、:1384
README 宣称与基准数字(含 managed platform 口径)README.md:45-68

本文为「Agent 记忆」系列第二篇,代码锁定 mem0ai/mem0 commit 4debc58(2026-08-10 克隆)。文中所有 file:line 以该克隆为准;若你读到更新版本,请以你本地代码为准并核对行号位移。