先设一个具体场景。一个订餐厅的 agent 和同一个用户聊了半年:一月,用户说自己住在北京,通常约朋友吃素食; 三月,用户说最近开始吃海鲜;四月,用户搬到巴黎;六月,用户问“这个周末帮我找个餐厅,别忘了我现在的情况”; 七月,用户又问“以前我和朋友吃饭一般会选什么地方?” 这时 agent 不能只会背聊天记录,它要知道哪些事实是现在的偏好, 哪些事实已经变成历史,哪些变化本身也值得保留。

最直接的做法有三种。第一,把半年聊天全塞进 prompt:短期能用,长期成本高,还容易让旧事实干扰当前回答。 第二,维护一份“用户档案”,每次新事实来了就覆盖旧值:当前状态很干净,但用户问历史时就没有证据。 第三,把每条事实都写进向量库,搜索时拿 top-k:实现简单,却会遇到重复事实、冲突事实、关系漏召回和时间解释的问题。 Mem0 受关注,正是因为它把这三种直接做法继续往前拆:记忆成为一层独立系统, 先从对话里抽出可检索事实,再在回答前挑一小部分相关记忆放回模型上下文。

在工程形态上,Mem0 位于应用和模型之间:应用把对话、用户维度和 agent 维度交给它, 它把可长期使用的信息抽成 memory records;模型需要回答时,应用再从 Mem0 搜出少量相关记忆放回上下文。 这个位置解释了它为什么容易成为 agent 记忆讨论里的核心组件:它不要求把所有历史塞进 prompt,也不要求每个应用自己重写一套记忆抽取、去重和检索逻辑。 Mem0 官方博客从几个角度反复强调同一件事: context window 更像 RAM,不是长期存储更大的上下文窗口也不能替代持久记忆把记忆写成一个文件又会把证据、偏好、临时状态和检索策略糊在一起。 这些文章共同指向一个工程判断:memory layer 的价值在于把“多记一些”拆成保存、检索、时间解释和排名策略几层责任。 v3、Temporal Reasoning 和 Memory Decay 就是在这条线上继续展开。

阅读契约。 读完这一篇,应该能沿着同一个餐厅场景复述四件事:paper mem0 怎样用相似旧记忆和 LLM 判断 ADD / UPDATE / DELETE / NOOP; mem0g 为什么把实体关系放进图;v3 为什么把写入改薄、把判断推到读取;Temporal Reasoning 和 Memory Decay 分别解决“站在哪一天回答”和“最近常用信息怎么排序”的问题。

证据边界。 本文把事实分成四层:Mem0 原始论文说明 paper mem0 与 mem0g 的算法设计; Mem0 官方博客和文档说明 Platform v3 的公开合约; mem0ai/mem0 公开源码说明 OSS 与 Python SDK 可见的调用路径; Platform 服务端内部实现不可见,因此 Temporal Reasoning 和 Memory Decay 的排序细节只按官方合约描述,不写成源码事实。

下面会一直沿着这个餐厅场景读 Mem0 的几代设计。先看 paper mem0 如何在写入时判断 ADD、UPDATE、DELETE、NOOP; 再看 mem0g 为什么把实体和关系放进图;然后看 v3 为什么改成 ADD-only、实体索引和 hybrid retrieval。 最后再接上 Temporal Reasoning 与 Memory Decay:一个处理“站在哪一天看历史”,一个处理“最近反复用到的记忆该不该更靠前”。

一、同一个问题,三种记忆路线

讲 Mem0 演进时,最容易混在一起的是两条命名线:论文里讨论的是 mem0 和 mem0g, 产品与 SDK 迁移材料里讨论的是 v2 API 和 v3 行为。本文会避开把它们硬套成“官方 v1、v2、v3”的读法, 改按算法取舍来读:如果用户偏好和居住地会变化,系统到底在什么时候合并旧事实、什么时候显式保存关系、什么时候只追加新证据。 这条线读顺以后,ADD-only、Temporal 和 Decay 就会显出同一个问题下的分层答案,而不只是一堆功能名。

