Skip to main content

02 - 写入路径:什么该被记下来

前置01 篇的三种记忆分型与四个动作。

本篇回答:一段对话结束后,具体是谁、在什么时候、按什么规则决定哪几句话值得留下来。这一步决定了检索的天花板 —— 抽取时丢掉的信息,任何检索策略都找不回来。

本篇会用到的词

意思
抽取(extraction)用一次模型调用把对话变成若干条独立的事实陈述
巩固(consolidation)新抽出的事实和已有记忆比对,决定是新增、改写、作废还是丢弃
自包含(self-contained)一条记忆脱离原对话也读得懂。「他也喜欢」不自包含,「用户的哥哥也喜欢滑雪」自包含
观测日期 / 当前日期抽取时用到的两个不同时间锚点:对话实际发生的日期,和跑抽取的日期。混用会把相对时间解析错
幻觉 ID让模型直接操作 UUID 时,它会编出一个格式正确但不存在的 ID。规避手段见 2.2

一、两条路线:抽取还是原样存

分歧点只有一个:要不要在写入时就做有损压缩路线 A · 抽取式 —— mem0 / LangMem / supermemory整段对话约 4000 token模型抽取一次调用3 条事实约 60 token+ 存储小两个数量级,检索命中的就是答案本身+ 一条记忆可以脱离原文单独读懂− 抽漏了就永久丢失,没有第二次机会− 抽取本身是模型调用,会编造原文没有的内容路线 B · 原样存 —— MemMachine / 部分自建方案整段对话约 4000 token切片 + 嵌入不调模型12 个片段约 4000 token+ 不丢信息,事后换检索策略还能重来+ 写入不调模型,成本只有嵌入− 存储大两个数量级,注入时还要二次压缩− 命中的是"包含答案的片段",不是答案2026 年的实测结论(MemDelta,arXiv 2606.29914):在 LongMemEval-S 上,Agent 自管记忆 42%,朴素向量检索 47% —— 抽取式并不天然更准。
两条路线不是二选一。生产系统常见的做法是原样存一份原始片段做兜底与审计,同时抽一份事实做主检索路径 —— 代价是存两份。

MemDelta 那个数字值得单独说一句:在受控对照下,让 Agent 自己决定记什么(42%),反而不如把对话原样切片做向量检索(47%)。这不是说抽取式没用,而是说抽取带来的收益不像宣传材料里那么确定,而它的成本是确定的。完整实验设计在 07 篇第四节。

二、mem0 的写入流水线

mem0 是抽取式路线里代码最完整、被读得最多的实现。下面按主分支 mem0/memory/main.py_add_to_vector_store 逐段拆。

整条流水线只有一次模型调用(阶段③)—— 这是 v2 相对旧版最大的改动① 取会话上下文最近 10 条消息用来解析代词不调模型② 检索已有记忆top_k = 10按租户过滤一次嵌入③ 抽取唯一一次模型调用JSON 模式输出成本与延迟都在这④ 批量嵌入一次 embed_batch失败则逐条降级部分失败不整批挂⑤ 哈希去重比对已有条目哈希完全相同就跳过只挡完全一致的⑥ 落库 + 记历史向量库 + history 表事件类型全是 ADD历史表是审计入口⑤只挡逐字相同的重复。「用户喜欢川菜」和「用户偏好川菜」哈希不同,会变成两条 —— 语义级去重要靠③里的提示词自己控制。②的 top_k=10 是硬编码的:已有记忆超过十条相关时,模型看不到第十一条,去重和关联都会漏。
值得注意的是②和③的顺序 —— 先检索再抽取,让模型在"已经记过什么"的前提下做判断。跳过②的实现(先抽后查)会产生大量语义重复条目。

2.1 阶段②:检索已有记忆的两个约束

