最近连着做了几个基于Langchain的对话应用聊天机器人、文档问答、客服助手都有涉及。做得多了就发现绝大多数需求绕不开同一个坎也就是会话记忆。没有记忆的对话就是一段段彼此孤立的提问前面说过什么模型一点都不知道体验非常割裂。这篇博文是我学习Langchain会话记忆的第五篇记录重点拆解临时记忆与长期记忆两个层面临时记忆解决单次会话内的上下文连贯长期记忆解决跨会话、跨时段的身份与偏好识别。我会从原理、代码实践、参数细节到踩坑经验完整过一遍适合刚接触Langchain的开发者也适合想在生产环境里把记忆模块做扎实的同学。1. 会话记忆为什么对话系统必须自带“记事本”1.1 没有记忆的对话体验有多崩溃先聊一个很直观的场景。你拨通客服电话把问题说了一遍对方让你等一会再描述一遍好不容易说完了转接给下一个人你又得从上到下重新讲一遍。这种体验放在AI对话里就是最典型的“无记忆”状态。很多刚开始做Langchain的人会有个误解大模型本身不是已经“记得”我上一句说了什么吗其实不是。模型接口本质上是一个无状态函数每一次请求接收一组消息返回一组回答请求结束之后这组消息就没了。所谓“上一轮说过什么”完全靠应用层自己去缓存、拼装再在下一轮请求里重新发给模型。Langchain里的Memory模块做就是这件事历史消息的存储、格式化和注入。我遇到过真实的项目事故。某个内部知识问答机器人上线第一版只接了文档检索完全没有会话记忆。用户问“它的使用限制是什么”得到回答后再追问“那它的替代品呢”模型根本不知道“它”指的是什么回答得牛头不对马嘴。后来把记忆模块补上体验立刻就不一样了。这也印证了一件事记忆不是优化项而是对话系统的刚需。1.2 临时记忆与长期记忆到底怎么分工在Langchain的生态里会话记忆通常被分成临时记忆和长期记忆两个层面。很多人会把它们混着用一提到记忆就想着往向量数据库里塞结果越做越重。其实先想清楚分工后面做起来会顺很多。临时记忆可以理解成“工作记忆”它的生命周期紧跟着当前会话。用户进入聊天页面开始一轮对话这期间的上下文由临时记忆负责会话结束或者服务重启临时记忆一般就清空了。它解决的核心问题是“这一轮对话里前文都说了什么”典型实现是ConversationBufferMemory、ConversationBufferWindowMemory、ChatMessageHistory这类组件。长期记忆解决的是另一类问题用户第二天又来了AI还记不记得这个用户上次聊过什么偏好、确认过什么事实。这种记忆需要持久化存储至少要做到进程间共享甚至跨天、跨周级别地保留。它的生命周期不跟随某一次会话而是跟随用户、跟随业务实体。典型实现是外部存储比如Redis、数据库加向量检索。我习惯用一个生活化的比喻临时记忆是便利贴贴在桌面上用完就扔长期记忆是笔记本里的档案页按用户归档下次见面还能翻出来。两者并不互斥真正靠谱的实战方案几乎都是组合使用。1.3 本篇适合谁、能收获什么如果你正在做聊天机器人、文档问答、客服助手这类需要多轮上下文的项目这篇博文应该能帮你把记忆模块从“能跑”提升到“好用”。我会先讲临时记忆最常用的几种组件和参数细节再讲长期记忆的两种落地方案最后整理一些实战中很容易踩的坑。读完你至少能回答这几个问题临时记忆和长期记忆分别用什么组件实现Buffer、Window、Summary之间怎么选向量检索式的长期记忆怎么和对话流程配合。2. 临时记忆让单轮对话连成一段连贯交流2.1 最省事的BufferMemory到底怎么工作临时记忆里最容易上手的是ConversationBufferMemory。它做的事情非常朴素把历史对话全部存下来下一轮对话的时候把所有历史拼成一段提示词一起发给模型。我用一个简化示例说明。为了不绑定某个具体模型服务商的SDK下面代码里的get_llm()是我项目里的一个封装返回已经配置好的对话模型客户端你换成自己项目里实际调通的那个即可。from langchain.chains import ConversationChain from langchain.memory import ConversationBufferMemory llm get_llm() # 替换为你项目里已配置好的模型客户端 memory ConversationBufferMemory() conversation ConversationChain(llmllm, memorymemory) print(conversation.run(我叫小明住在杭州。)) print(conversation.run(我是哪里人))第一轮运行结束之后memory里实际保存的内容类似这样Human: 我叫小明住在杭州。AI: ...。第二轮运行前ConversationChain会把这段历史取出来和当前输入“我是哪里人”一起组装成提示词再交给模型。模型看到前文说过“住在杭州”自然就能答出来。这里有几个细节要留意。ConversationChain内部默认的提示词模板里历史变量名固定是history所以ConversationBufferMemory的memory_key默认也是history。如果你自己写prompt模板变量名必须和memory_key对齐这一点在第四节会重点讲。BufferMemory的好处是逻辑简单、信息无损耗但它有个致命缺陷历史越长提示词越长模型花费和响应时间也跟着涨。实测下来当对话超过三十轮提示词里大部分内容可能都是早期无关紧要的闲聊既占上下文窗口又干扰模型聚焦当前问题。这也是为什么实际项目很少直接裸用无限Buffer。2.2 BufferWindowMemory限制上下文长度的实用方案如果你既想要临时的上下文连贯又不想让提示词无限膨胀ConversationBufferWindowMemory是最直接的方案。它的做法是只保留最近k轮对话更早的历史直接丢弃。from langchain.memory import ConversationBufferWindowMemory memory ConversationBufferWindowMemory(k6) conversation ConversationChain(llmget_llm(), memorymemory)这里的k非常容易理解错。它不是保留最近6条消息而是保留最近6轮“用户输入-模型输出”的来回。你可以理解成6个回合对应12条消息6条用户消息、6条AI消息。至于具体触发多少次对话后会开始截断取决于你调用run的轮数。比如做了10轮对话k6memory里就只剩下最后6轮前面4轮的内容不会再出现在提示词中。我在实际项目中k的值一般从10到20之间取。如果任务本身对前文依赖很强比如用户会反复修正某个要求k可以调大一些如果只是普通问答k小一点反而效果更稳定因为提示词更短、噪音更少、响应更快。一个常见的坑是用户在第一轮说过“我叫小明”聊到第十五轮你问“我叫什么”窗口已经把它挤掉了模型当然不知道。这种关键信息丢失问题不是调大k就能根本解决的正确的做法是把这类事实放到长期记忆里而不是指望短期窗口一直兜住。2.3 ChatMessageHistory更底层、更灵活的消息容器ConversationBufferMemory和WindowMemory都是高层封装它们底层其实都在操作一个叫ChatMessageHistory的消息容器。如果你需要更细粒度地控制消息而不是跟字符串模板打交道可以直接上手操作这个类。from langchain.memory import ChatMessageHistory history ChatMessageHistory() history.add_user_message(你好请帮我查一下订单状态。) history.add_ai_message(好的请提供你的订单号。) history.add_user_message(订单号是ABC123。) history.add_ai_message(已查到您的订单预计明天送达。)每个add_user_message和add_ai_message调用都会往history里追加一条带角色的消息。这个结构的好处是它天然符合大模型接口的message格式很多场景下你可以直接把这个列表传给模型的predict_messages接口而不需要拼字符串。我自己在自定义chain的时候比较喜欢用ChatMessageHistory作为“原始消息来源”再结合ConversationBufferMemory的chat_memory参数来装配。举个例子做一个客服机器人前几轮是固定的话术确认这部分可以手动写入history之后再进入真正的对话循环。这样的话术段就不会被误当成需要模型生成的普通对话。另外要注意直接修改history时如果中途出错容易造成数据不一致。我一般会把对history的追加操作集中在一个方法里同时在写入前做校验避免把空消息或者重复消息塞进去。2.4 临时记忆的适用边界临时记忆虽然好用但它只负责“当前会话”这是一个容易忽略的边界。如果你把它当成全局记忆来用会遇到两个典型问题。第一进程内存不落盘。服务一重启、容器一重建临时记忆里所有内容全部清空用户下次再来就是“陌生人”。在开发环境里这个问题不明显因为本地进程一直活着但一旦上了生产的容器调度重启是很频繁的事。第二上下文窗口终究有限。哪怕用窗口记忆把长度控制在20轮它依然只解决“最近聊了什么”解决不了“用户一直以来的偏好是什么”。比如一个老用户说“还是老规矩”模型需要知道他上次默认的偏好是“货到付款”而这条信息可能藏在3天前的对话里。这已经不是临时记忆能承担的任务该上长期记忆了。3. 长期记忆让AI跨会话仍然“记得你是谁”3.1 思路从“带全历史”变成“按需检索”长期记忆在工程上有一个关键的思路转变不要试图把所有历史从头到尾都塞进提示词而是把对话中产生的“有效信息”抽取出来持久化保存下次需要时再按需检索并注入。为什么不能简单地把整段历史存起来、下回继续拼进去首先是成本问题长期积累的对话可能有几百上千轮一次请求根本放不下其次是效率问题无关的旧信息会稀释模型对当前问题的注意力答案质量反而变差。所以长期记忆的核心是“沉淀信息”和“按需召回”而不是“无脑堆历史”。3.2 方案一对话记录持久化下次接着聊最简单的长期记忆就是把聊天记录持久化到外部存储。Langchain提供了RedisChatMessageHistory使用起来非常直接。from langchain.memory import RedisChatMessageHistory from langchain.memory import ConversationBufferMemory history RedisChatMessageHistory( session_iduser_001, urlredis://localhost:6379 ) memory ConversationBufferMemory(chat_memoryhistory)这里面的关键设计点是session_id。在真实业务中它不应该是一个简单的字符串而是要和你的用户体系绑定比如user_001、order_888、tenant_xx_user_yy。这样同一个用户在不同时间、不同设备上发起的新会话都能用同一个session_id找到历史记录。我最初做这个方案时把session_id设成了会话ID而不是用户ID导致用户每次刷新页面都开启新会话历史全部丢失。后来改成用户ID作为维度才真正做到了跨会话记忆。如果不方便引入Redis也可以用本地方案。Langchain生态里有接口可以对接本地存储核心是自定义一个历史存储类实现保存和加载两个动作。比如用SQLite存消息import sqlite3 import json from langchain.memory import ChatMessageHistory class SQLiteChatHistory(ChatMessageHistory): def __init__(self, session_id, db_pathmemory.db): self.session_id session_id self.db_path db_path self.conn sqlite3.connect(db_path) self._create_table() self._load() def _create_table(self): self.conn.execute( CREATE TABLE IF NOT EXISTS chat_history ( session_id TEXT, role TEXT, content TEXT ) ) self.conn.commit() def _load(self): cur self.conn.execute( SELECT role, content FROM chat_history WHERE session_id? ORDER BY rowid, (self.session_id,) ) self.messages [] for row in cur.fetchall(): if row[0] human: self.add_user_message(row[1]) else: self.add_ai_message(row[1]) def save_message(self, role, content): self.conn.execute( INSERT INTO chat_history (session_id, role, content) VALUES (?, ?, ?), (self.session_id, role, content) ) self.conn.commit()这段代码我简化过重点表达两个思路一是消息永久落盘二是用session_id作为索引。注意我这里save_message需要在你每次向模型发送消息之后手动调用如果你用的是ConversationChain最省事的做法是重写或者封装save_context让它同时更新内存和SQLite。持久化方案适合“接着上一次聊”的场景但它也有明显的局限当历史记录极度膨胀时你还是得考虑怎么截断、怎么摘要否则每次加载全部历史问题依然存在。所以生产级长期记忆最常用的是下面的向量化检索方案。3.3 方案二向量化长期记忆按语义召回向量化记忆的思路是把对话中产生的“事实片段”转成向量存进向量数据库每次对话开始前用当前输入去检索语义相关的记忆把命中的内容注入提示词。为什么用向量检索而不是SQL模糊查询因为记忆的匹配是语义层面的。“我住在杭州”和“你住在哪个城市”没有共同的关键词但语义高度相关。向量检索能通过语义相似度找到这种关联这是关键字匹配做不到的。我实现过一个小型的“用户画像记忆”机器人大致流程是这样的每一轮对话结束后判断用户有没有说出值得长期保存的事实比如姓名、城市、偏好、身份有的话抽取成一句自然语言文本向量化后存入向量库下一轮对话开始时用用户当前输入去向量库检索把命中的记忆拼进提示词。伪代码结构如下from langchain.vectorstores import FAISS from langchain.memory import ConversationBufferMemory class LongTermMemoryBot: def __init__(self, embedding_model): self.embedding_model embedding_model # 如果已经有历史向量库加载否则新建 self.vectorstore None # 短期记忆负责当前会话的上下文 self.short_memory ConversationBufferMemory(return_messagesTrue) def _recall(self, query, k3): if self.vectorstore is None: return [] docs self.vectorstore.similarity_search(query, kk) return [d.page_content for d in docs] def _remember(self, fact): if self.vectorstore is None: self.vectorstore FAISS.from_texts([fact], self.embedding_model) else: self.vectorstore.add_texts([fact]) def run(self, query): memories self._recall(query) memory_block \n.join(memories) if memory_block: prompt f已知的用户信息\n{memory_block}\n\n当前问题{query} else: prompt query # 这里把prompt发给模型并拿到回答 answer get_llm().invoke(prompt) self.short_memory.save_context({input: query}, {output: answer}) return answer这里面有几个工程细节很关键。第一每次对话结束后不但要保存短期记忆还要做一个“事实抽取”动作。可以基于规则比如检测“我叫”“我住在”“我喜欢”等句式也可以用大模型来抽取。我用的是模型抽取每轮结束后让模型判断“用户透露了哪些值得长期记住的信息”抽取出来再写入向量库这样比规则通用得多。第二向量库的写入需要做去重合并。用户如果改了城市不能把新旧两条都存进去否则检索时会产生冲突模型反而不知道以哪条为准。我通常让模型抽取时带上“事实ID”相同ID的信息采用覆盖式写入。第三similarity_search的k值一般取3到5这是召回精度和提示词长度的平衡点取多了提示词里全是重复信息取少了又可能漏掉关键事实。3.4 摘要记忆压缩历史的折中方案在临时记忆和长期记忆之间其实还有一类折中方案ConversationSummaryMemory。它的思路是用模型实时把历史对话总结成一段摘要然后只保存摘要不再保存完整历史。from langchain.memory import ConversationSummaryMemory memory ConversationSummaryMemory(llmget_llm()) conversation ConversationChain(llmget_llm(), memorymemory)每一轮对话之后SummaryMemory都会让模型更新一次摘要。随着对话进行摘要会越来越长但总体来说它压缩了大量原始对话成本远低于全量Buffer。适合那种对话特别长、预算可以承担额外模型调用、关注整体语义多于逐字细节的场景。它的缺点也很明显细节会丢。用户在某轮说过一个具体订单号摘要可能只会写着“用户咨询订单问题”细节信息直接消失。所以我的建议是摘要记忆适合做“整体背景”不适合做“关键事实存储”。关键事实还是放到向量化长期记忆里更可靠。有一个经验分享实际项目中摘要记忆可以当成长期记忆的“入口”。每轮对话结束如果发现对话已经很长先把摘要写出来再把摘要里的关键事实向量化存入向量库。这样既控制了成本又让长期记忆有稳定的信息输入来源。4. 常见问题、踩坑记录与搭配实践4.1 版本差异导致API飘忽不定Langchain的版本更新非常快不同版本之间Memory类的位置、构造函数、方法名都可能不一样。我自己踩过的典型例子某个项目的调用了ConversationBufferMemory.load_memory_variables拿历史换了一个版本之后这个方法的返回结构变了提示词拼装直接报错。遇到这种情况处理原则很简单不要背API去看你当前安装版本对应的官方文档和源码。写记忆相关代码时重点关注几个位置memory_key的默认值、load_memory_variables的返回值结构、save_context接收的键名。这些地方只要有一个不对齐对话链就会要么报错、要么静默丢失上下文。我建议在项目初始化时先写一个20行左右的“记忆自检脚本”模拟两轮对话打印memory里实际存了什么。这样升级依赖后自检脚本一跑就知道有没有问题。4.2 memory_key与history注入位置错误自定义Chain或者自定义PromptTemplate时最容易踩的坑是变量名对不上。默认情况下ConversationChain里的提示词模板有{history}占位符ConversationBufferMemory的memory_key也是history。但如果你自己写模板把历史变量叫成了{chat_history}就必须显式指定memory_keyfrom langchain.prompts import PromptTemplate from langchain.memory import ConversationBufferMemory memory ConversationBufferMemory(memory_keychat_history) prompt PromptTemplate( input_variables[input, chat_history], template以下是历史对话\n{chat_history}\n\nHuman: {input}\nAI: ) conversation ConversationChain( llmget_llm(), promptprompt, memorymemory )这里的规律是模板里写什么占位符memory_key就得叫什么同时input_variables里也要补上这个变量名。我见过很多次报错“Prompt got unexpected keys: history”本质就是memory_key和模板占位符不一致。还有一处容易忽略当你手动调用memory.save_context({input: query}, {output: answer})时传入的键名必须和对话链的输入输出变量名一致。如果ConversationChain里的输入变量叫input你这么传就没问题如果你在模板里把输入变量改成了question这里也得改成{question: query}。4.3 token失控与性能下降临时记忆最大的隐性成本是token。每个请求都会把历史重新发给模型模型按token计费历史越长花费越高。尤其是中文场景一段中文对话转换成token之后占用量往往比英文更大这个现象很容易被忽略很多人在英文示例里调好的k值换到中文就变得又贵又慢。我处理这个问题的经验是三层控制。第一层用窗口记忆限制最近N轮第二层用摘要记忆把更早的内容压缩成背景第三层做关键事实抽取把真正需要长期保存的信息向量化。这三层配合下来提示词长度基本能被控制在一个稳定范围不会随对话轮数无限增长。还有一个小建议如果你发现单轮响应延迟明显上升第一步不是加显卡、加并发而是先打印一下当前提示词的长度。八成情况是历史积累太多了裁掉一半无关历史速度立刻恢复。4.4 临时与长期记忆的最优组合方案经过几次项目迭代我现在比较固定的组合方案是这样的短期记忆用窗口记忆保持最近10轮对话负责保证当前会话流畅长期记忆用向量库保存用户的关键事实负责跨会话识别用户如果对话特别长再叠加摘要记忆摘要结果也向量化入库。整个流程跑起来是这样用户发起新对话系统先用当前输入去向量库做一次相似度检索召回3到5条相关记忆把召回到的记忆作为“已知信息”注入提示词再和短期窗口历史一起发给模型模型返回回答后先更新窗口记忆再用一个抽取模型判断这轮是否产生了值得长期保存的事实有则写入向量库。这个方案的优点是把两类记忆的职责分得很清楚窗口记忆管“刚才说了什么”向量记忆管“这个用户是谁”。就算服务重启用户下次再来说一句“还是老规矩”只要召回记忆里存了“上次默认货到付款”模型就能理解。记得给长期记忆加“遗忘机制”。用户改了收货城市旧记录如果还留在向量库里检索时可能同时命中新旧两条模型会困惑。我常用的做法是给每条事实带一个字段时间戳或者覆盖标记写入新事实时按照事实ID把旧向量删掉保证同一类事实只有最新一条。没有遗忘机制的记忆系统时间越久越容易自相矛盾。4.5 中文场景下的embedding与检索细节如果你做中文对话系统向量化长期记忆时embedding模型的选择很关键。不同模型的检索质量差距非常大我建议不要只看宣传指标而是用一个包含常见口语表达的测试集实际跑一轮检索看召回结果是不是你想要的。特别注意一些简称、口头语比如用户说“老地方见”好的embedding模型应该能召回“我们常去的那家咖啡店”相关记忆差的模型可能直接召回一堆无关内容。另外FAISS这类向量库在写入前最好对文本做一定清洗。不要直接把整段对话塞进去一条记忆最好是一句信息密度高的短句比如“用户住在杭州”“用户喜欢靠窗座位”而不是“用户说你好我住在杭州离地铁站很近旁边有个大商场”。短句的向量更聚焦检索命中率更高。保存前做一次合并把同一个用户的相似信息聚到一起也能减少向量库体积、提高检索准确度。最后聊一个调试小技巧。我在开发阶段习惯在每次对话前把“本次注入的长期记忆”和“短期历史长度”打印出来print(--- recalled long-term memory ---) for mem in memories: print(mem) print(--- short-term memory variables ---) print(short_memory.load_memory_variables({}))这样能看到模型实际看到了什么。大部分记忆丢失问题其实肉眼一看打印结果就能定位要么长期没召回要么短期窗口被挤爆。这个小习惯帮我省掉了大量盲猜时间。会话记忆体系其实没有太多玄学核心就是把存储边界划清楚把注入规则做严谨剩下的问题自然就少了。希望这篇记录能帮你在Langchain项目里少踩几个我踩过的坑。