称呼 更准确的读法 不要误读成
paper mem0 论文里的基础算法:抽取新 memory,再对相似旧 memory 判定 ADD/UPDATE/DELETE/NOOP。 官方产品里的“v1”。公开材料没有这样命名。
paper mem0g 论文里的图增强算法:显式抽 entity 和 relationship,并使用图数据库。 官方产品里的“v2”。它更像 mem0 旁边的一条 graph 变体。
v2 API / SDK 迁移材料里说的旧版本面:add 可能返回 ADD/UPDATE/DELETE,且有 enable_graphgraph_store 等图记忆配置。 单指 mem0g。v2 是产品/API/SDK 行为集合,不是论文算法名。
mem0 v3 2026 年 4 月后的新算法面:single-pass ADD-only、内置 entity linking、hybrid retrieval、Temporal Reasoning 和 Memory Decay。 只是把 mem0g 改名。v3 同时改了写入、图关系承担方式和检索排序。
Mem0 算法演进图,展示 paper mem0 在写入时裁决旧记忆,paper mem0g 增加文本记忆旁边的关系旁路,mem0 v3 用 ADD-only、linked ids 和融合检索生成 model view
这张图按判断发生的位置来读:paper mem0 在写入时改旧记忆,mem0g 增加 graph sidecar,v3 先追加和链接,再由检索层决定本轮 model view。

1.1 paper mem0:在写入时整理旧记忆

先只看一件事:用户三月说“我最近也开始吃海鲜了”。系统里已经有一条旧记忆“用户通常约朋友吃素食餐厅”。 这条新事实到底应该怎么进 memory store?如果直接新增,未来可能同时召回“素食”和“海鲜”,模型不知道哪个更适合当前回答; 如果直接覆盖,又会把一月的历史抹掉。paper mem0 的想法是:写入时先整理一次旧记忆,让 memory set 尽量紧凑。

写入前 新对话 paper mem0 要回答的问题
用户通常约朋友吃素食餐厅。 最近开始吃海鲜。 这是新偏好、偏好扩展,还是旧偏好被替换?
用户住在北京。 四月搬到巴黎。 旧居住地要更新,还是保留成历史?
用户喜欢安静的小餐馆。 不喜欢吵闹的餐吧。 这是同一偏好的补充,还是另一条独立记忆?

原始论文里的基础算法大致分两步。 第一步,LLM 从最新对话和近期上下文里抽出 candidate memory;第二步,对每条 candidate,先从已有 memory 里找出相似旧记录, 再让 LLM 比较新旧事实,输出 ADDUPDATEDELETENOOP。 这里的“相似”承担召回职责:先把可能有关的旧记忆拿到 LLM 面前,再交给后面的裁决。论文把这一步写成取 top-s 条 semantically similar memories,实验里 s 设为 10;也就是说,每条 candidate 只会携带少量相似旧记忆进入 prompt, memory store 不会整体进 prompt。

paper mem0 写入路径,可以这样读:

new conversation
  -> LLM extracts candidate memories
  -> embed each candidate
  -> vector search retrieves top-s similar existing memories
     (paper experiments use s = 10)
  -> LLM compares candidate with those retrieved old memories
  -> ADD / UPDATE / DELETE / NOOP changes the memory store

“怎么判断相似”也要分清职责。向量检索负责召回:candidate 变成 embedding,再用 vector similarity 找旧 memory。 召回结果只是候选集合,分数高不等于一定更新;top-s 只是一个上下文预算阀门, 用来避免把全部旧记忆塞进提示词。真正判断重复、补充、冲突还是删除的是后面的 LLM。 可见 OSS 里的 DEFAULT_UPDATE_MEMORY_PROMPT 也按这个思路组织:比较新事实和已有 memory,然后在四种操作里选一个。源码提示词里的 NONE 对应这里说的 NOOP

DEFAULT_UPDATE_MEMORY_PROMPT 的核心职责(简化):

new fact + similar existing memories
  -> ADD     新事实不在旧 memory 里
  -> UPDATE  同一对象的信息变了,或新事实更完整
  -> DELETE  新事实否定旧 memory,或用户要求移除
  -> NONE    已经存在,或者不值得写入
新事实和旧 memory 的关系 操作 放回餐厅场景里理解
旧 memory 没有这个事实 ADD 旧记忆只有餐厅偏好;新事实是用户搬到巴黎。
同一事实,只是换了说法 NOOP / NONE 旧:喜欢安静餐厅;新:偏好不吵的地方。
同一对象或偏好,但新信息更具体,或者值已经改变 UPDATE 旧:通常吃素食;新:现在也接受海鲜。
新事实明确否定旧事实,或者用户要求删除 DELETE 旧:喜欢海鲜;新:以后不要再推荐海鲜。

尤其要注意 UPDATE 这一行:paper mem0 的目标是让 memory set 更紧凑, 所以它可能把“偏好扩展或变更”折叠进同一条偏好记忆。后面的 v3 会走另一条路:不急着折叠, 先把变化作为新证据保存下来。