# mem0/memory/main.py,_add_to_vector_store 内
# Phase 1: Existing memory retrieval
search_filters = {k: v for k, v in filters.items() if k in ("user_id", "agent_id", "run_id") and v}
query_embedding = self.embedding_model.embed(parsed_messages, "search")
existing_results = self.vector_store.search(
query=parsed_messages,
vectors=query_embedding,
top_k=10, # 硬编码:模型最多能看到十条已有记忆
filters=search_filters, # 三个租户维度,缺一个就会跨范围检索
)

两点要看清楚:

  • filters 决定隔离边界user_id / agent_id / run_id 三个维度,调用方漏传就退化成全库检索。多租户系统里这是一个真实的越界风险,07 篇第三节展开
  • 查询用的是整段对话的嵌入,不是逐条事实的嵌入。对话涉及多个话题时,这个"平均向量"可能哪个话题都不太像,检索出来的十条已有记忆跟真正要写的事实关系不大

2.2 阶段③:UUID 要先映射成整数

这段代码上面挂着一行注释 # Map UUIDs to integers (anti-hallucination)

existing_memories = []
uuid_mapping = {}
for idx, mem in enumerate(existing_results):
uuid_mapping[str(idx)] = mem.id # 记下 "0" → "a3f1c8e2-..." 的对照
existing_memories.append({"id": str(idx), "text": mem.payload.get("data", "")})
# 给模型看到的是 [{"id": "0", "text": "..."}, ...],不是真正的 UUID

为什么必须这么做:直接把 UUID 给模型,让它回答"这条新事实和哪条旧记忆有关",它会返回一个格式完全正确、但库里不存在的 UUID。32 位十六进制串对模型来说是高熵噪声,复制过程中出错的概率远高于复制一个个位数。

映射成 "0""9" 之后,模型的输出空间被压到十个值,越界立刻能校验出来。这是一条通用经验:任何需要模型引用现有实体的场合,都不要让它直接搬运长标识符

2.3 infer=False:绕过抽取的旁路

def _add_to_vector_store(self, messages, metadata, filters, infer, prompt=None):
if not infer:
# 不调模型,每条非 system 消息原样存一条记忆
for message_dict in messages:
if message_dict["role"] == "system":
continue
msg_embeddings = self.embedding_model.embed(message_dict["content"], "add")
mem_id = self._create_memory(message_dict["content"], {...}, per_msg_meta)
return returned_memories
# ...以下才是抽取流水线

这条旁路就是第一节的路线 B。它在两种场景下有用:

  • 写入的内容已经是结构化事实(比如从业务事件流灌进来的),再抽一遍没有意义还会引入误差
  • 成本敏感的批量灌数:一百万条历史消息全走抽取,是一百万次模型调用

三、从四操作巩固退回纯追加

mem0 论文里的写法是两阶段:抽取出候选事实之后,再让模型对照已有记忆决定做什么。提示词至今还在 mem0/configs/prompts.py 里:

DEFAULT_UPDATE_MEMORY_PROMPT = """You are a smart memory manager which controls the memory of a system.
You can perform four operations: (1) add into the memory, (2) update the memory,
(3) delete from the memory, and (4) no change.
...
- ADD: Add it to the memory as a new element
- UPDATE: Update an existing memory element
- DELETE: Delete an existing memory element
- NONE: Make no change (if the fact is already present or irrelevant)
"""

而主分支的默认路径已经不走它了。同一个文件里新增了:

# V3 Additive Extraction Prompt (ADD-only with memory linking)
ADDITIVE_EXTRACTION_PROMPT = """
# ROLE
You are a Memory Extractor ... Your sole operation is ADD: identify every piece of
memorable information and produce self-contained, contextually rich factual statements.
...
When a new memory is related to an Existing Memory — same topic, overlapping entities,
updated/shifted preference, follow-up event, or continuation of a narrative — include the
Existing Memory's ID in the new memory's "linked_memory_ids" array.
"""
同一句话「我现在改吃粤菜了」,两种语义下库里的最终状态完全不同旧默认 · 四操作巩固旧:用户偏好川菜模型判定 UPDATE原地改写这一条库里只剩一条:用户偏好粤菜判错就永久丢:判成 DELETE 时原文没有备份「上个月还爱吃川菜」这个事实不可恢复新默认 · 纯追加 + 链接旧:用户偏好川菜只产生 ADD带 linked_memory_ids库��里两条并存,新条目指向旧条目+ 没有不可逆操作,判错可以事后补− 库只增不减,冲突推迟到检索时才暴露
这次转向的实质是把"哪条才对"的判断从写入时推迟到检索时。写入时模型只看得到十条相关记忆,检索时至少还知道当前问的是什么 —— 判断依据更充分。代价是库的增长和检索侧复杂度。

