对比naive RAG的单阶段(存)vs真实记忆层的四阶段架构(提取→检索→决策→归并),解决向量库只增不减导致矛盾信息累积的核心问题。
大多数给 AI Agent 添加"记忆"功能的人做法都一样:把每条消息都 embedding 一下,扔进 Pinecone 或 pgvector,查询时调用 similarity_search。这套方案跑 demo 没问题,上生产就崩了——因为数据只增不减,永远不会更新或删除,存储空间被矛盾信息填满。
举个例子:用户 3 月告诉你的 Agent"我住在奥斯汀",7 月又说"我刚搬到丹佛"。 naive 的向量存储会把这两条都留下。查询时,两条都会作为"我住在哪里"的 top-k 匹配返回回来,现在你的 LLM 同时持有了两个矛盾的事实,却没有依据判断哪个是最新的。这才是 Mem0 这类记忆层真正要解决的问题,背后的机制也比"带几步附加操作的向量数据库"有意思得多。下面来看看它到底是怎么工作的。
管道有四个阶段,不是一个
Naive RAG 只有一个阶段:embedding 然后存储。真正的记忆层有四个:
提取(Extraction) — 将原始对话轮次转换为待处理事实候选 检索(Retrieval) — 找到可能与每个候选相关的已有记忆 决策(Decision) — 对每个候选,决定是 ADD、UPDATE、DELETE 还是 NOOP 整合(Consolidation) — 将解析后的状态写回,而不是简单追加
第一个阶段人人都做。第二和第三个阶段才是让记忆真正有用的关键,而不是变成一堆无法协调的 embedding 的堆场。
你不会直接 embedding 原始消息"对,我上周刚搬到丹佛,还在到处拆箱子呢"。你会用 LLM 提取 prompt 从中抽取持久化事实:user.location = Denver。关于箱子和拆箱的内容都是对话噪音——对当前轮次有用,六周后就毫无价值了。
EXTRACTION_PROMPT = """
Extract durable facts about the user from this message.
Ignore small talk, emotional tone, and one-off requests.
Return a list of atomic facts as short subject-predicate-object statements.
Message: {message}
"""
candidates = llm.extract(EXTRACTION_PROMPT.format(message=turn))
# -> ["user lives in Denver"]
原子化在这里很关键。"用户住在丹佛、远程工作、养了条狗"应该拆成三条独立的事实候选,而不是一个 blob——因为每条都可能需要独立地被添加、更新或删除,如果它们被焊在一起,你就无法单独操作其中某一条了。
这部分最容易做反。写时,你不是在用相似性搜索回答问题——而是在找可能与新候选矛盾或需要细化的记忆。所以对于"用户住在丹佛",你会对候选做 embedding,然后拉取最近的几条已有记忆:
candidate_embedding = embed("user lives in Denver")
neighbors = vector_store.search(candidate_embedding, k=5)
# -> [("user lives in Austin", score=0.89), ("user has a dog", score=0.31), ...]
高分的邻居(用户住在奥斯汀,相似度 0.89)恰恰就是需要处理的情况——语义上足够接近,是关于同一个事实的,但不是完全一样。这就是下一阶段需要解决的冲突。
对于每个候选及其邻居,第二次 LLM 调用(或者更精简的实现中用一个分类器)来决定要做什么。Mem0 的公开文档描述的正是这个操作:给定一个新事实及其最近的已有记忆,输出 ADD、UPDATE、DELETE 或 NOOP 之一。
DECISION_PROMPT = """
New fact: {candidate}
Existing related memories: {neighbors}
Decide the operation:
- ADD: new fact, no real overlap with existing memories
- UPDATE: new fact supersedes an existing memory (same subject, changed value)
- DELETE: new fact explicitly contradicts and invalidates an existing memory
- NOOP: new fact is already captured, do nothing
Return: {{"operation": ..., "target_id": ..., "resolved_fact": ...}}
"""
decision = llm.decide(DECISION_PROMPT.format(
candidate="user lives in Denver",
neighbors=[("m_204", "user lives in Austin")]
))
# -> {"operation": "UPDATE", "target_id": "m_204", "resolved_fact": "user lives in Denver"}
这就是把向量存储变成记忆系统的步骤。没有它,你有一个 append-only 的日志加上一个模糊搜索索引。有了它,你就更接近一个可变的键值存储,只是"键"是语义相似性而不是精确字符串。
需要警惕的失败模式:过于激进的 DELETE/UPDATE 阈值会把只是看起来相似的事实合并掉。"用户住在丹佛"和"用户出生在丹佛"余弦相似度能达到 0.85 以上,但意思完全不一样。这就是为什么决策步骤需要 LLM 做语义推理,而不是只看相似度分数阈值——一个纯阈值规则(比如相似度 > 0.8 就覆盖)会在最需要做对的场景下悄悄破坏记忆。
解析后的操作会被原子化地应用:UPDATE 覆盖 m_204 已有的向量和元数据(新的 embedding、递增的版本号、更新后的时间戳),而不是插入新行然后让旧数据腐烂。DELETE 会对记忆加墓碑标记而不是静默删除,这样如果用户问"等等,我之前不是告诉你我住在奥斯汀吗",你还能追溯为什么某条记忆消失了。
if decision["operation"] == "UPDATE":
vector_store.update(
id=decision["target_id"],
embedding=embed(decision["resolved_fact"]),
text=decision["resolved_fact"],
version=existing.version + 1,
updated_at=now(),
)
记忆清理干净后,查询时的排序仍然不应该是纯余弦相似性。生产环境的记忆层会将相似性与时效性和访问频率混合起来,因为一小时前的fact和八个月前的fact都可能同时是 top-3 最近邻,但它们的可信度并不同等:
score = (0.6 * similarity) + (0.25 * recency_decay(age_days)) + (0.15 * access_frequency)
衰减函数的重要性比它的权重看起来大得多——线性衰减会把一条 30 天前的fact和一条 300 天前的fact视为几乎一样,而指数衰减(更接近艾宾浩斯遗忘曲线的实际形态)让过时的事实逐渐淡出,却没有硬性删除机制来抹掉那些仍然为真的信息。
如果你在构建 Agent 记忆,逻辑只是每轮都 store.add(embedding),那你拥有的不是记忆——而是一本索引混乱的日记,模型只能通过吸管一样细的通道去读。四阶段循环——提取、检索邻居、决定操作、整合——这才区分了随积累变得更有用和变得更慢更矛盾的两种系统。那个决策步骤也是你无法用更大的 embedding 模型或更快的向量索引来捷径的部分;它需要对两个事实是否是同一个事实、是否是更新版本、还是完全不相干的内容做真正的推理。