这样就能自然回答“过于相似为什么不会直接变成重复 ADD”。相似度只负责把可能重复或冲突的记录拉到同一个 prompt; paper mem0 还会让 LLM 在旧记忆和候选事实之间做裁决。 这仍然达不到严格数据库约束:如果向量检索没有召回正确旧记忆,或者 LLM 把同义重复误判成新事实, 仍然可能出现重复 ADD。而且 top-s 的大小本身就是取舍:设小了会漏掉该比较的旧记忆, 设大了又会增加 LLM 上下文、成本和延迟。paper mem0 的优点是 memory set 更紧凑;缺点是写入时的 UPDATEDELETE 会削弱历史证据。用户后来问“我以前为什么更常选素食餐厅”时, 被覆盖或删除的旧版本未必还在。

1.2 paper mem0g:把“关系”从文本记忆里拆出来

再给餐厅场景加一个关系问题:用户说“我经常和 Alice 约饭”,后来又说“Alice 对贝类过敏”,再后来问 “周末和 Alice 在巴黎吃饭,有什么要避开?” 纯文本 memory 可以存下这些句子,也能通过向量相似度找回一部分相近内容。 但它没有天然的“对象表”:Alice 是谁、她和用户是什么关系、贝类过敏和海鲜推荐有什么关系,都要从散落的文本里临时推。 mem0g 的改动,是在文本 memory 旁边放一条结构化路径:memory records 仍然写入向量存储, entity 和 relationship 另外进入 graph store。图留在文本记忆旁边,负责给“谁和谁有关”一个可走的索引。

步骤 文本 memory 路径 graph 路径
写入 抽取“用户经常和 Alice 约饭”“Alice 对贝类过敏”等事实,写入 vector memory store。 同时抽取 Aliceshellfish allergyParis 等 entity 和关系边。
读取 用 query 的语义相似度召回相关 memory records。 从 query 或召回结果里的 entity 出发,沿 graph store 找邻居和关系。
合并 把语义相关的文本事实放进候选上下文。 把“和 Alice 有关”“和过敏禁忌有关”的结构化事实补进候选上下文。
mem0g 可以想成两条并行写入:

message
  -> memory facts -----------------> vector memory store
  -> entities + relationships -----> graph store

search(query)
  -> vector memories
  -> graph neighbors around matched entities
  -> merged context for the model
最小 graph 例子:

User  --eats_with----> Alice
Alice --allergic_to--> shellfish
User  --located_in---> Paris

所以这里的 entity 和 relationship,确实是图数据库里的对象。论文 把 mem0g 的记忆表示成 directed labeled graph:entity 是 node,relationship 是 edge/triplet; 实现上使用 Neo4j 作为底层 graph database。读取时不会只按字符串查一个节点,两条路径会一起工作: entity-centric retrieval 先从 query 里的关键实体定位图节点,再沿 incoming / outgoing edges 拿邻居关系; semantic triplet retrieval 则把关系三元组也做语义编码,用 query embedding 去匹配相关 triplet。

mem0g 的 graph 检索可以这样理解:

query: "What should I avoid for dinner with Alice in Paris?"

entity-centric:
  Alice / Paris -> graph nodes
  -> incoming + outgoing edges
  -> Alice --allergic_to--> shellfish
  -> User --located_in--> Paris

semantic triplet:
  query embedding
  -> match encoded triplets
  -> rank relevant relationships above threshold

这解决的是另一类漏召回:两个事实的句子可能不相似,却因为共享实体或关系路径而应该一起出现。 “Alice 对贝类过敏”和“用户想订巴黎海鲜餐厅”句面并不相近,但推荐时必须把它们放在一起看。 graph 路线让系统可以顺着 Alice 这个节点找到过敏禁忌,再把它和餐厅偏好合并进本轮上下文。 它扩展对象连接这条线;关键词分数和向量相似度仍然属于另一层问题。后面说的 v3 entity linking 则是另一回事: 它把实体关系变成检索排序信号,不再暴露一套完整 graph traversal。

mem0g 新增的东西 解决什么问题 带来的成本
entity 给人、组织、项目、地点等对象一个稳定锚点。 需要做实体识别、别名合并和跨轮对齐。
relationship 把“谁和谁有什么关系”从文本里显式抽出来。 关系可能变化,也可能和旧边冲突,需要维护策略。
graph store 支持按实体邻接关系、多跳路径和语义 triplet 补充检索。 引入额外存储、查询路径和结果合并逻辑。
vector + graph 合并 让“语义相关”和“关系相连”同时参与上下文选择。 需要处理两种结果的去重、排序和冲突。

所以,mem0g 的重点可以概括为:文本记忆继续承载事实正文,关系型信息拥有自己的索引方式。 这一步解决了“相关对象怎么串起来”的问题,却没有解决“历史版本该不该被覆盖”的问题。 图可以告诉系统 Alice、贝类过敏和巴黎餐厅之间有关联,但当用户后来换了同行人,或者同一关系在不同时间发生变化时, 系统仍然要决定旧事实是更新、删除,还是作为历史保留。这个压力会继续落到下一步。

