图 1:本文封面,Agent 循环与双层记忆结构。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料整理,与任何公司的当前招聘无关。「Agent 的记忆机制怎么设计」考的不是背「短期加长期」,而是说清两层记忆怎么落地:短期怎么在有限窗口里腾挪,长期的写入、检索、遗忘怎么接到主循环上。
一句话结论:短期记忆就是上下文窗口里的对话历史,靠裁剪控预算;长期记忆是外挂持久存储,走「滚动摘要」和「向量库检索」两条路线,每轮结束后写入、下一轮思考前召回。
先把记忆分两层:一个管这场对话,一个管所有对话。
短期记忆的作用域是单条会话:用户说了什么、调了哪个工具、工具返回了什么,都以消息列表躺在窗口里,会话结束就消散。长期记忆跨会话:事实、偏好、历史经验持久化到外部存储,开新会话还能召回。这个二分是答题地基。
短期记忆:窗口本身就是存储,剩下的问题全是裁剪。
上下文窗口按 token 计费,对话一长必然撞墙,长上下文还会让模型被无关旧内容带偏,变慢变贵。主流框架的裁剪套路就三类:滑动窗口,按 token 预算只保留最近若干条;直接删除过期消息;摘要替换,把较早消息压成摘要拼回去。前两类简单但丢信息,第三类保信息但要多花一次模型调用。细节坑:带工具调用的消息后面必须跟对应的工具结果消息,砍历史时劈开这对组合,接口直接报错。
长期记忆路线一:滚动摘要,把长历史压成几百字要点。
思路是「有损压缩」:窗口一溢出,就把挤出的旧对话压成要点摘要,摘要随对话滚动更新、常驻系统提示;再长还可以层级摘要。优点是读取零成本;缺点是有损——「三个月前随口提的过敏史」这种颗粒度的信息活不过两轮压缩。
长期记忆路线二:向量库检索,用事实和片段换原文。
把记忆当搜索引擎做。写入侧:每轮结束后做重要性筛选,琐事丢弃,要点向量化入库。读取侧:下一轮思考前把当前问题同样向量化,按相似度召回 top-k(常见取 3 到 10),按相关度加时间衰减加权排序,拼回 prompt。优点是容量近乎无限、细节原样保留;代价是召回质量问题——相似度不等于真相关。框架口径可对照看:LangChain 的长期记忆按命名空间加键存文档、支持向量搜索,分语义、情景、程序三类;LlamaIndex 的 Memory 组件内置「窗口超预算就冲刷旧消息进长期记忆块」。
用代码把写入和检索两条通路跑一遍(Node.js 22.15.0,node agent-memory-demo.js):
const DIM = 128;
function embed(text) { // bigram 哈希模拟 embedding(真实系统换 embedding 模型)
const v = new Array(DIM).fill(0);
for (let i = 0; i < text.length - 1; i++)
v[((text.codePointAt(i) * 31 + text.codePointAt(i + 1)) >>> 0) % DIM]++;
return v;
}
function cosine(a, b) { // 手写余弦相似度
let d = 0, x = 0, y = 0;
a.forEach((ai, i) => { d += ai * b[i]; x += ai ** 2; y += b[i] ** 2; });
return d / (Math.sqrt(x) * Math.sqrt(y) || 1);
}
const BUDGET = 40, KEEP = 2, shortTerm = [], store = []; // 窗口预算 40 字,保留最近 2 轮原文
let summary = '', clock = 0;
function onTurnEnd(text) { // 写入通路:每轮对话结束后调用
clock++; shortTerm.push(text);
if (shortTerm.join('').length + summary.length <= BUDGET) return;
const out = shortTerm.splice(0, shortTerm.length - KEEP); // 挤出最旧轮次
summary += (summary ? ' + ' : '') + out.map(t => `要点:${t.slice(0, 10)}`).join(';');
for (const t of out) store.push({ text: t, vec: embed(t), ts: clock }); // 写入向量库
console.log(`[触发摘要] 挤出 ${out.length} 轮 -> 滚动摘要 + 向量库`);
}
function recall(query, k = 2) { // 读取通路:下一轮思考前调用
const qv = embed(query);
return store.map(m => {
const sim = cosine(qv, m.vec), decay = Math.exp(-(clock - m.ts) / 10);
return { text: m.text, sim, decay, score: sim * 0.7 + decay * 0.3 }; // 相关度+时间衰减
}).sort((a, b) => b.score - a.score).slice(0, k);
}
喂入一段多轮对话(用户先说过「对花生过敏」「喜欢川菜」,又聊了爬山和天气,最后问「推荐个聚餐餐厅」),实测输出:
[触发摘要] 挤出 2 轮 -> 滚动摘要 + 向量库
[触发摘要] 挤出 1 轮 -> 滚动摘要 + 向量库
滚动摘要: 要点:用户说他对花生过敏;要点:用户喜欢川菜,尤其水 + 要点:讨论了周末去哪里爬山
近期原文: [ '用户提到预算人均两百以内', '聊了一下最近的天气' ]
向量库存量: 3 条
召回: "讨论了周末去哪里爬山" sim=0.19 decay=1.00 综合=0.433
召回: "用户喜欢川菜,尤其水煮鱼" sim=0.19 decay=0.90 综合=0.405
两个机制点都跑出来了:窗口超预算时旧轮次被挤出,同时进摘要和向量库;召回时「爬山」这条无关记忆纯靠时间衰减新(decay=1.00)压过「川菜」排第一——召回噪声的实物演示,真实系统要靠打分权重和重排去压。
图 2:写入与检索双通路,以及 prompt 的装配结构。
工程权衡:三笔账要算清。
第一笔上下文成本:摘要、检索片段、近期原文最终挤在同一个窗口,token 预算要在三者间分配,塞得越多越贵越容易分心。第二笔召回噪声:那条「爬山」就是例子,时间衰减能压旧噪但会放新噪,top-k 和权重都得按业务调。第三笔记忆一致性:用户改了主意(「我现在不过敏了」),库里的旧事实不会自动失效,摘要里也躺着旧结论——需要显式的更新与遗忘:按主键覆盖写入、给记忆加有效期、定期重算摘要。这层常被漏答,补上是加分项。
面试现场的答题结构。
先立分类框架:短期管单会话、长期跨会话。再讲落地:短期是窗口加裁剪三件套加消息合法性约束;长期讲清两条路线的写入时机、检索时机与优缺点。最后谈权衡:预算分配、召回噪声、一致性与遗忘,收尾落到一句——记忆系统没有银弹,生产里几乎都是「窗口 + 滚动摘要 + 向量库」的组合拳。你做的 Agent 项目里记忆是怎么存的?有没有被「过期记忆」坑过?欢迎聊聊。