深入拆解 Mem0:一条 LLM 驱动的记忆编辑流水线

从源码层面拆解 Mem0 的记忆实现:LLM 驱动的事实萃取、语义去重决策引擎、Graph Memory 双通路架构,以及每一个设计决策背后的代价。

大多数人第一次听到 Mem0 时,脑子里浮现的画面是:对话 → embedding → 存进向量数据库 → 下次检索。一个 RAG 的变种,无非是把文档换成了对话历史。

这个理解是错的。

Mem0 的核心不是向量检索——是一条 LLM-as-editor 流水线。每次用户调用 memory.add(),系统不是在"存数据",而是在让大模型做一次编辑决策:这段对话里有什么值得记住的?跟已有记忆是什么关系?该新增、合并、还是把旧记忆删掉?

这意味着 LLM 的推理能力就是整个记忆系统的天花板。不是向量数据库的召回率,不是 embedding 模型的维度,而是大模型能不能正确判断"用户说不喜欢芝士披萨了"应该覆盖掉"用户喜欢芝士披萨"。

本文从源码层面拆解这条流水线的每一个环节,分析它的设计决策和代价。

对话是糟糕的记忆原材料

先看一个场景。用户和 AI 助手聊了十轮,其中有这么一段:

用户:"把上次那个方案改一下,就是 John 上周提的那个。" 助手:"好的,我把第三部分的数据源从 MySQL 改成了 PostgreSQL。"

这段对话离开上下文就是噪声。"上次那个方案"指什么?"John"是谁?"改一下"改了什么?如果你把这两句话直接 embedding 后存入向量数据库,下次检索时用户问"我们数据库方案是什么",你能召回的只是一段含义模糊的对话碎片。

这就是 Mem0 不直接存对话的原因。它要求大模型先做一次 记忆萃取(fact extraction)——从对话中提炼出独立可理解的事实陈述,再存储这些萃取后的 facts。上面那段对话,萃取后应该变成:

{"facts": ["项目数据源从 MySQL 改为 PostgreSQL", "John 上周提过数据源方案"]}

每条 fact 脱离对话上下文依然可理解——这才是可靠的记忆单元。

萃取管线:两次 LLM 调用的代价

第一次调用:从对话中提取事实

_add_to_vector_store() 是整条管线的入口(memory/main.py:475)。当 infer=True(默认行为)时,系统不会直接存消息,而是走萃取路径。

萃取的核心是一个精心设计的 prompt。Mem0 维护了两套萃取 prompt:USER_MEMORY_EXTRACTION_PROMPT 提取用户信息,AGENT_MEMORY_EXTRACTION_PROMPT 提取助手信息(configs/prompts.py:62-173)。系统根据消息中是否同时存在 agent_id 和 assistant 角色消息来自动切换模式(main.py:521)。

用户萃取 prompt 的设计值得注意几点:

信息分类框架。 Prompt 明确列出了 7 类需要记住的信息:个人偏好、重要个人信息、计划和意图、活动偏好、健康信息、职业信息、杂项。这不是随意分类——它在暗示 LLM 的注意力方向,让模型不至于只关注显式陈述而遗漏隐含偏好。

硬边界约束。 USER_MEMORY_EXTRACTION_PROMPT 中两次用大写加注释强调:"GENERATE FACTS SOLELY BASED ON THE USER'S MESSAGES. DO NOT INCLUDE INFORMATION FROM ASSISTANT OR SYSTEM MESSAGES."(prompts.py:66-67)。为什么要强调两次?因为 LLM 有一个反直觉的倾向:当 assistant 说"我也喜欢《黑暗骑士》"时,模型可能会把这条信息归入用户画像。重复强调是 prompt engineering 中对抗模型偏好的标准手法。

Few-shot 示例设计。 六个示例覆盖了四种典型场景:空输入("Hi."→ 空列表)、无关陈述("树上有树枝"→ 空列表)、显式偏好、多事实提取。注意第六个例子(prompts.py:101-103)——用户问"你喜欢什么电影?",助手回答了自己的偏好,但输出只提取用户的电影偏好。这个 few-shot 样本在教模型区分信息归属。