1.3 mem0 v3:把写入做薄,把判断推到读取

到这里,前两条路线的优点和压力都比较清楚了。paper mem0 能把 memory set 维护得更紧凑, 代价是写入时就要裁决旧事实该保留、更新还是删除;mem0g 让关系更清楚,代价是多了一条图维护路径。 两者都把写入看成“整理仓库”的时刻。这个取舍在短期偏好里很有效,一旦事实带着时间变化,就会变得吃力。 用户一月住北京、四月住巴黎,系统要保存的是一段状态变化的证据链,不能只留下一个永远正确的地址字段。

v2 到 v3 迁移文档Token-Efficient Memory Algorithm 文章 里说的 single-pass ADD-only,可以按一次责任迁移来理解:写入阶段只负责把新证据安全入库, 读取阶段再决定这次回答该采用哪一个视角。写入阶段的任务缩成三件事: 抽取值得长期保存的新事实;用去重、链接和实体索引把它放好;把本轮 model view 交给后面的检索和排序。

状态变化,shape-level 示例:

t1 memory:
  text: "User lives in Beijing as of 2025-01-10"
  timestamp: "2025-01-10T09:00:00Z"

t2 memory:
  text: "User lives in Paris as of 2025-04-02"
  timestamp: "2025-04-02T11:00:00Z"
  linked_memory_ids: ["t1"]

search("Where does the user live now?", reference_date="2025-04-10")
  -> 更应该把 Paris 排在前面

search("Where did the user live before March?", reference_date="2025-03-01")
  -> 仍然可以找回 Beijing

这段形状只用来说明责任边界,不代表公开 API 的完整返回格式。可以顺着四个动作读: ADD-only 让北京和巴黎两条证据都留下来;linked_memory_ids 表示它们谈的是同一类状态变化; Platform 的 timestamp 记录事实进入历史的时间;reference_date 让搜索表达“这次站在哪一天看”。北京没有在写入时被判死,巴黎也没有抹掉旧证据,答案由读取阶段按问题视角排出来。

公开 OSS 的 _add_to_vector_store() 可以顺着“证据入库”的顺序读。它先取最近消息和相似旧记忆,给 LLM 做一次新 memory 抽取; 然后做 batch embedding、hash 去重、vector insert、history ADD,最后补上 entity linking。 这里仍然会看 existing memories,但它们的作用是辅助去重、连接和上下文判断,旧记录不会在这条路径里被 UPDATEDELETE

# _add_to_vector_store(), simplified shape
last_messages = db.get_last_messages(...)
existing_results = vector_store.search(..., top_k=10)

extracted = llm.generate_response(ADDITIVE_EXTRACTION_PROMPT, ...)
embeddings = embedding_model.embed_batch(memory_texts, "add")

for memory in extracted:
    if hash_seen(memory):
        continue
    vector_store.insert(memory)
    history.event = "ADD"
    link_entities(memory)
v3 选择 接过的压力 留给后面的约束
single-pass extraction 写入阶段少做多轮新旧裁决。 抽取质量更依赖一次调用的提示词、相似旧记忆和近期上下文。
ADD-only 把状态变化保留成新证据。 重复、冲突和过时事实必须交给检索层处理。
linked_memory_ids 让新旧事实有连接,后面可以解释“怎么变过”。 检索和展示时要理解这些连接,不能只看单条文本。
内置 entity linking 保留 mem0g 的一部分关系线索,减少外部 graph store 负担。 实体信号只进入检索排序,不承担整套图查询答案。
hybrid retrieval 把“当前问题应该看哪条记忆”放到读取阶段。 model view 的质量取决于多信号融合,不能只依赖写入时的整洁度。

所以 ADD-only 的重点从省 token 扩展到了责任分配:写入保留证据,读取生成本轮视角。 写入阶段少做不可逆判断,读取阶段再结合语义、关键词、实体、时间和访问历史,决定本轮 model view。代价也很清楚: 旧事实仍在库里,存储会增长,检索层必须处理重复、冲突和过时事实。后面的 Temporal Reasoning 和 Memory Decay, 就是在回答同一个后续问题:历史都留下来以后,当前这一次到底该看哪几条?

1.4 先把三层对象分清:窗口、记录和模型视图