为什么会退回去,从代码本身能读出三条理由:

  1. UPDATE 和 DELETE 是不可逆的。模型在只看到十条相关记忆的情况下判断"这条该删",判错就没有第二次机会
  2. 四操作要求模型同时做两件事:识别新事实、并对每条做决策。合并任务会同时拉低两者的准确率
  3. 纯追加天然幂等。同一段对话重跑一遍,最坏结果是多几条被哈希或语义去重挡掉的重复;四操作重跑一遍可能把上次刚建的条目删掉

代价被明确接受了:库只增不减,冲突并存。这就把压力全部转移到检索侧和作废机制上 —— 也就是 03 篇04 篇的主题。

四、写入时机:热路径还是后台

LangMem 的概念文档把这两种时机叫 active(热路径)和 background(后台),并列出了各自的代价:

时机延迟影响记忆生效速度处理位置适合
热路径立即生成回复的同一轮内关键上下文更新
后台延迟两次调用之间或之后模式分析、摘要
横轴是真实时间,刻度按一次 2000 token 输入的常见量级取热路径写入检索记忆 40ms业务模型调用 1.8s抽取 + 写入 1.6s用户看到首字之前要等 3.4 秒后台写入检索记忆 40ms业务模型调用 1.8s用户 1.84 秒就拿到回复异步抽取 1.6s用户在这 1.6 秒内追问,记忆还没写完折中做法:后台写入 + 本会话内保留一份进程内缓存,让紧接着的追问读到刚说的话,异步写入只负责跨会话那一层。
后台写入的窗口期是一个真实的正确性缺口,不是理论问题:用户说完「我改吃粤菜了」立刻追问「那推荐一家」,如果这一轮读到的还是旧记忆,效果比没有记忆更糟。

默认选后台。热路径写入只在一种情况下值得:用户的当轮输入明确是在给系统下指令("以后都用中文回我"),且这条指令必须立刻生效。这类输入可以用一个轻量分类器识别出来单独走热路径,其余走后台。

五、抽取质量的三条硬要求

5.1 每条必须自包含

# ❌ 抽出来的记忆依赖原对话才能读懂
{"text": "他也喜欢这个"}
# 检索时命中了这一条,注入上下文之后模型看到的是一句没有指代对象的话,
# 结果是模型要么忽略它,要么按自己的猜测补全 —— 后者更糟。

# ✅ 指代全部解析掉
{"text": "用户的哥哥也喜欢滑雪"}

mem0 的提示词里专门给了一段"最近 k 条消息"就是为了解代词:## Last k Messages — Recent messages (up to 20) preceding New Messages. Use to resolve references and pronouns in New Messages.

5.2 相对时间必须锚定到绝对时间

这是抽取里最容易被忽略、后果又最持久的一条。mem0 的提示词把两个日期分得很开:

输入含义用途
Observation Date对话实际发生的日期唯一可以用来解析「昨天」「上周」的锚点
Current Date跑抽取的日期,可能比对话晚几个月不得用于解析对话里的相对时间

提示词里给的理由很直白:一条写着「用户上周去了巴黎」的记忆,六个月后读回来毫无意义;写成「用户在 2023 年 5 月 15 日那周去了巴黎」则永远成立。

批量灌历史数据时这条最容易踩:一次性把两年的聊天记录跑抽取,如果拿跑批的当天当锚点,两年里所有的「昨天」会全部解析到同一天。

