凌晨一点半,故障群里还有六个人在线。
客服 Agent 给一个老用户报了三个月前的活动价,那个价早就下架了。用户下单,财务第二天对账才发现差额。
复盘会上有人问:这个价格是从哪来的?
答案是:三个月前做活动时,运营让 Agent"记住这次活动规则",它就真的老老实实记了三个月,一次都没忘。
那天我记住了一件事——Agent 记不住不致命,记错了才致命;而绝大多数记错,都不是没存好,是没人管该忘什么。
上周一位学员面字节,问题正好撞在同一件事上:
"Agent 的记忆系统你会怎么设计?"
他答得挺完整:向量库存历史对话,检索出来拼进 prompt,加个滑动窗口防爆。
面试官只追了一句:"你这些历史,什么时候删?"
他卡住了。
这期解析就从这个"删"字开始。第 11 期,欠了五期的债,今天还。
先把「记忆」这个词拆开
面试官问记忆系统,八成的人第一句话就是"用向量库存对话历史"。
这句话里有三个偷换,一开口面试官就知道你没真做过:
- 把对话历史当记忆。对话历史是原料,记忆是产品。原料不加工,出来的是噪声。
- 把存储当记忆系统。存储只管写得进去;记忆系统要管写什么、取什么、什么时候删。
- 把"存得下"当"用得上"。上下文窗口放得下,不等于模型用得着——这一点下面有硬数据。
先把这个地基立住:
⚠️认知冲击
短期记忆是上下文工程,长期记忆是数据工程。
这是两套技术栈、两拨人、两种故障模式。混在一起讲,面试官听到的就是一锅粥。
如果只让我说一句:
Agent 记不住,不是存得少,是忘得不对。
三种记忆,三笔账
先把能放"记忆"的地方数清楚,一共三处,成本差了两个数量级:
类型 | 载体 | 写入频率 | 单条能删吗 | 最常见误用 |
参数记忆 | 微调后的模型权重 | 天 / 周级,离线 | 不能 | 想用它记住用户偏好 |
工作记忆 | 上下文窗口 | 每轮对话 | 会随会话归零 | 想用它承载长期事实 |
外部记忆 | 向量库 / 关系库 / KV | 每轮多次,在线 | 能 | 只写不删,不做门控 |
三句话记住这三笔账:
参数记忆不可删。用户说"别再给我推辣的了",你重新训一遍权重?做不到,更做不到"只删这一条"。
工作记忆不可靠。它随会话生灭。你把用户地址写在工作记忆里,下次开新会话,等于他从没说过。
外部记忆不可不管。它最容易上手,也最容易烂——因为写进去零成本,烂掉还不报错。
真正要设计的是第三层,它的全部难点就四个字:写、取、更新、忘。
短期记忆:这不是"存",是预算
先破一个执念:上下文窗口不是越大越好。
斯坦福和 UC Berkeley 那篇《Lost in the Middle》(arXiv:2307.03172)做过一组很干净的对照实验——把答案文档放在输入上下文的不同位置,其他一切不变。
结果是 U 形曲线:放在开头和结尾,模型答得好;放在中段,性能显著掉档。论文里最扎人的一个数字是——GPT-3.5-Turbo 在多文档问答中,当答案落在上下文中段时,准确率掉到比闭卷作答(56.1%)还低。
同一篇论文还有一组更实用的数据:把检索回来的文档从 20 篇加到 50 篇,GPT-3.5-Turbo 只涨了约 1.5%,Claude-1.3 约 1%。
翻译成人话:往上下文里塞更多东西,不是给模型更多信息,是给它更多需要绕过的噪声。
这就是为什么滑动窗口这种"最笨"的做法到今天还在用——它不是省钱手段,是精度手段。
窗口管理三档,代价一目了然:
策略 | 怎么做 | 代价 |
滑动窗口 | 只留最近 N 轮 | 丢掉最早的信息,而"最早"往往正是用户第一次交代偏好的那轮 |
递归摘要 | 把老对话压成摘要 | 有损,且压缩发生在你还不知道用户要问什么之前 |
分层记忆 | 少量核心事实常驻,其余按需检索 | 实现复杂,写入和检索都得做对 |
第二档那个"压缩发生在前"是结构性缺陷,很多人没意识到:模型在不知道你未来会问什么的情况下,替你把信息扔了。摘要里丢掉的那个数字,往往就是后面被追问的那个数字。
还有个细节:摘要本身会引入幻觉。摘要里的错误会被当事实传给下一轮摘要,一轮一轮卷下去,最后没人知道原始版本长什么样。所以摘要必须带引用(指向原始轮次),否则你连排查的锚点都没有。
最后是预算。别让模型"看着办",把 token 预算写进代码里的常量:
预算项 | 经验占比 | 超了怎么处理 |
系统指令 + Skill 正文 | 10% 到 15% | 精简 description,正文按需加载 |
工具定义 | 10% 到 20% | 工具多了改检索式加载,别全量常驻 |
检索结果 + 记忆注入 | 30% 到 40% | 设 top-k 上限,单条超长直接截断 |
历史对话 | 30% 到 40% | 超限走摘要或滑动 |
输出预留 | 约 10% | 别占满窗口,模型得留地方写字 |
占比是工程经验区间,不是论文结论,按你自己的窗口和任务调。但有一条必须做到:四份预算加输出预留必须显式等于 100%,超了就是你在赌模型自己会算账。
长期记忆:这是数据工程
先给一个全行业都在抄的参考架构。
UC Berkeley 的 MemGPT(arXiv:2310.08560)借了操作系统虚拟内存的思路:模型当前能看到的那段叫主上下文,外面那个大盘子叫外部上下文,中间靠模型自己发起函数调用来回换页。它把记忆分成三类——常驻的核心记忆、可检索的历史记忆、全量归档。2024 年这套东西开源成了 Letta。
它的关键不是"分层"这个结构,而是记忆的调度权交给了模型:存什么、取什么,由模型在推理循环里自己决定。
但这个设计里还缺一半——模型会决定"存",却基本不会主动决定"删"。删除必须由你的工程侧兜住。
所以长期记忆落地就是四个动作。
① 写:门控。
这是最容易被跳过的一步。不是所有对话都配进长期记忆,先分类再决定:
类别 | 例子 | 处置 |
FACT | 用户在上海,做跨境电商 | 存,长期有效 |
PREFERENCE | 回答要短,不要用列表 | 存,可更新 |
EVENT | 上周三在问退款 | 存,短期 |
NOISE | "你好""谢谢""嗯嗯" | 直接丢 |
再叠三条硬规则,这三条说出来面试官会点头:
- 异步写。记忆抽取不能挂在主链路上——用户是在等回答,不是在等你写库。
- 幂等写。同一事实重复出现要 upsert,去重键用 tenant + user + kind + 归一化文本。不做去重,三天后你的向量库里躺着八条一模一样的"用户不吃辣"。
- 溯源写。只允许用户自己说出来的内容升级为长期记忆。工具返回值、检索到的文档、网页正文,永远不允许直接写进长期记忆。
第三条是安全底线。单轮的 prompt 注入只污染这一次回答;而如果你让被注入的内容写进了长期记忆,它就变成永久污染——之后每一次对话都会把这句话当事实读出来。这叫记忆投毒,比普通注入危险一个量级。
💡插一句
判断一条内容能不能进长期记忆,问一个问题就够了:这句话,是用户亲口说的吗?不是,就只能活在这一轮上下文里。
② 取:从"最相似"改成"最有用"。
纯向量检索有个死穴——词汇不匹配。用户说"我最近忌口",记忆里存的是"不吃辣",两句话字面零重叠,向量距离可能很远。
所以生产上基本是混合检索:dense 向量召回 + BM25 稀疏召回,两路用 RRF(倒数排名融合)合并,最后 rerank。
这里有个代价必须说清,面试里讲出来是加分项:RRF 只看名次,不看分数量级。一路检索给出 100 分、另一路给出 3 分,在 RRF 眼里只差几个名次,绝对相关性被抹平了。默认用 RRF 是因为它免校准、跨检索器通用;但如果你需要精细控权——比如业务上要求关键词命中必须压过语义近似——就得换成 min-max 归一化加权,代价是要拿评估集调参。选哪个,取决于你要"省事"还是"可控",别把它当标准答案背。
然后才是关键一步:最终得分不是相似度,是相似度乘以新鲜度再乘历史命中价值。
③ 更新:冲突要显式覆盖。
用户三个月前说不吃辣,上周说"开始吃辣了"。两条记忆同时躺在库里,检索命中哪条纯看运气。这是长期记忆最隐蔽的 bug——它不报错,只是偶尔答错。
正确做法是按槽位覆盖:diet.spicy这个槽位从 false 改成 true,老记录标记为"被覆盖"而不是物理删除(保留审计链)。零散的 EVENT 攒够频次后可以晋升成结构化 FACT——记忆固化,固化后不走检索、直接常驻,既省 token 也更稳。
④ 忘:这才是判分点。
三种忘法,从软到硬:
- 衰减(软遗忘)权重随时间指数衰减,命中一次就续期。本质是"不删,但排后面"。
- TTL 分层(中)按记忆类型给不同保质期。
- 硬删除(硬)合规要求。必须能按 tenant + user + agent 三元组级联删除,并回答"这条记忆是从哪句话来的"。
TTL 分层建议这么切:
记忆类型 | 例子 | 保质期 | 到期动作 |
会话事件 | 刚才在问退款,刚下过什么单 | 7 天 | 归档,不参与默认召回 |
临时偏好 | 这次想吃辣的 | 30 天 | 衰减,命中可续期 |
稳定事实 | 所在城市,职业,行业 | 不过期 | 冲突时覆盖 |
敏感信息 | 手机号,身份证,地址 | 不存 | 写入阶段直接拦掉 |
为什么"忘"比"记"更关键?因为检索质量不是被"存得少"拖垮的,是被"老数据挤掉新数据"拖垮的。
向量库里 1000 条记忆,其中 800 条已经过期。你召回 top-5,塞给模型的是 5 条历史噪声。模型不是不聪明,是拿了一手错牌——然后你骂它幻觉。
回到开头那个故障:如果那条活动规则有一个 7 天的 TTL,财务那笔差额根本不会发生。
四个高频坑
# | 坑 | 现场长什么样 | 正确做法 |
1 | 把对话历史全量回灌当记忆 | 窗口越聊越满,模型反而越来越糊 | 工作记忆只留最近 N 轮加摘要,长期事实走检索注入 |
2 | 只写不删 | 老偏好盖掉新偏好,老价格当现价 | TTL 分层加同槽位覆盖加定期合并 |
3 | 只用纯语义检索 | "忌口"召回不到"不吃辣" | dense 加 BM25 加 RRF 融合再加 rerank |
4 | 外部内容直接进长期记忆 | 一次网页注入,永久污染 | 溯源门控:只有用户原话可升级 |
第 1 条最反直觉也最普遍:日志里看到"上下文很长",第一反应是模型能力不够,其实是你的记忆在抢预算。
Java 实战:三层记忆可运行骨架
先看结构:
下面这份骨架用 Spring AI 1.1.x + JDK 21。Spring AI 只负责两件事——LLM 调用和向量检索,记忆策略全是普通 Java:
// ===== 1. 记忆模型与写入门控:不是所有对话都配进长期记忆 ===== public enum MemoryKind { FACT, PREFERENCE, EVENT, NOISE } public record MemoryRecord(String id, String tenantId, String userId, MemoryKind kind, String slot, String text, String sourceQuote, // 溯源:来自用户哪句原话 Instant createdAt, int hitCount) {} @Component public class MemoryWriteGate { private static final Set<MemoryKind> KEEP = Set.of(MemoryKind.FACT, MemoryKind.PREFERENCE, MemoryKind.EVENT); private final PiiFilter piiFilter; // 手机号 / 身份证 / 密钥 public boolean admit(MemoryKind kind, String text, String source, String sourceQuote) { if (!KEEP.contains(kind)) return false; // NOISE 直接丢 if (text.strip().length() < 6) return false; // "好的""嗯" 不记 if (piiFilter.hits(text)) return false; // 隐私不进记忆 if (sourceQuote == null || sourceQuote.isBlank()) return false; // 无溯源不写 return // 外部内容永不升级.equals(source); "USER_UTTERANCE" } }// ===== 2. 混合召回 + 时效衰减:目标不是"最相似",是"最有用" ===== public record ScoredMemory(MemoryRecord memory, double score, List<String> hits) { public String render() { // 注给模型的一行文本 return // hits 记录哪路召回命中,便于排查 + memory.kind() + // RRF 平滑常数 + memory.text() + // 半衰期,按记忆类型可调 + String.join(// 两路召回:dense 管语义,BM25 管词汇不匹配("忌口" vs "不吃辣"), hits) + // 按名次累加 1 / (RRF_K + rank); // 低分直接丢,别硬凑 top-k } } @Service public class HybridMemoryRecall { private static final int RRF_K = 60; // 必须带租户过滤,防串号 private static final double HALF_LIFE_DAYS = 30; // 指数衰减 private final VectorStore vectorStore; private final Bm25Index bm25; public List<ScoredMemory> recall(String tenantId, String userId, String query, int topK) { // 命中越多越值钱 List<Document> dense = vectorStore.similaritySearch(SearchRequest.builder() .query(query).topK(topK * 3).build()); List<Document> sparse = bm25.search(tenantId, userId, query, topK * 3); Map<String, Double> rrf = new HashMap<>(); addRank(rrf, dense, "- [", tenantId, userId); "] " addRank(rrf, sparse, "(", tenantId, userId); return rrf.entrySet().stream() .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) .map(e -> scoreOf(e.getKey(), e.getValue(), tenantId, userId)) .filter(m -> m.score() > 0.01) "+" .limit(topK) .toList(); } private ScoredMemory scoreOf(String id, double rrfScore, String tenantId, String userId) { MemoryRecord r = load(id, tenantId, userId); ")" long ageDays = ChronoUnit.DAYS.between(r.createdAt(), Instant.now()); double decay = Math.pow(0.5, (double) ageDays / HALF_LIFE_DAYS); "dense" double boost = Math.min(Math.log1p(r.hitCount()) / Math.log1p(20), 1.0); "sparse" return new ScoredMemory(r, rrfScore * (0.6 * decay + 0.4 * boost), List.of("rrf")); } }四个工程细节,面试里说出来就是加分项:
- 写入口只有门控一个。任何绕过
MemoryWriteGate的写路径都是后门,代码层面就要堵死。 - 召回必须带租户过滤。
load里少一个tenantId条件,就是跨租户数据泄漏,这类事故在真实项目里出过不止一次。 - 低分不硬凑 top-k。召回到 2 条相关记忆就注入 2 条,凑满 5 条等于故意往上下文里掺噪声。
- 长期记忆不进 ChatMemory。历史归 Spring AI 的窗口管理,长期事实归召回注入。混在一起,你事后根本分不清哪句话是用户说的、哪句话是你的记忆系统编的。
30 行复现实验:亲手看一次"老数据挤掉新数据"
上面那套逻辑别信我,跑一遍。池子里放 5 条候选记忆,其中 4 条是 90 天前的过期活动价,只有 1 条是当前有效的:
public static void main(String[] args) { List<MemoryRecord> pool = mockExpiredPricePool(); // 4 条 90 天前 + 1 条现行 System.out.println(// rank 里新鲜度 = 0.5 ^ (ageDays / halfLife),halfLife 越大衰减越慢 + ids(rank(pool, 3, Double.MAX_VALUE))); System.out.println("A 纯相似度 -> " + ids(rank(pool, 3, 90))); System.out.println("B 半衰期 90 天 -> " + ids(rank(pool, 3, 30))); System.out.println("C 半衰期 30 天 -> " + ids(rank(pool, 3, 7))); } "D 半衰期 7 天 -> " static List<String> rank(List<MemoryRecord> pool, int topK, double halfLife) { return pool.stream() .map(r -> new AbstractMap.SimpleEntry<>(r.id(), 0.6 * Math.pow(0.5, ageDays(r) / halfLife) + 0.4 * 0.5)) .sorted(Map.Entry.<String, Double>comparingByValue().reversed()) .limit(topK).map(Map.Entry::getKey).toList(); }跑完你会看到同一份数据、同一个 top-3 请求,因为半衰期这一个参数不同,注入给模型的记忆完全不同。把半衰期调到 90 天,三个月前的活动价还在 top-3 里——这就是开头那个故障的最小复现。
这不是严格 benchmark,但它能让你在 30 行代码里看清一件事:衰减半衰期不是一个"优化参数",它是你的 Agent 会记错多久的直接决定项。
「记忆」这个类比失效的地方
"记忆"是从人脑借来的词,但 Agent 的记忆和人脑有三处根本不同。面试里主动说这三点,比多背三个框架管用:
第一,人会自动遗忘,Agent 不会。人脑的遗忘是默认行为,Agent 的遗忘是必须显式配置的功能。你不写 TTL,它就真的记到天荒地老——"自然而然忘掉"这件事在工程里不存在。
第二,人的记忆有权重直觉,Agent 的权重得你来定。相似度、时间、频次,三路怎么加权、半衰期给多长,全是你拍的数字。定错了不报错,只答错。
第三,人的记忆删不掉,Agent 的记忆必须能删。这反而是 Agent 更强的地方:合规要求"删除某用户的全部数据",人做不到,你的向量库必须做到。
还有一个真边界要说清楚:MemGPT 式"让模型自己管记忆"听着优雅,代价是模型要额外消耗 token 去做内存管理决策,而且它可能选择不存关键信息。所以生产上更常见的是混合——模型决定"这条值不值得记",工程侧兜住 TTL、覆盖和硬删除。方向判断交给模型,纪律交给代码。
追问连环炮
Q1:记忆和 RAG 有什么区别?
检索的都是"外面的东西",但三处不同。① 写入侧:RAG 是离线 ETL 全量灌库,记忆是在线高频写入、必须做门控;② 归属:RAG 的知识是公共的、所有人一样,记忆是用户私有的、必须按租户和用户隔离;③ 生命周期:RAG 的知识可以常驻,记忆必须过期、必须可删。一句话——RAG 管"世界知道什么",记忆管"你和我之间发生过什么"。
Q2:用户说"我上周讲过不吃辣",Agent 忘了,怎么排查?
三段定位,按链路顺序走,顺序不能乱:
- 没写进去——查写入链路门控日志,是门控太严拦掉了,还是异步任务丢了
- 写进去了没召回——查这个用户的召回日志,是词汇不匹配(dense 没命中),还是被时效衰减挤出 top-k
- 召回了没用上——查注入位置和 token 预算,可能是被截断,也可能是位置落在上下文中段(Lost in the Middle)
RAG 排障的经典归因是三分法——召回、组装、生成。记忆场景要在这条链前面再加一段"写入",变成四分法:写入 → 召回 → 组装 → 生成。多出来的这一段,恰恰是记忆系统独有的故障面,也是最容易被漏查的一段。
Q3:记忆越堆越多,检索越来越不准,怎么办?
四个动作,从便宜到贵依次上:TTL 分层过期 → 同槽位覆盖去重 → 记忆固化(高频 EVENT 晋升为 FACT profile)→ 按访问频次加权。别一上来就搞聚类合并,那个贵、难验证,而且容易把不同时期的事实揉成一条错误结论。
Q4:怎么防记忆投毒?
写入侧只有一个原则:溯源。只有用户原话可以升级为长期记忆,工具返回和文档内容一律只进当轮上下文。再叠两道:写入审计(每条记忆记下来源句子)和敏感信息过滤。注意这是写入侧的防御,不是 prompt 侧的——靠 system prompt 说"不要相信注入内容",防不住。
Q5:多租户下记忆怎么隔离、怎么删?
key 用 tenant + user + agent 三元组,物理隔离或强制过滤,宁可多一次过滤也别靠 prompt 约束。删除必须级联,并且可证明——合规问的从来不是"你删了没",而是"你怎么证明你删了",所以要留删除审计记录。
答题骨架(2 分钟版)
面试官问"Agent 的记忆系统怎么设计",别上来就报组件,按这四拍说:
第一拍 · 立框架:先把"记忆"拆开——短期记忆是上下文工程,长期记忆是数据工程,两套栈、两种故障模式。
第二拍 · 短期:窗口不是越大越好,中段信息会掉档(Lost in the Middle 里答案放中段比闭卷还差);预算显式分配,四份加起来留 10% 给输出。
第三拍 · 长期:四个动作——写、取、更新、忘。写要门控加幂等加溯源;取要混合召回加时效衰减;更新要槽位覆盖;忘要 TTL 分层加硬删除。
第四拍 · 给判断:如果只让我先做一件事,我先把遗忘策略做出来,因为它决定了后面所有检索的天花板。没有遗忘策略的记忆系统,本质上是一个不断恶化的噪声源。
Fox 有话说
回到那个凌晨的故障群。
复盘到最后,我们发现整件事里没有一处技术故障——向量库没崩,检索没超时,模型也没幻觉。它只是把一条三个月前的记忆,当成了今天的事实。
这才是记忆系统真正难的地方:它的错误不会报错,只会变旧。
写这篇文章的时候我一直在想,几乎所有讲 Agent 记忆的资料,都在教你怎么"存得更多、取更准"。但我在真实的故障现场看到的是反过来的——问题从来不是记不住,是记着不该记的。
所以这期的最后一句,请你记住:
你不是在给 Agent 加记忆,你是在给它加一套遗忘策略。
敢让它忘,你才敢放心让它记。