从 RAG 到 Zero-Mem:Agent 记忆的四种写法,我更看好这一种(附代码)

最近在排查我们线上 Agent 的一个老毛病:长对话跑到第 30 轮,它就开始"忘事"。不是真忘了,是记忆模块的摘要把关键信息合并掉了。我顺着这条路翻到一篇 7 月底刚挂出来的论文 Zero-Mem: Zero-Token Memory Operations for LLM Agents(arXiv:2607.29377v1,香港理工和西南财经联合出品),读完之后我把原本打算加在记忆链路里的好几层 LLM 调用全删了。这篇文章讲的就是一件事:Agent 的记忆操作,是不是根本不需要调用 LLM。
我尽量用大白话把它的核心讲清楚,顺便把论文里最关键的那套机制用代码复现了一遍。如果你写过 RAG、做过对话机器人、或者正在给 Agent 接长期记忆,这篇值得你花二十分钟。
先说清楚:Agent 为什么需要"记忆"
现在的 Agent 不是一问一答就结束。它会跟用户聊很久,中间调用工具、执行动作、拿到观测结果。所有这些历史,论文里叫 interaction history,记作 H = (s₁, …, s_T)。每个 s 叫一个 trace unit(轨迹单元),里面可能是用户说的原话、Agent 的回复或动作、工具返回的结果、时间戳、是谁说的、属于哪一次会话。
问题的关键是:当用户在第 40 轮问"我上周三订的那家东京酒店叫什么"时,系统得从这几十轮乱七八糟的历史里,把正确的证据捞出来,塞给大模型去回答。这就是 memory 模块干的事:
R(q) = Memory(q, H) # 从历史里检索出证据集
a = Reader(q, R(q)) # 大模型拿着证据生成答案
绝大多数现有方案,比如 Mem0、A-Mem、Zep、MemoryOS,走的都是"生成式记忆"路线:用 LLM 去总结、去反思、去构建层级抽象、去维护一张知识图谱。听起来很高级,但论文点破了一个被大家忽略的代价。
生成式记忆的隐性账单
用 LLM 来"管理记忆",每写一段摘要、每抽一条关系、每更新一次图谱,都是在烧 token、烧时间。更要命的是第二笔账:当你后面要查记忆时,你查到的不是原始记录,而是 LLM 替你生成的"摘要版记忆"。一旦当初摘要时把两个相似的人合并了、把某个细节漏了、把时间线搞模糊了,你后面就再也找不回原始证据了。论文原话是:omitted or merged details can obscure the original evidence。
我线上那个 30 轮忘事的 bug,根因就在这。摘要把"东京的航班"和"东京的酒店"揉成了一句,后续检索自然对不上。
于是论文抛出一个很硬的问题:结构化的记忆访问,真的必须靠生成(generation)吗?它的答案是 Zero-Mem,一个"零 token 记忆操作"框架。定义特别干脆:除了最后那次问答(final-QA reader),记忆的构建、组织、路由、检索、闭环、校准,全都不调用 LLM,不消耗任何 LLM 的输入/输出 token。Encoder 的推理(比如跑个 embedding 模型)单独算账,不算在"LLM token"里。

