跳至主要内容
Agent 记忆系统的读写与遗忘

Agent 记忆系统的读写与遗忘

一个跑了两小时的批量处理 Agent,第 90 步开始违反自己第 3 步定下的约束。日志里没有报错,只是早期那条「所有通知只发内部组」的规则,在几十轮工具输出之后被挤出了上下文窗口。当时的实现其实是「把全部历史消息拼进 prompt」——看起来状态都在,实际上既没有结构也没有可查询性,更糟的是我根本不知道它什么时候被挤掉的。

那次之后我把「上下文」和「记忆」分成两件事:上下文是这一轮给模型看的一份投影,记忆是系统里可写入、可检索、可过期、可删除的一份持久化数据。

本系列第 13/15 篇 · 上一篇:Agentic RAG:把检索交给 Agent 调度 · 下一篇:多 Agent 协作:拓扑、通信与终止

四层记忆,读写方式完全不同

套用认知科学的分法不太严谨,但工程上很好用:

层 内容 生命周期 读取方式
工作记忆 当前步骤的状态、待填字段、最近观测 单次任务 每次循环投影进上下文
情景记忆 发生过的事:某次检索结果、某次失败与原因 任务到会话级 按时间/任务 ID 检索
语义记忆 从经历中提炼的事实与偏好 长期 向量检索 + 实体过滤
程序性记忆 稳定的做法:工具用法、模板、指令片段 长期、版本化 直接注入,不检索

工作记忆就是第 1 篇那份结构化 State,我坚持外置,不靠消息历史。它的关键性质是可读可写的字段,而不是一段文本,因为完成条件和预算检查都要在代码里算。

情景记忆常被忽略,但很有用。「上周用 scope=changelog 查这个库返回全空,改 scope=tickets 才有结果」这类失败经验,比任何静态文档都更能指导路由。它按 task_id 和时间索引,通常不需要向量检索。

语义记忆就是大家最常说的「长期记忆」:用户偏好、项目约定、领域事实。它和第 4–10 篇的 RAG 链路在技术上是同一套东西,但有三个不同点,下面展开。

程序性记忆别混进检索。工具 schema、输出格式模板、稳定的系统指令,应该版本化、直接注入,让模型去「检索」自己的操作手册只会引入不确定性。

写入:门槛比抽取算法重要

长期记忆最容易失控的地方是写入太随意。把每轮对话都摘要后存起来,几周之后库里全是「用户询问了 X」「助手回答了 Y」这类没有信息量的噪音,检索时还会挤掉真正有用的条目。

我给写入设了三道门槛:

PYTHON8 行
1
2
3
4
5
6
7
8
def should_write(fact, session):
if fact.kind not in ("preference", "constraint", "stable_fact", "correction"):
return False # 闲聊、一次性查询不记
if fact.confidence < 0.8 or not fact.evidence_turn_ids:
return False # 无出处的不记
if fact.subject_type == "project" and not session.user_confirmed:
return False # 影响面大的要确认
return True

evidence_turn_ids 是我后来补的。每条记忆必须能追到产生它的原始对话轮次,否则几个月后无法判断它是用户明说的、还是模型自己脑补的。这个字段在合规删除和纠错时也救命。

抽取方式我更倾向结构化而非自由文本:让模型输出 {subject, predicate, object, scope, confidence, evidence} 这样的记录,而不是一段摘要。结构化记录才能去重、才能做冲突检测、才能按 user_id / project_id 做隔离。自由文本摘要适合放进情景记忆,不适合放进语义记忆。

冲突消解是写入里最难的一块。「用户偏好简洁回答」和三个月后「这个项目的报告要写详细」并不矛盾,因为它们 scope 不同;但「数据库用 MySQL」和「数据库换成 Postgres」就是矛盾。我的处理是:同 subject + predicate 的新记录不覆盖旧记录,而是给旧记录打 superseded_by,保留时间线;检索时默认只取未被取代的最新一条,需要追溯时能查历史。直接覆盖会丢掉「什么时候改的」,而这恰恰是情景记忆里最有价值的信息之一。

写入还要注意并发。多轮对话或第 14 篇的多 Agent 场景里,两个分支可能同时写同一条记忆。我给每条记录带 expected_version,写入用乐观锁,冲突就合并重抽,别用「后写的赢」。

读取:和 RAG 像,但不一样

语义记忆的检索同样可以走「向量召回 + 元数据过滤 + 重排」,但有三个额外维度:

实体与权限维度。记忆几乎总是属于某个用户、某个项目、某个租户。user_id / project_id / acl 必须下推到召回阶段(第 5、7 篇反复强调过),否则会出现跨用户串记忆——这是最严重的一类事故,而且不会报错。我宁可在检索层做硬过滤,也不指望 prompt 里写「不要使用其他用户的信息」。

时序维度。「现在的预算是多少」和「上季度的预算是多少」需要按 valid_from / valid_to 过滤。记忆条目最好带生效时间,而不是只有创建时间。第 10 篇讲的时效冲突处理,在记忆里更常见,因为偏好本来就会变。

召回数量要克制。长期记忆通常只需 3–5 条注入上下文,而且应该作为「已知约束」放在系统指令附近,不是混在检索材料里。塞太多历史偏好会喧宾夺主,我见过一个 Agent 因为注入了十几条旧偏好,回答风格变得又长又拧巴。

