这是「近五年 AI 技术演进」系列深挖的第十一篇。上一篇 AGENTS.md 解决的是「AI 怎么在你的仓库里少犯错」;这一篇解决一个更根本的问题:它凭什么记得昨天发生的事。一句话定位:短期 / 工作 / 长期三层记忆管理。全文约 6500 字,含一张三层对照表、三段可运行代码和六张原创信息图。
一、一个让人恼火的瞬间
周一上午,你跟 AI 说:「这个项目用 pnpm,别用 npm,装依赖的时候注意 workspace 软链。」
它答应得很痛快。那天它表现很好,pnpm add、pnpm -r build,一次没错。
周三下午,你开了个新对话,让它给新模块加个日期库。它敲下的第一条命令是:
npm install dayjs你盯着屏幕愣了两秒。不是生气,是那种「原来你根本没记住」的荒谬感——明明两天前才说过,明明它当时还复述了一遍「好的,用 pnpm」。
这不是模型不听话,也不是你说得不够清楚。它压根就没有「记住」这个动作。
二、先把残酷事实说清楚:模型没有记忆
2.1 它是一个无状态的函数
把大模型的调用过程写成伪代码,就一行:
next_tokens = model(context_tokens) # 输入一坨 token,输出下一坨 token没有隐藏的self.state,没有磁盘上的memory.db,也没有「上次那个用户」。函数执行完,一切归零。你在界面上看到的「连续对话」,是应用层每次把整段历史重新拼进context_tokens再发一遍的结果。
换句话说:对话的连续性是一种表演,是靠每一轮重发全文伪造出来的。
顺手澄清一个常见误解:KV Cache 不是记忆。它缓存的是同一前缀的注意力中间结果,作用只是让你重发那几千 token 时不必重算——它是加速手段,不是存储机制。换个会话、换个前缀,缓存立刻作废。
2.2 上下文窗口就是它的全部世界,而且这个世界很挤
模型能「看见」的只有当前这次调用里的 token,这就是它的全部世界。这个世界有三重物理约束:
- 长度有限。现在动辄 128K、200K,甚至标称百万级。听起来很大,换算成中文也就一本中等厚度的书。你让它读一个中型仓库的代码,几轮工具调用就满了。
- 按 token 计费,而且每一轮都计。一个 3 万 token 的上下文跑 20 轮,就是 60 万 token 的输入账单。记忆不免费,遗忘才是。
- 注意力会稀释。这是最容易忽略的一条。论文 Lost in the Middle 测出一条 U 形曲线:放在上下文开头和结尾的信息召回准确率最高,塞在中间那段的明显下滑。所以「窗口够大随便塞」是危险的幻觉——能装进去,不等于能被用上。
2.3 所以「记忆」是工程的职责
既然模型自己不记,就只能由外面替它记、替它在合适的时机把东西塞回去。这件事的雏形是 RAG——把外部知识检索出来拼进上下文。两者是同一个族谱上的东西:本质都是「把外部信息喂给模型」。但 Memory 比 RAG 多走了关键的一步:
| RAG | Agent Memory | |
|---|---|---|
| 信息来源 | 静态文档库,事先准备好 | 运行过程中动态产生 |
| 时间维度 | 无,文档没有「什么时候学的」 | 有,记忆带时间戳、会过期、会被覆盖 |
| 写入方 | 人(灌数据) | Agent 自己写,边干边记 |
| 目标 | 回答这一次的问题 | 让下一次会话比这一次更聪明 |
打个比方:RAG 是图书馆,书是别人写好的,你只能借阅;Memory 是你自己的笔记本,你边干边写,写错了还得划掉重写。从「一次性工具」变成「长期助手」,分界线就在这里。
三、三层记忆:把「记不住」拆成三个不同的问题
「让 Agent 有记忆」这句话太笼统,落地的时候必须拆开。因为「记住」至少是三种完全不同的需求:记住刚才说了什么、记住这件事干到哪了、记住这个人是谁。
人的大脑其实也是这么分的:工作记忆(working memory)容量只有 4±1 个组块,几分钟就散;长期记忆可以存几十年,但提取靠线索。Agent 的三层架构几乎是照着这个抄的。
3.1 短期记忆:脑子里正在想的事
存什么。当前这一轮对话的原始消息序列:用户说了什么、模型回了什么、每一次工具调用和返回。就是那一坨messages数组。
存在哪。就在上下文窗口里,物理上存在于你下一次请求的messages字段里。没有别的地方。
怎么管理。它是易失的——会话结束(或messages被清空)就没了。特征是三个字:原、多、贵。原文保真度最高,但体积最大、最贵,而且随轮次线性增长。所以对它只有一个动作:在它撑爆窗口之前处理掉。顺带记住:短期记忆不是「模型记住了」,而是「你还没删」——它的生命周期由你的代码决定,不由模型决定。
3.2 工作记忆:桌上摊开的草稿纸
这一层是三层里最容易被跳过、但工程价值最高的一层。
存什么。当前任务的中间状态:目标是什么、已经查到哪些事实、试过哪条路失败了、下一步打算干什么、产出物落在哪个路径。它是结构化的字段,不是自然语言。
存在哪。你的应用内存里、一个 dict 里、一个State对象里——或者被序列化到磁盘/数据库做检查点。关键是:它在上下文窗口之外,只是因为需要才被渲染进 prompt。
怎么管理。必须显式读写。这是它和短期记忆最大的区别:短期记忆是自动堆积的,工作记忆是代码主动维护的。每次工具调用返回后,你(或者模型通过一个update_state工具)要把「刚查到什么」写进state["findings"];每次决定下一步,要把它写进state["plan"]。
为什么非要多这一层?三个理由:
- 它抗压缩。二十轮对话压成三百字摘要必然丢细节,但
state["files_created"]压缩前后一模一样。 - 它抗崩溃。进程挂了、超时了、用户关了页面,只要状态被持久化,下次从断点续跑。
- 它抗跑偏。长任务里模型最容易犯的错是忘记最初目标。把目标钉在 state 里每轮渲染一次,比在对话历史里翻找可靠得多。
草稿纸的类比很贴切:你不会把想的事都记在脑子里,你会写下来摊在桌上,抬眼就能看见。
3.3 长期记忆:笔记本和档案柜
存什么。跨会话仍然有价值的东西,大致三类:
- 用户偏好:「用 pnpm 不用 npm」「回答别超过 300 字」「我不看 emoji」;
- 项目结论:「订单服务的时间字段统一用 UTC 存、前端转本地时区」;
- 历史决策及其理由:「2026 年 3 月放弃了 MongoDB,因为运维成本,改回 MySQL」——这条比结论本身更值钱,因为它防止你在半年后重新讨论一遍。
存在哪。一般是混合存储:偏好和画像用关系库/键值库(要精确命中、要能改),语义类经验用向量库(要模糊召回),实体关系复杂的用知识图谱(Zep 那类方案走的就是这条路)。
怎么管理。两个动作:写入和召回。
写入的关键不是「怎么存」,而是「存什么」。绝大多数失败的记忆系统死在这一步——什么都记,结果库里 90% 是废话。我的经验是三条准入标准:被用户纠正过的(说明模型默认行为错了)、跨会话还会用到的(一次性任务细节不入库)、有明确结论的(「试了 A 和 B,最后选 B」而不是「讨论了 A 和 B」)。
召回的关键是别只看向量相似度。一个可用的打分公式来自 Generative Agents 那篇论文,三个因子加权:
score = α × 相关性(embedding 相似度) + β × 时效性(越近越高,指数衰减) + γ × 重要性(写入时由模型打 1~10 分)只用相似度会出问题:三个月前那条「这个项目用 npm」的相似度可能比上周那条「改用 pnpm 了」更高,因为它出现次数多、表述更典型。加上时效性权重才能让新结论压过旧结论。
3.4 三层对照表
这张表建议存下来,实现记忆系统时对着它填:
| 维度 | 短期记忆 | 工作记忆 | 长期记忆 |
|---|---|---|---|
| 存储位置 | 上下文窗口(messages数组) | 应用内存中的结构化 State,可持久化为检查点 | 数据库 / 向量库 / 知识图谱 |
| 生命周期 | 单次会话,会话结束即失 | 单个任务,任务结束可归档 | 永久,直到被显式覆盖或遗忘 |
| 容量 | 受窗口限制(几十万 token 上限,实际可用更少) | 几 KB ~ 几十 KB,字段数可控 | 理论无上限,实际受成本与检索精度约束 |
| 读写方式 | 自动追加,代码裁剪 | 代码显式读写(state["plan"] = ...) | 写入:模型判断 + 规则过滤;读取:检索召回 |
| 典型内容 | 原始对话、工具返回全文 | 任务目标、已查事实、失败路径、下一步、产物路径 | 用户偏好、项目约定、历史决策与理由 |
| 失效方式 | 会话结束 | 任务完成 | 被新记忆覆盖 / 过期 / 主动遗忘 |
| 主要风险 | 撑爆窗口、注意力稀释 | 状态字段设计不全,模型无从判断进度 | 记忆污染、召回不准、无限膨胀 |
四、三层之间怎么流动
架构图好看,落地时真正难的是流动。哪一层的东西什么时候搬到另一层,搬多少,谁来搬。
4.1 上行一:短期 → 工作记忆(摘要沉淀)
触发时机通常是两个:每 N 轮,或者上下文占用超过阈值(比如 70%)。
动作是:把最近一段对话交给模型,让它输出结构化的增量,合并进 state。
请从以下对话中提取,只输出 JSON: { "facts": ["已确认的事实,每条一句话"], "decisions": ["已做出的决定"], "open": ["还没解决的问题"], "artifacts": ["产出的文件路径或 ID"] } 没提到的字段返回空数组,不要推测。注意「没提到就返回空数组,不要推测」这句——不加这句,模型会为了填满字段而编造。这是工作记忆被污染的头号来源。
沉淀完之后,那段原始对话就可以压缩甚至丢弃了。这就是短期记忆的出口。
4.2 上行二:工作 → 长期记忆(任务结束归档)
任务完成后,问三个问题:
- 这条结论在别的会话里还有用吗?
- 它是否纠正了某个默认行为?
- 半年后有人问「当初为什么这么定」,这条能回答吗?
三个里命中一个才写。全不命中的,留在工作记忆里随任务一起丢掉。
归档时务必带上元数据:时间、来源会话、置信度、作用域(全局 / 某项目 / 某仓库)。没有作用域的记忆是灾难——你在 A 项目里定的「用 pnpm」,会被搬到 B 项目里去,而 B 项目是 npm workspace。
4.3 下行:长期 → 上下文(召回回填)
两个时机:
- 会话开始,预取。拿到用户 ID 和作用域(当前仓库路径),直接按 key 查偏好类记忆。这部分不需要语义检索,精确查询又快又准,通常几百 token,直接注入系统提示。
- 任务进行中,按需检索。遇到具体问题时用当前任务描述去向量库检索,取 top-k,加时间衰减重排,再把命中项拼成一段「相关历史」注入。这部分要克制,k 别超过 5,宁缺毋滥。
4.4 三个动作,对应三种「遗忘」
记忆系统里,写什么和忘什么同等重要:短期记忆的遗忘是压缩(保住语义、丢掉原话),工作记忆的遗忘是归档(任务结束,状态转成一条长期记忆或直接丢弃),长期记忆的遗忘是失效(新结论覆盖旧结论,或按 TTL 过期)。没有遗忘机制的系统,三个月后一定变成一个又慢又贵又不准的垃圾场。
五、一个例子走完三层:pnpm
回到开头那个场景。如果记忆系统是对的,全过程应该长这样:
T0,周一你第一次说。「这个项目用 pnpm」作为一条普通消息进入短期记忆。此时它没什么特殊,就是对话历史里的第 7 条。
T1,同一会话内,它被沉淀。上下文用到 60% 触发压缩,模型抽取增量时把这条识别成偏好类事实:「包管理器偏好 = pnpm,理由:workspace 软链」,写进工作记忆的facts字段。原始对话被压成摘要,但这条事实完整保留——因为它是结构化的,不依赖原文措辞。
T2,任务结束时,它被归档。那天任务完成,归档逻辑判断:这条结论跨会话有用(命中问题 1),且纠正了默认行为(命中问题 2)。于是写入长期记忆,作用域标记为repo: trip-miniapp,置信度 0.9,来源是用户明确指令。
T3,周三新会话,它被召回。新会话启动,代码先做精确预取:scope = repo:trip-miniapp的偏好类记忆有 3 条,其中一条是 pnpm。这 3 条一共 120 token,拼进系统提示:
【项目约定】 - 包管理器使用 pnpm(用户明确要求,勿用 npm/yarn) - 时间字段统一 UTC 存储,前端转本地时区 - 数据库为 MySQL 5.7,注意不支持窗口函数于是周三它敲的是pnpm add dayjs。
这就是全部魔法。模型依然没有记忆,一行都没变。变的是:有一层工程代码在它每次醒来之前,替它把最该知道的三句话放在了枕头边。
六、代码:三层记忆到底怎么写
6.1 一个最小可用的实现
不用框架,几十行就能跑通三层。核心就四个方法:remember(写短期)、render_state(把工作记忆渲染进上下文)、consolidate(上行沉淀)、recall(下行召回)。
import json, time from dataclasses import dataclass, field, asdict @dataclass class MemoryItem: content: str scope: str # 作用域:user:123 / repo:xxx kind: str # preference / decision / fact importance: int = 5 # 1~10,写入时打分 ts: float = field(default_factory=time.time) confidence: float = 1.0 id: str = "" # 存储层生成 similarity: float = 0.0 # 检索时回填,不落库 class AgentMemory: def __init__(self, llm, store, scope): self.llm, self.store, self.scope = llm, store, scope self.short_term = [] # 短期:messages self.state = {"goal": "", "facts": [], "plan": [], "artifacts": []} @staticmethod def _tokens(messages): # 粗估即可,不用精确分词 return sum(len(m["content"]) for m in messages) // 2 # ---- 写:短期 ---- def remember(self, role, content): self.short_term.append({"role": role, "content": content}) # ---- 读:工作记忆渲染进上下文 ---- def render_state(self): s = self.state return (f"【任务目标】{s['goal']}\n" f"【已确认】{'; '.join(s['facts']) or '无'}\n" f"【下一步】{'; '.join(s['plan']) or '无'}") # ---- 上行:短期 -> 工作(上下文超阈值时触发)---- def consolidate(self, threshold_tokens=80000): if self._tokens(self.short_term) < threshold_tokens: return None prompt = ("从以下对话提取增量,只输出 JSON:" '{"facts":[],"decisions":[],"open":[],"artifacts":[]};' "没提到的字段给空数组,禁止推测。\n\n" + json.dumps(self.short_term[-40:], ensure_ascii=False)) delta = json.loads(self.llm(prompt)) self.state["facts"] += delta["facts"] self.state["plan"] = delta["open"] self.short_term = [{"role": "system", "content": "【早期对话摘要】" + self.llm( "用 200 字概括以下对话,保留数字和结论:\n" + json.dumps(self.short_term[:-40], ensure_ascii=False))}] return delta # ---- 上行:工作 -> 长期(任务结束时调用)---- def archive(self, conclusions): for c in conclusions: item = MemoryItem(content=c["text"], scope=self.scope, kind=c["kind"], importance=c["importance"]) self.store.upsert(item) # 同 scope+kind+主题则覆盖,见 10.4召回那一侧,是「精确查 + 语义查」两条路合并:
# ---- 下行:长期 -> 上下文(会话开始时调用)---- def recall(self, query, k=5): pinned = self.store.query(scope=self.scope, kind="preference") # 精确 hits = self.store.search(query, scope=self.scope, k=k) # 向量 now = time.time() def score(it): recency = 0.995 ** ((now - it.ts) / 3600) # 每小时衰减 return 0.5 * it.similarity + 0.3 * recency + 0.2 * it.importance / 10 merged = {it.id: it for it in pinned + hits}.values() top = sorted(merged, key=score, reverse=True)[:k] return "【相关记忆】\n" + "\n".join(f"- {it.content}" for it in top)两个细节值得注意:pinned(偏好类)走精确查询并始终注入,因为偏好不该靠相似度碰运气;语义检索的结果要经过时间衰减重排,否则老记忆会靠「表述更典型」压过新结论。
6.2 用框架:LangGraph Checkpointer
如果任务本身就跑在图里,工作记忆这一层可以直接交给框架。LangGraph 的持久化层 就是干这个的:图里有一个State,每次节点执行完,框架自动存一个检查点(checkpoint)。
from langgraph.graph import StateGraph, MessagesState from langgraph.checkpoint.sqlite import SqliteSaver builder = StateGraph(MessagesState) builder.add_node("plan", plan_node) builder.add_node("research", research_node) builder.add_edge("__start__", "plan") builder.add_edge("plan", "research") builder.add_edge("research", "plan") # 循环,直到 plan 里没有待办 with SqliteSaver.from_conn_string("memory.sqlite") as saver: graph = builder.compile(checkpointer=saver) config = {"configurable": {"thread_id": "task-20261020-001"}} graph.invoke({"messages": [("user", "给订单模块加时区处理")]}, config)thread_id就是工作记忆的主键。只要它不变,进程重启、服务重发、用户第二天回来,都能graph.invoke(None, config)从断点继续——因为状态存在 sqlite 里,不在内存里。
更实用的是它支持时间旅行:graph.get_state_history(config)拿到整条检查点链,可以回滚到任意一步重跑。调试长任务时这功能价值极高——某一步走错了,不用从头再来。
但要清楚它的边界:Checkpointer 只管工作记忆,不管长期记忆。它回答的是「这个任务干到哪了」,不回答「这个用户是谁、他偏好什么」。长期记忆还得自己搭(或者用 LangMem、Mem0、Letta 这类专门方案)。
七、上下文窗口管理:压缩、截断、淘汰
短期记忆总会满。满了以后只有三种动作,选错会很痛。
7.1 三种策略各管什么
| 策略 | 做什么 | 适合 | 代价 |
|---|---|---|---|
| 压缩(Compaction) | 让模型把一段历史概括成摘要,替换原文 | 长对话、探索型任务、结论比过程重要 | 丢细节、多一次模型调用、可能引入摘要错误 |
| 截断(Truncation) | 直接丢掉最早的若干条,或把超长工具输出砍掉 | 工具返回巨大但只用一次(比如读了一个 3000 行文件) | 丢上下文、模型可能重复已做过的操作 |
| 淘汰(Eviction) | 按打分挑价值最低的丢,不是按时间 | 混合型历史,早期有关键结论、近期有大量废话 | 需要能算分,实现复杂 |
三者的选择有个简单判据:这段内容「以后还会被引用」吗?会,就压缩;不会,就截断;拿不准,就淘汰。
在工具输出这一侧,截断几乎没有争议——一个 5000 行的日志读进来,模型真正用的可能就那 3 行。更好的做法是在工具层就截断:返回前 N 行 + 「共 N 行,已截断,需要更多请用 offset 分页」,而不是让全文涌进上下文再想办法删。
7.2 优先级原则:谁最不该被丢
窗口紧张时,按这个顺序保:
- 系统指令与硬约束(绝对不能动)
- 当前任务目标与状态(工作记忆渲染块)
- 最近 3~5 轮对话(原文,不改写)
- 召回的长期记忆(挑 top-3,宁少勿滥)
- 中段历史摘要(可再压)
- 早期工具输出原文(最先丢)
这个顺序背后的逻辑是:越靠前的东西越是「怎么做事」,越靠后的越是「做过什么」。丢「做过什么」模型顶多重复劳动;丢「怎么做事」它会立刻违反约束。
还有个位置技巧:因为 Lost in the Middle 那个 U 形曲线,最关键的约束应该放在上下文的最前面或最后面,别埋在中间。系统提示放最前是对的,任务目标如果很长,值得在最后再复述一遍——这不是浪费 token,是买准确率。
7.3 工程上的几个具体做法
- 阈值设在 70%~80%,别等 100%。一是留缓冲应对突发的巨型工具返回,二是压缩本身也要消耗 token。
- 压缩要保数字和路径。摘要 prompt 里明确要求「保留所有数字、文件路径、函数名、错误码」——这些是模型最容易在概括时弄丢、又最需要精确复现的东西。
- 压缩后重新注入状态。摘要的可靠性不如结构化字段,压缩触发后把
render_state()的输出作为第一条 system 消息再插一次。 - 留一个「不可压缩区」。用户的原话(尤其纠正类)尽量保留原文。说「别用 npm」被摘要成「用户对包管理器有偏好」,语义就衰减了。
- 小心压缩把 prompt cache 打掉。主流 API 的缓存按前缀命中,而压缩替换的是上下文中间的内容——一旦替换,后面所有 token 的前缀都变了,缓存全废。于是会出现一个诡异现象:为了省 token 做的压缩,反而让那一轮账单变贵。对策是让压缩发生在边界上(按轮次整段替换),而不是从历史中间挖掉一段。
八、Context Engineering:为什么它比 Prompt Engineering 更关键
前几年大家聊 Prompt Engineering:怎么写角色、怎么给例子、怎么加「让我们一步步思考」。现在这个词正在被另一个词替换:Context Engineering(上下文工程)。Anthropic 有篇专门的博客讲这件事(Effective context engineering for AI agents),核心论点一句话:
上下文是有限资源,工程的任务是在这个预算里放最有价值的信息。
这个视角的转换是必然的。Prompt Engineering 的隐含假设是「上下文够大,把话说清楚就行」,而 Agent 的实际情况是:一个任务跑二十轮,工具返回、检索结果、中间产出全在抢同一个窗口。写一句漂亮的 prompt 已经不是瓶颈,争的是名额。
一个真实的预算表大概长这样(以 128K 窗口为例):
| 占用项 | 典型大小 | 能否压缩 | 说明 |
|---|---|---|---|
| 系统提示 + 硬约束 | 2K ~ 5K | 否 | 每次都要精确,尽量精简 |
| 工具定义(50 个工具) | 5K ~ 15K | 部分 | 可按需动态加载,只挂当前需要的 |
| 长期记忆召回 | 0.5K ~ 2K | 是 | top-k 可调 |
| 工作记忆状态块 | 0.3K ~ 1K | 否 | 结构化,性价比最高 |
| 对话历史 | 10K ~ 60K | 是 | 压缩的主要对象 |
| 工具返回结果 | 10K ~ 80K | 是 | 最先被截断 |
| 输出预留 | 4K ~ 8K | 否 | 不留会截断回答 |
看完这张表能得出一个反直觉的结论:最划算的投入是工作记忆那一行。三百 token 的状态块,能顶掉几万 token 的对话历史——因为它把「我干到哪了」压缩到了极致。这也是我第三节说工作记忆工程价值最高的原因:它不是在存记忆,它是在省上下文。
九、实现方案怎么选
9.1 四种路子
手写状态管理。一个 dict、一张表、几百行代码。完全可控、调试直观;但压缩逻辑、去重、作用域、并发全要自己写。适合任务形态固定、规则清晰的产品。
框架内置(LangGraph Checkpointer 等)。白送工作记忆这一层:自动检查点、断点续跑、时间旅行、按thread_id隔离。代价是接受它的状态模型,且它不解决长期记忆。适合本来就用图编排的项目。
向量数据库。把记忆当文档存,靠 embedding 检索。实现快、天然支持模糊召回;但把「记忆」降级成了「搜索」——没有覆盖语义、没有时间推理、没有冲突解决。很多项目的「记忆系统」其实就是这一层,然后死在第十节那几个坑上。
托管记忆服务(Mem0 / Letta / Zep 等)。直接调add(messages)/search(query),通常内置了抽取、去重、冲突消解。代价是记忆结构受制于人、数据要出境。Letta 的思路值得一提——它源自 MemGPT 论文,把上下文当内存、外部存储当磁盘,让模型自己通过工具调用「换页」,这个类比至今仍是理解 Agent 记忆最好的框架。
9.2 一个务实的组合
别一上来就搭全套,分三个阶段:
- 第一阶段:只做工作记忆。一个结构化 state + 一个检查点存储。这一步就能解决 60% 的「Agent 干到一半忘了自己在干嘛」。
- 第二阶段:加「偏好类」长期记忆,只存用户明确纠正过的内容,走精确查询注入。先别做语义检索,把准的做对。
- 第三阶段:上下文真的经常满了,再上压缩和语义召回。这时你已经有真实数据,知道该压什么。
- 什么时候才需要向量库:记忆条目超过几千条、且查询意图确实模糊(「上次那个部署失败的事」)时。几百条记忆用 SQL
LIKE加时间排序,效果往往更好。
十、四个真实的坑
10.1 记忆污染:错的东西一旦写进去,会一直错
这是最贵的坑,因为它有复利效应:一条错误记忆会被反复召回、反复强化,最后变成「事实」。
常见来源有三个:摘要时推测补全(「用户大概想要 A」)、把一次性状态写成通用偏好(「这次用 npm」→「偏好 npm」)、以及把模型自己的话当成用户的话(Agent 说「我建议用 yarn」,归档成「用户要用 yarn」)。
三个对策:标注信源(每条记忆带source: user_explicit / user_implicit / agent_inferred,只有前两类能进「硬约束」区);写前确认(高重要性记忆入库前问一次:「我记下了:这个项目用 pnpm,对吗?」);可撤销(记忆列表对用户可见、可删、可改——用户看不到的记忆系统,等于把错误的黑盒装进了产品)。
10.2 召回不准:该来的没来,不该来的来了
两种失败模式:漏召回,用户问「按我们之前的规范来」却没命中,模型开始自由发挥——对策是关键词与向量双路召回再合并,专有名词、项目代号这类东西 BM25 反而比 embedding 准;误召回,召回了别的项目、别的用户、或已失效的旧结论——对策是检索前先按scope过滤再算相似度,而不是先算相似度再过滤。
还有一种更隐蔽的:召回得太多。top-10 塞进去,模型反而抓不住重点。三个精准的胜过十个模糊的。
10.3 无限膨胀:记忆越多,越慢越贵越不准
记忆系统的成本是三重叠加的:存储、检索、以及注入上下文的 token(每次会话都要付)。更麻烦的是精度——库里一万条记忆,检索到相关条目的概率反而下降,因为噪音变多了。
必须显式设计淘汰:TTL(任务类 30 天过期,偏好类长期有效)、合并(相似度 > 0.9 的同类归并,保留最新表述与最高置信度)、降权(长期未被召回的记忆逐步降权,低于阈值归档冷存储)、总量上限(每个 scope 设 500 条,超了按「重要性 × 时效性」淘汰末尾)。
一句话:记忆系统的质量不看存了多少,看召回命中率。
10.4 记忆冲突与越权
冲突。用户上周说「用 pnpm」,这周说「算了还是 npm,pnpm 在 CI 上有问题」。两条记忆都成立,但只有一条有效。解决靠三样东西:时间戳、显式失效(写入新记忆时把同 key 的旧记忆标记为superseded)、召回时重排。绝不要靠「两条都召回让模型自己判断」——它很可能挑错。
越权。多租户产品里,记忆隔离做错一次就是数据事故。scope必须由服务端从会话身份推导,绝不能由模型或前端传入。这条没有例外。
十一、结语:记忆是 Agent 变成同事的门槛
回头看这一路:Prompt 解决「怎么说」,RAG 解决「知道什么」,Function Calling 和 Tool 解决「能做什么」,MCP 解决「怎么接」,Skill 解决「怎么做」,AGENTS.md 解决「在这个项目里怎么守规矩」——到了 Memory,解决的是**「上次做到哪了,这次接着来」**。
前面那些能力,做好一个就能做出一个漂亮的 demo。而记忆这件事,做不好就永远只能做 demo。因为人对「助手」的期待里,有一条是隐性的、但不可谈判的:你应该记得我。
技术上它一点都不玄。模型仍然是无状态的函数,每次调用仍然从零开始。所谓记忆,就是三层容器加上三条搬运规则:
- 短期记忆装原话,快满了就压缩;
- 工作记忆装状态,用代码显式读写,抗压缩抗崩溃;
- 长期记忆装结论,带作用域带时间戳,按需召回;
- 然后用「沉淀、归档、召回」三个动作把它们连起来。
真正难的不是写这几个函数,是判断什么值得记。这件事没有标准答案,只有一条经验法则:记那些「如果忘了会重犯的错」和「如果忘了会重问的问题」。其余的,让它忘掉——遗忘不是缺陷,是设计。
参考资料
- LangGraph 持久化与 Checkpointer 文档
- Anthropic: Effective context engineering for AI agents
- MemGPT: Towards LLMs as Operating Systems
- Lost in the Middle: How Language Models Use Long Contexts
- Zep: A Temporal Knowledge Graph Architecture for Agent Memory
- Mem0 / Letta / LangMem