Memory 长期记忆
前面几篇,你的 Agent 已经会查工具、会编排、会框架化。但它有个致命短板:每次对话结束,它就「失忆」了。 下次用户再来,它不记得用户是谁、做过什么、偏好什么,一切从头再来。
这一篇回答一个核心问题:怎么让 Agent「记住」该记的东西,并且记得准、取得到、忘得掉。 读完你会发现,Memory 的难点从来不是「存」,而是「存对、取对、忘对」。
1. 记忆不是「重放」,是「沉淀」
1.1 「有记忆」是个美丽的错觉
很多人以为 Agent「有记忆」。实则不然——大模型本身是彻底无状态的。它每次被调用,都是一个孤立的计算:输入 token,输出 token,完事,内部不留任何东西。
那为什么你感觉它「记得上一句」?因为客户端把历史对话原样「重放」给了它:
# 表面:Agent 记得你刚才说"我养了只猫"
messages = [
{"role": "user", "content": "我养了只猫"},
{"role": "assistant", "content": "真可爱,什么品种?"},
{"role": "user", "content": "它叫什么名字好?"}, # ← 模型之所以能接上,是因为前两句被塞进来了
]
resp = llm.invoke(messages) # 模型看到的,是这三条完整的消息,不是"记住"了它们模型没有「记住」,是有人把历史又喂给它一遍。 你删掉前两条,它立刻就「失忆」。
1.2 为什么「重放」装不下「成长」
这个「重放」机制有个根本矛盾:它能让模型接上话,却装不下真正的「成长」。
- 装不下量:三个月的对话,几十万字,你不可能全塞进上下文窗口——窗口有上限。
- 装不下长期:用户三个月前说「我是做外贸的」,你不可能要求他每次对话都重新自我介绍一遍。
- 装不下沉淀:重放的是「原始对话」,不是「提炼出的事实」。
于是问题从「能不能记住」变成了「怎么把该记住的,从转瞬即逝的对话里沉淀下来」。
一句话心智模型:Memory = 把「该记住的」在合适时机写进外部存储,在需要时检索出来,在过时时忘掉。
打个比方:上下文窗口是「工作台」,长期记忆是「档案柜」。 工作时,工作台上只放眼前要用的东西;重要的信息,归档进档案柜;要用的时候,再回柜子里调出来。工作台再大也放不下所有档案,但你不必把所有档案都摊在台面上——这叫「外置 + 按需检索」。
2. 为什么需要长期记忆
2.1 为什么上下文窗口当不了长期记忆
上下文窗口有三个硬伤,决定了它只能当「工作台」,不能当「档案柜」:
硬伤一:会满。 窗口有 token 上限(哪怕长上下文模型有上百万 token,也终究有上限)。塞满了就得截断,最旧的被挤掉——于是「三个月前用户说他 lactose 不耐受」这条关键信息,很可能在你最需要它的时候,已经被挤出了窗口。
硬伤二:会贵。 历史越长,每轮输入塞的 token 越多,成本随对话轮数线性上涨。一个每天对话几百轮的客服,光是把几十轮历史反复塞进每次调用,就是一笔惊人的 token 开销。
硬伤三:会串。 所有内容混在一个窗口里,没法按「用户、主题、时效」隔离。A 用户的信息和 B 用户的搅在一起,当前话题和三个月前的话题搅在一起——检索时「串味」。
这三条是结构性的,不是「窗口做大点」就能解决的。所以要把记忆外置:数据库、向量库、甚至一个 JSON 文件。
取舍:把记忆外置,换来「可持久、可检索、可隔离、可遗忘」,代价是「多了一套写入-检索-淘汰机制要维护」,而且「记得对不对」本身成了新的风险——外置记忆是会存错、取错、过时的,这正是下一节的核心难点。
2.2 为什么记忆要「提炼 + 召回 + 淘汰」
很多人以为 Memory 就是「把所有对话都存下来」。这是最根本的误解。真正能用的记忆,要过三道工序——不是可选的优化,是缺一不可:
写入(提炼)。 不是原样存对话,而是提取成结构化的事实。存「用户今天说咖啡不错」是噪音;存「用户偏好:咖啡,风格:Jasper」才是可用的记忆。没有提炼,你存进去的是一堆海量噪音,检索时什么都搜不出来。
检索(召回)。 需要时,按相关性把最相关的几条取回来塞进上下文,而不是全量搬。全量搬又贵又乱,还会稀释关键信息的注意力——这也是「Attention 稀释」问题:塞进上下文的无关信息越多,模型对真正关键信息的注意力越弱。
遗忘(淘汰)。 过时的、矛盾的记忆要清理或覆盖。「用户偏好:只喝美式」三个月后他改口「开始喝拿铁了」,旧记忆不淘汰,Agent 就拿着过期的事实做错误决策。
一句话:Memory 的难点不在「存」,而在「存对、取对、忘对」。 三道工序,任何一道做砸了,记忆就从「资产」变成「负债」。
3. 怎么用得可靠又安全
3.1 三类记忆,各管一段
先用一张表把记忆的「物种」分清楚。这是很多实现混乱的根源——把不同生命周期的东西混在一个池子里:
| 类型 | 存什么 | 生命周期 | 例子 |
|---|---|---|---|
| 短期记忆 | 当前任务的工作状态 | 一次对话内 | 「用户刚才说要看 3 月的报表」 |
| 长期记忆 | 跨会话沉淀的事实/知识 | 长期、可更新 | 「用户是做外贸的,主要客户在欧洲」 |
| 实体记忆 | 某个对象(人/公司)的档案 | 跟随对象 | 「客户 ACME 的付款账期是 60 天」 |
这三类的生命周期天然不同,这就是为什么不能混在一个池子里:
- 短期记忆跟对话走,对话结束就该失效;
- 长期记忆跨会话沉淀,要持久、要能覆盖更新;
- 实体记忆跟对象走,只要这个对象还在,它的档案就在。
黄金法则:别把三类记忆混在一个池子里。 user_id=42 今天说要查报表(短期的)和 user_id=42 是做外贸的(长期的)如果塞进同一个向量库,检索时就会「串味」——该取的取不到,该淘汰的淘汰不掉。
工程上的落地方式很朴素:用不同的存储/表/命名空间把三类记忆物理隔离。 短期记忆可以就是一个随会话销毁的内存列表;长期记忆落向量库+结构化库;实体记忆落一个按对象 id 索引的 key-value 库。
3.2 一个最小可跑的长期记忆
不堆框架,用最直白的方式把「写入-检索-淘汰」的完整链路写出来,让你看到本质(真实工程里,LangChain 的 Memory 组件、LangGraph 的 Checkpointer、或自建向量库,做的是同样的事,只是封装更好):
import json, time
from dataclasses import dataclass
@dataclass
class Memory:
content: str # 提炼后的事实,不是原始对话
user_id: str # 隔离标签(见 3.3)
timestamp: float # 时间戳(用于淘汰)
score: float = 1.0 # 重要性权重
class SimpleMemoryStore:
def __init__(self, path="memory.json"):
self.path = path
try:
self.items = [Memory(**m) for m in json.load(open(path))]
except FileNotFoundError:
self.items = []
def _persist(self):
json.dump([m.__dict__ for m in self.items], open(self.path, "w"), ensure_ascii=False)
def write(self, content, user_id, importance=1.0):
# 写入前先做"提炼判断":这值得记吗?(见 3.3 红线一)
self.items.append(Memory(content=content, user_id=user_id,
timestamp=time.time(), score=importance))
self._persist()
def recall(self, user_id, query, top_k=3):
# 召回:只取该用户的、按相关性排序的 Top-K(见 3.3 红线二)
candidates = [m for m in self.items if m.user_id == user_id] # 强制按 user 隔离
return sorted(candidates, key=lambda m: relatedness(m.content, query), reverse=True)[:top_k]
def forget(self, user_id, threshold_days=90):
# 遗忘:淘汰过期记忆(见 3.3 红线三)
now = time.time()
self.items = [m for m in self.items if not (
m.user_id == user_id and (now - m.timestamp) > threshold_days * 86400
)]
self._persist()这段代码的骨架,就是一切 Memory 系统的最小公分母——写入前判断、按用户隔离、检索排序、按时间淘汰。 真实系统用向量库做语义检索(而不是上面那个朴素的 relatedness 关键词匹配),用 LangGraph Checkpointer 做状态持久化,但这四条逻辑一条都不会少。
3.3 可靠的三个红线
这是 Memory 最容易出事、也最该守住的地方:
红线一:防「记忆污染」。 模型可能把「某次对话里用户随口说的话」误当成「长期事实」存进去。比如用户开个玩笑「我其实是个外星人」,模型存成长期记忆,三个月后一本正经地问「你们母星最近怎么样」。对策:写入前加一道「这值得记吗」的判断,只沉淀稳定的、跨会话有用的事实,过滤情绪化、一次性、上下文特异的表达。
红线二:防「跨用户串号」。 多租户场景,A 用户的记忆绝对不能检索给 B 用户。这是安全级的问题,不是体验问题——串号等于把隐私直接喂给陌生人。对策:每条记忆落地就打上 user_id/tenant_id 标签,检索时强制过滤,和 RAG 的权限隔离是同一套逻辑(详见 03 篇)。
红线三:防「过时误导」。 用户换了需求,旧记忆还在,Agent 就按错的来。对策:写入时记录时间戳 + 支持更新覆盖,让新事实能纠正旧事实。这是「遗忘」的第一层含义:不是怕存多了,是怕旧的盖过新的。
3.4 高效的一条:记忆要「压缩」不「堆积」
别让记忆无限膨胀。记忆库里滚到几万条时,检索变慢、成本上升、噪声淹没信号。对策是定期压缩:把碎片化的记忆合并成更精炼的摘要。
比如几十条碎片:用户3月说过预算紧、用户5月说过预算紧、用户7月又提成本敏感……压缩成一条:用户长期成本敏感,决策时优先考虑性价比方案。
这既是省存储,也是让检索更准(一条高密度事实,比几十条低密度碎片更容易被命中)。这是「遗忘」的正面意义:不是丢信息,是让信息更凝练。 凝练和淘汰,是同一个动作的两面——淘汰掉重复的细节,凝练出高价值的本质。
4. 容易踩的几个坑
- 把整个对话历史当长期记忆存 —— 存了一堆「今天天气不错」的噪音。根因:没做「提炼」。正确姿势:只沉淀稳定、跨会话有用的事实,写入前先问「这值得记吗」。
- 检索时全量召回 —— 把所有记忆都塞进上下文,又贵又乱,还稀释注意力。根因:没做「召回」。正确姿势:按相关性取 Top-K,只取最相关的几条。
- 不加用户隔离标签 —— 多租户下记忆串号,A 看到了 B 的隐私。根因:安全意识缺失。正确姿势:每条记忆落地就带
user_id,检索强制过滤。 - 只增不删 —— 记忆越滚越大、越来越旧,Agent 拿着过时信息决策。根因:没有「遗忘」机制。正确姿势:写入带时间戳,定期压缩合并、淘汰过期、让新事实覆盖旧事实。
- 把工作台当档案柜 —— 想靠「把窗口做大」来替代长期记忆。根因:没想清「重放」和「沉淀」的本质区别。正确姿势:上下文明确定位为「工作台」,长期记忆外置到「档案柜」,按需检索。
5. 记住这一句
Memory 不是「存对话」,是「记事实」——存得准(提炼)、取得准(召回)、忘得掉(淘汰)。三条红线守住:不污染、不串号、不过时。上下文是工作台,长期记忆是档案柜,别混为一谈。
下一篇预告:记忆解决了「记性」,让你能跨会话成长。但 Agent 干得好不好,你现在依然是「两眼一抹黑」——它到底答对了没有、走没走弯路、烧没烧钱,你一无所知。这就到了 09 · 评估与可观测性。