目录
3992 字
20 分钟
Agent 记忆系统设计:工作、语义、情景、程序四层,以及各自的失效语义

一个 Agent 记不住事,通常不是因为没给它配向量库,而是因为配了之后,你把所有东西都当成了同一种东西。

用户的偏好、三天前那次失败的排查、系统提示词里「不要直接执行删除操作」这条规矩、上一轮的中间结果——在代码里它们往往都只是一条 documents 记录,用同一个 embedding 编码,进同一个 collection,靠 cos 相似度捞出来。但它们真正需要的写入时机、生命周期和失效方式各不相同。混在一起,就会出现那种熟悉的现象:Agent 记得住用户上个月的口味偏好,却忘了五分钟前自己已经删过一次那个文件。

一、先接受约束:上下文不是免费的#

设计记忆之前,先承认一个物理事实——上下文窗口变大了,但有效上下文没有同比变大。

《Lost in the Middle》测过一件反直觉的事:把答案放在长文档中间,模型准确率会显著下降,呈 U 形曲线——头尾高、中间低。在多文档问答上,当答案恰好落在中间时,GPT-3.5 的成绩甚至低于它完全不看文档、凭记忆作答的成绩。塞进去的上下文不仅没帮忙,还成了干扰。

Chroma 的《Context Rot》把这件事推得更远:他们测了 18 个前沿模型,发现性能随输入长度增长而下降,即使任务只是「把一串词原样重复一遍」;更值得玩味的是,把文档顺序打乱后成绩有时反而更好——一致性本身也是负担。

结论是:问题不是「能塞多少」,而是「该塞哪些」。记忆系统的全部工作就是回答这个问题——而答案取决于你面对的是哪一类信息。

二、四层记忆:CoALA 的分类仍是最好用的工程起点#

《Cognitive Architectures for Language Agents》(CoALA)把语言智能体的记忆分成四类,LangGraph、LangMem 等实现直接沿用了它:

层存什么人类类比生命周期
工作记忆当前任务的上下文你现在脑子里装的事一次会话/任务
语义记忆事实与偏好你知道的知识长期,缓慢演化
情景记忆经历过的具体事件你做过的事长期,只增不改(但会失效)
程序记忆行为准则与技能你的本能与习惯长期,随版本迭代

关键不在分类本身,而在于这四层的默认操作完全不同:工作记忆靠压缩与丢弃;语义记忆靠覆盖与合并(用户改了口味,旧偏好就该被替换);情景记忆靠追加与失效(那次排查失败是历史事实,永远为真,但它描述的世界状态可能已经过期);程序记忆则不该被自动修改(行为准则该由人改,不该由 Agent 在一次对话里自己改掉)。

把它们塞进同一个向量库、用同一套「相似度 top-k」去读,等于放弃了这四种语义。下一节先看最容易被低估的第一层。

三、工作记忆:压缩是有损的,所以要在结构上留锚点#

Anthropic 给出的两组数字值得记住:单纯的 context editing(接近 token 上限时自动清理过期的工具调用与结果)带来约 29% 的性能提升,与 memory tool 组合后约 39%;在一个 100 轮的网页搜索评测里,context editing 把 token 消耗削减了约 84%。

也就是说,大部分历史轮次不但没用,还有害。但压缩有陷阱——摘要是有损的,被丢掉的那句可能正是后面要用的那句。Anthropic 的解法不是「摘要得更聪明」,而是三条结构性策略:Compaction(把历史压成结构化摘要,保留决策、待办与关键约束)、结构化笔记(把跨会话必须留下的东西写进外部文件)、子 Agent 架构(让子 Agent 在独立上下文里做脏活,只把结论带回主上下文)。

共同点是一句话:别指望把信息「记住」在上下文里——上下文是工作台,不是仓库。

四、检索:为什么单个相似度不够#

Generative Agents 给出的打分函数至今仍是事实上(de facto)的标准做法,MemGPT、Mem0、LangGraph 的实现里都能看到它的影子:

score = α_recency × recency + α_relevance × relevance + α_importance × importance

三个信号缺一不可:Relevance 是查询与记忆的余弦相似度,缺了它你捞回一堆无关的东西;Recency 按上次被访问的时间(不是写入时间)做指数衰减,论文用的系数约为每小时 0.995,缺了它 Agent 会忽略刚刚发生的事;Importance 是写入时让 LLM 打的 1~10 分,缺了它最近的琐事会永远盖过很久以前的关键事实。

有两个实现细节论文没强调,但代码里一定会遇到:

第一,importance 必须先归一化。 它值域是 1~10,而 recency 在 (0, 1]、relevance 在 [-1, 1]。不归一化就直接加权求和,importance 会以数量级优势压倒另外两项——你会得到一个「按重要性排序」的检索器,相似度形同虚设。