核心思路:保留原始痕迹,而不是替代它
Zero-Mem 不把历史替换成"生成的抽象",而是把原始轨迹当成正史(source of record)。每个派生出来的单元,都带着原文、来源 ID、会话时间、边界标识这些元数据。这样任何被检索出来的证据,都能追溯回某一次真实的对话,而不是某个模型自己编出来的"记忆陈述"。
在这个基础上,它从原始轨迹上长出两个互补的、且都不靠生成的视图:
- 实体-上下文图(关系视图):把不同交互之间"谁和谁一起出现过"的关系露出来。
- 时间层次结构(局部/时序视图):保留对话的局部顺序和会话状态。
查询来了之后,系统按查询的结构特征,给两个视图分配不同权重,两边都检索,再顺着结构把支撑关系或者上下文补全。最后一步确定性校准,先扔掉互相冲突的证据,再把答案锁死在检索到的轨迹上。整条链路里,唯一调用 LLM 的,只有最后那个 reader。
它和我们熟知的 RAG、Mem0 到底差在哪
程序员对 RAG 不陌生,所以先把这三个东西摆在一起看,差异就清楚了。
原始 RAG(raw retrieval):把历史切成固定大小的 chunk,查询来了做向量相似度检索,取 top-k 塞给大模型。它保住了原文,问题在"平"。不同用户、不同会话、不同时间点的语义相似片段会被搅在一起;当答案的证据分散在好几段对话里时,单凭相似度很容易漏掉其中一环。
生成式记忆(Mem0 / A-Mem / Zep 一类):在 RAG 前面加了一层 LLM,负责总结、反思、建图、演进记忆。它让大历史更好查,但代价就是前面说的:每次管理都烧 token,而且你后面查到的是"模型生成的记忆",不是原始记录。一旦摘要阶段合并或遗漏,错误就被固化进记忆,还难以追溯。
Zero-Mem:两头都不走极端。它像 RAG 一样保住原始轨迹(可追溯),又像生成式记忆一样组织了结构(图 + 层次),但构建这些结构用的是 NER、BM25、稠密向量、PageRank 这类"非生成"手段,全程不调 LLM。你可以把它理解成:用检索系统的工程方法,去实现生成式记忆想要的"结构化访问",但把最贵、最不可控的那块(LLM 生成)彻底拿掉。
一句话总结三者差异:RAG 是平的,生成式记忆是抽象过的,Zero-Mem 是结构化但保真的。
下面我按论文的四个组件,逐个拆开讲,并配上能跑的代码。
组件一:保真记忆底座(Provenance-preserving Token-Free Substrate)
这一层干三件事。
1. 关系轨迹图。 论文用了一个"非生成"的 NER 模型(比如 spaCy)去扫每个上下文单元,从识别出的实体构建一张观测到的实体-上下文图:
G = (V_d ∪ V_e, E_de ∪ E_dd)
- V_d 是上下文节点,V_e 是实体节点。
- E_de 是"实体-上下文共现边":实体 e 出现在上下文单元 dᵢ 里,就加一条边,权重是归一化的出现频率:
w(dᵢ, e) = c(e, dᵢ) / Σ_{e′∈E(dᵢ)} c(e′, dᵢ) - E_dd 是相邻上下文单元之间的边,用来保留局部连续性。
注意这里只记录"观测到的共现和相邻",不去生成什么语义三元组(subject-predicate-object),也不去推断隐含关系。这正是它能保真的原因。
2. 层次轨迹单元。 光有图还留不住对话的顺序和时间状态。论文按多种粒度组织轨迹:
T(H) = U_turn ∪ U_window ∪ U_episode ∪ U_local
- Turn(轮次):最原子的话语。
- Window(窗口):短程上下文。
- Episode(事件段):按语义连续性和时间/会话边界,把相邻窗口聚成一段连贯的事件。
- Local span(局部跨度):某个候选轮次前后的邻居,需要周边语境时补上。
所有单元都从底层原始轨迹继承来源信息。
3. 词面 + 稠密信号。 再用 BM25(词面)和 BGE-M3(稠密向量)给轨迹单元建索引。词面信号管精确匹配:人名、日期、数字、标题、引号短语。稠密信号在字面重叠弱时提供语义锚点。论文强调:这些表示只用来索引、播种和打分,绝不生成或改写记忆内容。
组件二:查询条件路由(Query-Conditioned Evidence Routing)
每个查询来时,先构造一个轻量画像:
ϕ(q) = {subject, keywords, answer-type, temporal-cues, boundary}
subject 和 keywords 是内容锚点;answer-type 和 temporal-cues 描述这条证据在结构上的需求;boundary 限定可查的会话范围。这些信号都从查询和已有元数据里拿,不碰标准答案(gold answer)。
路由决定哪个视图优先:
Route(q) ∈ {relational, local}
判断依据是确定性的查询结构信号:问句形式、是否要时间或聚合、subject 锚点是否存在。设 ρ 为全局共享的主视图权重。关系型查询给图视图 ρ、层次视图 1-ρ;局部型查询反过来。两个视图在全模型里都跑,路由主要控制融合时的相对权重。论文里 ρ 和阻尼系数 γ 都设成 0.6。
组件三:双视图检索与闭环(Dual-View Retrieval and Closure)
图检索:个性化 PageRank 传播。 先把查询里抽出的每个实体 ê,对齐到图上最相似的观测实体 e:
η₀(e | q) = cos(e, ê), e = argmax_{e′∈V_e} cos(e′, ê)
对齐不到实体时,稠密上下文匹配当兜底排序。接着从对齐到的实体出发,沿共现关系传播激活。设 Z(e) 是含有实体 e 的句子集合,传播公式:
η_{t+1}(e′) = Σ_{e∈E_t} η_t(e) · Σ_{z∈Z(e)∩Z(e′)} sim(q, z)
直观理解:一个实体如果和"已经被激活的实体"在跟查询相关的同一句话里共现,它就会拿到高分。把传播后的实体激活和稠密上下文先验合并成重置向量 r_q,再用个性化 PageRank 把证据铺到整张关系图上:
π_q = (1 − γ) r_q + γ Pᵀ π_q
P 是行归一化的图转移矩阵,γ 是阻尼系数。上下文节点上的 PageRank 值,就是图视图的排序。最后再用精确词面和短语匹配,对名字、日期、数值、标题做精修。
这里有个值得停下来想一下的点:为什么是 PageRank,而不是简单按共现次数排序。普通共现排序只能回答"哪条轨迹和查询实体共现最多",但记忆检索常常需要"绕一圈"——查询问的是 A,证据主链在 A 上,但支撑细节在和 A 共现的 B 上,B 又连着 C。PageRank 的随机游走会把这种"二阶、三阶邻居"的激活顺着边传出去并加权累积,所以一条本身没直接命中查询、但处在关键关系路径上的轨迹,也能被抬上来。重置向量 r_q 的作用,是保证游走最终总会以 (1−γ) 的概率"弹回"到查询种子上,避免激活无限扩散到全图。这正是它比扁平相似度检索更能抓"分散证据"的原因。
层次检索:由粗到细。 这一视图走的是 Episode → Window → Turn → Local 的 coarse-to-fine。先定位相关事件段,再收窄到窗口,最后露出原始证据。某个被选出的轮次如果依赖临近信息,就把它的 local span 补上。和图传播不同,这条路线显式维护了顺序、时间局部性和会话级上下文。
闭环(Closure)。 两个视图的打分先各自做 query 级归一化,再按路由系数 ρ 融合:
S_fuse(d) = ρ · bS_primary(d) + (1 − ρ) · bS_secondary(d)
主证据 M(q) 融合出来之后,再从两个视图补上有界的支撑:
C(q) = Dedup( M(q) ∪ N_g(M(q)) ∪ N_h(M(q)) )
N_g 提供带关系桥接的图排名上下文,N_h 补回相邻轮次或局部跨度。两者按需可空。靠共享单元 ID 或来源去重,得到一份紧凑、既有关联支撑又有局部支撑的证据集。
组件四:确定性证据校准(Deterministic Evidence Calibration)
这是我觉得整篇最实用的一块。它在证据层和答案层都做确定性校准(不调用模型):
证据层先按硬约束过滤掉违反来源或查询边界的候选,再按 subject、时间、答案类型的兼容性重排:
R(q) = Rank_{ϕ(q)}( Filter( C(q), ϕ(q) ) )
reader 据此产出初稿答案 a₀。对于能做确定性检查的回答形式(比如列表、标量、带类型的答案),论文把它和证据里的候选对照:
A(q) = Extract( R(q), ϕ_type(q) )
a = Calibrate( a₀, q, A(q), R(q), ϕ(q) )
校准规则很清楚:a₀ 如果证据支持且形式规范,就保留;否则做证据保留的归一化、抽取式截断、或逐条列表剪枝。一个标量答案只有在 A(q) 里存在唯一类型兼容候选时才替换;没有确定性修正可用时,保留 a₀。
换句话说,答案质量这道关,也是用规则和证据兜住的,不靠再调一次 LLM。
代码复现:把核心跑起来
我把上面这套流程做了一个最小可运行版本,纯 Python、零第三方依赖。生产环境里你只要把 mock_ner 换成 spaCy、vec_of 换成 BGE-M3 句向量,再把 PageRank 跑在完整实体-上下文图上,就是论文的工程版。代码已实测可跑(文末有输出)。
# -*- coding: utf-8 -*-
"""Zero-Mem 核心流程最小可运行复现(纯 Python,零第三方依赖)。"""
import re, math
# ---- 工具:纯 Python 向量 ----
def norm(v):
s = math.sqrt(sum(x*x for x in v)); return [x/s for x in v] if s else v
def dot(a, b):
return sum(x*y for x, y in zip(a, b))
def vec_of(word, dim=8):
g = hash(word) & 0xffffffff; v = []
for _ in range(dim):
g = (g*1103515245 + 12345) & 0x7fffffff
v.append((g % 1000)/1000.0 - 0.5)
return norm(v)
# ---- 0. 原始交互轨迹 ----
traces = [
"Alice booked a flight to Tokyo with the travel agent on Monday.",
"The travel agent confirmed Alice's hotel in Tokyo for Tuesday.",
"Bob asked about the Tokyo restaurant recommendation from Alice.",
"Alice said the flight was delayed but the hotel in Tokyo was fine.",
"Bob canceled his train ticket from Osaka to Kyoto last Friday.",
]
# ---- 1. 实体-上下文图(关系视图) ----
KEYWORDS = {"flight","hotel","train","tokyo","restaurant","agent",
"osaka","kyoto","monday","tuesday","friday"}
def mock_ner(text):
caps = re.findall(r"\b([A-Z][a-zA-Z]+(?:\s[A-Z][a-zA-Z]+)?)\b", text)
lows = [w for w in re.findall(r"[a-zA-Z]+", text.lower()) if w in KEYWORDS]
return set(caps) | set(lows)
vocab = sorted({e for t in traces for e in mock_ner(t)} | {"Bob"})
emb = {w: vec_of(w) for w in vocab}
context_nodes = {i: t for i, t in enumerate(traces)}
entity_nodes = set(vocab)
adj = {i: [j for j in (i-1, i+1) if 0 <= j < len(traces)] for i in range(len(traces))}
# ---- 3. 查询条件路由 ----
def route(query):
if any(k in query.lower() for k in ["when","which","who","where"]):
return "relational"
return "local"
# ---- 4. 图检索:个性化 PageRank 传播 ----
def graph_retrieve(query, gamma=0.6, steps=3):
q_ents = mock_ner(query)
seeds = [0.0]*len(traces)
for e in q_ents:
if e in emb:
sims = {en: dot(emb[e], emb[en]) for en in entity_nodes}
best = max(sims, key=sims.get)
for i, t in context_nodes.items():
if best in mock_ner(t):
seeds[i] += 1.0
if sum(seeds) == 0:
seeds = [1.0/len(traces)]*len(traces)
n = len(traces)
P = [[0.0]*n for _ in range(n)]
for i in range(n):
nbrs = list(set(adj[i] + [j for j in range(n)
if any(e in mock_ner(traces[i]) and e in mock_ner(traces[j])
for e in entity_nodes)])) or [i]
for j in nbrs:
P[i][j] = 1.0/len(nbrs)
pi = list(seeds)
for _ in range(steps):
pi = [(1-gamma)*seeds[i] + gamma*sum(P[j][i]*pi[j] for j in range(n))
for i in range(n)]
return pi
# ---- 5. 层次检索:粗到细 ----
STOP = {"the","a","an","to","of","on","for","with","and","but","is","was","were","did",
"which","when","who","where","what","from","in","it","that","this"}
def hier_retrieve(query, top=2):
qkw = {w for w in re.findall(r"[a-z]+", query.lower()) if w not in STOP}
scores = {i: sum(1 for w in qkw if w in t.lower()) for i, t in context_nodes.items()}
return sorted(scores, key=scores.get, reverse=True)[:top]
# ---- 6. 双视图融合 ----
def fuse(graph_scores, hier_rank, rho=0.6, primary="relational"):
gmin, gmax = min(graph_scores), max(graph_scores)
fused = {}
for i in range(len(traces)):
g = (graph_scores[i]-gmin)/(gmax-gmin+1e-9)
h = 1.0 if i in set(hier_rank) else 0.0
fused[i] = (rho*g + (1-rho)*h) if primary=="relational" else (rho*h + (1-rho)*g)
return fused
if __name__ == "__main__":
q = "When did Alice book the flight to Tokyo?"
print("查询:", q)
print("路由:", route(q))
gs = graph_retrieve(q, steps=1)
hr = hier_retrieve(q)
fused = fuse(gs, hr, rho=0.6, primary="relational")
top = sorted(fused, key=fused.get, reverse=True)[:2]
print("图视图分数:", [round(x, 3) for x in gs])
print("层次检索 Top:", hr)
for i in top:
print(f" [{i}] {traces[i]} (score={fused[i]:.3f})")
本地跑出来的结果:
查询: When did Alice book the flight to Tokyo?
路由: relational
图视图分数: [3.49, 3.09, 3.09, 3.49, 0.84]
层次检索 Top: [0, 3]
[0] Alice booked a flight to Tokyo with the travel agent on Monday. (score=1.000)
[3] Alice said the flight was delayed but the hotel in Tokyo was fine. (score=1.000)
你看,查询"Alice 订去东京的航班是什么时候",图视图把第 0 条(booked a flight to Tokyo)和第 3 条(flight delayed)顶上来了,第 4 条关于 Bob 新干线的内容分数明显掉下去。这就是关系视图在起作用:它顺着"Alice–flight–Tokyo"的共现把相关轨迹激活。真实场景里轨迹成千上万,PageRank 的区分度会比这个小玩具强得多。
想试层次视图,把查询换成不带 wh- 词的,比如 "Alice flight Tokyo Monday",路由会切到 local,检索顺序就改成按时间/局部窗口走。
实验结果:又快又准,还零 token
论文在两类基准上做了评测。
LoCoMo(长程多轮对话记忆):Zero-Mem 在 GPT-4o-mini 和 Qwen2.5-14B 两个底座上都拿了平均 F1 和 BLEU-1 第一。比起最强基线 GAM,平均 F1 高了 5.40(GPT-4o-mini)/ 4.87(Qwen)个点。
HotpotQA(多跳问答,上下文从 56K 拉到 448K token):在所有底座和所有长度下都是最高 F1,平均比最强基线高 5.52 个点。上下文越长、干扰越多,它越能显出"在长上下文里定位并连接分散证据"的本事。
最狠的是效率表(统一配置,GPT-4o-mini,4 并发,RTX 4090):
| 方法 | F1 | BLEU-1 | 记忆操作 token | 每查询耗时 |
|---|---|---|---|---|
| GAM | 53.75 | 47.51 | 28,570,674 | 6.00s |
| SimpleMem | 48.02 | 41.68 | 14,096,246 | 5.43s |
| LightMem | 38.44 | 34.36 | 877,086 | 0.51s |
| Zero-Mem | 59.15 | 52.96 | 0 | 0.22s |
Zero-Mem 把记忆操作的 LLM token 直接干到 0,总耗时 334.77 秒、每查询 0.22 秒,比最快的基线 LightMem 还快 57.6%。注意论文特意说明:零 token 不等于零计算,encoder 推理、检索、确定性校准照样花钱,只是这笔钱远比反复调 LLM 便宜,而且没把成本转嫁到一个更慢的非生成管线上。
消融实验也讲得通:完整模型 HotpotQA 56K 上 F1 72.07;只留图视图掉到 62.50,只留层次掉到 54.88,说明两者互补;去掉证据闭环掉到 67.90,去掉证据校准掉到 70.13,两块都有正贡献。检索预算实验显示 top-5 到 top-10 收益最大,之后边际递减,论文主实验取 top-5 对齐其它基线。
我的几点判断
读完整篇,我自己的结论是:很多 Agent 记忆系统的"智能",其实是被 LLM 一遍遍总结喂出来的虚假智能,代价是 token、延迟和保真度三重流失。 Zero-Mem 的聪明之处不是某个惊世骇俗的算法,而是把"记忆管理"重新定义成一道"在带来源的证据上做结构化选择"的工程问题,从而把 LLM 彻底请出记忆链路。
它特别适合这几类场景:
- 长对话客服/助理,经常被"你上周说过的那句话"这类跨轮查询折磨;
- 多跳问答、需要连接分散在多段文档里证据的检索;
- 对延迟和成本敏感、但又不想牺牲可追溯性的生产系统。
当然它也有边界。第一,关系视图依赖 NER 质量,实体抽不准图就歪;第二,它假设原始轨迹可被保留,那些合规上必须定期清理历史的环境要另想办法;第三,融合权重 ρ、阻尼 γ 是全局固定值,论文没做查询级自适应。如果你面对的是高度异构、跨用户的超大规模轨迹,图规模和 PageRank 的开销本身会变成新瓶颈,这时候该考虑的可能是图的增量维护和近似 PageRank。
我们那条"30 轮忘事"的线,我打算按这个思路改:原文落库保真,上面铺一张实体-上下文图做跨轮关联,再配时间层次兜局部语境,检索阶段零 LLM 调用,只在最后问答时用一次模型。光是省掉的摘要调用,按我们的对话量估算每个月能抠回不少 token 账单。
如果你打算在自家系统里试,我整理了一个落地清单,照着走能少踩坑:
- 先保真,再结构。 原始轨迹落库时把来源 ID、会话 ID、时间戳、轮次序号一起存。没有这些元数据,后面的图和层次都无从谈起,确定性校准也做不了过滤。
- NER 是关系视图的天花板。 论文用 spaCy,生产里建议换成和你领域匹配的 NER(金融用金融实体,医疗用医疗实体)。实体抽歪了,图就歪,PageRank 再漂亮也是垃圾进垃圾出。
- 稠密向量别省。 BM25 管精确匹配,BGE-M3 这类稠密向量管语义兜底。两者一起上,查询实体没被 NER 抓到时,稠密相似度还能把路指回来。
- ρ 和 γ 先取 0.6 跑通,再调。 论文给的默认值是经验起点,不是金标准。关系型查询多就调高 ρ,时间/局部型查询多就调低。
- 图要做增量维护。 轨迹是流式增长的,每来一轮就增量更新节点和边,别每次全量重建。规模上来后用近似 PageRank(比如幂迭代截断)控成本。
- 校准规则要紧贴你的答案形态。 列表题、标量题、带类型题能做确定性检查;开放式长文回答就别硬校准,保留 reader 原稿。
什么时候我不建议上 Zero-Mem:如果你的场景历史必须定期合规清理、留不住原始轨迹,那"保真"前提就破了;如果你面对的是超大规模、跨用户的统一图谱,图规模和 PageRank 本身会变成新瓶颈,这时候该考虑图的分布式存储和近似计算,而不是照搬这套单机思路。
论文说代码会在同行评审后开源(github.com/TheMoon0815/Zero-mem)。在那之前,上面这份复现够你先把骨架搭起来、在自家数据上跑通验证了。
如果你也在做 Agent 长期记忆,或者对"零 token 记忆"这个方向有不同看法,欢迎在评论区聊聊,我挺想看大家在保真和成本之间是怎么取舍的。
更多推荐



所有评论(0)