主题
Agent 记忆系统:为什么跑着跑着就失忆,记忆该怎么存怎么忘
本文是 AI 应用系统学习系列的 L2 核心篇。前置:35. Agent 系统:ReAct、工具与记忆、42. Context Engineering。 学完可以配合面试题食用:05-agent-architecture-design、29-multi-agent-collaboration
提出问题
先看一个做 Agent 的人都见过的场景。一个客服 Agent,第一轮用户说"我上个月买的那台咖啡机又漏水了"。第二轮用户发来一句"还是漏",Agent 回复:"请问您遇到什么问题呢?"
用户瞬间失去耐心。失败点很具体:上下文窗口放不下几百轮对话,早期消息被截断丢掉了。窗口是消耗品,第 50 轮的请求里根本看不到第 1 轮说了什么。
解法是把记忆搬到窗口外面:窗口里只放当下用得着的内容,历史放外部存储,要用的时候再查回来。这就是 Agent 记忆系统要解决的事——决定存什么、存成什么样、怎么找回来、什么时候删。
窗口与外部记忆的分工
上下文窗口和外部记忆不是二选一,是分工。窗口里放本次任务的工作集:系统提示、当前任务描述、最近几轮对话、工具返回的关键结果。外部记忆放任务之外还要长期保留的东西:用户是谁、历史结论、踩过的坑。
分工的判断标准就一条:这个信息下一次会话还用得上吗?"用户偏好代码评审严格一点"会话结束后依然有用,该进外部记忆;"git diff 的修改建议"出了会话就是废纸,留在窗口里随它去。
窗口还有第二个用途:模型改变行为只看当前这屏 token,外部记忆再丰富,不查回来就等于不存在。所以记忆系统的核心工作是"查回来"——查什么、什么时候查、查多少。
三层记忆
按存活时间和用途,记忆分三层。拿做项目类比:脑子里正在推演的方案是短期记忆,桌上的便利贴和任务看板是工作记忆,项目经验和技术积累是长期记忆。
- 短期记忆:对话历史加 scratchpad。原样保留近期消息,存活范围是当前会话。满了会被截断或摘要,天然靠不住。
- 工作记忆:当前任务的状态——目标、已完成的步骤、中间变量、待办。通常表现为一段不断被改写的任务状态 JSON,存活范围是一次任务而不是一轮对话。
- 长期记忆:跨会话存活。向量库存经历类信息,知识图谱存实体关系,普通表存结构化事实。第 35 篇 Agent 系统:ReAct、工具与记忆 里 ReAct 循环的 Thought 轨迹就是典型的短期记忆材料。
mermaid
flowchart LR
A[用户输入] --> B[记忆检索]
B --> C[窗口组装<br/>系统提示 + 检索到的记忆 + 近期对话 + 任务状态]
C --> D[LLM 推理]
D --> E[产出回复 / 工具调用]
E --> F[记忆写入决策]
F -->|近期原文| G[短期: 对话历史]
F -->|状态快照| H[工作: 任务状态]
F -->|值得长期保留| I[长期: 向量库 / 知识图谱]
G --> B
H --> B
I --> B检索是关键路径:每轮请求前先用当前输入去长期记忆查相关条目,塞进窗口。所以记忆系统本质上是带写入策略的检索系统,第 47 篇讲的向量数据库是它的存储底座。
什么值得记
不是所有对话都值得存。全存有两个直接后果:检索时噪声淹没真正有用的条目,存储成本白烧。按"下次还有用"过滤,值得记的大致四类:
- 用户偏好:语言、代码风格、沟通习惯。"用户希望回答里先给结论"——存一条,之后几百次会话受益。
- 任务结论:做完了什么、得到什么结果。"7 月 3 日把支付模块重构为状态机,原因是原 if-else 分支 14 处"。
- 错误教训:踩过的坑。某工具的参数有坑、某 API 限流阈值是 10 QPS——这类记忆价值密度最高。
- 事实性知识:项目结构、部署环境、关键路径。
另一对维度是 Episodic 和 Semantic。Episodic 是经历:哪天处理了什么工单、当时怎么解决的。Semantic 是从经历里提炼出的事实:这个用户的发票系统是金蝶的。经历适合回顾和追溯,事实适合直接复用。好的记忆系统会定期把多条 Episodic 提炼合并成一条 Semantic,检索效率和回答质量都更好。
怎么找回来
存进去只是前半件事,找得回来才算数。基础做法是相关性召回:把当前输入 embed 成向量,去记忆库做相似度检索,取 top-k 塞进窗口。很多实现到这一步就停了,然后遇到一个经典 bug:
用户上周说"我不用 Python,用 Go"。今天问"帮我写个脚本"。纯相似度检索,"写脚本"和"上周聊过 Python"的向量相似度不低,Python 相关的记忆照样被召回,Agent 开开心心写出 Python。相关性不等于正确性。
所以召回通常要叠三个修正。时间衰减:给每条记忆一个随时间下降的分数,30 天前的偏好和今天的偏好冲突时倾向信新的。权重混排:相似度分数、时间分数、重要性分数加权合并,经验配置是相关性 0.6、时近性 0.3、重要性 0.1,按业务调。显式覆盖:写入时识别到"我不用 Python 了"这类修正语句,直接把旧条目标记为被替换,不留给检索去猜。
记忆压缩与 token 预算
窗口永远不够用,所以窗口内的历史要分档管理:近详远略。最近 5-10 轮留原文——对话的语气、代词指代都在细节里,摘要会丢。更早的滚动摘要——把 20 轮压成 200 字,保留结论和关键决定,丢掉过程。
token 预算拿 32k 窗口举例:系统提示 2k,检索回来的长期记忆 2-4k,任务状态 1k,近期对话原文 8-10k,滚动摘要 1-2k,剩下全部留给本轮输入和输出。输出至少预留 4k,回复被截断的 Agent 比失忆的更常见。
压缩的触发时机有两种:被动压缩是历史超出预算才动手,实现简单,代价是压缩那一刻请求延迟会抖一下;主动压缩是后台定期把老对话滚进摘要,请求时窗口永远不超限,代价是要维护后台任务。请求延迟敏感就选主动压缩。
遗忘机制
不删东西的记忆库会变成垃圾场。三个月后检索"用户偏好",返回的第一条是用户半年前的临时想法,第二条是重复存了五遍的同一条目。遗忘机制至少三件事:
- TTL:临时性信息直接设过期。"用户今天要赶飞机"三天后就是噪声,到期删除或降权。
- 重要性打分:写入时打分,来源可靠、复用频率高、用户显式强调的条目分数高。低分记忆过期淘汰,高分记忆不过期。
- 去重与冲突合并:新记忆写入前先查相似条目。相似度超过阈值,两种处理:内容矛盾就新覆盖旧(保留时间戳),内容雷同就合并计数("提过 3 次"本身是重要性的证据)。
去重对向量库尤其重要:同一件事换个说法存十遍,检索时十条全召回,直接挤掉别的记忆的名额。
动手实操:80 行写一个最小记忆层
不依赖任何框架,纯 Python 把上面几件事串起来:写入去重、远期滚动摘要、相关性召回、冲突更新。生产系统比这复杂,主干就这么多。
python
import json, time
from openai import OpenAI
client = OpenAI() # 需设置 OPENAI_API_KEY
class MemoryStore:
def __init__(self, keep_recent=6):
self.keep_recent = keep_recent # 近期保留原文的轮数
self.history = [] # 短期:原文对话
self.summary = "" # 远期:滚动摘要
self.facts = {} # 长期:key -> {"v": 内容, "t": 时间, "n": 次数}
# --- 写入:去重 + 冲突更新 ---
def remember(self, key, value):
old = self.facts.get(key)
if old and old["v"] == value: # 完全重复:只更新次数和时间
old["n"] += 1
old["t"] = time.time()
else: # 新事实或冲突:新覆盖旧
self.facts[key] = {"v": value, "t": time.time(),
"n": old["n"] + 1 if old else 1,
"old": old["v"] if old else None}
# --- 压缩:近期留全文,远期滚进摘要 ---
def compress(self):
if len(self.history) <= self.keep_recent:
return
overflow, self.history = self.history[:-self.keep_recent], self.history[-self.keep_recent:]
text = "\n".join(f"{m['role']}: {m['content']}" for m in overflow)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content":
f"把下面的对话压缩成 150 字以内的摘要,保留结论和决定:\n{text}"}])
self.summary = (self.summary + "\n" if self.summary else "") + resp.choices[0].message.content
# --- 检索:关键词重合数做相关性打分,够用但记得换 embedding ---
def recall(self, query, k=3):
scored = []
for key, rec in self.facts.items():
overlap = len(set(key) & set(query)) # 字符级重合,粗打分
scored.append((overlap, rec["t"], key, rec))
scored.sort(reverse=True)
return [rec["v"] for _, _, key, rec in scored[:k] if _ > 0]
# --- 组装:按 token 预算把三层记忆拼进窗口 ---
def build_context(self, query):
recalled = self.recall(query)
msgs = [{"role": "system", "content":
"已知用户信息与历史结论:\n" + "\n".join(recalled)}]
if self.summary:
msgs.append({"role": "system", "content": "更早对话摘要:\n" + self.summary})
return msgs + self.history + [{"role": "user", "content": query}]
# --- 使用 ---
store = MemoryStore()
store.remember("偏好语言", "用户主用 Go,不用 Python")
store.remember("发票系统", "公司用金蝶")
store.history = [{"role": "user", "content": "帮我写个脚本统计日志错误数"},
{"role": "assistant", "content": "已生成脚本并运行"}]
store.compress()
for m in store.build_context("再帮我写个定时任务"):
print(m["role"], ":", m["content"][:60])关键行说明:remember() 里 n 字段是复用计数,检索时"提过多次"的事实可以加权重;compress() 只压缩溢出部分,近期原文不动;recall() 用字符重合做演示,换成 embedding 相似度就是生产形态;build_context() 是每轮请求的组装入口,检索发生在这一步而不是写死的系统提示里。
跑一下使用代码,system 消息里会带上"用户主用 Go,不用 Python",后续请求就不会再输出 Python。试两分钟就能看到区别。
生产形态:独立记忆服务
字典和向量索引维护到一定规模就会想拆出去。mem0、Letta 这类产品把记忆层做成了独立服务,应用侧只面对两个接口:
- add(messages, user_id):传入对话,服务端自己判断哪些值得记、写入哪层。
- search(query, user_id):传入当前问题,返回组装好的记忆条目列表。
表结构大同小异,核心是两张表:memories(id、user_id、content、embedding、metadata、score、updated_at)和 history(记录每条记忆的增删改,出问题能回溯)。选型判断:记忆是产品核心能力就自建,控制召回逻辑和打分策略;是通用辅助功能就用服务,省掉运维。不管用哪家,分层和写入策略的思路与第 35 篇的接口设计相通。
常见误区与小结
常见误区:
- 什么都存。全量对话直接进库,检索噪声指数上涨,两周后记忆质量反而不如不用。
- 只管写不管忘。没有 TTL 和去重,三个月后 top-k 全是重复和过时条目。
- 只做相似度检索。缺时间衰减和显式覆盖,用户改了口,Agent 还拿旧偏好说话。
- 把记忆当另一个 RAG。知识库回答"世界是什么样",记忆回答"这个用户/任务是什么样",写入策略和生命周期完全不同,混在一套管线里两边都做不好。
- 摘要压掉关键细节。把"用户说不用 Python"压没了,后面每次都犯错。
小结:记忆系统让 Agent 的能力跨过单次会话存活。本文的三层结构、写入过滤、带时间衰减的召回、滚动压缩、遗忘机制,就是第 42 篇 Context Engineering 在"跨会话"维度的落地。下一篇讲 Agent 和 Workflow 的选型边界——什么时候该上循环,什么时候写死流程反而更好。
参考
- mem0 文档:https://docs.mem0.ai
- Park et al., "Generative Agents: Interactive Simulacra of Human Behavior", UIST 2023(记忆流 + 反思机制的开山论文)
- Letta(MemGPT)文档:https://docs.letta.com