第二,recency 刷新在「命中」而不是「写入」上。 这更贴近人类记忆,但会带来马太效应:热门记忆永远不衰减,冷门记忆一旦沉底就再捞不上来。生产系统通常要加兜底规则,比如定期把从未被命中的高 importance 记忆「强提醒」一次。

五、失效语义:删除是错的,覆盖也是错的#

这是最被低估、也最容易写错的一层:新信息与旧记忆矛盾时,该怎么办?

最常见的两种做法都是错的:覆盖旧记录,你失去了「用户曾经住在上海」这个事实,而它在时间推理时会用得上;只追加新记录,检索会同时捞回「用户住上海」和「用户住北京」,两者权重相当,模型只能靠猜。

Zep 的方案是双时间轴(bi-temporal):每条事实同时记录事件时间(它在现实世界里何时成立,t_valid)和事务时间(系统何时得知、何时让它失效)。新事实到来时,给旧事实打上 t_invalid,然后追加新记录——术语叫 invalidate, don’t delete。

Mem0 则走更工程化的路:让 LLM 对每条新信息输出 ADD / UPDATE / DELETE / NOOP 四种操作之一。NOOP 尤其重要——它让「这条信息和已有的重复,什么都不用做」成为显式选项,避免去重逻辑靠阈值硬凑。

两者的共识是一句话:记忆的写入是一次判断,不是一次 insert。

六、把整理挪出请求路径#

上面所有的冲突消解、摘要重写、去重都需要额外的 LLM 调用。放在用户请求路径上,用户就得为「你整理一下记忆」等上几秒。

《Sleep-time Compute》(Lin、Snell 等,Letta 与 UC Berkeley)的思路很直接:利用用户不在场的空闲时间把上下文预先「重新表示」一遍,让多个后续查询共享这次推理的成果,把成本从「每次查询付一遍」摊成「离线付一次」。Letta 把它做成后台 Agent:与主 Agent 共享记忆块,但在会话之间异步运行,负责合并、去重、让被推翻的记录失效。

判断标准很简单:凡是结果只取决于「过去」、不取决于「这次提问」的整理工作,都该挪到请求路径之外。 摘要、去重、冲突消解都符合;检索不符合,但它最贵的那一步是建索引,而索引恰恰可以提前建好。

七、一个可跑的最小实现#

下面的 TypeScript 模块把前面几节的要点落到代码里:三信号打分(含归一化)、命中刷新 recency、带失效语义的写入流水线。embedding 与 LLM 决策都用可替换的函数抽象掉。

/** 一条记忆。注意 validTo 的存在——我们没有「删除」,只有「失效」。 */
export type Memory = {
id: string;
text: string;
kind: 'semantic' | 'episodic';
embedding: number[];
importance: number; // 1..10,写入时由 LLM 打分
createdAt: number;
lastAccess: number; // 上次被检索命中的时间,不是写入时间
validFrom: number; // 事件时间:这条事实何时开始成立
validTo: number | null; // null 表示当前仍然有效
};
const HOUR = 3_600_000;
const GAMMA = 0.995; // 每小时的衰减系数
const recency = (m: Memory, now: number) => GAMMA ** ((now - m.lastAccess) / HOUR);
const cosine = (a: number[], b: number[]) => {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8); // 落在 [-1, 1]
};
const W = { recency: 0.4, relevance: 0.4, importance: 0.2 } as const;
/** 检索:三信号加权。已被失效(validTo 非 null)的记忆一律不参与。 */
export async function retrieve(
store: Memory[], query: string, k = 6, now = Date.now(),
): Promise<Array<Memory & { score: number }>> {
const q = await embed(query);
return store
.filter((m) => m.validTo === null)
.map((m) => ({
m,
// importance / 10 这一步不能省:不归一化,1..10 会压死另外两项
score: W.recency * recency(m, now)
+ W.relevance * cosine(q, m.embedding)
+ W.importance * (m.importance / 10),
}))
.sort((a, b) => b.score - a.score)
.slice(0, k)
.map(({ m, score }) => {
m.lastAccess = now; // 命中即刷新:常被想起的事衰减更慢
return { ...m, score };
});
}

写入部分才是重点。注意 UPDATE 分支不是覆盖,而是给旧记录封口、再追加新记录:

type Op = 'ADD' | 'UPDATE' | 'DELETE' | 'NOOP';
export async function write(
store: Memory[], incoming: string, now = Date.now(),
): Promise<void> {
// 1. 先捞出可能相关的既有记忆,作为冲突判断的依据
const related = await retrieve(store, incoming, 5, now);
// 2. 让 LLM 判断:新增 / 修正 / 推翻 / 废话
const { op, targetId, importance } = await decideOp(
incoming,
related.map(({ id, text }) => ({ id, text })),
);
switch (op) {
case 'ADD':
store.push(makeMemory(incoming, importance, now));
return;
case 'UPDATE': {
const old = store.find((m) => m.id === targetId);
if (!old) return;
// 关键:不覆盖。旧事实作为「曾经为真」保留,用于时间推理。
old.validTo = now;
store.push(makeMemory(incoming, importance, now));
return;
}
case 'DELETE':
// 只有「用户明确要求忘记」才物理删除。
// 记忆投毒的第一道防线就设在这里:删除请求不该被 LLM 的 NOOP 吞掉。
store.splice(store.findIndex((m) => m.id === targetId), 1);
return;
case 'NOOP':
return; // 与既有记忆等价,显式什么都不做,避免制造重复条目
}
}