语言检测。 "You should detect the language of the user input and record the facts in the same language"(prompts.py:58)——用中文聊天,记忆就用中文存。这对跨语言场景的 embedding 检索有影响,后面会讨论。

整个萃取调用走 json_object 响应格式(main.py:532),并且有两层 JSON 解析兜底:先尝试直接解析,失败则用 extract_json() 从"话多的"LLM 输出中抽取 JSON(main.py:540-546)。这是对不同 LLM provider 输出一致性问题的防御性编程。

第二次调用:与已有记忆的冲突裁决

萃取出 facts 只是第一步。更关键的是第二次 LLM 调用——决定这些新 facts 和已有记忆的关系。

对每个新提取的 fact,系统先用 embedding 在向量库中搜索最相似的 5 条已有记忆(main.py:569-574)。注意这里用的是 fact 本身的 embedding 而不是原始对话的 embedding——因为 fact 已经是语义清晰的独立陈述,用它检索能更精确地找到语义重叠的旧记忆。

新 fact: "不喜欢芝士披萨了"
    ↓ embedding 检索
已有记忆 #0: "喜欢芝士披萨"
已有记忆 #1: "最喜欢的食物是意大利菜"
已有记忆 #2: "上周点了 Margherita 披萨"

检索完成后,所有相关旧记忆被收集、去重,然后送入第二次 LLM 调用。这次调用使用 DEFAULT_UPDATE_MEMORY_PROMPTprompts.py:175-323),让 LLM 扮演 "smart memory manager",对每条记忆做出四种操作之一:

操作 语义 示例
ADD 全新信息,记忆库中不存在 "名字是 John" → 新增
UPDATE 已有记忆需要补充或修正 "喜欢打板球" → "喜欢和朋友打板球"
DELETE 新信息与已有记忆矛盾 "不喜欢芝士披萨" → 删除"喜欢芝士披萨"
NONE 已有记忆已包含该信息 "名字是 John"(已存在)→ 不操作

这是 Mem0 最核心的设计决策。 它没有用向量相似度阈值做去重(那只能处理近似重复),也没有用时间戳做覆盖(那会丢失累积信息),而是让 LLM 做 语义级别的编辑裁决

UPDATE 操作的语义特别值得注意。Prompt 中的例子(prompts.py:215-218)区分了两种情况:

  • 旧记忆"喜欢打板球"+ 新 fact"喜欢和朋友打板球"→ UPDATE(新信息更具体)
  • 旧记忆"喜欢芝士披萨"+ 新 fact"爱芝士披萨"→ NONE(语义相同,不需更新)

这个区分完全依赖 LLM 的语义理解能力。没有任何规则引擎或相似度阈值能做出这种判断。

UUID 防幻觉映射

一个精巧的工程细节:系统在送入第二次 LLM 调用之前,把所有记忆的 UUID 替换成简单的整数索引(main.py:584-588)。

# mapping UUIDs with integers for handling UUID hallucinations
temp_uuid_mapping = {}
for idx, item in enumerate(retrieved_old_memory):
    temp_uuid_mapping[str(idx)] = item["id"]
    retrieved_old_memory[idx]["id"] = str(idx)

原因是:如果让 LLM 看到 "id": "a1b2c3d4-e5f6-..." 这样的 UUID,它在输出时很可能"创造"一个看起来像 UUID 但实际不存在的 ID。用整数("0", "1", "2")就不会有这个问题——LLM 不会把 "0" 幻觉成 "7"。操作执行完毕后,再通过 temp_uuid_mapping 把整数映射回真实 UUID(main.py:646)。

这个细节反映了 LLM 应用开发中一个重要原则:减少 LLM 输出中需要精确匹配的信息量。

Embedding 缓存

每个新萃取的 fact 在搜索旧记忆时已经计算了 embedding。系统用字典缓存这些 embedding(main.py:556-568),后续创建或更新记忆时直接复用,避免重复调用 embedding API:

new_message_embeddings = {}
for new_mem in new_retrieved_facts:
    messages_embeddings = self.embedding_model.embed(new_mem, "add")
    new_message_embeddings[new_mem] = messages_embeddings  # 缓存

