请介绍一下 AI Agent 的记忆机制?实际开发中怎么设计记忆模块?
大模型本身没有记忆,它每次调用都是一张白纸。所谓"Agent 的记忆",全部是外部系统在每一轮把相关内容挑出来、塞进上下文的结果。 所以记忆机制的本质是两件事:写什么、存在哪,以及 什么时候取、取多少放回去。前者决定能力上限,后者决定成本与准确率。
1. 结论:三层记忆,各管一段时间尺度
| 层 | 管多久 | 存什么 | 存放位置 | 取用方式 |
|---|---|---|---|---|
| 工作记忆(Working Memory) | 单次任务内 | 当前目标、已执行步骤、工具原始结果 | 进程内存 / 上下文窗口 | 全量拼接,按需截断 |
| 短期记忆(Short-term Memory) | 一次会话内 | 最近 N 轮对话、阶段性结论 | 会话表(Redis / MySQL) | 滑动窗口 + 增量摘要 |
| 长期记忆(Long-term Memory) | 跨会话、长期 | 用户偏好、事实、历史决策与教训 | 向量库 + 关系库 + KV | 语义召回 + 结构化过滤 |
三层不是替代关系,是逐层沉淀:工作记忆里的重要结论会写进短期记忆,短期记忆里稳定不变的部分会被抽取成长期记忆。
2. 机制:一次对话的读写路径
用户输入
│
├─▶ ① 召回:长期记忆按 query 向量检索 top-k ─┐
├─▶ ② 拼接:系统提示 + 长期记忆 + 短期窗口 ─┤─▶ 送给模型
│ │
└─▶ ③ 反思/抽取:本轮是否产生值得记的事实?─┘
│ 是
▼
④ 去重与合并 ──▶ ⑤ 写入(关系库存事实,向量库存语义)
│
▼
⑥ 滚动:窗口超阈值则摘要最旧的一段
写入环节比读取更容易被忽略。绝大多数"记忆不好用"的案例,不是召回算法差,而是写进去的是流水账:把整段对话原文塞进向量库,检索出来的片段又长又散,模型还得自己从中提炼。
3. 粒度与存储选型
| 记忆内容 | 建议粒度 | 存储 | 写入时机 | 备注 |
|---|---|---|---|---|
| 用户偏好("回答要简短") | 一条断言 | 关系库 KV | 首次出现即写 | 需支持更新与失效 |
| 用户事实("在杭州,做后端") | 结构化字段 | 关系库 | 会话结束抽取 | 冲突时以新值为准 |
| 历史事件("上周排查过 Redis 雪崩") | 事件摘要 + 时间 | 向量库 | 事件结束 | 带时间戳便于时序召回 |
| 任务教训("这个接口要传 X-Env") | 一句话经验 | 向量库 | 反思通过后 | 高价值,优先召回 |
| 原始对话 | 单轮 | 对象存储 / 日志 | 实时 | 仅用于回溯,不进向量库 |
关键设计:长期记忆只存"结论",不存"过程"。过程留在日志里,靠 trace 查;进记忆的必须是可复用、可验证的短句。
4. 代码:一个够用的记忆模块
接口只要四个方法:remember、recall、forget、summarize。先写接口,再换实现,是这类模块最容易演进的方式。
class Memory:
def __init__(self, store, embedder, window=20, budget_tokens=6000):
self.store, self.embedder = store, embedder
self.window, self.budget = window, budget_tokens
def remember(self, user_id, text, kind="fact", ttl_days=180):
"""写入前先去重:同义或高度相似的条目做合并,而不是追加"""
vec = self.embedder(text)
hit = self.store.search(user_id, vec, top_k=1)
if hit and hit[0].score > 0.92:
self.store.update(hit[0].id, text=text) # 覆盖而非新增
else:
self.store.insert(user_id, text, vec, kind=kind, ttl_days=ttl_days)
def recall(self, user_id, query, top_k=5, min_score=0.35):
vec = self.embedder(query)
items = self.store.search(user_id, vec, top_k=top_k * 2)
items = [i for i in items if i.score >= min_score and i.kind != "raw"]
return self.fit(items, top_k) # 超预算就截断
def build_context(self, user_id, query, history):
facts = self.recall(user_id, query) # 长期:按需
turns = history[-self.window:] # 短期:滚动窗口
return {"system_extra": render(facts), "messages": turns}
配套的存储表,两个索引就能覆盖绝大多数场景:
CREATE TABLE agent_memory (
id BIGINT PRIMARY KEY,
user_id VARCHAR(64) NOT NULL,
kind VARCHAR(16) NOT NULL, -- fact / event / lesson / preference
content TEXT NOT NULL,
embedding VECTOR(1024) NOT NULL, -- 语义召回
created_at DATETIME NOT NULL,
expire_at DATETIME NULL, -- TTL,过期自动清理
KEY idx_user_kind (user_id, kind),
VECTOR INDEX idx_vec (embedding)
);
kind 字段很关键:召回时可以按类型配比,比如偏好最多 2 条、事件最多 3 条,避免某一类记忆霸占上下文。
5. 常见追问
问:短期记忆为什么不能只靠上下文窗口? 窗口再大也有上限,而且长上下文的"中间遗忘"(lost in the middle)很明显;更现实的是成本——每轮都把全部历史发一遍,费用随轮数平方增长。
问:什么时候做摘要? 按 token 阈值触发(如窗口使用率超 70%),而不是每轮都做。摘要保留"结论、决定、未完成事项",丢弃寒暄与重复确认。
问:记忆冲突怎么办?
写一条覆盖一条,并在条目上带 updated_at 与置信度,召回时同主题只取最新或置信度最高的。不要指望模型自己从两条矛盾事实里挑对。
问:怎么防止记忆被污染(用户诱导写入错误事实)? 写入前过一道判定:只允许从"用户明确陈述"或"工具返回结果"中抽取,且与既有记忆冲突时不覆盖高置信条目,转入待确认队列。生产环境还应支持用户查看和删除自己的记忆。
问:多 Agent 共享记忆吗? 共享"事实层",不共享"工作记忆"。工作记忆按 Agent 隔离,事实层用同一份带命名空间的存储,避免互相踩脚。
小结
Agent 记忆 = 分层存储 + 精准写入 + 按预算召回。工程上先把"写什么"定清楚(结论而非流水账),再把"取多少"用 token 预算卡住,最后补上去重、冲突处理和 TTL。比起换更强的向量库,这三件事对效果的提升要直接得多。