一个跑了两小时的批量处理 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」这类没有信息量的噪音,检索时还会挤掉真正有用的条目。
我给写入设了三道门槛:
1 | def should_write(fact, session): |
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 因为注入了十几条旧偏好,回答风格变得又长又拧巴。
1 | def recall_memory(query, ctx, limit=5): |
注意过滤条件里 superseded_by: None 和 valid_to >= now 是在 ANN 之前下推的,不是查完再筛。
压缩与外置:窗口快满时怎么办
长会话的上下文管理,我的顺序是:先减,再压,最后才外置。
减:工具输出只留结构化摘要和必要字段(第 3 篇的 truncate);已完成步骤的中间推理直接丢弃,只保留产物;历史消息按轮折叠成「用户问了什么 → 系统做了什么 → 结论是什么」。
压:对确实需要保留的长内容做摘要。摘要必须是可追溯的——记下它压缩了哪些轮次、原始内容存在哪里,别把原文删了只留摘要。摘要会丢限定条件,第 10 篇讲抽取式压缩时的风险在这里同样成立,尤其是全局约束,我永远把约束单独存成结构化字段,不参与摘要。
外置:把大段材料、完整日志、中间产物写到外部存储,上下文里只留一个可按需取回的引用(句柄)。这和 Agent 常用的「文件系统当外部记忆」是同一个思路。关键是别外置完就忘了——要有一个 fetch(handle) 工具,并在状态里记着有哪些句柄可用。
1 | def compact_if_needed(state, ctx, budget): |
critical_fields 单独拎出来是因为它们被压掉之后,系统不会有任何报错,只会悄悄开始违规。开头那个事故就是这么发生的。我现在把「约束字段是否在上下文里」做成一个每轮检查的断言,缺了就强制重新注入。
压缩本身也有代价:多一次模型调用、引入摘要偏差、以及让 trace 变难读。我在压缩点一定写一条日志,记录压缩前后 token 数、被压的轮次范围、以及保留的关键字段清单,否则排查「它从哪一步开始忘了规则」会非常痛苦。
遗忘:不删就不是记忆系统
只写不删的长期记忆,最终会变成噪音库,还会带来合规问题。我给记忆设四类退出机制:
- TTL 与衰减:临时性事实(「这周在出差」)带过期时间;长期条目的检索权重随时间衰减,但不自动删除,只是不再优先召回。
- 容量上限:每个
user_id + project_id设条数上限,超限时按「最近使用 × 置信度 × 是否被引用」淘汰,淘汰前记一条审计日志。 - 显式纠错:用户说「不对,我不是这么说的」时,不只是加一条新记忆,要定位到原记录、标记作废,并记下纠错来源。让新旧两条并存而不标记,模型会摇摆。
- 合规删除:用户要求删除个人数据时,要能按
user_id一次性删干净,包括向量索引、情景记忆、压缩摘要和所有派生缓存。这一条必须在设计之初就支持,事后补几乎一定漏——尤其容易漏掉摘要里被压缩进去的个人信息,以及向量库里已删除条目的软删除残留。
删除还要处理索引一致性:从向量库删了但缓存里还有,或者摘要里还引用着已删条目,都会造成「删了还答得出来」。我用一个 memory_version 做失效广播,缓存和摘要都带版本号,不匹配就重建。
优劣与边界
收益:长任务里约束不会随窗口漂移丢失;跨会话的偏好和项目约定可以复用;失败经验能沉淀成路由知识;显式遗忘让系统可控、可合规。
代价:一整套写入、去重、冲突、过期、删除的数据工程,复杂度接近再做一个小型知识库;记忆污染(错误事实被写入并长期复用)比一次性答错危害大得多,因为会持续影响后续所有任务;跨用户隔离一旦有漏洞是安全事故;可观测性变难——答案里某个判断来自哪条记忆,需要额外溯源。
什么时候不要:单轮问答、任务之间无状态、或者会话很短就能结束的场景,加记忆纯属负担。我的判断标准是:如果同一个用户/项目下周还会回来,且上次的约束仍然有效,才值得做长期记忆;否则工作记忆加一份结构化 state 就够了。
还有一个值得写下来的边界:记忆不该代替事实源。库存、价格、订单状态这类会变的业务数据,应该每次查权威系统,不能靠记忆缓存。记忆适合放「偏好、约定、做法」,不适合放「当前状态」。把两者混在一起,是很多 Agent 上线后给出过期答案的根因。
一个 Agent 装不下所有上下文,就会自然走向分工。下一篇讲多 Agent 协作:什么时候真的需要多个 Agent,拓扑怎么选,以及最容易失控的终止条件。你现在的系统里,有多少条记忆是模型自己推断出来的、却没有任何原始出处?
评论