读到 ADD-only 时,容易冒出一个误解:既然历史都留下来,是不是所有旧事实都会进入上下文? 答案要从三层对象看。context window 是本轮模型能看到的工作区;memory record 是持久化后可检索的事实; model view 是检索之后放回本轮上下文的少量结果。ADD-only 改的是 memory record 的写入方式; Temporal 和 Decay 改的是 model view 的选择方式。两者都不等于把全部历史塞进 context window。

Context vs Memory 对比图,展示 context window 的短期消息与 persistent memory 的 durable ledger 通过 search lens 进入 model view
上下文窗口解决“这一轮看什么”,持久记忆解决“以后还能不能找回来”,检索层解决“当前问题该看哪几条”。
术语 本文含义 常见误读
context window 本次请求发给模型的工作区,适合短期推理和工具结果。 把窗口变大当成长期记忆,忽略成本、遗忘和 lost-in-the-middle。
memory record 从对话中抽出的持久事实,带 payload、metadata、embedding 和历史记录。 把它当成“当前真相”,忽略它也可能是过去某一刻的真相。
model view 检索、过滤、排序后被展示给模型的少量记忆。 把它等同于全部历史,导致没召回的事实被误判为不存在。
ranking layer 把语义、关键词、实体、时间和访问历史转成当前结果顺序的层。 把排名结果当成写入覆盖的结果。

二、写入为什么只 ADD:先留下证据,不急着改判旧事实

先把写入想成一位档案员。用户今天说“我开始吃海鲜了”,比起少写一条, 更危险的动作是立刻把“一直偏好素食餐厅”改掉。因为六月用户可能问“以前我和朋友吃饭一般选哪里”, 这时旧事实仍是历史证据,不能当垃圾清掉。v3 的取舍就是把档案员的权限收窄:先把新证据写进去, 不在写入时急着判定旧证据该覆盖、删除,还是永远失效。

Mem0 的 Token-Efficient Memory Algorithm 文章给出 single-pass ADD-only 的方向。公开 OSS 路径也能看到同样的形状,不过可以按“证据进入仓库”的顺序来读: Memory.add() 先处理输入、过滤器和 metadata,再进入 _add_to_vector_store()。 在这条可见路径里,LLM 提取新 memory;运行时再做 batch embedding、hash 去重、vector insert、history ADD 和 entity linking。 重点不在某个函数名,而在职责变化:写入路径负责“把新证据放好”,不负责“把旧历史判没”。

ADD-only Write Path 图,展示 messages、existing memory、extract facts、dedup hash、vector insert、history ADD 与被阻断的 overwrite old
ADD-only 的核心是阻断原地覆盖;它不等于承诺系统永远不会产生重复记忆。
旧路线的压力 ADD-only 的处理 得到的优势
写入时误判 UPDATE / DELETE 新事实作为新记录进入 memory store。 减少不可逆覆盖,保留之后解释历史的证据。
同一偏好在不同时间有不同答案 变化本身成为记忆,并可带时间信号。 “现在是什么”和“当时是什么”不再抢同一条记录。
图关系带来额外存储和维护成本 linked_memory_ids 和内置 entity linking 保留关联。 仍能表达相关性,但不要求 OSS 用户维护外部 graph store。
写入链路越长,成本和延迟越难控 单次抽取、batch embedding、hash 去重后写入。 写入路径更短,失败面更小,后续判断集中到检索层。

2.1 LLM 只做抽取和连接,不做最终状态裁判

这里容易误会:ADD-only 仍然会用 LLM,只是角色变了。 旧路线让它像裁判,决定旧 memory 该 UPDATE 还是 DELETE; v3 更像让它做证据整理:这句话值不值得长期保存、它和哪条旧记忆有关、里面的“上周”“昨天”该落到哪一天。 ADDITIVE_EXTRACTION_PROMPT 正是在约束这些职责:用户和 assistant 消息都可能包含信息;相对时间要用 observation date 落到具体日期; 与旧记忆相关时使用 linked_memory_ids,避免直接改写旧记录。

新消息:
  "我现在改喝 oat milk 了,之前 almond milk 有点过敏。"

写入时更像这样判断:
  1. 这是一条新的长期事实吗?是。
  2. 它是否和旧偏好有关?是,链接旧 memory。
  3. 它是否应该覆盖旧 memory?不在写入阶段决定。
{
  "memory": [
    {
      "id": "0",
      "text": "User switched from almond milk to oat milk lattes after developing an almond sensitivity",
      "linked_memory_ids": ["existing-memory-id"]
    }
  ]
}

这个 JSON 真正要表达的是:“变化本身”成为一条新的、可追溯的事实。 旧偏好没有被静默删除;后续检索可以同时看到“曾经喜欢什么”和“后来为什么变了”。 长期 agent 需要保留这种可回放历史,不能每次只剩一个最新结论。