这意味着一次 add() 调用中,每个 fact 最多只调一次 embedding API。对于提取了 10 个 facts 的场景,这节省了可观的延迟和成本。

完整数据流:一次 add() 背后发生了什么

把上面的分析串起来,一次 memory.add() 的完整数据流如下:

用户调用 memory.add(messages, user_id="alice")
│
├─ ThreadPoolExecutor 并行启动两条路径
│
│  ┌─────────── Vector Store 路径 ───────────┐
│  │                                          │
│  │  1. 解析消息格式(str/dict/list)        │
│  │  2. 判断 user/agent memory 模式          │
│  │  3. ◆ LLM 调用 #1: Fact Extraction      │
│  │     "Hi, I'm Alice, I love sushi"        │
│  │     → ["Name is Alice", "Loves sushi"]   │
│  │                                          │
│  │  4. 对每个 fact:                          │
│  │     - 计算 embedding(缓存)              │
│  │     - 向量检索 top-5 相似旧记忆           │
│  │                                          │
│  │  5. 收集所有相关旧记忆,去重              │
│  │     UUID → 整数索引映射                   │
│  │                                          │
│  │  6. ◆ LLM 调用 #2: Memory Manager        │
│  │     裁决 ADD/UPDATE/DELETE/NONE           │
│  │                                          │
│  │  7. 执行操作:                             │
│  │     ADD    → vector_store.insert()        │
│  │     UPDATE → vector_store.update()        │
│  │     DELETE → vector_store.delete()        │
│  │     NONE   → 静默更新 session IDs         │
│  │                                          │
│  │  8. 每步操作记录到 SQLite history         │
│  └──────────────────────────────────────────┘
│
│  ┌─────────── Graph Store 路径 ─────────────┐
│  │                                           │
│  │  1. ◆ LLM 调用: 实体抽取(tool_call)     │
│  │     "Alice loves sushi"                   │
│  │     → {alice: person, sushi: food}        │
│  │                                           │
│  │  2. ◆ LLM 调用: 关系建立(tool_call)     │
│  │     → alice --loves_to_eat--> sushi       │
│  │                                           │
│  │  3. 图数据库中检索相似实体                 │
│  │     cosine similarity ≥ 0.7               │
│  │                                           │
│  │  4. ◆ LLM 调用: 冲突检测与删除裁决        │
│  │     → 标记矛盾关系为 valid=false          │
│  │                                           │
│  │  5. MERGE 新实体/关系到 Neo4j             │
│  │     (embedding 存入节点属性)             │
│  └───────────────────────────────────────────┘
│
└─ 合并两条路径结果,返回给调用方

最坏情况下,一次 add() 触发 2 次 LLM 调用(vector 路径)+ 3 次 LLM 调用(graph 路径)= 5 次 LLM 调用。这是"智能"的代价。

Graph Memory:第二条记忆通路

Vector store 存的是扁平的事实列表——"Alice 喜欢寿司"、"Alice 是软件工程师"、"Alice 住在旧金山"。这些 facts 之间没有结构化关系。当用户问"Alice 有什么特征"时,向量检索只能靠语义相似度把相关 facts 捞出来。

Graph memory(memory/graph_memory.py)提供了第二种视角:实体和关系。同样的对话,graph 路径会产出这样的知识图谱:

alice --loves_to_eat--> sushi
alice --works_as--> software_engineer
alice --lives_in--> san_francisco

实体抽取:LLM + Tool Calling

Graph 路径的第一步是实体抽取。和 vector 路径不同,graph 路径使用 tool calling(而非 JSON 响应格式)来结构化 LLM 输出。_retrieve_nodes_from_data()graph_memory.py:219-250)给 LLM 提供 extract_entities tool,让它输出结构化的实体列表。

一个重要的设计细节:当用户说"I love sushi"时,"I"会被映射为 user_idgraph_memory.py:229)。这避免了图中出现大量悬空的"I"节点——所有自指代词都会解析到具体用户实体。

