☰
字节面试:Agent 的记忆系统怎么设计?
2026/9/29 7:22:30 网站建设 项目流程

凌晨一点半,故障群里还有六个人在线。

客服 Agent 给一个老用户报了三个月前的活动价,那个价早就下架了。用户下单,财务第二天对账才发现差额。

复盘会上有人问:这个价格是从哪来的?

答案是:三个月前做活动时,运营让 Agent"记住这次活动规则",它就真的老老实实记了三个月,一次都没忘。

那天我记住了一件事——Agent 记不住不致命,记错了才致命;而绝大多数记错,都不是没存好,是没人管该忘什么。

上周一位学员面字节,问题正好撞在同一件事上:

"Agent 的记忆系统你会怎么设计?"

他答得挺完整:向量库存历史对话,检索出来拼进 prompt,加个滑动窗口防爆。

面试官只追了一句:"你这些历史,什么时候删?"

他卡住了。

这期解析就从这个"删"字开始。第 11 期,欠了五期的债,今天还。

先把「记忆」这个词拆开

面试官问记忆系统,八成的人第一句话就是"用向量库存对话历史"。

这句话里有三个偷换,一开口面试官就知道你没真做过:

  1. 把对话历史当记忆。对话历史是原料,记忆是产品。原料不加工,出来的是噪声。
  2. 把存储当记忆系统。存储只管写得进去;记忆系统要管写什么、取什么、什么时候删。
  3. 把"存得下"当"用得上"。上下文窗口放得下,不等于模型用得着——这一点下面有硬数据。

先把这个地基立住:

⚠️认知冲击

短期记忆是上下文工程,长期记忆是数据工程。

这是两套技术栈、两拨人、两种故障模式。混在一起讲,面试官听到的就是一锅粥。

如果只让我说一句:

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

"你好""谢谢""嗯嗯"

直接丢

再叠三条硬规则,这三条说出来面试官会点头:

  1. 异步写。记忆抽取不能挂在主链路上——用户是在等回答,不是在等你写库。
  2. 幂等写。同一事实重复出现要 upsert,去重键用 tenant + user + kind + 归一化文本。不做去重,三天后你的向量库里躺着八条一模一样的"用户不吃辣"。
  3. 溯源写。只允许用户自己说出来的内容升级为长期记忆。工具返回值、检索到的文档、网页正文,永远不允许直接写进长期记忆。

第三条是安全底线。单轮的 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")); } }

四个工程细节,面试里说出来就是加分项:

  1. 写入口只有门控一个。任何绕过MemoryWriteGate的写路径都是后门,代码层面就要堵死。
  2. 召回必须带租户过滤。load里少一个tenantId条件,就是跨租户数据泄漏,这类事故在真实项目里出过不止一次。
  3. 低分不硬凑 top-k。召回到 2 条相关记忆就注入 2 条,凑满 5 条等于故意往上下文里掺噪声。
  4. 长期记忆不进 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 忘了,怎么排查?

三段定位,按链路顺序走,顺序不能乱:

  1. 没写进去——查写入链路门控日志,是门控太严拦掉了,还是异步任务丢了
  2. 写进去了没召回——查这个用户的召回日志,是词汇不匹配(dense 没命中),还是被时效衰减挤出 top-k
  3. 召回了没用上——查注入位置和 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 加记忆,你是在给它加一套遗忘策略。

敢让它忘,你才敢放心让它记。

资料展示:

下面是我整理的AI大模型 学习资料和工具包预览,适合收藏后按主题逐步学习

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询