PYTHON9 行
1
2
3
4
5
6
7
8
9
def recall_memory(query, ctx, limit=5):
vec = embed(query)
cands = store.search(
vec,
filter={"user_id": ctx.user_id, "project_id": ctx.project_id,
"superseded_by": None, "valid_to": {"$gte": now()}},
top_k=limit * 4,
)
return rerank_by_recency_and_confidence(cands)[:limit]

注意过滤条件里 superseded_by: None 和 valid_to >= now 是在 ANN 之前下推的,不是查完再筛。

压缩与外置:窗口快满时怎么办

长会话的上下文管理,我的顺序是:先减,再压,最后才外置。

减:工具输出只留结构化摘要和必要字段(第 3 篇的 truncate);已完成步骤的中间推理直接丢弃,只保留产物;历史消息按轮折叠成「用户问了什么 → 系统做了什么 → 结论是什么」。

压:对确实需要保留的长内容做摘要。摘要必须是可追溯的——记下它压缩了哪些轮次、原始内容存在哪里,别把原文删了只留摘要。摘要会丢限定条件,第 10 篇讲抽取式压缩时的风险在这里同样成立,尤其是全局约束,我永远把约束单独存成结构化字段,不参与摘要。

外置:把大段材料、完整日志、中间产物写到外部存储,上下文里只留一个可按需取回的引用(句柄)。这和 Agent 常用的「文件系统当外部记忆」是同一个思路。关键是别外置完就忘了——要有一个 fetch(handle) 工具,并在状态里记着有哪些句柄可用。

PYTHON10 行
1
2
3
4
5
6
7
8
9
10
def compact_if_needed(state, ctx, budget):
used = count_tokens(ctx)
if used < budget.soft_limit:
return ctx
keep = state.critical_fields() # 约束、计划、账本,永不压缩
old_turns = ctx.turns[:-budget.keep_recent]
handle = store.write(old_turns)
summary = summarize(old_turns, keep_conditions=True)
state.handles.append(handle)
return build_context(keep, summary, ctx.turns[-budget.keep_recent:], state.handles)

critical_fields 单独拎出来是因为它们被压掉之后,系统不会有任何报错,只会悄悄开始违规。开头那个事故就是这么发生的。我现在把「约束字段是否在上下文里」做成一个每轮检查的断言,缺了就强制重新注入。

压缩本身也有代价:多一次模型调用、引入摘要偏差、以及让 trace 变难读。我在压缩点一定写一条日志,记录压缩前后 token 数、被压的轮次范围、以及保留的关键字段清单,否则排查「它从哪一步开始忘了规则」会非常痛苦。

遗忘:不删就不是记忆系统

只写不删的长期记忆,最终会变成噪音库,还会带来合规问题。我给记忆设四类退出机制:

  • TTL 与衰减:临时性事实(「这周在出差」)带过期时间;长期条目的检索权重随时间衰减,但不自动删除,只是不再优先召回。
  • 容量上限:每个 user_id + project_id 设条数上限,超限时按「最近使用 × 置信度 × 是否被引用」淘汰,淘汰前记一条审计日志。
  • 显式纠错:用户说「不对,我不是这么说的」时,不只是加一条新记忆,要定位到原记录、标记作废,并记下纠错来源。让新旧两条并存而不标记,模型会摇摆。
  • 合规删除:用户要求删除个人数据时,要能按 user_id 一次性删干净,包括向量索引、情景记忆、压缩摘要和所有派生缓存。这一条必须在设计之初就支持,事后补几乎一定漏——尤其容易漏掉摘要里被压缩进去的个人信息,以及向量库里已删除条目的软删除残留。

删除还要处理索引一致性:从向量库删了但缓存里还有,或者摘要里还引用着已删条目,都会造成「删了还答得出来」。我用一个 memory_version 做失效广播,缓存和摘要都带版本号,不匹配就重建。

优劣与边界

收益:长任务里约束不会随窗口漂移丢失;跨会话的偏好和项目约定可以复用;失败经验能沉淀成路由知识;显式遗忘让系统可控、可合规。

代价:一整套写入、去重、冲突、过期、删除的数据工程,复杂度接近再做一个小型知识库;记忆污染(错误事实被写入并长期复用)比一次性答错危害大得多,因为会持续影响后续所有任务;跨用户隔离一旦有漏洞是安全事故;可观测性变难——答案里某个判断来自哪条记忆,需要额外溯源。

什么时候不要:单轮问答、任务之间无状态、或者会话很短就能结束的场景,加记忆纯属负担。我的判断标准是:如果同一个用户/项目下周还会回来,且上次的约束仍然有效,才值得做长期记忆;否则工作记忆加一份结构化 state 就够了。

还有一个值得写下来的边界:记忆不该代替事实源。库存、价格、订单状态这类会变的业务数据,应该每次查权威系统,不能靠记忆缓存。记忆适合放「偏好、约定、做法」,不适合放「当前状态」。把两者混在一起,是很多 Agent 上线后给出过期答案的根因。

一个 Agent 装不下所有上下文,就会自然走向分工。下一篇讲多 Agent 协作:什么时候真的需要多个 Agent,拓扑怎么选,以及最容易失控的终止条件。你现在的系统里,有多少条记忆是模型自己推断出来的、却没有任何原始出处?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Agent #上下文 #记忆

评论