实体提取完成后,_establish_nodes_relations_from_data() 进行第二次 LLM 调用,在已知实体列表的约束下建立关系(graph_memory.py:252-292)。注意这里把实体列表作为上下文传入——这限制了 LLM 只能在已提取的实体间建立关系,减少幻觉。

图搜索:Cosine Similarity + BM25 二次排序

Graph search 的实现揭示了一个有趣的混合策略(graph_memory.py:96-130)。

第一阶段,用 Neo4j 的向量索引做 cosine similarity 过滤(graph_memory.py:309-329)。Cypher 查询中有一个反常的公式:

round(2 * vector.similarity.cosine(n.embedding, $n_embedding) - 1, 4) AS similarity

2 * cosine_sim - 1 是什么?Neo4j 的 vector.similarity.cosine 返回 [0, 1] 范围的值(已归一化),而这个变换把它映射回 [-1, 1]。代码注释写的是 "denormalize for backward compatibility"——说明早期版本用的是标准 cosine similarity([-1, 1]),后来 Neo4j 改了返回值范围,但阈值参数(默认 0.7)没改。这是一个典型的"向后兼容比正确性更重要"的工程权衡。

第二阶段,对检索到的三元组做 BM25 重排序(graph_memory.py:119-122):

search_outputs_sequence = [
    [item["source"], item["relationship"], item["destination"]] for item in search_output
]
bm25 = BM25Okapi(search_outputs_sequence)
tokenized_query = query.split(" ")
reranked_results = bm25.get_top_n(tokenized_query, search_outputs_sequence, n=5)

这里有一个值得推敲的选择:BM25 的输入是把三元组的三个元素作为"文档"的 token 列表,query 的 tokenization 是简单的空格分词(query.split(" "))。这意味着对于中文查询或包含下划线的关系名(如 loves_to_eat),BM25 的效果会严重退化——中文没有空格分词,而 loves_to_eat 作为单个 token 几乎不会匹配任何 query token。

这是一个设计取舍:用最简单的 BM25 实现覆盖大多数英文场景,而不是引入分词器增加复杂度。但对于非英文用户,graph search 的 BM25 重排序基本不起作用,退化为纯 cosine similarity 排序。

软删除与时间推理

Graph memory 的删除不是物理删除。_delete_entities()graph_memory.py:383-438)使用 soft-delete 策略:

SET r.valid = false, r.invalidated_at = datetime()

关系不会从图中消失,只是被标记为 valid = false 并记录失效时间。所有查询都加了 WHERE r.valid IS NULL OR r.valid = true 过滤条件。

为什么要这样做?代码注释引用了 issue #4187,原因是 temporal reasoning——保留历史关系状态,使得未来可以回答"Alice 以前喜欢什么"这类时间相关的查询。这是一个面向未来的设计决策,当前代码还没有利用 invalidated_at 时间戳做任何查询,但数据已经在那里了。

实体合并:四分支 Cypher

向图中添加新关系时,_add_entities()graph_memory.py:440-657)面临一个组合爆炸的问题:source 和 destination 各自可能已存在于图中(通过 embedding 相似度匹配),也可能是全新节点。这产生了 4 种组合,每种需要不同的 Cypher 查询:

Source 已存在 Destination 已存在 策略
Yes Yes 直接 MERGE 关系
Yes No MATCH source, MERGE destination + 关系
No Yes MERGE source, MATCH destination + 关系
No No MERGE 两个节点 + 关系

每条 Cypher 都使用 MERGE ... ON CREATE SET ... ON MATCH SET 模式,确保节点幂等创建,并在已有节点上累加 mentions 计数。关系同样使用 MERGE,如果关系已存在(即使之前被 soft-delete),会重新激活:r.valid = true, r.invalidated_at = null

这是 Neo4j 图建模中的标准模式,但四个分支的冗余代码揭示了一个未解决的问题:注释中的 # TODO: Create a cypher query and common params for all the casesgraph_memory.py:468)——团队知道这里可以优化,但尚未动手。

并发双写架构

Vector store 和 graph store 的操作是 完全并行 的。add() 方法用 ThreadPoolExecutor 同时启动两条路径(main.py:458-465):

