做AI Agent开发的人,基本都会遇到同一个坎:模型能力再强,只要不带记忆,每次对话都像第一天上班的新同事。你上周刚跟它说过的偏好、约定好的称呼、上个月定的方案细节,它第二天就忘得干干净净。这个痛点,我在这套“走进AI Agent”系列里反复提过,到了第三篇,专门把“记忆”这件事摊开讲。
这篇文章要解决的,就是怎么让Agent真正“记住你”。我会从记忆体系的整体设计讲起,再拆短期记忆、长期记忆的工程实现,最后给出一套可以直接落地的最小闭环方案,同时把常见坑位都标出来。适合已经用LangChain、LangGraph这类工具搭过基础Agent、但觉得交互体验还差点意思的开发者,也适合准备系统学习AI Agent的读者拿来当路线参考。
1. 记忆体系整体设计与思路拆解
1.1 没有记忆的Agent,和有记忆的Agent差在哪里
先说一个最直观的差异:同样是“帮我推荐咖啡豆”,无记忆Agent会问一遍“你喜欢什么口味”,而有记忆Agent会直接说“这次还要偏深烘焙、带坚果调的哥伦比亚豆吗?上次你说过不喜欢酸感重的”。后者给人感觉完全不一样——前者是工具,后者才像搭档。
从产品角度看,记忆能力直接影响留存。用户愿意回来用你的Agent,很多时候不只是因为模型聪明,而是因为“它懂我”。这种懂,就是靠记忆系统堆出来的。从技术角度看,记忆不是单一模块,而是横跨提示词工程、检索系统、存储设计、状态管理等多个环节的一套体系。
很多开发者的误区是:记忆不就是把对话历史存起来下次再塞给模型吗?早期我也是这么干的,后来发现完全不是这么回事。上下文窗口有限,token费用摆在那里,用户历史可能很长,陈旧信息还会干扰当前判断。真正可用的记忆系统,需要分层设计、按需存取、定期更新。
1.2 三层记忆模型:短期、长期、程序记忆
我在实操中比较习惯把Agent记忆拆成三层,这个框架便于理解,也便于做工程拆分。
短期记忆(工作记忆)对应当前会话内的上下文。用户这轮说了什么、上轮问题是什么、Agent已经给出了哪些回复,这些信息都在短期记忆里。它天然就是对话历史本身,实现上最简单,但管理起来有讲究——窗口怎么截、压缩怎么做、关键信息怎么保留,都不像表面那么简单。
长期记忆(跨会话记忆)负责把用户真正“记住”。包括用户偏好、历史事实、过去讨论过的方案、约定过的规则等。它需要从每轮对话中提炼出值得存的东西,写进存储,并在后续会话中按需召回。这一层是“让Agent记住你”的核心战场。
程序记忆对应Agent“会做什么”的技能型和流程型记忆。比如用户经常让Agent用某种格式输出,或者你封装了一个工具、一套执行流程,Agent会记住在什么场景下该怎么调用。这部分跟Agent的工具链、技能库耦合比较深。这一层相对进阶,我在这篇文章里会重点讲前两层,第三层只做思路提示。
理解这三层之后,你就能意识到“让Agent记住你”不只是一个“存起来再取出来”的存储问题,而是一套从感知、筛选、存储到召回、更新、遗忘的完整闭环。
1.3 为什么不能简单把对话历史全塞给模型
最朴素的记忆方案确实就是“把历史消息全部拼起来,一股脑丢给模型”。这个方案在小规模、短会话场景下能用,但规模一上来就会出三个问题。
第一个问题是上下文窗口物理瓶颈。主流模型的上下文窗口从几十K到两百K不等,看起来很大,但对话历史是累积的,几十轮后就可能占掉大半窗口。更麻烦的是,你还要留出空间给系统提示词、工具定义、检索回来的参考资料、模型输出,真正留给“用户历史”的空间远没有想象中充足。
第二个问题是注意力稀释。这一条很多人会忽略。模型对上下文不同位置的关注度并不均匀,历史太长时,越靠前的信息越容易被忽略,或者被大量无关信息淹没。用户三个月前说过“我住在南山区”这种关键信息,如果被夹在500条对话里一起送进去,模型很可能“看不见”。这样既花了tokens,又没有真正记住。
第三个问题是成本。每次请求的token消耗直接跟上下文长度正相关,生产环境日均上万次请求时,每多塞一千token都是一笔肉眼可见的账单。我见过一个团队就因为没有做上下文裁剪,一个月token账单翻了三倍。
所以,记忆系统的本质不是“存很多”,而是“存得准、取得到、塞得少”。后面每个环节的设计,都要围绕这句话来转。
2. 短期记忆:让当前会话自然连贯
2.1 对话历史管理的常规做法与窗口策略
短期记忆在工程上其实就是消息列表管理。每次请求时,把user消息、assistant回复、函数调用结果按时间顺序拼成一个message数组,跟当前用户输入一起发给模型,这就是基础形态。
但消息列表不能无限增长,所以要定一个窗口策略。我常用的是按轮次窗口加按token阈值双条件控制:保留最近N轮对话,同时统计消息总token数,超过阈值就触发压缩或丢弃。N和阈值的取值没有绝对标准,取决于你用的模型上下文大小和业务复杂度。以32K上下文模型为例,我一般会把system prompt控制在1000~2000 token,历史消息控制在8000~12000 token以内,剩下留给检索结果和输出。
实现的时候要特别注意一个细节:不要只按“轮”截断,因为用户和Agent的单轮消息长度可能差异很大。有人习惯按字符数切,但中英混合场景下用token更准确。实际项目里,我会把消息逐条加入,统计token数,直到接近阈值再停下。这样截断行为更平滑,不会出现“明明还剩一大半窗口,却因为某几轮特别长把后面全挤掉了”的情况。
2.2 上下文压缩:让Agent在有限窗口里记住关键信息
纯截断的问题是,被丢掉的部分可能包含关键偏好、待办事项或用户刚交代过的约束。只留最近几轮,一旦中间隔了比较长的查询,前面的关键信息就丢了。解决办法是做上下文压缩或改写。
原理不复杂:用一次独立的LLM调用,把旧对话里的核心信息提炼成一段摘要,然后把摘要作为一条特殊消息放在历史开头,再附上最近几轮完整消息。这样既保留了关键信息,又不占太多窗口。
我实际用过的压缩Prompt大概是这样的风格:
请把以下对话历史压缩成一份摘要,要求: 1. 保留用户的偏好、任务约束、已确定的事实 2. 保留尚未完成的待办事项 3. 去掉寒暄、可省略的客套、重复表达 4. 摘要控制在200字以内 对话历史: {history}这个方案我跑通之后,效果立竿见影。之前经常出现“用户上午说过不要辣,下午点单时Agent又推荐辣味菜品”的尴尬情况,加了压缩后基本根治了。不要小看这一步,它承担的是“艾宾浩斯遗忘曲线”里把关键记忆从短期转巩固的那个角色。
另外要提醒一点,不是每次请求都要做压缩。触发条件可以用“历史超过每轮窗口的80%”这类阈值。否则每个请求都额外调一次LLM,成本和延迟都会上升。
2.3 实操心得:工具调用消息与系统提示词的处理
短期记忆里最容易翻车的,其实是工具调用相关的消息。
现在很多Agent都有工具调用能力,函数调用过程中的tool/function消息会穿插在对话里。这类消息对模型理解“前面发生了什么”很重要,比如“搜索到了3条结果”“数据库查到了5行记录”。但如果全部保留,会占大量窗口。我建议的策略是:完整保留最近两轮工具消息,更早的只保留摘要,并把工具调用的中间结果做瘦身——只保留结论字段,不保留大段原始返回。
另一个容易忽略的点是system prompt的处理。很多人把系统提示词当成“写一次就不动”的东西,但在带记忆的Agent里,系统提示词需要动态生成:开头是固定的人格设定,中间插入压缩后的历史摘要,再插入检索回来的长期记忆,最后是当前对话状态。这样每次请求的system prompt都是拼装出来的,而不是静态字符串。
我踩过的一个坑是:把长期记忆也塞在system prompt里,导致system prompt越来越长,最终挤占了对话历史的空间。后来我把记忆注入跟system prompt解耦,记忆内容单独作为一条memory消息放在历史最前面,效果明显更好。
3. 长期记忆:跨会话记住用户的关键工程
3.1 记忆写入:从对话里提炼“该记的事”
长期记忆的核心问题不是“怎么存”,而是“什么值得存”。不是每一句用户话都值得进长期记忆,否则存储里全是噪音。
我常用的方案是“总结—抽取—清洗”三步走。对话到达某个结束点(比如一个完整任务完成、或用户明显切换了话题)时,触发一次记忆更新流程。先让LLM把本轮对话的关键信息总结出来,再从中抽取结构化条目,最后做一次冲突检测和去重。
抽取环节可以用一个返回JSON的Prompt。比如:
从对话中抽取值得长期记忆的信息,返回JSON数组。 字段说明: - type: user_preference(用户偏好) / personal_info(个人信息) / task_status(任务进度) / project_fact(项目事实) - content: 记忆内容,一句话描述 - keywords: 关键词数组,用于检索 - importance: 1~5,重要程度 - timestamp: 当前时间 对话内容:{conversation}这个流程跑起来之后,你手里就有结构化、可检索、带重要度的记忆条目了。importance字段很有用,它决定这一条记忆在检索时的权重,也决定系统会不会主动把它放进长期存储。
需要注意的是,记忆写入不要每轮都做,否则成本高、噪音大。我通常设定触发条件:用户明确表达了偏好;任务发生状态变化;出现新的个人信息;用户与Agent约定后续要做的事。这些条件可以用简单的规则判断,也可以让LLM在返回结果时附带一个should_update_memory标记。
3.2 记忆存储:结构化字段与向量语义双通道
长期记忆存哪里?我见过很多新手的处理方式是存MySQL或者直接丢进JSON文件,这本身上没问题,但要做好设计。
我比较推荐双通道存储:结构化通道用关系型数据库或Redis存用户偏好、个人信息这类强结构化条目;语义通道用向量数据库存更偏语义描述的记忆,比如“用户喜欢在周末晚上研究咖啡拉花技巧”。前者适合精确查询,后者适合模糊检索。
为什么不能只靠结构化通道?因为很多记忆本质是语义性的,你很难用字段精确描述。比如“用户对最近的某个项目很有热情”,这个信息怎么建表?建一个“项目热情度”字段?既不现实也不通用。但在向量库里存一句自然语言,关联上相似度检索,就可以被正确召回。
向量库选型上,轻量项目可以用Chroma这类本地库,生产环境可以考虑Weaviate、Qdrant,或者直接用pgvector挂在PostgreSQL上。嵌入模型的选择也有讲究,中文场景下bge系列、m3e这类模型效果通常比纯英文模型更好。向量维度不用刻意选很大,768或1024都够用,关键是检索阈值要调准。
我经历的一个事故是:向量库相似度阈值设太高,导致语义相关但字面不同的记忆召回不出来。比如用户说过“我不吃香菜”,过了两个月问“推荐一下麻辣烫店”,Agent因为没召回“不吃香菜”这条记忆,兴致勃勃地给出一堆放香菜的方案。这个问题的根子不是模型不强,而是检索召回环节做不好。
3.3 记忆读取:检索、排序与注入
记忆读取决定Agent“想得起什么”。我常用的读取流程是:先把当前用户query和最近上下文拼成一个检索请求,从向量库召回topK条相关记忆;再从结构化库里按用户ID和场景做精确查询;最后按重要度、时间衰减、相关性综合排序,取前N条注入上下文。
召回数量要克制。我一般向量召回5~10条,结构化查询取2~5条,最终注入3~6条。注入太多会挤占窗口、分散注意力;太少又可能漏掉关键信息。这个数量要结合模型能力和业务形态调,没有固定值。
注入时最好给记忆加上时间信息,比如“(2024年3月5日记录)”。这样模型能区分新记忆和旧记忆,如果旧记忆跟当前事实冲突,它更倾向采信更新的内容。
还有一个容易踩的坑:记忆检索请求里一定要带用户身份过滤。否则你查到的可能是另一个用户的记忆。我见过一个demo产品上线后,用户A的偏好被注入到用户B的对话里,直接导致产品被打差评。这个bug排查很久,最后发现是检索接口漏了user_id过滤。
3.4 更新与遗忘:用户改主意了怎么办
长期记忆不是写了就完事。用户可能改主意,偏好可能过期,记忆也需要更新和遗忘机制。
最经典的情况是用户说“我以后不喝深烘了,改喝浅烘”。如果系统只往库里加新记忆而不处理旧记忆,检索时旧记忆和新记忆同时命中,Agent就会行为矛盾。我的处理方式是:抽取环节如果发现新记忆跟旧记忆存在冲突,走“覆盖更新”逻辑——把旧记忆标记为expired,新记忆写入生效。
遗忘机制也必须有。一条记忆如果不被召回、不被确认,时间久了就应当降权或清除。我常用的是时间衰减公式:recall_score = base_score * exp(-λ * age_days)。λ根据业务调,比如λ=0.01表示大约70天内记忆权重衰减到36%。这样能保证记忆系统不会越积越臃肿,也不会因为陈年旧记忆干扰当前判断。
顺便提一句,让用户主动管理记忆也是一个很好的产品设计。给Agent加一个“我记错了”的反馈入口,让用户能看到Agent记住了什么、能手动删除某条记忆,这在C端产品里非常加分。这一条我强烈建议做,因为它既解决遗忘问题,又让用户感到“系统被自己掌控”。
4. 实操案例:搭建“记住你”的最小闭环
4.1 场景定义与整体流程
这一节我用一个完整的小案例串起来。场景很简单:用户第一次告诉Agent“我喜欢喝深烘焙的咖啡,不喜欢酸味重的”,然后过了一段时间,用户问“推荐一款适合我的咖啡豆”。目标就是Agent能结合长记忆给出个性化推荐,而不是从头问一遍。
整体流程设计为5步:对话采集、记忆抽取、记忆存储、记忆检索、上下文注入。每一步都能对应用前面讲到的机制。
先用基础的消息循环跑对话,当检测到用户说出偏好类信息时,触发记忆抽取。抽取结果写入结构化存储和向量库。下一次会话开始时,检索模块根据用户身份拉取相关记忆,注入到系统提示词中,再让模型生成回复。
4.2 记忆抽取环节的Prompt与结果
这是整个流程里最能看到“工程感”的一步。抽取Prompt长这样:
你是记忆管理助手,负责从对话中提取值得长期保存的信息。 对话: {conversation} 任务: 1. 判断是否存在值得保存的长期记忆 2. 如果有,输出JSON数组,每个元素包含: - type: user_preference / personal_info / task_status / project_fact - content: 记忆内容 - keywords: 检索用关键词 - importance: 1~5 - conflict_with: 如果有冲突的旧记忆,列出旧记忆ID,否则为null 如果没有值得保存的内容,返回空数组。假设用户说了“我喜欢喝深烘焙的咖啡,不喜欢酸味重的”,LLM应该返回类似:
[ { "type": "user_preference", "content": "用户偏好深烘焙咖啡,不喜欢酸味重的咖啡", "keywords": ["咖啡", "深烘焙", "酸味"], "importance": 4, "conflict_with": null } ]如果库里已经有一条“用户喜欢浅烘焙咖啡”,这一步就应该检测到conflict_with字段,走更新逻辑,而不是简单追加。
4.3 检索注入与回复生成
到了用户再次提问“推荐适合我的咖啡豆”时,检索模块工作。我用一个简单的语义检索函数来说明思路:
import requests def search_memory(user_id, query, top_k=5): # 1. 先按user_id过滤,避免串号 # 2. 对query做embedding # 3. 在向量库召回复合条件的记忆 # 4. 按重要度和时间衰减排序 resp = requests.post( "http://localhost:8000/search", json={"user_id": user_id, "query": query, "top_k": top_k} ) return resp.json()["results"]召回结果可能是:“用户偏好深烘焙咖啡,不喜欢酸味重的咖啡”。注入后,系统提示词变成:
你是用户的咖啡助手。关于用户,你有以下长期记忆: - (2024年6月2日) 用户偏好深烘焙咖啡,不喜欢酸味重的咖啡 请基于这些信息,结合用户当前问题回答。模型看到这条信息,再结合商品库里的描述,推荐结果自然就会避开浅烘焙和酸感强的豆子,优先推深烘焙、坚果/巧克力调的款型。这个最小闭环跑通之后,你就能直观理解“记住你”是怎么发生作用的了。
4.4 参数选择与成本控制建议
在这个闭环里,有几个关键参数需要调:向量维度、召回数量、相似度阈值、触发记忆抽取的频率。
我建议起步配置:向量维度用768或1024;召回topK设为5~8条;相似度阈值从0.7起调,根据实际命中情况上下浮动;记忆抽取只触发在用户表达明确偏好或任务状态变化的时机,不要每个请求都跑。这样在个人项目或小团队产品里,记忆抽取带来的额外LLM调用量是可控的。
成本控制上还有一个技巧:不要把整段历史都交给抽取模型,而是先把对话做简单截取,只把跟潜在记忆相关的部分发过去。比如检测到用户说“我喜欢”“我讨厌”“以后别”这类句式时,再触发抽取,能省下大量无关调用的花费。
5. 常见问题与排查技巧实录
5.1 五大高频问题速查表
长期跟Agent记忆系统打交道,我总结了几类高频问题,列成表格方便对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 记忆一直不生效 | 相似度阈值太高,召回为空 | 降低阈值,查看实际召回结果 |
| 用户A的记忆出现在用户B对话里 | 检索时漏了user_id过滤 | 在所有查询路径加用户身份过滤 |
| 用户改主意了,Agent还是按旧偏好回答 | 缺少记忆覆盖更新机制 | 抽取时检测conflict,标记旧记忆过期 |
| 上下文被大量旧记忆挤占,回复跑偏 | 注入记忆条数过多,未按相关度排序 | 限制注入条数,增加时间衰减和重要度排序 |
| 记忆抽取成本过高 | 每轮对话都触发抽取 | 只在关键句式或状态变化时触发 |
这五类问题里,前两个属于系统性bug,后三个属于设计缺陷。系统性bug要修代码,设计缺陷要调策略。
5.2 排查思路与避坑经验
如果记忆不生效,我建议先做“可视化回溯”:把每次请求的完整上下文打印出来,看看检索模块到底返回了什么、注入了什么。这一招比任何调试工具都管用。很多看起来玄乎的“模型不听话”,其实都是上下文里根本没有该有的记忆,或者记忆被放在了太靠后的位置。
另一个经验是关于embedding的“跨语言陷阱”。如果你用的embedding模型中文能力弱,用户的“深烘焙”和“深焙”可能被映射到完全不同的向量区域,导致召回失败。解决办法一个是换中文效果好一点的embedding模型,另一个是在存储时主动添加同义词关键词,在抽取阶段就顺手做一次扩展。
还有个细节:记忆抽取用的LLM跟对话用的LLM要分开。对话模型可以追求智能和自然,抽取模型则要稳定、结构化输出能力强,而且通常用小一点的模型就够了。在LangGraph这类框架里,不同节点配置不同模型是很自然的事,这也能显著降低成本。
最后提一下隐私合规。涉及到用户的个人信息,要设计留存期限、用户可查看可删除的界面。很多时候产品被下架或收到用户投诉,不是模型没用对,而是记忆系统触碰了用户数据红线。这一条做在前面,后面能省掉无数麻烦。
我在摸爬滚打中形成的习惯是:给每条长期记忆都带一个created_at和source字段,既方便做时间衰减,也方便未来做数据审计。这套习惯从个人项目一路带到生产环境,一直没出过问题。