2.2 OSS 对 Platform-only 时间参数保持硬边界

后面会继续讲 Temporal Reasoning,所以这里先把源码证据边界说清楚。 讲到 observation date,很容易顺手把 Temporal Reasoning 也算进 OSS,但公开 OSS 能看到的是 ADD-only 写入、基础检索和参数边界; Platform v3 合约才暴露完整的时间推理参数。 公开源码里, Memory.add(timestamp=...) 在 OSS 路径会直接抛出 Platform-only 错误; Memory.search(reference_date=...) 也一样。 这属于 API 层边界:Temporal Reasoning 明确切到 Platform v3,不能按“代码没翻到”来解释。 所以后面谈 Temporal,只能说官方合约允许写入时间和查询观察时间分开; 至于服务端怎么把时间信号并入 ranking,公开源码没有给出实现细节。

三、读取:先找候选,再决定本轮给模型看什么

ADD-only 把历史留下来了,读取阶段就必须更谨慎。回到餐厅场景,query 是“这个周末帮我找餐厅,别忘了我现在的情况”。 “北京素食”“巴黎海鲜”“Alice 对贝类过敏”都可能相关,但它们的作用不同:有的是旧偏好,有的是当前地点,有的是同行人的限制。 读取层的问题已经从“哪几句话和 query 最像”,变成“哪几条记忆应该进入这一轮 model view”。

Mem0 OSS 的搜索路径从 Memory.search() 进入 _search_vector_store()。 按源码顺序读,会看到五个动作:先把 query 做关键词归一化和实体抽取;再把 query 转成 embedding; 然后用 semantic search 多取一些候选;如果 vector store 支持关键词检索,就把 keyword search 的结果转成 BM25 分数; 同时用 entity store 给 linked memories 算 boost;最后把这些信号交给 score_and_rank()

# _search_vector_store(), simplified shape
query_terms = lemmatize_for_bm25(query)
query_entities = extract_entities(query)
query_vector = embedding_model.embed(query, "search")

internal_limit = max(limit * 4, 60)
semantic_results = vector_store.search(..., top_k=internal_limit)
keyword_results = vector_store.keyword_search(query_terms, top_k=internal_limit)

bm25_scores = normalize_keyword_scores(keyword_results)
entity_boosts = compute_boosts_from_linked_entities(query_entities)

ranked = score_and_rank(
    semantic_results,
    bm25_scores=bm25_scores,
    entity_boosts=entity_boosts,
    threshold=threshold,
    top_k=limit,
)

这段顺序把几个容易混淆的点说清了。第一,BM25 是存在的,但它在可见 OSS 路径里是一个关键词分数信号; 候选池先来自 semantic search。第二,entity 通过 linked memories 给候选加权,并不单独给出最终答案。 第三,这条路径更像 additive score fusion:语义候选过门槛后,再叠加 BM25 和 entity boost。 因此,这里更像在同一批语义候选上叠加分数,不应读成把 vector、BM25、entity 几张独立榜单再做 RRF 混排。 可以把三种信号压成一句话:语义负责不跑题,关键词负责保留精确词,实体负责把相关对象带上。

Hybrid Retrieval 图,展示 query 分成 semantic、keyword、entity 三路信号,进入 candidate pool、threshold 和 score fusion 后生成 ranked results
Mem0 的 OSS 搜索先形成候选池,再把语义、关键词和实体信号合并成排名结果,已经超过一次单纯的 vector top-k。

3.1 先用语义守门,再融合关键词和实体

排名最怕“熟人误伤”。比如 Alice 是用户经常提到的人,query 里也出现了 Alice, 但这次问的是餐厅偏好,并不需要所有 Alice 相关旧约饭记录。如果只看实体,很多 Alice 相关记忆都会显得“重要”。 所以融合排序第一步先守门,再加分:semantic score 低于 threshold 的候选先出局, 后面才轮到 normalized BM25 和 entity boost。关键词或实体信号可以改变相关候选之间的顺序, 但不应该把一个语义不相关的候选硬送进 model view。

semantic candidates
  -> threshold gate
  -> add normalized BM25 when available
  -> add entity boost when available
  -> normalize by active signal budget
  -> return top_k
if semantic_score < threshold:
    drop(candidate)

combined = (semantic_score + bm25_score + entity_boost) / max_possible_score

换句话说,这里的最终分数来自 semantic score、BM25 score 和 entity boost 的加法组合。 BM25 和 entity 能调整相关候选之间的顺序,但不会把一个没有进入语义候选池的 memory 单独拉进最终结果。 这个设计保守一些:它牺牲了一部分关键词纯召回能力,换来的是不让熟人名、餐厅名或高频词把不相关记忆硬塞进 model view。

