1. 从两个榜单分数说起:这套记忆架构到底在解决什么
LongMemEval 95.60%,LoCoMo 93.60%。这两个数字放在一起看,做 LLM Agent 的人应该能立刻意识到分量——LongMemEval 考的是长程记忆的召回与推理,LoCoMo 考的是多轮对话里跨会话、跨时间线的信息一致性。两个榜单同时刷到 93 分以上,意味着这套叫 Agent Zero Memory 的东西,不是靠某个单点技巧冲分,而是在“记得住”和“用得对”这两件事上同时做到了接近上限的水平。
先把背景说清楚。LLM Agent 的记忆问题,本质上不是“存不下”,而是“存了之后取不准、取回来之后用不对”。一个跑了三个月的客服 Agent,对话历史可能有几十万 token,用户上周随口提过一句“我下个月要搬家”,这周问“推荐个附近的餐厅”,如果 Agent 把“搬家”这个信息丢了,推荐的就是旧地址周边的餐厅,体验直接崩掉。LongMemEval 这类基准测的就是这种能力:在超长上下文中,能不能精准定位到相关的那条记忆,并且正确推理出它对当前问题的影响。
LoCoMo 更狠一点。它是多会话(multi-session)的设定,对话被切成一段一段,中间有时间间隔,Agent 需要跨这些片段把同一个人的信息拼起来。比如第一段对话里用户说“我养了只猫叫豆豆”,第五段对话里用户说“豆豆最近不爱吃东西”,Agent 得知道这两个“豆豆”是同一只猫,并且把“猫”和“不吃饭”关联起来给出建议。这种跨会话的实体一致性,是很多记忆方案的死穴。
Agent Zero Memory 这个名字里的“Zero”,我理解不是“零记忆”,而是“零额外负担”——不需要为每个 Agent 单独训练记忆模块,不需要把历史全塞进 context window,而是用一套多轨并行的架构,把记忆的存储、检索、推理拆成几条独立的轨道,各跑各的,最后在决策层汇合。这个思路和传统的“向量数据库 + RAG”有本质区别,后面会展开讲。
适合谁来读这篇?如果你正在做 LLM Agent 的产品落地,被“记不住”“记混了”“记太多导致推理变慢”这几个问题折磨过,那这套架构的拆解值得你花时间。如果你只是好奇榜单分数怎么来的,也能从里面看到记忆系统设计的核心权衡。我会尽量把每个设计选择背后的“为什么”讲透,而不是只丢结论。
2. 多轨并行记忆架构的核心设计逻辑
2.1 为什么单轨记忆一定会撞墙
先看大多数团队做 Agent 记忆的默认路径:把所有对话历史 embedding 之后塞进向量库,查询时做相似度检索,top-k 结果拼进 prompt。这套方案在 demo 阶段很好用,但一上量就出问题。
第一个问题是检索粒度和推理需求不匹配。用户问“我上次说的那个项目 deadline 是什么时候”,向量检索会返回包含“deadline”“项目”这些词的片段,但真正有用的信息可能是三天前一句“这个项目老板说月底必须交”,里面根本没有“deadline”这个词。语义相似度在这里失效了,因为记忆的价值不在于“像”,而在于“相关”。
第二个问题是时间维度的丢失。向量库里的记忆是无序的,检索出来的片段没有时间戳的强弱关系。但很多记忆问题本质是时序推理:“用户先说了 A,后说了 B,B 覆盖了 A”。单轨架构里,A 和 B 可能同时被检索出来,模型看到两个矛盾的信息,要么瞎猜,要么直接摆烂说“信息不一致”。
第三个问题是写入放大。每轮对话都往向量库写,库越来越大,检索噪声越来越高。你可能会说“那就做摘要”,但摘要本身又是有损的,摘掉的可能正是后面要用的细节。这是一个死循环。
Agent Zero Memory 的多轨设计,就是针对这三个问题分别开药方。
2.2 三条轨道的分工:事实轨、时序轨、推理轨
根据公开的技术描述和榜单表现反推,这套架构至少包含三条并行轨道,我按功能给它们起名叫事实轨、时序轨、推理轨。这不是官方命名,但能帮你理解它的运作方式。
事实轨负责存“是什么”。用户的名字、偏好、设备型号、订单号这类结构化程度较高的信息,走这条轨。它的检索不是纯语义相似,而是结合了实体识别和槽位匹配。比如用户说“我的车是 Model Y”,事实轨会抽取出{用户: 车主, 车型: Model Y}这样的三元组存下来。下次用户问“我的车充电要多久”,事实轨直接命中“车型”这个槽位,不需要靠 embedding 去猜。
时序轨负责存“什么时候发生了什么”。每条记忆带时间戳,并且维护一个事件序列。它的核心能力是时序覆盖检测:当新记忆和旧记忆在同一个实体上冲突时,按时序保留新的,但把旧的标记为“已过期”而不是删除。这样既避免了矛盾信息同时出现,又保留了历史可追溯性。LoCoMo 里那种跨会话的实体一致性,很大程度上靠这条轨来保证。
推理轨负责存“推导出来的结论”。这是最容易被忽略但最关键的一条。用户说“我下周三要去北京出差”,事实轨存了“下周三”“北京”“出差”,时序轨存了事件时间。但推理轨会进一步推出“用户下周三不在本地”“可能需要订票”“本地安排要避开周三”。这些推导结果不是用户明说的,但后续对话极可能用到。推理轨把这些中间结论缓存下来,下次相关查询直接命中,不用重新推导。
三条轨并行跑,各自有独立的索引和检索策略,最后在一个融合层做结果合并。融合层不是简单拼接,而是按置信度和时效性加权。事实轨的命中置信度最高,时序轨次之,推理轨最低但覆盖面最广。这个权重设计直接影响了 LongMemEval 的分数——召回率和准确率的平衡点就在这里。
2.3 “Zero”的含义:零训练、零侵入、零上下文膨胀
再解释一下名字里的 Zero。我理解它对应三个设计目标:
零训练:不需要为记忆模块单独做 fine-tune。很多记忆方案要训练一个 retriever 或者 reranker,成本高且迁移性差。Agent Zero Memory 用的是现成的 embedding 模型加规则引擎,开箱即用。
零侵入:不需要改 LLM 本身的推理流程。记忆的读写发生在 LLM 调用之外,Agent 的主逻辑不用动。这意味着你可以把它套在任意现有的 Agent 框架上,不管是 ReAct 还是 Plan-and-Execute。
零上下文膨胀:检索回来的记忆不是全塞进 prompt,而是经过融合层压缩成结构化的“记忆卡片”,每条卡片几十个 token,只保留和当前 query 最相关的字段。这样即使记忆库很大,prompt 长度也可控。
这三个 Zero 加起来,才是它能在榜单上跑出高分的同时保持工程可落地性的原因。纯刷分的方案很多,但能同时兼顾分数和实用性的不多。
3. 核心细节拆解:记忆的写入、检索与融合
3.1 写入阶段:怎么把一段对话拆成三条轨的记忆
写入是整套系统的入口,也是最容易做错的地方。很多方案写入时只做 embedding,信息在入口就丢了。Agent Zero Memory 的写入流程我拆成四步:
第一步,实体与槽位抽取。对每轮对话跑一个轻量的 NER(命名实体识别)加关系抽取。这一步不需要大模型,用 spaCy 或者小型的 BERT 变体就够。抽出来的东西包括:人名、地名、时间、数字、产品名,以及它们之间的关系。比如“我昨天在京东买了个键盘”,抽出{时间: 昨天, 平台: 京东, 商品: 键盘, 动作: 购买}。
第二步,时序归一化。“昨天”“下周三”“上个月”这些相对时间,全部转成绝对时间戳。这一步看似简单,但跨时区、跨会话的时候很容易出错。我的经验是,时间归一化一定要在写入时做,不要留到检索时做,否则每次查询都要重新算,既慢又容易不一致。
第三步,冲突检测与版本管理。新记忆写入前,先查事实轨里有没有同槽位的旧值。如果有且值不同,按时序保留新的,旧值打上superseded_by标记。注意,是标记不是删除。LoCoMo 里有些问题专门考“用户之前说什么,后来改了什么”,删了就答不出来。
第四步,推理轨的结论生成。这一步可选但强烈建议做。对抽取出来的事实跑一组规则,生成可能的推导结论。规则不用太复杂,比如“出差 + 外地 + 具体日期”触发“该日期本地不可用”。这些结论存进推理轨,带一个较低的置信度。
写入的吞吐量是个工程问题。如果每轮对话都同步跑这四步,延迟会上去。我的做法是写入走异步队列,对话响应不等待写入完成。但要注意,异步写入有个坑:如果用户在写入完成前就问了相关问题,会查不到。解决办法是维护一个短期的“热记忆”缓存,最近几轮对话直接放内存,不走完整写入流程。
3.2 检索阶段:三条轨怎么各查各的
检索是分数的直接来源。三条轨的检索策略完全不同,这是设计上的关键。
事实轨检索走的是槽位匹配 + 语义兜底。用户 query 进来,先做一次实体识别,看能不能匹配到已知槽位。比如 query 里有“车”,事实轨里正好有车型槽位,直接命中。匹配不到才走 embedding 相似度。这个顺序很重要——槽位匹配的准确率远高于纯语义,能命中就不要用 embedding。
时序轨检索走的是时间窗口 + 事件链。先根据 query 里的时间词确定一个时间范围,比如“上周”就查上周的事件。然后沿着事件链做前后扩展,把相关的前置和后置事件也拉出来。时序轨的检索结果不是单条记忆,而是一段事件序列,这样模型能看到事情的来龙去脉。
推理轨检索走的是结论匹配。query 进来,直接匹配推理轨里缓存的结论。比如 query 是“周三有什么安排”,推理轨里正好有“周三本地不可用”的结论,直接返回。推理轨的命中率不高,但一旦命中,价值极大,因为它省掉了模型重新推理的步骤。
三条轨的检索是并行的,各自返回 top-k 结果,然后进融合层。这里有个细节:三条轨的 k 值不一样。事实轨 k 可以小一点,3 到 5 就够,因为槽位匹配很准。时序轨 k 要大一点,10 到 15,因为事件链需要上下文。推理轨 k 最小,1 到 2,因为结论本身就是压缩过的。
3.3 融合层:怎么把三路结果拼成模型能用的记忆卡片
融合层是整套架构里最“玄学”的部分,也是分数差距拉开的地方。我根据榜单表现和常见做法,推测它的融合逻辑包含三个维度:
置信度加权。每条记忆带一个置信度分数。事实轨的槽位匹配给 0.9,语义匹配给 0.7;时序轨的事件给 0.8;推理轨的结论给 0.6。加权后排序,取 top-N。
时效性衰减。记忆的分数随时间衰减,但衰减曲线不是线性的。事实类记忆(比如“用户的名字”)衰减很慢,几个月前的名字现在依然有效。时序类记忆衰减快,一周前的“今天要开会”现在基本没用。推理类记忆衰减最快,因为推导结论的时效性最强。
去重与压缩。三条轨可能返回重复信息。比如事实轨返回“用户住在北京”,推理轨返回“用户本地在北京”,这俩是同一件事。融合层要做语义去重,合并成一条。合并后的记忆卡片格式大概是:
[事实] 用户车型: Model Y (置信度 0.92, 更新时间 2024-01-15) [时序] 2024-01-10 用户提到下周三去北京出差 (置信度 0.85) [推理] 2024-01-17 用户不在本地 (置信度 0.60, 来源: 出差事件推导)这种结构化卡片塞进 prompt,模型读起来比一堆原始对话片段高效得多。实测下来,同样信息量,卡片格式的 token 消耗只有原始片段的 30% 到 40%。
4. 实操复现:从零搭一套简化版多轨记忆
4.1 环境准备与依赖选型
要复现这套架构,不需要从头造轮子。我列一下我实际用过的技术栈,都是成熟组件:
| 组件 | 选型 | 理由 |
|---|---|---|
| 实体抽取 | spaCy + 自定义规则 | 轻量、快、可离线,不需要 GPU |
| 向量检索 | FAISS 或 Chroma | 本地部署简单,API 友好 |
| 时序存储 | SQLite + 时间索引 | 够用,别上重型数据库 |
| 推理规则 | 纯 Python 规则引擎 | 规则不多的话,没必要上 Drools 这类 |
| 异步队列 | Redis + RQ | 写入异步化,避免阻塞对话 |
安装命令大概是这样:
pip install spacy faiss-cpu chromadb redis rq python -m spacy download zh_core_web_sm如果你处理的是中文对话,spaCy 的中文模型效果一般,可以考虑换成 HanLP 或者 LTP。我实测 HanLP 在中文实体抽取上比 spaCy 准 10 个百分点左右,但速度慢一些。看你的延迟要求。
4.2 写入管道的代码骨架
写入管道的核心是一个MemoryWriter类,我贴一下关键逻辑:
import time from datetime import datetime class MemoryWriter: def __init__(self, fact_store, temporal_store, inference_store): self.fact_store = fact_store self.temporal_store = temporal_store self.inference_store = inference_store self.rules = load_inference_rules() def write(self, dialogue_turn): # 第一步:实体与槽位抽取 entities = extract_entities(dialogue_turn.text) slots = extract_slots(entities, dialogue_turn.text) # 第二步:时序归一化 timestamp = normalize_time(dialogue_turn.time_ref, dialogue_turn.timestamp) # 第三步:冲突检测 for slot in slots: old = self.fact_store.get(slot.key) if old and old.value != slot.value: self.fact_store.mark_superseded(old.id, slot.id) self.fact_store.put(slot, timestamp) # 第四步:推理轨结论生成 for rule in self.rules: if rule.matches(slots, timestamp): conclusion = rule.apply(slots, timestamp) self.inference_store.put(conclusion, confidence=0.6) # 时序轨写入 self.temporal_store.put(dialogue_turn, timestamp)这段代码里有个细节值得说:冲突检测那一步,mark_superseded不是删除旧值,而是打标记。查询的时候默认过滤掉被标记的,但如果有 query 明确问“之前是什么”,可以放开过滤。这个设计在 LoCoMo 的跨会话问题上很关键。
4.3 检索与融合的代码骨架
检索侧的核心是MemoryRetriever,三条轨并行查,然后融合:
class MemoryRetriever: def retrieve(self, query, top_n=5): # 并行查三条轨 fact_results = self.fact_store.search(query, k=5) temporal_results = self.temporal_store.search(query, k=15) inference_results = self.inference_store.search(query, k=2) # 加权 for r in fact_results: r.score *= 0.9 if r.match_type == 'slot' else 0.7 for r in temporal_results: r.score *= 0.8 for r in inference_results: r.score *= 0.6 # 时效性衰减 now = time.time() for r in fact_results + temporal_results + inference_results: age_days = (now - r.timestamp) / 86400 decay = self.decay_curve(r.type, age_days) r.score *= decay # 合并去重 all_results = fact_results + temporal_results + inference_results all_results.sort(key=lambda x: x.score, reverse=True) deduped = semantic_dedup(all_results) return deduped[:top_n]衰减曲线decay_curve我用的是一组经验参数:事实类exp(-age/180),时序类exp(-age/7),推理类exp(-age/3)。这些数字不是理论最优,是我调了几轮之后觉得比较稳的。你可以根据自己的场景调,比如客服场景时序衰减可以更慢,因为用户的问题周期可能更长。
4.4 记忆卡片的生成与注入
检索出来的结果要转成模型能读的卡片格式:
def format_memory_cards(results): cards = [] for r in results: if r.type == 'fact': cards.append(f"[事实] {r.key}: {r.value} (置信度 {r.score:.2f})") elif r.type == 'temporal': cards.append(f"[时序] {r.time_str} {r.event} (置信度 {r.score:.2f})") else: cards.append(f"[推理] {r.conclusion} (置信度 {r.score:.2f})") return "\n".join(cards)注入 prompt 的时候,把卡片放在 system message 或者 user message 的前面,加一句“以下是相关记忆,供参考”。实测下来,卡片放在 user message 里比放在 system message 里效果好,因为模型对 user message 的关注度更高。
5. 常见问题与排查技巧实录
5.1 检索结果不相关怎么办
这是最常见的问题。排查顺序我一般是这样:
先看实体抽取有没有错。如果 query 里的关键实体没抽出来,后面全白搭。中文场景下,人名和地名的抽取最容易出错。我的做法是维护一个领域词典,把常见实体加进去,抽取时优先匹配词典。
再看时间归一化对不对。“下周三”这种相对时间,如果归一化错了,时序轨整个跑偏。建议写个单元测试,把各种时间表达都覆盖一遍。
最后看融合权重。如果事实轨的结果总是被时序轨挤掉,说明时序轨的权重给高了。调权重的时候一次只调一个参数,不然你分不清是哪个改动起了作用。
5.2 记忆冲突怎么处理
冲突分两种:真冲突和假冲突。
真冲突是用户确实改了信息,比如“我换工作了”。这种按时序保留新的,旧的标记过期。
假冲突是抽取错误导致的,比如把“我朋友的车是 Model 3”抽成了“用户的车是 Model 3”。这种要靠实体消歧来解决。我的做法是在槽位抽取时加一个主语判断,只有主语是“我”或者用户 ID 的才写入用户槽位,其他的一律走普通记忆存储。
5.3 写入延迟太高怎么优化
写入延迟主要来自实体抽取和推理规则。优化手段:
- 实体抽取用更小的模型,或者做 batch 处理
- 推理规则只保留高频的,长尾规则异步跑
- 写入走队列,对话响应不等待
我实测下来,把写入异步化之后,对话的 P99 延迟从 800ms 降到了 200ms 左右。代价是写入有最多几秒的延迟,但大多数场景可以接受。
5.4 记忆库越来越大怎么办
这是所有记忆系统的终极问题。我的策略是分层存储:
| 层级 | 存储内容 | 保留策略 |
|---|---|---|
| 热层 | 最近 7 天记忆 | 全量保留,内存缓存 |
| 温层 | 7 天到 90 天 | 保留事实和重要时序,推理结论压缩 |
| 冷层 | 90 天以上 | 只保留事实轨,时序和推理归档 |
冷层的记忆不参与实时检索,但如果 query 明确指向很久以前,可以触发冷层查询。这样既控制了检索规模,又不丢历史信息。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 检索结果全是无关记忆 | 实体抽取失败 | 检查 NER 输出,补充领域词典 |
| 同一信息反复出现 | 去重逻辑失效 | 检查 semantic_dedup 的阈值 |
| 时序推理错误 | 时间归一化有误 | 单元测试覆盖相对时间表达 |
| 写入后查不到 | 异步队列积压 | 检查队列长度和消费者数量 |
| 推理轨从不命中 | 规则覆盖不足 | 统计规则触发率,补充高频规则 |
| 记忆卡片太长 | top_n 太大 | 减小 k 值,加强压缩 |
6. 这套架构的边界与我的实际体会
榜单分数好看,不代表所有场景都能直接套。我在实际项目里踩过的坑,有几个值得单独说。
第一,领域迁移不是免费的。Agent Zero Memory 的实体抽取和推理规则都是领域相关的。你在客服场景调好的规则,换到医疗场景可能一半都失效。迁移的时候,实体词典和规则库要重新过一遍,工作量不小。
第二,多轨并行带来的是工程复杂度。三条轨意味着三套存储、三套检索逻辑、一个融合层。调试的时候,一个问题可能出在任意一条轨上。我的建议是先把事实轨跑通,再加时序轨,最后加推理轨。一次性上三条轨,出了问题你根本不知道从哪查。
第三,榜单分数和用户体验之间有 gap。LongMemEval 和 LoCoMo 考的是特定类型的记忆问题,但真实用户的问法千奇百怪。我遇到过用户问“你还记得我上次说的那个吗”,这种指代极其模糊的 query,任何检索系统都很难处理。这时候与其硬检索,不如让模型直接反问“您指的是哪件事”,体验反而更好。
第四,推理轨的置信度阈值要谨慎。推理轨的结论是推导出来的,不是用户明说的。如果置信度阈值设太低,模型会把推导结论当事实用,容易出错。我一般把推理轨的结论在卡片里明确标注“推理”,让模型知道这是推导的,不是用户原话。
最后分享一个我在调这套架构时觉得最有用的技巧:给每条记忆加一个“来源”字段。来源可以是“用户明说”“系统推导”“外部导入”。检索的时候,来源影响置信度权重。用户明说的权重最高,系统推导的次之,外部导入的最低。这个字段看起来不起眼,但在排查“为什么模型用了错误信息”的时候,能帮你快速定位是哪个环节引入的。
这套架构后续还能扩展的方向,我比较看好的是记忆的主动遗忘。现在所有方案都在做“怎么记住”,但真实的人类记忆是会遗忘的,而且遗忘本身是一种智能。哪些记忆该忘、什么时候忘、忘了之后怎么恢复,这些问题目前还没有好的答案。如果你在做相关的东西,这块值得深挖。