with concurrent.futures.ThreadPoolExecutor() as executor:
    future1 = executor.submit(self._add_to_vector_store, messages, metadata, filters)
    future2 = executor.submit(self._add_to_graph, messages, filters)
    concurrent.futures.wait([future1, future2])

Search 也是同样的模式(main.py:941-952)——向量搜索和图搜索并行执行,结果合并返回。

这个设计意味着:vector store 和 graph store 之间没有事务一致性保证。 如果 vector store 写入成功但 graph store 失败(比如 Neo4j 超时),系统不会回滚 vector store 的写入。两条路径的结果是独立返回的——graph 路径的失败不会影响 vector 路径的结果。

这是一个务实的选择。强一致性在这个场景下代价太大(跨异构存储的分布式事务),而最终一致性对记忆系统来说是可接受的——最坏情况是某些关系没有同步到图中,但 facts 依然在向量库里。

隐蔽的 NONE 事件:Session ID 静默更新

代码中一个容易被忽略但工程意义重大的分支出现在 NONE 事件的处理中(main.py:668-694)。

当 LLM 判定某条记忆不需要更新(NONE)时,系统并没有什么都不做。如果当前请求携带了 agent_idrun_id,系统会 静默更新这些 session 标识符到已有记忆上

if memory_id and (metadata.get("agent_id") or metadata.get("run_id")):
    existing_memory = self.vector_store.get(vector_id=memory_id)
    updated_metadata = deepcopy(existing_memory.payload)
    if metadata.get("agent_id"):
        updated_metadata["agent_id"] = metadata["agent_id"]
    if metadata.get("run_id"):
        updated_metadata["run_id"] = metadata["run_id"]
    self.vector_store.update(vector_id=memory_id, vector=None, payload=updated_metadata)

这个设计的目的是 跨会话的记忆关联。同一条记忆可能在不同的 agent 或 run 中被引用——通过 NONE 事件的 session ID 更新,记忆的可见范围会随着使用而扩展。注意 vector=None 表示不更新 embedding,只改 metadata。

设计的边界在哪里

萃取质量的 prompt 天花板

整个记忆系统的质量上限取决于两个 prompt:fact extraction prompt 和 memory update prompt。它们的缺陷会直接传导到记忆质量。

考虑这个场景:用户说"今天跟 John 吵了一架,他总是迟到"。理想的萃取应该是 ["与 John 有矛盾", "John 经常迟到"]。但如果 LLM 萃取成 ["今天跟 John 吵了一架"],那"John 经常迟到"这个持久性更强的事实就丢了——它被嵌入了一条关于事件的记忆中,而不是作为独立事实存储。

萃取 prompt 通过 7 类信息框架和 few-shot 示例来引导 LLM,但 few-shot 覆盖的场景有限。对于复杂的、嵌套的、或隐含的信息(如情感倾向、关系变化),萃取质量完全取决于底层 LLM 的推理能力。换一个更弱的 LLM,记忆质量可能断崖式下降。

系统提供了 custom_fact_extraction_prompt 配置项(main.py:515),允许用户替换默认 prompt。这是一个逃生舱——当默认 prompt 对特定领域效果不好时,用户可以针对性优化。但这也意味着用户需要理解 prompt 工程,门槛不低。

Embedding 维度锁定

一旦用某个 embedding 模型存了记忆,所有已有向量就和这个模型的维度绑定了。如果你从 OpenAI text-embedding-3-small(1536 维)切换到 text-embedding-3-large(3072 维),或者切换到完全不同的 embedding provider,所有已存向量和新查询向量之间的 cosine similarity 计算会失去意义——检索结果退化为噪声。

系统没有内置的 embedding 迁移机制。这是所有基于向量检索的系统的共同问题,但对于 Mem0 这种长期记忆系统尤其致命——记忆的价值在于积累,而积累的前提是 embedding 空间的一致性。

跨语言检索的隐患

萃取 prompt 要求"检测用户语言并用相同语言记录"。这对单语言场景很好,但如果用户用中文和英文交替聊天,记忆库中就会混存中英文 facts。大多数 embedding 模型对跨语言的语义对齐能力有限——用英文 query 检索中文记忆,或者反过来,召回率可能显著下降。