3.2 当前是否适用,需要时间和访问信号

过了语义门槛,也只是说明“相关”,还没有说明“当前该用”。同一个用户先喜欢 A,后来改成 B; 两条 memory 都应该保留,因为一条回答“当时是什么”,另一条回答“现在可能是什么”。 这时读取层还需要两类额外信号:Temporal Reasoning 处理“站在哪个时间点看”,Memory Decay 处理“最近哪类信息被反复用到”。 它们沿着语义相关性继续往下判断:当前问题更需要哪一条相关记忆。

四、Temporal Reasoning:让问题站到某一天

“我现在住在巴黎”和“三月之前我住在哪里”都围绕同一个居住状态,但答案不该一样。 Temporal Reasoning 的作用,就是让 query 带着一个观察点进入历史,避免系统永远默认“最新事实最大”。 它保留旧记录,同时让写入记录“何时观察到这个事实”,让搜索表达“以哪一天为观察点”。 Temporal Reasoning 官方文章文档 给出的公开合约包括:Platform v3 支持,默认启用; 写入可以带 timestamp;搜索可以带 reference_date;返回 shape 仍是普通 search 结果。

Temporal Layer 图,展示 add 阶段 timestamp 进入 memory time,search 阶段 reference_date 与 as of when 问题在 timeline 上选择 ranked answer
Temporal layer 让检索问题表达“站在哪一天看这段历史”,从而不必靠覆盖来处理时间变化。

4.1 请求形态:写入时间和观察时间分开

回到前面的北京/巴黎例子:一月说住在北京、四月说住在巴黎,是事实被观察到的时间; 四月追问“现在住哪里”,或者三月追问“当时住哪里”,是本次搜索希望站到的时间。它们看起来都叫“时间”,但属于两件事。 如果这两个时间混在一起,系统只能猜“最新事实”是否应该覆盖旧事实。Platform 请求把它们拆开: timestamp 描述记忆进入历史的时间,reference_date 描述本次搜索要站在哪一天回答。 Python SDK 的 Platform client 会把 add/search 请求发到 v3 endpoint: /v3/memories/add//v3/memories/search/。 源码可见的是请求 shape 和参数转发;时间推理怎么在服务端参与 ranking,不在公开源码里。

# Platform shape, simplified
client.add(
    messages=[{"role": "user", "content": "I currently live in Beijing."}],
    user_id="u1",
    timestamp="2025-01-10T09:00:00Z",
)

client.add(
    messages=[{"role": "user", "content": "I currently live in Paris."}],
    user_id="u1",
    timestamp="2025-04-02T11:00:00Z",
)

client.search(
    "Where do I live now?",
    filters={"user_id": "u1"},
    reference_date="2025-04-10",
)

这里最重要的设计在于两个动作被拆开:写入时给证据贴上时间, 搜索时告诉系统这次要从哪一天回看。 这样,“现在住巴黎”和“之前住北京”不再争抢同一条覆盖记录; 当前问题可以偏向巴黎,历史问题仍然能回到北京。

4.2 Temporal 处理矛盾历史,filter 仍然负责范围

filter 和 Temporal 很容易被混在一起。filter 先解决“在哪个抽屉里找”:某个 user、agent、run, 或者业务侧 metadata。Temporal 解决的是“打开同一个抽屉以后,按哪一天解释里面的历史”。 同一个用户范围里,二月事实和三月事实可以同时存在;“过去三个月有哪些变化”和“去年我更喜欢什么” 都需要这些历史一起在场,不能只留下最新事实。

五、Memory Decay:最近常用可以加权,但不能替代相关性

时间视角解决的是“何时为真”,但还不够。长期 agent 会积累很多都相关、也没有过期的事实: 常用项目、常见联系人、偏好的回答风格、最近反复出现的任务。它们都可能相关,但上下文只能放少量结果。 如果某类信息最近反复被使用,它通常更值得排在前面;但这个问题不能靠删除旧记忆解决, 因为那些旧记忆以后仍可能在别的问题里有用。 Memory Decay 官方文章文档 把它定义在 search-time soft re-ranking:候选集通过基础阈值之后,排名会被温和调整;记忆不会因此删除,事实也不会在写入时被改写。

Memory Decay 图,展示 candidate pool 通过 threshold 后,根据 access history 把 recent touch 上调、stale 下调,形成 final top_k
Memory Decay 的位置在搜索后段:先保证基础相关性,再用访问历史影响最终 top-k。

5.1 项目开关只改变 search-time ranking