decideOp 用 function calling 把四种操作定义成工具让模型选(和之前写过的 Function Calling 实战是同一路子):

async function decideOp(text: string, related: Array<{ id: string; text: string }>) {
const res = await callModel({
system:
'你在维护一个 Agent 的长期记忆。对比「新信息」与「相关记忆」,选择唯一一个操作:\n' +
'- ADD:新信息是一条全新的事实\n' +
'- UPDATE:新信息修正了某条既有记忆(旧记忆从此失效,但要保留)\n' +
'- DELETE:新信息明确指出某条既有记忆是错的、或用户要求忘记\n' +
'- NOOP:新信息与既有一致,无需记录\n' +
'同时给新信息打 1-10 的重要性分:1 是日常琐事,10 是长期关键约束。',
tools: [{
name: 'apply',
input_schema: {
type: 'object',
properties: {
op: { enum: ['ADD', 'UPDATE', 'DELETE', 'NOOP'] },
targetId: { type: ['string', 'null'], description: 'UPDATE/DELETE 时的目标记忆 id' },
importance: { type: 'integer', minimum: 1, maximum: 10 },
},
required: ['op', 'importance'],
},
}],
user: `新信息:${text}\n相关记忆:${JSON.stringify(related)}`,
});
return res.toolInput;
}

八、先量 baseline:很多时候你不需要记忆层#

动手之前,有件反直觉但必须做的事:先用「全量上下文」跑一遍基线。

Zep 论文里有一组很值得警惕的数字:在 DMR 基准上 Zep 拿到 94.8%、MemGPT 是 93.4%——赢得很勉强,而且论文自己也承认全上下文基线几乎追平了 Zep。真正拉开差距的是更难的 LongMemEval:准确率最多提升 18.5%,同时把上下文从约 11.5 万 token 压到约 1600 token,响应延迟降低约 90%。

这组对比的正确读法不是「Zep 赢了」,而是:记忆层的价值主要体现在成本和延迟上,其次才是准确率。如果你做的是单轮问答、或上下文本来就只有几千 token,加一层记忆只会引入新的失败模式,却几乎不带来收益。

评估时至少分开看三组数字:质量(LongMemEval、LOCOMO 这类含多轮与时序推理的基准)、成本(每次会话注入的 token 数、p95 延迟——Mem0 报的「p95 延迟降低 91%、token 成本降低 90% 以上」与 Zep 量级一致,说明这是记忆层最稳的收益来源)、以及失败模式(漏召、误召、投毒)。

第三组最容易被忽略,也最危险:记忆一旦写入就会被反复采样,「写」的权限必须比「读」严格得多——这也是上面 DELETE 分支要单独强调的原因。

九、五个反复踩的坑#

  1. 四层记忆共用一个 collection:程序记忆被相似度检索捞出来当「参考事实」,Agent 就开始自己改写自己的规矩了。行为准则应该留在系统提示词里,不该走检索。
  2. importance 不归一化:三信号加权退化成单信号排序,前面说过,但真的每天都有人踩。
  3. 用写入时间做 recency:那只是「新旧排序」,不是记忆衰减。差别在于一条三个月前写入、昨天刚被用到的偏好,应该被当成「新鲜」的。
  4. UPDATE 写成覆盖:省了一张表的空间,丢了时间推理的能力,还答不上「用户什么时候改的主意」。
  5. 没有遗忘接口:删除请求必须有一条物理删除路径,而且别让 NOOP 把它吞掉。

结语#

Agent 记忆系统里真正难的部分,从来不是向量数据库选型,而是语义:什么该在什么时机被写进去、什么时候算失效、什么情况下该忘掉。

把这四层分开设计之后,很多「Agent 变笨了」的问题会自己浮现出病因——是工作记忆没做压缩(上下文腐烂),是语义记忆没做冲突消解(两条矛盾偏好同时被召回),还是情景记忆只追加不失效(把三个月前的世界状态当成现在的)。每种的修法都不一样;它们混在一起时,你只能看到一个笼统的「不好用」。

记忆的价值不在于记得多,而在于能说清楚为什么现在还记得它。

参考来源#

Agent 记忆系统设计:工作、语义、情景、程序四层,以及各自的失效语义
https://www.hehonglei.cn/posts/agent-memory-system-design/
作者
Honglei He
发布于
2026-10-15
许可协议
CC BY-NC-SA 4.0