深蓝黑背景封面。左侧中部是一个 Agent 循环示意:中央圆角框写「大模型 Agent」,四周三个小框「观察」「思考」「行动」用弧形箭头连成环。Agent 框向左引一条双向箭头连到青色卡片「短期记忆」,卡内写「对话历史 + 中间结果」,下标「就放在上下文窗口里,随会话结束而消失」;向右引一条双向箭头连到紫色卡片「长期记忆」,卡内写「向量库 + 滚动摘要」,下标「跨会话持久保存,用时检索召回」。两卡下方一行小字注「短期靠上下文窗口硬塞,长期靠写入与检索两个动作」。右侧大标题「Agent 的短期记忆和长期记忆怎么实现」,副标题「一个管这场对话,一个管所有对话」,小注「容量有限靠摘要压缩,容量无限靠向量检索」。左上角标注「面试经典 · Agent」,右上角圆角胶囊标注「Day 10 · 12-07」。 图 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)压过「川菜」排第一——召回噪声的实物演示,真实系统要靠打分权重和重排去压。

深蓝黑背景概念图,顶部小字「概念图 · Agent 记忆机制」,主标题「写入一条线,读取一条线」。画面分上下两条泳道。上泳道标题「写入通路(每轮结束后)」:对话片段先进「重要性筛选」框,满足条件的兵分两路——一路进青色框「滚动摘要:把长历史压成几百字要点」,另一路进紫色框「embedding 转向量,写入向量库」,向量库内画三条小横条标注「事实 / 偏好 / 经验」;两个框右侧汇合进「持久化存储」。下泳道标题「读取通路(下一轮思考前)」:当前问题先进「query 向量化」框,再到「向量库检索 top-k」框,检索结果与「短期对话历史」「滚动摘要」三个来源一起汇入最大的装配框「拼装 prompt:系统提示 + 摘要 + 检索片段 + 近期对话」,箭头指向「大模型生成回复」。两条泳道之间一根竖向虚线箭头标注「检索结果写回上下文,成为下一轮的短期记忆」。底部总结条:短期记忆是上下文窗口本身,长期记忆 = 摘要压缩 + 向量检索;两条通路都要控制 token 预算。最底行小字:常见检索参数 top-k 取 3~10,并按相关度与时间衰减打分排序。 图 2:写入与检索双通路,以及 prompt 的装配结构。

工程权衡:三笔账要算清。

第一笔上下文成本:摘要、检索片段、近期原文最终挤在同一个窗口,token 预算要在三者间分配,塞得越多越贵越容易分心。第二笔召回噪声:那条「爬山」就是例子,时间衰减能压旧噪但会放新噪,top-k 和权重都得按业务调。第三笔记忆一致性:用户改了主意(「我现在不过敏了」),库里的旧事实不会自动失效,摘要里也躺着旧结论——需要显式的更新与遗忘:按主键覆盖写入、给记忆加有效期、定期重算摘要。这层常被漏答,补上是加分项。

面试现场的答题结构。

先立分类框架:短期管单会话、长期跨会话。再讲落地:短期是窗口加裁剪三件套加消息合法性约束;长期讲清两条路线的写入时机、检索时机与优缺点。最后谈权衡:预算分配、召回噪声、一致性与遗忘,收尾落到一句——记忆系统没有银弹,生产里几乎都是「窗口 + 滚动摘要 + 向量库」的组合拳。你做的 Agent 项目里记忆是怎么存的?有没有被「过期记忆」坑过?欢迎聊聊。