Decay 的开关也能说明它的位置。它属于项目级搜索策略,删除倒计时不在某条 memory 上。 公开 SDK 里能看到项目设置层面的开关: project.update(decay=True) 会把 decay 写入项目更新 payload。源码注释也把它限定为 search-time ranking:最近使用的记忆被 boost,stale 记忆被轻微压低; 关闭时恢复到 pre-decay behavior。具体访问历史的服务端记录和计算不在公开 OSS 路径里。

# Platform project setting, simplified
client.project.update(decay=True)

5.2 关键边界:先相关,再衰减

Decay 最容易被误读成“最近问过什么就优先什么”。如果真这样,用户最近反复问过报销流程, “报销”记忆就可能闯进一次餐厅偏好查询。正确边界是:最近使用只影响相关候选之间的顺序, 不能让无关事实越过相关性门槛。Memory Decay 的公开边界正是围绕这个风险设计的: 它只适用于 Platform v3;会在比最终 top-k 更大的候选池上工作; 基础相关性 threshold 先执行,decay multiplier 后执行;公开返回的 score 会被 clamp 到常规范围;访问历史强化是异步的。 文档还给出量级边界:候选池会扩成 top_k * 3 且至少 50 条,decay multiplier 夹在 0.3x 到 1.5x。 这些细节都在保证一件事:decay 只改变排名偏置,不负责删除事实。

边界 含义 避免的误读
search-time 候选被召回后再调整排名。 不会在写入时把旧记忆衰减成别的事实。
threshold first 基础语义相关性先过门槛。 不会让频繁访问的无关记忆进入结果。
soft multiplier 近期访问上调,长期不用下调。 没有 TTL 或自动删除语义。
project opt-in 通过项目设置启用。 需要项目配置开启,不能假定所有 Mem0 使用都默认生效。

六、把几层合起来:旧事实不覆盖,新问题有视角

现在回到开头的问题:用户偏好从 A 变成 B,系统到底该记住什么? 如果只保留 B,历史解释会断;如果把 A、B 和所有相关事实都塞进上下文,模型又会被旧事实干扰。 Mem0 这条路线用分层避开两个极端:ADD-only 保留变化证据; hybrid retrieval 先形成候选池;Temporal Reasoning 让查询带上时间视角;Memory Decay 让近期使用历史影响结果顺序。 最后进入模型的是这一轮问题下的 model view,只包含当前回答需要的少量记忆。

Mem0 的 BEAM benchmark 文章memory simulation guide 也在强调同一个评测方向:生产记忆不能只看“有没有召回”,还要看过时事实、矛盾事实、时间条件和检索漂移。 这正是 model view 质量问题。

Memory Rules 图,展示 new fact 进入归属判断后,按状态变化、近期使用、必须精确、长期稳定分别走 ADD 加时间、decay boost、应用规则和稳定事实
更可靠的做法,是先决定谁负责哪类问题:历史证据、当前判断、精确规则和稳定背景不应该混在一个覆盖动作里。

6.1 什么时候靠 Mem0,什么时候靠应用层

这也给应用设计留下一条清楚的分界线。Mem0 适合处理“帮助模型理解上下文”的信息: 用户偏好、常用工具、长期项目背景、近期反复出现的话题。它不适合做必须精确执行的最终状态来源: 支付地址、权限状态、合规开关、医疗禁忌。后一类信息应该由业务数据库和应用层规则拥有; Mem0 可以帮助模型解释和召回相关上下文,但不应该替应用做最终授权。

信息状态 适合机制 为什么要这样做 会在哪些地方失效
偏好发生变化 ADD-only 记录变化,Temporal query 判断时间视角。 保留“以前”和“现在”的证据。 只保留最新值会丢历史,只保留旧值会答错当前。
近期反复使用 Memory Decay 对 search result 做软排名。 常用信息更容易进入当前模型视图。 它不能替代业务优先级和强规则。
必须精确执行 应用层数据库、权限系统或显式规则。 不让概率检索决定必须精确的状态。 把授权、支付、医疗判断放进 memory 会有风险。
长期稳定背景 普通 memory record 与 hybrid retrieval。 减少重复提问,保持个性化上下文。 如果缺少 filters,跨用户或跨 agent 边界会混乱。

6.2 一句话总结 Mem0 的路线

Mem0 最值得借鉴的地方,在于把“长期记忆”从一个更大的 prompt 里拆出来,也避免让模型每次重读全部历史; 它把几件事分开放: 写入保留证据,检索形成候选,时间解释历史视角,衰减表达近期使用,应用层负责强一致规则。 这套分层让 agent 不必在“覆盖旧记忆”和“永远展示全部历史”之间二选一,也解释了为什么 v3 的后续机制都围绕 model view 质量展开。

参考资料