Python Agent 记忆系统设计:分层、压缩与检索
一、Agent 的记忆不是"接个向量库"那么简单
大部分 Python 开发者的第一版记忆方案是这样的:把对话历史截断成最近 N 轮,超出的部分丢进向量库,下次提问时按相似度捞回来拼进 prompt。这个方案在 demo 里能跑,进生产后通常活不过三个月。原因不在向量库选型,而在两件事:上下文本身在稀释模型的注意力,以及检索出来的东西不一定是该记住的东西。
Anthropic 在 2025 年 9 月 29 日发布的《Effective context engineering for AI agents》里给了一个很好用的心智模型:他们把上下文看成一种有限的注意力预算。Transformer 里 n 个 token 会形成 n² 组两两关系,上下文越长,能分给每一组关系的注意力就越稀。Chroma 的 context rot 研究也观察到同一现象——token 数量上去之后,模型从长上下文里准确回忆信息的能力反而下降。所以正确的问题不是"我能塞多少进去",而是"哪一小组高信号 token 最可能让模型做出正确行为"。
这篇文章把 Agent 记忆拆成三层来讲:短期记忆(对话内)、长期记忆(跨会话),以及夹在中间、最容易被忽略但收益最大的一层——压缩与检索策略。所有数据都来自公开论文与官方文档,文末列出出处。
二、先分层:短期状态与长期记忆是两套东西
先明确一个区分,很多"记忆 bug"都出在这里没分清:
- 短期记忆 = 一次任务/一轮会话的状态。它要求的是可恢复:Agent 跑到第 12 步崩了,重启后能从第 11 步继续,而不是从头再来。
- 长期记忆 = 跨会话的事实、偏好、经验。它要求的是可检索:新会话开始时按当前任务把相关片段捞出来。
LangGraph 把这两层做成了两个独立抽象:checkpointer 负责线程内状态(用 thread_id 隔离),store 负责跨线程的长期记忆(用 namespace 组织)。LangChain 1.0 与 LangGraph 1.0 在 2025 年 10 月 22 日同时 GA,官方承诺在 2.0 之前不引入破坏性变更,这套 API 目前是稳定的。
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from langgraph.store.postgres.aio import AsyncPostgresStore
DB_URI = "postgresql://user:pwd@localhost:5432/agent"
async with AsyncPostgresSaver.from_conn_string(DB_URI) as checkpointer:
async with AsyncPostgresStore.from_conn_string(DB_URI) as store:
graph = builder.compile(checkpointer=checkpointer, store=store)
# 短期:同一 thread_id 的每一步都会落 checkpoint
config = {"configurable": {"thread_id": "support-9847"}}
await graph.ainvoke({"messages": [{"role": "user", "content": "继续上次的排查"}]}, config)
# 长期:跨 thread 可读,按 user_id 分 namespace
await store.aput(("user", "u-42", "preferences"), "lang", {"value": "zh-CN"})
注意 checkpointer 只解决"不丢状态",它不解决"太长了"。一个跑 50 步的 Agent,checkpoint 里堆着每一轮的工具返回,直接重放进上下文照样会撑爆。这也是为什么需要下面两节。
三、长期记忆分三类,存储形态不一样
LangMem 的文档里把长期记忆按认知科学的三分法拆开,这个划分很实用,因为它直接决定"存哪里"和"怎么取":
| 类型 | 管什么 | 典型存储 |
|---|---|---|
| 语义记忆 semantic | 事实、偏好、关系三元组 | 结构化 profile,或可检索的 collection |
| 情景记忆 episodic | 过去发生过什么、当时怎么想的 | collection(配 few-shot 回放) |
| 程序记忆 procedural | Agent 该有的行为方式和规则 | 直接写进 prompt 规则,或 collection |
其中"profile 还是 collection"是个真实的分叉点。偏好类的信息(用户叫什么、喜欢什么风格、语言设置)适合用 profile:一条文档、新信息覆盖旧的,取的时候直接读,不需要检索。经验类的信息适合用 collection:不断追加、按相似度搜。把两者混在一张表里,就会出现"用户三个月前的偏好在检索里压过了现在的偏好"这种问题。
from langmem import create_memory_manager
from pydantic import BaseModel
# 案例 A:collection —— 无界的事实堆积,运行时检索
facts = create_memory_manager(
"anthropic:claude-3-5-sonnet-latest",
instructions="抽取值得长期保留的事实、事件与关系,并标注重要性。",
enable_inserts=True,
)
extracted = facts.invoke({"messages": conversation})
# 案例 B:profile —— 固定 schema,只保留最新状态
class UserProfile(BaseModel):
name: str
preferred_name: str
response_style_preference: str
special_skills: list
prefs = create_memory_manager(
"anthropic:claude-3-5-sonnet-latest",
schemas=[UserProfile],
instructions="抽取用户偏好设置",
enable_inserts=False, # 关闭追加,只更新这一份文档
)
profile = prefs.invoke({"messages": conversation})
四、压缩:比检索更值钱的一步
如果说这三层里只能优化一个地方,我会选压缩。Anthropic 在 2025 年 9 月 29 日随 Claude Sonnet 4.5 推出的两项上下文管理能力,给了一组很有参考价值的数字:
- context editing(清理陈旧工具结果)单独使用,在内部 agentic search 评测集上带来 29% 的性能提升;
- context editing + memory tool(外部文件式记忆)组合使用,提升 39%;
- 在一个 100 轮的网页搜索评测里,context editing 让原本会因上下文耗尽而失败的任务跑完了,同时 token 消耗降低 84%。
这三条数字值得记下来:收益不是来自"记住更多",而是来自"扔掉得更聪明"。实践中可用的压缩策略大致三种,通常要组合使用。
4.1 摘要压缩(head + summary + tail)
保留最近的若干轮原文,把更早的历史交给一次 LLM 调用压成结构化摘要。关键是摘要要保留"结论和未解决项",而不是流水账——否则压完等于没压。
MAX_TOKENS = 60_000
KEEP_RECENT = 6
def compress(messages, count_tokens, summarize):
if count_tokens(messages) < MAX_TOKENS:
return messages
head, tail = messages[:-KEEP_RECENT], messages[-KEEP_RECENT:]
summary = summarize(head) # 一次 LLM 调用,产出结论/决定/待办
return [
{"role": "system", "content": f"<history_summary>{summary}</history_summary>"},
*tail,
]
4.2 工具结果清理
Agent 的上下文膨胀绝大多数来自工具返回:一次文件读取几千 token、一次搜索返回十条网页摘要。这些内容的特点是大多可以重新取回。做法是给每个工具结果打标,超出窗口时把旧结果替换成一句占位说明(例如"已清理:文件 src/app.py 第 1-400 行,需要时可重新读取"),保留它的存在性,丢掉它的正文。
4.3 外部文件式记忆
Anthropic 的 memory tool 走的是这条路:Agent 通过工具调用在专门的记忆目录里增删改查文件,存储后端由开发者自己掌控,数据可以完全留在自己基础设施里。好处是记忆不再占用上下文,坏处是它变成了一类需要自己治理的数据——得有目录约定、过期策略和检索方式,否则半年后你会得到一个塞满碎文件的目录。
一个常见误区:把压缩当成"省钱的优化",排期排在功能之后。但从上面的数字看,压缩直接决定长任务能不能跑完。跑不完的任务,成本是无穷大。
五、检索:相似度只是三个信号之一
LangMem 文档里有一句话点得很准:记忆相关性不只是语义相似度,还应该把重要性和强度(近期/频繁被用到的程度)一起算进去。只按余弦相似度排序,最典型的失败是:用户上周明确说过"以后都用中文",库里还躺着去年那句"可以用英文"。
Mem0 在 2026 年 4 月发布的新记忆算法把这条路走得更彻底。它把检索拆成三个并行打分再融合:语义相似度、BM25 关键词匹配、实体匹配。另外两个改动也值得借鉴:
- 单次 ADD-only 抽取。旧实现要两次 LLM 调用:先抽候选事实,再跟已有记忆做 ADD/UPDATE/DELETE 对账。新版砍掉对账,只做新增——每个抽出来的事实都是独立记录,信息变化时新旧并存。官方说法是抽取延迟大约减半,而且记忆质量更好,因为模型把算力花在理解输入上,而不是跟现有状态做 diff。这个设计的价值在于保留了状态变化的历史:知道用户从纽约搬到了旧金山,比只知道他现在在旧金山更有用。
- 实体链接。把专有名词、引文、复合名词短语抽出来单独索引一层。查询时先匹配实体,命中同一实体的记忆获得排序加成。
下面是一个把三个信号 + 时效衰减融合起来的最小实现,可以直接替换掉原来那一行 vector_store.similarity_search(query):
# candidates: 已经过粗筛(向量召回 top_200)的记忆列表
def rank_memories(query, candidates, now, top_k=8):
sem = minmax({m.id: cosine(query.vec, m.vec) for m in candidates})
kw = minmax({m.id: bm25(query.text, m.text) for m in candidates})
ent = minmax({m.id: entity_overlap(query, m.entities) for m in candidates})
scored = []
for m in candidates:
base = 0.50 * sem[m.id] + 0.20 * kw[m.id] + 0.30 * ent[m.id]
recency = 0.5 ** (age_in_days(m, now) / 30.0) # 30 天半衰
scored.append((m.id, 0.75 * base + 0.25 * recency))
scored.sort(key=lambda x: -x[1])
return scored[:top_k]
粗暴但有效的一条经验:权重不要凭感觉调,拿一组自己业务的真实问题做回归。记忆系统最难的不是搭起来,而是判断改动是变好还是变坏——这需要评测集,不是靠手感。
六、三条路线的公开数据对比
下面是三个主流记忆方案各自公布的指标。注意它们的评测集和方法并不统一,不能横向比大小,只能看各自的方向和量级。
Mem0:token 效率优先(2026 年 4 月)
| 基准 | 旧算法 | 新算法 | 平均 token/次 | p50 延迟 |
|---|---|---|---|---|
| LoCoMo | 71.4 | 92.5 | 7.0K | 0.88s |
| LongMemEval | 67.8 | 94.4 | 6.8K | 1.09s |
| BEAM (1M) | — | 64.1 | 6.7K | 1.00s |
| BEAM (10M) | — | 48.6 | 6.9K | 1.05s |
Mem0 强调的对比对象是"把全部历史塞进上下文"的做法:同一批基准下,全上下文方案的每次查询通常要 25,000+ token,而他们控制在 7,000 token 以内。另外官方明确说明:这些分数来自其托管平台,开源 SDK 会有"方向一致但数值不完全相同"的表现,且评测框架已开源于 mem0ai/memory-benchmarks,可以自己复现。
Zep / Graphiti:时序知识图谱(论文 arXiv:2501.13956,2025 年 1 月 20 日提交)
- 在 Deep Memory Retrieval(DMR)基准上 94.8% vs MemGPT 的 93.4%;
- 在更难的 LongMemEval 上,准确率最高提升 18.5%,同时响应延迟降低 90%。
Graphiti 的核心思路是"事实会变":图里保留实体和关系的时间线,新事实进来时不是覆盖旧的,而是把旧边置为失效,检索时同时走向量、全文和时间三个维度。这套建模对"用户换工作了""项目换了技术栈"这类场景更自然。
Letta(原 MemGPT):把上下文切成可编辑的块
Letta 的 memory block 抽象很值得抄:每个块有 label(用途)、description(使用说明)、value(内容)、limit(字符上限),常驻上下文、不需要检索,Agent 通过内置工具自己读写。多个 Agent 还能共享同一个块——这直接变成了多智能体协作里的一个同步原语(父 Agent 可以实时看到子 Agent 的结果块在更新)。
它另外提出的 sleep-time compute(论文 arXiv:2504.13171,2025 年 4 月 17 日提交)思路是:让 Agent 在空闲时段"预思考",把原始上下文加工成"已学习的上下文"写回记忆块。论文给出的数字是——在 Stateful GSM-Symbolic 和 Stateful AIME 上,达到同等准确率所需的测试时算力减少约 5 倍;加大 sleep-time 算力还能把准确率再提高 13%(GSM-Symbolic)和 18%(AIME);用 Multi-Query 设置把一次预计算摊到多个相关查询上,单次查询平均成本降低 2.5 倍。工程上的落地方式通常是两个 Agent:前台一个快模型负责对话,后台一个更强的模型负责整理记忆——因为后台不受延迟约束。
七、几条踩过才知道的工程经验
- 写入要不要同步做,取决于延迟预算。在对话主链路上调 LLM 抽记忆,用户会感觉到卡顿。Letta 的 sleep-time 设计、Mem0 的"抽取与检索异步执行"都在解决这个问题。
- 别急着覆盖旧记忆。Mem0 从 UPDATE/DELETE 改成 ADD-only 之后分数反而涨了,说明"保留变更历史"本身就是信息。要做的是给检索加时效权重,而不是把旧记录删掉。
- Agent 自己说的话也是记忆。Mem0 新算法专门提了一条:以前 Agent 说"我已帮你订好了 3 月 3 日的机票"会被忽略,现在这类 Agent 生成的事实与用户陈述同等权重。漏掉它,下一轮 Agent 就会重复劳动。
- 记忆必须有评测。没有回归集的记忆调优等于盲调。可以先从 30-50 条真实业务问题开始,人工标注"正确记忆是什么",再改召回策略。
- 先检查是不是真的需要长期记忆。如果任务都在单次会话内完成,checkpointer 就够,别再叠一层向量库——多一个组件就多一份一致性问题。
八、小结
Agent 记忆系统的核心矛盾是:上下文是有限的注意力预算,而经验是无限的。所有方案的差别,只在于它们选择在哪一步做压缩、用什么结构保存、以及按什么规则召回。落地顺序建议是:先把短期状态做可靠(checkpointer + 恢复),再做压缩(这一步的公开收益最确定),最后才做长期记忆的检索层。
如果你正在选型,一个务实的起点是:记忆量小、结构清楚用 Letta 式的内存块;要 self-host、可查询从 Mem0 开源版或 LangGraph store + 自己的打分函数起步;事实频繁变化、需要时间推理再考虑 Zep / Graphiti 这类时序图谱。三条路都不便宜,先量清楚自己的问题规模再动手。
参考来源
- Anthropic,《Effective context engineering for AI agents》(2025-09-29)
- Anthropic,《Managing context on the Claude Developer Platform》(2025-09-29)
- LangChain,《Long-term Memory in LLM Applications》(LangMem 概念文档)
- LangMem,《Introduction》与核心 API 文档
- Mem0,《Introducing The Token-Efficient Memory Algorithm》(2026 年 4 月)
- PyPI,
mem0ai2.0.20 包说明与基准表 - Rasmussen 等,《Zep: A Temporal Knowledge Graph Architecture for Agent Memory》,arXiv:2501.13956(2025-01-20)
- Letta,《Memory Blocks: The Key to Agentic Context Management》(2025-05-14)与 Letta 官方文档
- Lin 等,《Sleep-time Compute: Beyond Inference Scaling at Test-time》,arXiv:2504.13171(2025-04-17)