
这次说说Agent的记忆。之前在群里聊Agent落地有个朋友抛了个问题“我的Agent工具调得挺顺规划也能看但它怎么像个金鱼上午问过的事下午再问它一点印象都没有。”这个描述非常精准几乎每个做Agent项目的人都会撞上这面墙。模型本身不记事所谓“让Agent记住你”本质上是我们用工程手段在模型外面搭一套记忆系统让它表现得像是记住了。这篇就专门拆这个问题Agent记忆到底怎么做从上下文窗口的调度到长期记忆的存储再到用LangGraph落地一个带记忆的Agent最后聊聊我踩过的那些坑。1. 记忆为什么成了Agent的命门1.1 大模型天生没有记忆Agent的记忆全靠工程补很多刚入坑的人会有一个错觉ChatGPT之类的产品能记住我上次说的话那模型应该自带记忆吧其实不是。大模型本身是无状态的每次调用都像一次全新的对话它只依赖你传入的上下文内容。你看到的“记住”是产品层把历史聊天记录拼进新的请求里再一起发给模型。Agent也是这样。一个Agent的核心循环通常是接收用户指令 → 规划任务 → 调用工具 → 生成回复。这个循环本身不包含任何“历史”概念。如果你不把上次的对话内容带回新的请求里Agent永远不会知道你昨天问过什么。这就带来一个直接的工程难题记忆不是模型能力而是系统设计。你需要在代码里明确“记住什么、存在哪儿、什么时候读、什么时候写、怎么更新”。如果这一步没想清楚后面接再强的模型也白搭。我见过不少团队的做法是先把所有聊天记录一股脑塞进上下文窗口反正窗口大塞得下。前期确实能跑但随着对话轮次变长效果会肉眼可见地变差——回复速度变慢、token费用飙升而且模型会被无关历史干扰回答反而越来越不靠谱。1.2 先想清楚要记住什么四层记忆模型在做记忆模块之前我建议先做一道填空题这个Agent到底需要记住哪些东西不同业务场景答案完全不同。以我自己的经验可以把Agent需要记住的内容拆成四层记忆层级存什么典型实现生命周期瞬时记忆当前这一轮的用户输入、临时变量、工具返回结果状态变量、会话上下文单次任务工作记忆最近几轮对话历史、当前任务执行进度滑动窗口、滚动摘要单次会话长期记忆用户偏好、身份信息、历史事实、业务沉淀结构化数据库 向量库跨会话持久情景记忆之前某次具体事件、某个任务的处理过程事件日志 向量检索跨会话持久为什么要把记忆拆层因为不同层级的访问频率、更新频率、重要程度完全不一样。工作记忆每轮都要读、每轮都要写速度要求极高长期记忆则是低频读取、低频写入但必须稳定可靠。如果混在一起处理性能会出问题更新策略也会互相打架。我最初做记忆模块时就吃过这个亏把所有历史记录都丢进一个向量库每次请求都去检索结果延迟直接翻倍有些冷门记忆还检索不到。后来把“短期工作记忆”和“长期持久记忆”分开了整个系统才顺起来。2. 短期记忆实战上下文窗口的调度艺术2.1 上下文窗口不是越大越好短期记忆的核心载体是上下文窗口。很多人觉得上下文窗口就是越大越好尤其是现在有的模型已经支持几十万token似乎把整本聊天记录塞进去没问题。但实操中你会发现几件事第一成本不是线性的。大部分模型按token计费多塞一万token每次请求就要多付一部分费用。如果你的Agent日活在几千人每人每天几十次请求多出来的token费用是实打实的。第二延迟会明显增加。模型处理输入需要时间输入越长首字延迟越高。用户等一个回复等超过两三秒体验已经很糟了如果因为上下文太长拖到四五秒基本上就没法用了。第三无关信息会干扰模型判断。上下文越长模型越容易“迷失在中间”。这是有公开研究佐证的模型对长上下文中间部分的注意力会衰减。当你把三天前的闲聊和今天的核心问题混在一起时模型很难分清楚哪些是重点。所以短期记忆设计的核心不是“尽量多塞”而是“尽量少而精地塞”。2.2 滚动摘要和关键信息抽取的配合既然不能全塞那就要做取舍。我常用的方案是“滚动摘要 关键信息抽取”两条腿走路。滚动摘要的思路很直接维护一个不断更新的对话摘要。新对话来了把上一轮的摘要和新的对话内容合并让模型重新生成一份更完整的摘要然后把旧的对话细节丢掉。这样上下文里始终只有一份摘要加上最近一两轮完整对话长度基本可控。关键信息抽取则是主动从对话里抓取值得长期保存的事实。比如用户说“我平时用的是macOS”“这个方案我们下周要评审”“帮我盯一下预算不能超过五万”这些属于高价值事实应该抽出来单独存而不是混在摘要里一笔带过。这两者的区别在于摘要是对历史的压缩信息密度低但覆盖全关键信息抽取是结构化的提炼信息密度高但不求全。两者配合才能在有限的窗口里装下最值得看的东西。2.3 实操一条消息进来后上下文组装的过程这里给一个我实际在用的上下文组装逻辑配合代码注释你可以直接参考。def build_context(user_message, memory_store, history, max_history_tokens2000): # 1. 先读长期记忆只看与当前意图相关的部分 long_term_records memory_store.retrieve_relevant(user_message, top_k5) # 2. 把最近对话截断到token预算内这里用1个中文字约等于1.5个token估算 budget max_history_tokens recent_history [] for turn in reversed(history): tokens estimate_tokens(turn) if budget - tokens 0: break recent_history.insert(0, turn) budget - tokens # 3. 如果还有余量把滚动摘要也放进去 summary if budget 500: summary history.summary[:budget // 2] # 4. 组装最终上下文 context if long_term_records: context ## 用户档案来自长期记忆\n context render_records(long_term_records) \n if summary: context ## 历史摘要\n summary \n if recent_history: context ## 最近对话\n render_history(recent_history) \n context ## 当前输入\n user_message \n return context这段代码的核心思路是记忆分优先级长期记忆只取与当前问题相关的5条不占太多空间最近对话只保留在token预算内的部分如果预算还有剩余才考虑放滚动摘要。这样即使对话进行很多轮上下文长度也能维持在一个可控的范围内。这里有两个细节值得注意。第一token估算不能太粗糙不同模型的中英文token比例不同。我一开始用固定比例估算结果换了模型之后整个预算全乱了后来改成按模型分词器实际计算。第二长期记忆检索这条必须放在组装上下文的入口处做而不是等对话结束后再补。因为用户当前这句话才是检索的queryquery选错了检索结果就是错的。3. 长期记忆落地让Agent真正“记住你”3.1 从对话中抽离用户画像什么时候写、写什么、怎么写长期记忆的“写”是个比“读”更微妙的问题。写多了记忆库全是噪音写少了该记住的没记住。我踩过的最大一个坑是把所有对话全量写入记忆结果用户随口一句“今天好热”也被当成一条记忆存起来了等真正要检索高价值信息时检索结果全是天气感叹。后来我总结了一套比较稳定的写入策略不要让Agent自己决定写什么而是给它一个明确的结构化抽取任务。在每一轮对话结束时额外调用一次轻量级模型调用来完成信息抽取只提取四类信息用户身份相关的稳定事实我是做后端开发的、我在上海、我用的是Windows用户表达过的明确偏好喜欢代码简洁、不喜欢过度设计、回复要带示例任务相关的关键约束这个项目6月底上线、预算不能超20万用户明确表达过的不满或禁忌不要用专业术语跟我解释、上次那个方案我不喜欢为什么是这四类因为它们有一个共同点在后续对话中被再次引用到的概率高。至于那些“今天天气真不错”之类的寒暄既不影响后续决策也不构成稳定的用户特征就不值得占用记忆空间。写入的时候还有一条原则只记录事实不做推断。如果用户说“我最近在忙一个电商项目”你不应该把它推断成“用户是电商从业者”更不能推断成“用户需要电商解决方案”。模型推断很可能会偏差一旦错误写进长期记忆它会在后续对话中反复污染上下文而且你自己很难发现。3.2 向量检索与语义召回手搭一个记忆仓库长期记忆的“读”核心是检索。因为记忆条目会越来越多不可能全部塞进上下文必须通过检索筛选出与当前问题最相关的部分。目前业界主流的方案是向量检索加关键词混合召回。整体流程是这样的记忆条目在写入时用embedding模型转成向量存进向量数据库读取时把用户当前输入转成向量用相似度检索找到最相关的记忆条目。这个流程听起来简单但有几个细节直接决定了效果。第一embedding模型选型很重要。我试过两套方案一套用通用中文向量模型一套用专门针对对话场景微调的模型。在“用户偏好检索”这个场景下两者效果差距非常明显。通用模型擅长的是语义相似度但记忆检索往往不是求语义相似而是求“相关”。比如用户现在问“预算还剩多少”需要召回的记忆不是所有提到“预算”的句子而是“项目预算在5月9日确认过是20万”这样的事实。建议在选择embedding模型时用你自己业务里的真实数据做几个检索样例对比别只看榜单分数。第二向量检索需要配合关键词检索做兜底。向量检索对同义改写很友好但对专有名词、人名、项目代号容易出问题。比如用户说“上次那个React项目”如果记忆里存的是“前端重构项目”向量检索可能匹配不上。我在实际项目中会同时维护一份关键词索引用BM25做一次召回再把两种结果合并去重效果会稳很多。第三检索结果必须带时间戳和置信度。时间戳是为了处理“记忆冲突”——用户四月份说要A方案六月份改口说B方案这时候应该返回B方案还是两个都返回我的做法是默认以时间最新的为准但如果两条记忆都跟当前问题相关就同时提供让模型自己去判断。置信度则用于过滤低质量命中低于某个阈值就不返回。3.3 记忆的更新与遗忘不删除的记忆是垃圾堆记忆系统最容易被忽略的是更新和遗忘。不少团队的记忆库做着做着就爆炸了什么垃圾都往里堆检索质量直线下滑最后整个模块被弃用。这其实是正常现象——你在现实中也记不住每件事你的大脑会遗忘大部分细节只保留重要的。我在工程上用了两招来模拟这种机制。第一招是重要度评分。每条记忆写入时会有一个重要度分数由抽取时的大模型打分。比如“用户明确提到项目上线时间”这种影响后续决策的记忆重要度给9分“用户提到自己在用某个工具”给6分“用户早上打了个招呼”给2分。当记忆库达到容量上限时优先淘汰低分条目。第二招是时间衰减。记忆不是写进去就永久有效每条记忆都有一个半衰期。用户偏好如果长时间没被再次提及它的检索权重会逐渐衰减。这样做的好处是长期不活跃的记忆不会彻底删除但排名会自动下降不会霸占检索结果。当用户再次提起某个旧话题时相关记忆会被重新激活权重恢复。基于这两招遇到记忆冲突时也能处理比如用户之前说“我一般9点开会”后来某次说“我明天10点开会”系统发现新记忆与旧记忆冲突会先让新记忆“赢”——因为它时间更新、上下文更具体——同时把旧记忆标记为“待确认”状态后续如果用户再次重申旧时间再把旧记忆恢复。4. LangGraph动手实现一个带记忆的Agent4.1 为什么选LangGraph状态、节点和可控的回放聊完记忆的底层逻辑上代码实战。现在Agent开发框架很多LangChain、LlamaIndex、AutoGen、LangGraph各有拥趸。我个人在需要精细控制记忆读写流程的项目里更推荐LangGraph。原因有三点第一它把Agent流程建模成图。图的节点是具体的操作边是状态转移这使得“记忆检查”“记忆写入”这类逻辑可以变成图中独立的节点而不是散落在代码各处的隐式逻辑。想看清楚整个流程看图的拓扑就行。第二它天然带状态管理。LangGraph里有一个全局的State对象所有节点都可以读写。工作记忆、临时变量、当前任务进度放State里天然适配记忆分层的设计。第三支持断点续跑和人工干预。这在Agent的多轮长任务里非常有用——某个任务执行到一半需要用户提供信息Agent停下来等待用户补充后从断点继续不需要重新跑一遍。需要说明的是LangGraph只是我现在的选择。你完全可以用LangChain的agents、或者直接手写状态机实现同样的事情重点是记忆读写流程要可控、可观测框架反而是次要的。4.2 代码骨架记忆模块与LangGraph的整合下面给一个可以运行的骨架代码演示如何把记忆模块嵌入LangGraph的Agent循环。from typing import TypedDict, List from langgraph.graph import StateGraph, END class AgentState(TypedDict): user_message: str context: str response: str memory_updates: List[str] # 节点1读记忆组装上下文 def load_memory(state: AgentState): user_message state[user_message] # 从长期记忆库中检索相关记忆 records memory_store.retrieve_relevant(user_message, top_k5) # 组装上下文包含历史摘要、最近对话和检索出的长期记忆 context build_context(user_message, memory_store) return {context: context} # 节点2调用LLM生成回复 def generate_response(state: AgentState): context state[context] response llm_call(context) # 实际项目里这里是调模型的封装 return {response: response} # 节点3写记忆抽取关键信息入库 def save_memory(state: AgentState): # 每轮对话结束后抽取值得长期保存的信息 new_records extract_memories(state[user_message], state[response]) memory_store.add_all(new_records) # 同时更新当前会话的滚动摘要 update_summary(state[user_message], state[response]) return {} # 构建图 graph StateGraph(AgentState) graph.add_node(load_memory, load_memory) graph.add_node(generate_response, generate_response) graph.add_node(save_memory, save_memory) graph.set_entry_point(load_memory) graph.add_edge(load_memory, generate_response) graph.add_edge(generate_response, save_memory) graph.add_edge(save_memory, END) app graph.compile()这段代码的逻辑非常清晰读记忆 → 生成回复 → 写记忆三个节点串成一条流水线。实际项目中你可以在中间加更多的节点比如工具调用、任务规划、错误处理。记忆读取和写入始终是独立的两个节点不会和业务逻辑纠缠在一起。在LangGraph里有个很实用的特性你可以单独测试任何一个节点。比如只想看save_memory这个节点在当前state下会不会写出脏数据可以直接构造一个假的state喂进去不用跑完整个agent流程。这在调记忆抽取策略时特别有用我调试时经常这么干。4.3 向量库选型与部署细节记忆存储的选型我的经验是别一上来就上分布式向量库大部分场景用不上。如果你用的是PostgreSQL可以直接用pgvector插件省掉一套独立服务。如果项目已经用了Elasticsearch它的kNN检索能力也够用。独立向量数据库像Milvus、Qdrant在数据量达到百万级、对高并发有强需求时再考虑。部署细节上有两点特别提醒。第一embedding服务要单独部署。我见过把embedding和LLM调用放在同一个服务里结果embedding任务阻塞了主流程整个Agent的响应时间翻倍。embedding模型一般比较小完全可以单独起一个服务甚至可以做成批量处理不要让它影响主推理链路。第二记忆的写入要做异步化。上面的代码为了清晰把save_memory做成了同步节点。实际生产里这步应该丢到消息队列里异步执行。因为用户已经收到回复了他不关心你记忆写得好不好但如果你因为写记忆延迟了回复他会立刻感知到。异步之后即使记忆写入偶尔失败也不影响当次对话体验只需要重试机制来兜底。5. 踩坑实录记忆模块最常翻车的五个场景5.1 记忆读得太慢整个Agent变卡记忆检索本身不长但如果你在每次请求里都做“向量检索 关键词召回 重新排序”耗时很容易超过300毫秒。这在对话场景里已经很明显了。解决办法有三个一是给检索加缓存相同或相似的问题在短时间内直接命中缓存二是把召回数量限制下来top_k设为3到5就够用了不是越多越好三是把重排序模型换成轻量级的代价是精度稍微下降但速度快一个量级。我实测下来前三者配合检索耗时能降到50毫秒以内体感完全不一样。5.2 记忆写得太随意把错话当事实这是我在客服场景里踩的最深的坑。用户说“我这单不想要了”Agent把“用户拒单”写进长期记忆但用户下一轮又说“刚才是误操作还是要的”结果Agent因为读了旧记忆一口咬定用户不想要场面非常尴尬。问题出在写入策略太激进没有做负面反馈识别。后来我加了一道校验写记忆之前先判断这句话是稳定陈述还是一时情绪。如果是情绪表达、临时抱怨、或者指令中包含“暂时、可能、试试”这类不确定词就只写工作记忆不写长期记忆。只有当同一个事实在对话中出现过两次以上或者用户用明确的肯定词强调时才写入长期记忆。5.3 检索不到问题出在embedding而不是代码排查记忆检索问题时最容易先怀疑代码逻辑但大概率问题出在embedding模型上。我遇到过用户问“上次说的那个安全方案怎么样了”记忆库里有“我们讨论了权限管理的修改方案”两者在语义上相关但通用embedding模型算出来的相似度很低导致检索为空。这类问题排查很快拿用户的query去向量库里手动查一下相似度分数如果分数确实低就是embedding模型的问题。解决办法是换更适配领域数据的embedding模型或者在召回阶段加入关键词匹配兜底。我一般推荐后者因为换embedding模型成本不低而且不一定保证换完就变好。5.4 多轮对话中的记忆覆盖如果你的Agent会处理多轮复杂任务比如“帮我把这个文档翻译成英文然后总结成三点”每一步都会读取当前上下文。如果某一步的中间结果写回了长期记忆而下一步又从长期记忆里读到了这个中间结果就可能出现逻辑混乱——Agent拿了“总结三点”的中间产物当成“翻译结果”继续处理。解决办法是区分记忆的作用域。工作记忆和长期记忆必须分开存储工作记忆只存在于当前会话的State里不会被持久化长期记忆只存跨会话仍然有效的信息不存临时中间产物。如果没有这个区分多轮任务的执行逻辑早晚会乱。5.5 敏感信息与隐私边界最后说一个容易让人忽略但影响很大的问题记忆模块会记住用户的敏感信息。用户可能在对话中报出自己的手机号、身份证号、甚至一些隐私偏好。长期记忆如果不设访问边界一旦记忆库泄露后果比聊天记录泄露更严重因为记忆库是结构化整理过的价值密度更高。我的建议是在记忆抽取阶段就做敏感信息检测和脱敏身份证号、银行卡号、密码这类直接不落库即便业务上必须存也要加密存储并且在检索时对返回结果做权限校验。另外要给用户提供“清除记忆”的能力界面上一键就能把该用户的记忆数据全部删掉别省这个功能。写在最后做Agent记忆模块这段时间我的体会是记忆不是什么高深莫测的算法它更像一个尽职尽责的秘书——该记的认真记不该记的主动忘汇报时只挑重要的说。技术难点其实都集中在“取舍”这个动作上上下文窗口里放什么、长期记忆里存什么、检索结果返回什么、旧记忆什么时候删每一个选择都直接影响用户体验。如果你正在做Agent我的建议是先从最简单的方案开始用SQLite存结构化偏好用滚动摘要管短期历史跑通了再加向量检索再上LangGraph或者类似框架做编排。别一上来就追求大而全的记忆系统先让Agent在几次对话里表现像一个“有记性的人”这个基本目标达成之后再考虑更复杂的场景。最后分享一个小技巧给记忆模块加上日志。每次写入和读取的记忆都打点记录线上出了问题能回溯到是哪条记忆导致了错误回复。这个习惯帮我排查了至少一半的记忆相关Bug强烈建议你也养成。