5.3 抽取范围要明确到角色

mem0 的两版提示词在这一点上是反的,说明这是个需要按场景决定的选项:

# USER_MEMORY_EXTRACTION_PROMPT —— 只从用户消息抽
# [IMPORTANT]: GENERATE FACTS SOLELY BASED ON THE USER'S MESSAGES.
# DO NOT INCLUDE INFORMATION FROM ASSISTANT OR SYSTEM MESSAGES.

# ADDITIVE_EXTRACTION_PROMPT —— 两边都抽
# You extract from BOTH user and assistant messages. User messages reveal personal facts...
# Assistant messages contain recommendations, plans, suggestions...

只从用户消息抽:安全,不会把模型自己的幻觉固化成"事实"。代价是丢掉助手给出的方案和承诺 —— 用户下次问"你上次推荐的那家店"就找不到了。

两边都抽:能记住方案与承诺,但要防住模型给自己背书。新版提示词的防线是一条排除清单:不抽"你看起来很有热情"这类助手对用户的主观刻画,除非用户明确认可;不抽"好问题!"这类客套。

六、写入成本

按一次 2,000 token 的对话、抽出 3 条事实估算,用输入价格 3/MTok、输出3/MTok、输出 15/MTok 的中档模型:

每次会话的模型开销,以"不做记忆"为基准(条长按相对倍数画)不做记忆1.00×业务调用:2000 in + 400 out+ 单次抽取1.70×抽取多读一遍 2000 token+ 抽取 + 巩固2.20×巩固要把候选事实和十条旧记忆一起再发一次压成本的三条路:抽取换 Haiku 级小模型(价差 3 到 5 倍)、多轮合并成一次抽取、把巩固改成只在检索到高相似条目时才触发。
倍数比绝对金额更有用,因为它不随模型降价变化。mem0 v2 取消独立巩固调用之后,默认配置回到了图中第二行。

这笔开销在账单里默认算不出来 —— 抽取调用和业务调用用的是同一个 API key。要拆开,得在网关侧按调用来源打标,做法见可观测性 06 篇

七、四种静默失效

写入路径出问题时通常不报错,只是记忆变少或变脏。四种最常见的:

失效现象根因怎么发现
抽了个空会话结束后一条记忆都没多模型返回了空 facts 数组,或 JSON 解析失败被 except 吞掉监控"每会话新增记忆数"的分布,长期为 0 的用户是信号
抽成流水账记忆库里全是「用户问了一个问题」这类无信息条目提示词没有约束"什么不该抽",模型倾向于多抽抽样人读,或用另一个模型给每条记忆打"以后还用得上吗"的分
时间全塌到一天所有记忆的时间描述指向跑批那天拿 Current Date 当锚点解析相对时间查记忆文本里的日期分布,出现尖峰即是
跨租户串号用户 A 读到了 B 的记忆filters 里少传一个维度,检索退化成全库在存储层加租户列的强制约束,别只靠调用方传参

前三种是质量问题,第四种是事故。第四种不要靠代码评审防:在向量库或数据库层面把租户字段做成必填的分区键,调用方漏传时直接报错,而不是悄悄返回全部数据。

八、小结

  • 抽取式的收益在受控评测里不像宣传的那么确定,而它的成本(模型开销约 1.7 倍)是确定的;生产系统常见做法是两条路线各存一份
  • mem0 v2 的流水线只有一次模型调用,且把 UUID 映射成个位数整数给模型看 —— 后者是任何需要模型引用实体的场合都该抄的做法
  • 从四操作巩固退回纯追加,本质是把"哪条才对"的判断从写入时推迟到检索时;不可逆操作在信息不全时代价太高
  • 默认走后台写入,只给明确的指令类输入开热路径
  • 抽取质量的三条硬要求:自包含、时间锚定到观测日期、明确抽取角色范围

下一篇:03 - 冲突、遗忘与时间,处理纯追加留下的那个问题 —— 库里两条矛盾的记忆并存时,谁说了算。

← 回到 专题索引