Graph 搜索的 BM25 简化假设

如前分析,graph search 的 BM25 重排序使用空格分词。这意味着:

  1. 中文查询的 BM25 重排序基本无效
  2. 关系名中的下划线(loves_to_eat)不会被分词
  3. 没有 stemming 或 lemmatization

对于英文短查询("Alice food preferences"),BM25 能提供额外的精确匹配信号。但对于更复杂的场景,它的贡献趋近于零。

两次 LLM 调用的延迟代价

每次 add() 至少需要两次串行 LLM 调用(萃取 → 裁决),加上可选的 graph 路径的 3 次调用。对于 OpenAI GPT-4 级别的模型,单次调用延迟在 1-5 秒,这意味着一次 add() 的端到端延迟可能达到 3-10 秒。

系统通过并行化 vector 和 graph 路径来缓解,但 vector 路径内部的两次 LLM 调用是串行的——第二次调用依赖第一次的萃取结果。这是架构层面无法消除的延迟。

面试深度拷问

Q1: Mem0 的 memory.add() 和普通的向量数据库 insert 有什么本质区别?

表面回答: "Mem0 会用 LLM 从对话中提取事实,然后存入向量数据库。"

源码级回答: add() 不是一个存储操作,是一条编辑流水线。它包含两次串行 LLM 调用:第一次用 USER_MEMORY_EXTRACTION_PROMPT(或 AGENT_MEMORY_EXTRACTION_PROMPT)从对话中萃取独立事实;第二次用 DEFAULT_UPDATE_MEMORY_PROMPT 让 LLM 扮演 memory manager,对比每个新 fact 与已有记忆的 top-5 相似结果,做出 ADD/UPDATE/DELETE/NONE 四种裁决。裁决结果通过 UUID→整数映射防止 LLM 幻觉。整个流程的核心假设是:LLM 的语义理解能力比向量相似度阈值更适合做记忆去重和冲突解决。

追问链:

→ "为什么第二次调用中要用整数替换 UUID?"

→ "如果两次 LLM 调用用的是不同 provider(比如第一次用 GPT-4o,第二次用 Claude),会有什么问题?"

→ "NONE 事件真的什么都不做吗?什么情况下 NONE 也会触发写操作?"

(答案:当 metadata 中存在 agent_id 或 run_id 时,NONE 会静默更新 session 标识符。main.py:668-694。)

Q2: Graph memory 的搜索中为什么要对 cosine similarity 做 2x - 1 变换?

表面回答: "数学变换,调整相似度范围。"

源码级回答: Neo4j 的 vector.similarity.cosine 返回 [0, 1] 范围的归一化值,而 Mem0 的 threshold 默认值 0.7 是在标准 cosine similarity [-1, 1] 范围下设定的。2 * sim - 1 是一个反归一化操作,将 [0, 1] 映射回 [-1, 1],使得阈值比较语义一致。代码注释 "denormalize for backward compatibility"(graph_memory.py:312)说明这是 Neo4j 更新返回值范围后的兼容性修复,而非原始设计。

追问链:

→ "默认 threshold 0.7 在反归一化后对应原始 cosine similarity 的多少?"

(答案:归一化值 = (0.7 + 1) / 2 = 0.85,即只有 cosine similarity > 0.85 的节点才会被返回。这其实是一个相当严格的阈值。)

→ "Graph search 后的 BM25 重排序对中文查询有效吗?为什么?"

Q3: 如果同一个 user_id 在高并发下同时调用两次 add(),会发生什么?

表面回答: "可能会有竞态条件。"

源码级回答: 考虑这个场景:两次 add() 同时萃取出了同一个 fact "喜欢跑步"。两者各自用 embedding 检索旧记忆时,都没有找到这条 fact(因为另一次 add 还没写入),所以两者的 LLM 裁决都会返回 ADD。结果:同一条 fact 被存了两次,产生了重复记忆。

