AI Agent 开发实战(三):记忆不是存聊天记录,搞懂这三层记忆架构才算入门
这是「AI Agent 开发实战」系列的第 3 篇。上一篇讲了 LLM 调用与 Prompt 工程,这一篇继续拆三大基石——记忆系统。很多人的 Agent 跑两轮就"失忆",或者上下文一长就 Token 爆炸,根因都是没搞懂记忆该怎么设计。
一、为什么 Agent 需要记忆
先说一个反直觉的事实:LLM 本身是没有记忆的。
你每次调用/chat/completions,LLM 看到的只有你传过去的 messages 数组。它不记得上一次对话说了什么,不记得用户的偏好,不记得三步之前做了什么。
┌──────────────────────────────────────────────────┐ │ 无记忆的 Agent │ │ │ │ 用户:我叫张三 │ │ Agent:你好张三! │ │ │ │ 用户:我叫什么? │ │ Agent:抱歉,我不知道你叫什么。 │ │ │ │ 原因:第二次调用时 messages 里没有第一次的内容 │ └──────────────────────────────────────────────────┘这不是 LLM “笨”,而是它根本看不到。
记忆系统的本质,就是在每次调用 LLM 之前,把相关信息塞进 messages 数组里。
听起来简单,但问题在于:
- 上下文窗口有限(4K ~ 200K tokens),不可能全塞进去
- 什么该塞、什么不该塞、什么时候塞,直接决定 Agent 的智商
- 不同类型的记忆,存储方式、检索方式、过期策略完全不同
二、三层记忆架构
工程上,Agent 的记忆通常分为三层:
┌──────────────────────────────────────────────────────────┐ │ Agent 记忆架构 │ │ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ 第一层:短期记忆(Working Memory) │ │ │ │ 存在 LLM 的上下文窗口里 │ │ │ │ 内容:当前对话历史、当前任务的中间状态 │ │ │ │ 生命周期:单次会话 │ │ │ │ 特点:LLM 直接可见,但容量有限 │ │ │ └──────────────────────────────────────────────────┘ │ │ ↑↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ 第二层:长期记忆(Long-term Memory) │ │ │ │ 存在向量数据库 / 关系数据库中 │ │ │ │ 内容:用户偏好、历史决策、知识库 │ │ │ │ 生命周期:跨会话持久化 │ │ │ │ 特点:需要检索才能进入短期记忆 │ │ │ └──────────────────────────────────────────────────┘ │ │ ↑↓ │ │ ┌──────────────────────────────────────────────────┐ │ │ │ 第三层:工作记忆(Scratchpad / Task Memory) │ │ │ │ 存在内存 / 缓存中 │ │ │ │ 内容:当前任务的执行计划、已完成步骤、待办事项 │ │ │ │ 生命周期:单次任务 │ │ │ │ 特点:结构化存储,不直接进 LLM 上下文 │ │ │ └──────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────┘三层对比:
| 维度 | 短期记忆 | 长期记忆 | 工作记忆 |
|---|---|---|---|
| 存储位置 | LLM 上下文窗口 | 向量数据库 / 关系库 | 内存 / Redis |
| 存储内容 | 对话历史 | 用户画像、知识、历史决策 | 执行计划、步骤状态 |
| 生命周期 | 单次会话 | 永久(或手动删除) | 单次任务 |
| LLM 可见性 | 直接可见 | 需检索后注入 | 按需注入 |
| 容量 | 小(受 Token 限制) | 大 | 中 |
| 检索方式 | 不需要(全量在上下文) | 向量相似度 / 关键词 | 直接读取 |
三、短期记忆:上下文窗口管理
短期记忆就是 LLM 的 messages 数组。核心问题是:对话越来越长,Token 放不下怎么办?
朴素方案:全量保留
List<Message>messages=newArrayList<>();messages.add(newSystemMessage(systemPrompt));// 每轮对话都往里塞while(running){messages.add(newUserMessage(userInput));LlmResponseresp=llm.chat(messages,tools);messages.add(newAssistantMessage(resp.getContent()));}// 问题:第 20 轮时,messages 可能已经 50K tokens,窗口爆了方案一:滑动窗口截断
最简单粗暴——只保留最近 N 轮对话:
publicclassSlidingWindowMemory{privatefinalintmaxMessages;// 保留最近多少条消息publicList<Message>manage(List<Message>messages){if(messages.size()<=maxMessages){returnmessages;}// 保留 System Prompt(第一条)+ 最近 N 条List<Message>result=newArrayList<>();result.add(messages.get(0));// System 一定保留result.addAll(messages.subList(messages.size()-maxMessages+1,messages.size()));returnresult;}}优点:简单、快、可预测。
缺点:丢掉的对话可能正好是 LLM 需要的。用户第 2 轮说了"我偏好高股息股票",到第 10 轮 Agent 就忘了。
方案二:摘要压缩
用 LLM 把旧对话总结成一段摘要,替换原始消息:
publicclassSummaryMemory{privateStringsummary="";privatefinalintsummarizeThreshold=10;// 超过 10 轮触发摘要publicList<Message>manage(List<Message>messages){if(messages.size()<=summarizeThreshold){returnmessages;}// 把旧消息交给 LLM 总结List<Message>oldMessages=messages.subList(1,messages.size()-5);StringnewSummary=llm.chat(List.of(newSystemMessage("请将以下对话总结为关键信息,保留用户偏好、重要决策和未完成的事项。"),newUserMessage(formatMessages(oldMessages))));// 用摘要替换旧消息summary=summary+"\n"+newSummary;List<Message>result=newArrayList<>();result.add(messages.get(0));// Systemresult.add(newSystemMessage("之前的对话摘要:\n"+summary));result.addAll(messages.subList(messages.size()-5,messages.size()));// 最近 5 轮returnresult;}}优点:保留了关键信息,Token 占用大幅降低。
缺点:摘要本身要调一次 LLM(有成本和延迟),且摘要可能丢失细节。
方案三:混合策略(推荐)
实际工程中,通常组合使用:
┌─────────────────────────────────────────────────┐ │ 上下文窗口(128K) │ │ │ │ System Prompt(固定 2K) │ │ ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ │ │ 对话摘要(动态 1K) │ │ ████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ │ │ 最近 N 轮原文(动态 10K) │ │ ████████████████████░░░░░░░░░░░░░░░░░░░░░ │ │ │ │ 从长期记忆检索的相关信息(动态 2K) │ │ ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ │ │ 当前用户输入(0.5K) │ │ ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ │ │ 预留输出空间(4K) │ └─────────────────────────────────────────────────┘四、长期记忆:跨会话持久化
短期记忆解决的是"单次会话内"的问题。长期记忆解决的是:Agent 怎么记住跨会话的信息?
比如:
- 用户上次说"我偏好高股息央企股"——下次会话 Agent 应该记住
- Agent 上次帮用户分析过茅台——下次可以引用之前的结论
- 用户问过的问题模式——Agent 可以预判需求
存储方案选型
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 向量数据库 | 语义记忆(用户偏好、知识) | 语义检索强 | 部署复杂 |
| 关系数据库 | 结构化记忆(用户画像、配置) | 查询精确 | 语义检索弱 |
| 键值存储 | 简单事实记忆 | 读写快 | 无法语义检索 |
| 图数据库 | 关系型记忆(实体关联) | 关联推理强 | 过度设计风险 |
工程建议:从向量数据库开始,80% 的场景够用了。
向量记忆的核心流程
写入记忆 读取记忆 ──────── ──────── 用户说了一句话 ┌──────┐ 新一轮对话开始 │ │ │ │ ▼ │ 判断 │ ───────▶│ 提取"记忆 worthy" │ 是否 │ 是否 │ 的信息 │ 值得 │ 需要 │ │ │ 记住 │ 历史 │ ▼ │ │ 记忆? │ Embedding └──────┘ │ 向量化 │ Yes │ Yes │ ▼ ▼ ▼ 存入向量库 查询向量库 存入向量库 │ ▼ Top-K 相关记忆 │ ▼ 注入到 messages 的 System 部分写入:什么值得记住
不是每句话都值得存。Agent 需要判断:
publicclassMemoryWriter{privatefinalLlmClientllm;privatefinalVectorStorevectorStore;publicvoidmaybeSave(StringuserMessage,StringagentResponse){// 用 LLM 判断这条对话是否包含值得记住的信息Stringjudgment=llm.chat(List.of(newSystemMessage(""" 判断以下对话是否包含值得长期记忆的信息。 值得记忆的:用户偏好、个人事实、重要决策、任务结论。 不值得记忆的:寒暄、临时性问题、已过期信息。 只回答 YES 或 NO。 """),newUserMessage("用户: "+userMessage+"\n助手: "+agentResponse)));if(judgment.trim().toUpperCase().startsWith("YES")){// 提取结构化记忆Stringmemory=llm.chat(List.of(newSystemMessage("将以下对话中的关键信息提取为一句简洁的记忆陈述。"),newUserMessage("用户: "+userMessage+"\n助手: "+agentResponse)));// 向量化并存储vectorStore.add(memory,Map.of("timestamp",Instant.now().toString(),"type","user_preference"));}}}读取:怎么找到相关记忆
publicclassMemoryRetriever{privatefinalVectorStorevectorStore;privatestaticfinalintTOP_K=5;publicStringretrieve(StringcurrentQuery){// 1. 把当前查询向量化// 2. 在向量库中找 Top-K 最相似的记忆List<MemoryItem>memories=vectorStore.search(currentQuery,TOP_K);if(memories.isEmpty()){return"";}// 2. 拼接成上下文StringBuildersb=newStringBuilder("相关历史记忆:\n");for(MemoryItemm:memories){sb.append("- ").append(m.getContent()).append("\n");}returnsb.toString();}}一个完整的记忆增强 Agent 循环
publicclassMemoryEnhancedAgent{privatefinalLlmClientllm;privatefinalMemoryWritermemoryWriter;privatefinalMemoryRetrievermemoryRetriever;privatefinalContextManagercontextManager;publicStringrun(StringuserId,StringuserInput){// 1. 从长期记忆中检索相关信息StringrelatedMemories=memoryRetriever.retrieve(userInput);// 2. 组装 messagesList<Message>messages=newArrayList<>();messages.add(newSystemMessage(systemPrompt));if(!relatedMemories.isEmpty()){messages.add(newSystemMessage(relatedMemories));}messages.addAll(contextManager.manage(sessionHistory));messages.add(newUserMessage(userInput));// 3. 调用 LLMLlmResponseresp=llm.chat(messages,tools);// 4. 写入长期记忆(异步,不阻塞响应)memoryWriter.maybeSave(userInput,resp.getContent());returnresp.getContent();}}五、工作记忆:任务执行的黑板
工作记忆是 Agent 在执行单个复杂任务时的"草稿纸"。
和短期记忆的区别:短期记忆是对话历史(谁说了什么),工作记忆是任务状态(做了什么、还要做什么)。
┌──────────────────────────────────────────────┐ │ 工作记忆示例 │ │ │ │ 任务目标:分析 600519 是否值得加仓 │ │ │ │ 执行计划: │ │ ✅ Step 1: 查询最近 30 天收盘价 │ │ ✅ Step 2: 计算 20 日均线和 MACD │ │ ⬜ Step 3: 搜索近期公司新闻 │ │ ⬜ Step 4: 综合分析给出建议 │ │ │ │ 中间结果: │ │ - 30 天均价: 1685.3 │ │ - 20 日均线: 拐头向上 │ │ - MACD: 金叉 │ │ │ │ 待办: │ │ - 需要确认近期是否有利空消息 │ └──────────────────────────────────────────────┘工作记忆的工程实现
publicclassScratchpad{privateStringgoal;// 任务目标privateList<TaskStep>plan;// 执行计划privateMap<String,Object>results;// 中间结果privateList<String>notes;// 备注// 序列化为文本,注入到 LLM 上下文publicStringtoPromptText(){StringBuildersb=newStringBuilder();sb.append("【当前任务】").append(goal).append("\n\n");sb.append("【执行计划】\n");for(TaskStepstep:plan){sb.append(step.isDone()?"✅":"⬜").append(" Step ").append(step.getIndex()).append(": ").append(step.getDescription()).append("\n");}if(!results.isEmpty()){sb.append("\n【中间结果】\n");results.forEach((k,v)->sb.append("- ").append(k).append(": ").append(v).append("\n"));}if(!notes.isEmpty()){sb.append("\n【备注】\n");notes.forEach(n->sb.append("- ").append(n).append("\n"));}returnsb.toString();}// 更新步骤状态publicvoidmarkStepDone(intindex,Objectresult){plan.get(index).setDone(true);results.put(plan.get(index).getDescription(),result);}}关键点:工作记忆不是全量塞进 LLM 上下文的。聪明的做法是只注入"当前步骤 +/- 2 步"的上下文,而不是整个计划。这样既能让 LLM 知道自己在哪一步,又不浪费 Token。
六、记忆的遗忘策略
记忆不是越多越好。过期的、矛盾的、低价值的信息如果不清理,反而会让 Agent 越来越蠢。
| 遗忘策略 | 触发条件 | 实现 |
|---|---|---|
| TTL 过期 | 超过设定时间 | 每条记忆带 timestamp,定期清理 |
| 重要性衰减 | 长时间未被检索命中 | 检索时降权,长期不命中则删除 |
| 矛盾覆盖 | 新记忆与旧记忆矛盾 | 用 LLM 判断,保留新的,删除旧的 |
| 容量限制 | 超过最大条数 | 按 importance score 淘汰最低的 |
| 用户显式删除 | 用户说"忘掉这个" | 按关键词或时间范围删除 |
publicclassMemoryJanitor{privatefinalVectorStorevectorStore;privatestaticfinalintMAX_MEMORIES=1000;privatestaticfinallongTTL_DAYS=90;// 定期清理publicvoidcleanup(){// 1. 删除过期记忆longcutoff=Instant.now().minus(TTL_DAYS,ChronoUnit.DAYS).toEpochMilli();vectorStore.deleteByMetadata("timestamp",cutoff);// 2. 如果还是太多,按重要性淘汰longcount=vectorStore.count();if(count>MAX_MEMORIES){// 按检索命中次数排序,淘汰最少的List<MemoryItem>all=vectorStore.findAll();all.sort(Comparator.comparingInt(MemoryItem::getHitCount));inttoDelete=(int)(count-MAX_MEMORIES);for(inti=0;i<toDelete;i++){vectorStore.delete(all.get(i).getId());}}}}七、三种记忆的协同流程
把三层记忆拼在一起,一次完整的 Agent 调用流程:
用户输入 │ ▼ ┌─────────────────────────────────────────┐ │ 1. 检索长期记忆 │ │ 用用户输入查询向量库 │ │ 拿到 Top-K 相关历史记忆 │ └──────────────────┬──────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 2. 读取工作记忆 │ │ 当前任务计划、已完成步骤、中间结果 │ └──────────────────┬──────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 3. 组装上下文 │ │ System Prompt │ │ + 长期记忆摘要 │ │ + 工作记忆状态 │ │ + 短期记忆(最近 N 轮对话) │ │ + 当前用户输入 │ └──────────────────┬──────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 4. 调用 LLM │ └──────────────────┬──────────────────────┘ │ ▼ ┌─────────────────────────────────────────┐ │ 5. 更新记忆 │ │ 短期:追加到对话历史 │ │ 工作:更新步骤状态和中间结果 │ │ 长期:异步判断是否值得持久化 │ └─────────────────────────────────────────┘八、常见踩坑
坑 1:把所有对话原样存向量库
问题:每轮对话都 Embedding 存库,结果向量库里全是"你好""谢谢"这种垃圾数据,检索时噪声巨大。
解法:写入前先过一道 LLM 判断(见上面的 MemoryWriter),只存"记忆 worthy"的信息。
坑 2:检索 Top-K 固定不变
问题:不管什么问题都返回 Top-5,简单问题信息过多(干扰 LLM),复杂问题信息过少(不够推理)。
解法:按相似度分数动态调整。分数 > 0.9 的全要,0.7 ~ 0.9 的最多取 3 条,< 0.7 的不要。
坑 3:记忆只存不删
问题:用户三个月前说"我看好茅台",现在改主意了说"茅台太高了要减仓",但旧记忆还在,Agent 检索到两条矛盾记忆,行为混乱。
解法:写入新记忆时,用 LLM 检查是否有矛盾旧记忆,有则标记旧记忆为"已失效"或直接删除。
坑 4:工作记忆全量注入
问题:任务执行到第 20 步,把前 19 步的所有中间结果全塞进上下文,Token 爆炸。
解法:只注入"当前步骤 + 相关的中间结果"。哪些相关?按步骤间的依赖关系决定。
九、小结
记忆系统的核心要点:
- 三层架构:短期记忆(对话历史)+ 长期记忆(向量库)+ 工作记忆(任务黑板)
- 短期记忆靠管理:滑动窗口截断 + 摘要压缩 + 混合策略
- 长期记忆靠检索:写入要过滤(不是什么都存),读取要排序(Top-K + 分数阈值)
- 工作记忆靠结构化:执行计划 + 步骤状态 + 中间结果,按需注入而非全量
- 遗忘比记忆更重要:TTL 过期 + 重要性衰减 + 矛盾覆盖 + 容量淘汰
下一篇讲三大基石的最后一块——工具调用。Agent 怎么知道有哪些工具可用、怎么让 LLM 选对工具、怎么处理工具执行失败,都会展开讲。
这是「AI Agent 开发实战」系列第 3 篇,后续会持续更新,欢迎关注。如有错误或想法,欢迎评论区交流。