系统没有任何并发控制机制——没有乐观锁、没有分布式锁、没有 compare-and-swap。_create_memory() 直接调用 vector_store.insert() 生成新 UUID 并写入。对于生产环境中的高并发场景,这是一个需要在应用层解决的问题(比如对同一 user_id 的 add 操作做串行化)。

追问链:

→ "Graph store 侧呢?Neo4j 的 MERGE 是否能防止实体重复?"

(答案:MERGE 是幂等的——相同 name + user_id 的节点不会重复创建。但关系可能重复创建,因为关系类型由 LLM 生成,两次调用可能产生略有差异的关系名。)

→ "如何在不修改 Mem0 源码的前提下解决这个问题?"

Q4: 为什么 Graph memory 用 soft-delete 而不是硬删除?

表面回答: "保留历史数据。"

源码级回答: _delete_entities() 设置 r.valid = falser.invalidated_at = datetime()graph_memory.py:427-428),而不是 DETACH DELETE。当前代码中 invalidated_at 字段没有在任何查询中被使用——所有查询只过滤 r.valid IS NULL OR r.valid = true。这说明 soft-delete 是为未来的 temporal reasoning 能力预留的(参考 issue #4187):比如"Alice 以前喜欢什么但现在不喜欢了"这类查询,需要知道关系的失效时间。

相比之下,vector store 路径的"删除"是从向量库中物理删除记忆(main.py:659-660),并在 SQLite history 中记录 is_deleted=1。两条路径的删除语义不一致——graph 侧保留历史状态,vector 侧不保留。

追问链:

→ "如果 add() 的 graph 路径检测到一个之前被 soft-delete 的关系需要重新激活,代码是怎么处理的?"

(答案:_add_entities() 的 MERGE 查询中有 ON MATCH SET r.valid = true, r.invalidated_at = null——重新激活关系并清除失效时间。)

工程启示

LLM-as-Editor 模式适用于什么场景

Mem0 的架构可以抽象为一个通用模式:用 LLM 做非结构化数据的编辑裁决。这个模式适用于:

  1. 冲突解决需要语义理解的场景——"喜欢芝士披萨"和"不喜欢芝士披萨了"的矛盾不是字符串比较能发现的。
  2. 信息密度需要压缩的场景——对话中 90% 是噪声,只有 10% 值得保留,而"值得保留"的标准需要理解上下文。
  3. 更新频率低、质量要求高的场景——每次写入 2 次 LLM 调用的成本适合低频高价值的数据(个人画像、长期偏好),不适合高频流水数据。

不适合这个模式的场景:

  1. 延迟敏感型应用——2-10 秒的 add 延迟对实时系统不可接受。
  2. 需要确定性行为的场景——LLM 的裁决不是确定性的,相同输入可能产生不同的 ADD/UPDATE/DELETE 决策。
  3. 萃取目标无法用自然语言 prompt 描述的场景——如果记忆内容是高度结构化的(日志、指标、事件),直接写入结构化存储比让 LLM 萃取更可靠。

双存储并行写入模式

Mem0 的 Vector + Graph 并行写入是一个值得借鉴的架构模式。两种存储提供互补的检索能力——向量检索擅长语义模糊匹配,图检索擅长关系遍历。并行写入牺牲了强一致性,但对记忆系统来说,"最终所有信息都会到位"比"每次写入都完美一致"更务实。

关键的设计启示是:允许不同存储路径独立失败。一条路径的异常不应该阻塞另一条路径的结果返回。这在实践中意味着你的应用逻辑不能假设两种存储总是同步的。

Prompt 是运行时基础设施

在 Mem0 的架构中,prompt 不是开发阶段的辅助工具,而是 运行时关键路径上的基础设施DEFAULT_UPDATE_MEMORY_PROMPT 的质量直接决定了记忆去重的准确率;FACT_RETRIEVAL_PROMPT 的覆盖范围直接决定了记忆的信息完整度。

这意味着 prompt 需要和代码一样的工程纪律:版本控制、回归测试、A/B 测试、性能基线。Mem0 通过 custom_fact_extraction_promptcustom_update_memory_prompt 两个配置项把 prompt 暴露为可替换组件——这是正确的抽象层次,但也意味着默认 prompt 的质量就是产品的基线质量。