
066、LangChain的Memory模块详解我上周调一个对话机器人的时候被同一个坑折磨了三个小时——多轮对话里模型死活记不住用户十分钟前说的“我住在杭州”每轮都像第一次见面。我第一反应是模型上下文窗口不够换了更长的提示词模板没用。后来把整个Chain的输入输出打出来才发现LangChain的ConversationBufferMemory默认只存了当前这轮的输入输出之前的历史早就被丢掉了。那一刻我才真正意识到Memory模块不是“给模型加记忆”而是“给Prompt拼历史字符串”很多人对它的理解从根上就偏了。先看最基础的ConversationBufferMemory。它做的事情简单到令人发指把每一轮对话的Human和AI消息按顺序存进一个列表然后用的时候全部拼成一段文本塞进Prompt。代码长这样fromlangchain.memoryimportConversationBufferMemory memoryConversationBufferMemory()memory.save_context({input:你好},{output:你好呀我是AI助手})memory.save_context({input:我住在杭州},{output:好的你住在杭州})print(memory.load_memory_variables({}))输出里会出现一长串“Human: 你好 AI: 你好呀… Human: 我住在杭州 AI: 好的…”。这就是它的全部魔法。如果你的应用只是两三轮闲聊这个类完全够用。但如果你把它拿去做长对话很快就会发现两件事第一Prompt越来越长Token消耗猛增第二模型被过时的细节干扰比如用户一周前说过“我不吃辣”今天点菜时模型还在强调“您不吃辣”特别蠢。真正让我决定写这篇笔记的是那个“Token爆炸”问题。我试过用ConversationTokenBufferMemory它允许你按Token数限制历史长度超出的部分自动丢弃。初始化的时候要传一个LLM对象进去因为它要靠LLM的tokenizer数token。这里踩过一个坑如果你传入的是ChatOpenAI它默认用get_num_tokens方法这个方法会走模型的tokenizer但如果网络抖动或者模型没有加载整个Memory就会直接报错。我后来干脆自己写了一个简单的token计数函数传进去反而更稳。fromlangchain.memoryimportConversationTokenBufferMemoryfromlangchain.schemaimportSystemMessagedefmy_token_counter(text):# 粗略估算中文按字符数算英文按空格分词# 别看简单生产环境够用别教条returnlen(text)//21memoryConversationTokenBufferMemory(llmNone,max_token_limit200,# 自定义计数函数绕开LLM。# 注意参数名是llm但你传None也行只要你把counting_func绑上去)其实LangChain官方文档里还有一个ConversationSummaryMemory。它和上面几个的思路完全不同——它不截断历史而是用LLM把历史对话总结成摘要。比如聊了十轮之后Memory里不再存原始对话而是存“用户介绍了自己住在杭州喜欢素食正在学习Python”。这看起来美得很但坑也大。第一每次总结都要额外调用一次LLM延迟和成本直接翻倍。第二摘要会丢失细节比如用户说过“下周去北京出差”摘要可能概括成“用户要出差”至于去哪、什么时候走全没了。我有个项目就是用SummaryMemory做客服问答结果用户问“我上次说什么时候到上海”模型一脸茫然因为摘要里根本没有具体日期信息。如果你要的是“既压缩历史又不丢关键实体”可以试试ConversationSummaryBufferMemory。它是上面几个的混合体在Token限制内的对话保留原始消息超过限制的部分用LLM生成摘要。比如你设了500 Token那么最近500 Token的内容完整保留更早的内容被压成一段总结。这个设计非常符合真实场景因为最近的上下文信息量最大历史只需要留个大概。唯一要小心的还是那个“总结幻觉”——LLM可能在摘要里填一些它“觉得合理”但其实没发生的事。我遇到过用户说“我不喜欢星巴克”摘要变成了“用户不喜欢咖啡”这性质就变了。还有一个不得不提的Memory类ConversationEntityMemory。它专门抽取对话里的实体人名、地名、产品名等并保存在一个“实体存记忆”里。它会用LLM去解析当前输入识别出实体再和已有的实体记忆合并。这个设计思路我很喜欢但实际用起来很容易踩坑。因为LLM抽取实体不是100%准确的有时候会把“三年前”当成实体把“三年”抽出来。而且实体记忆和对话历史混在一起时Prompt结构会变得特别复杂。我见过一个生产事故由于实体抽取在一次长对话中反复加上“用户所在地北京”这个条目模型把“北京”的概率权重拉得过高结果用户问“推荐一家餐厅”模型优先推荐北京菜而用户其实已经搬到上海了。讲了这么多类你可能会问到底该用哪个我的个人经验是如果做QA机器人或者固定领域客服用简单的ConversationBufferMemory配合滑动窗口就行别追求花哨。具体做法就是自己维护一个消息队列超过N条就把最旧的一条丢出去然后把剩余历史格式化拼进Prompt。这种做法虽然“土”但每一个行为都在你掌控之中不会出现LLM总结跑偏或者token数突变的诡异现象。如果你非要选官方Memory短对话用TokenBufferMemory长对话用SummaryBufferMemory同时强烈建议把max_token_limit调小一点——比如你模型上下文是8K留给History最多2K别贪。这里要特别提醒一个几乎所有文档都不讲的事LangChain的Memory并不是真正“记忆”。它只是一个变量插值工具。你调用load_memory_variables拿到的是一段字符串然后再塞到Prompt里。所以Memory类的核心接口很单纯save_context存消息load_memory_variables取历史。多个Memory可以整合成CombinedMemory这个类本质上就是维护一个字典把不同Memory的输出合并。但注意CombinedMemory里的每个子Memory要用不同的前缀否则拼在Prompt里会出现两个“History:”标签模型会懵。我自己写过一个例子把BufferMemory和EntityMemory合到一起用结果Prompt里出现“History: Human: … History: Entity: 用户在北京”那个重复的History让模型输出质量急剧下降。解决方案很简单用memory_key参数重命名每个子Memory的key。另外关于Memory与Chain的结合有件事很反直觉普通LLMChain可以用memory但如果你用SequentialChain把多个LLMChain串起来中间那个Chain千万别挂Memory。因为Memory会持久化保存中间结果导致下一轮对话时中间Chain会“回忆”到上一轮它的输出然后重复使用那些旧数据。我曾经调试一条问答链第一个Chain负责提取问题关键词第二个Chain负责作答。第一个Chain挂了Memory之后第二轮开始关键词提取就变成了“请根据之前的关键词……”这种荒谬的状态。后来我把Memory移到了最终回答的那个Chain上问题立刻消失。还有一个更隐蔽的坑和Memory无关但容易误伤当你在LCEL表达式中使用memory时很多人会忘了每次调用.invoke()之前需要先load_memory_variables。因为LCEL的RunnableSequence不自动处理Memory你需要把memory的加载步骤显式放到链里。比如fromlangchain.memoryimportConversationBufferMemoryfromlangchain_core.runnablesimportRunnableLambda memoryConversationBufferMemory(return_messagesTrue)defload_memory(state):# 别直接memory.load_memory_variables({})因为要跟用户消息合并return{history:memory.load_memory_variables({})[history]}chainRunnableLambda(load_memory)|prompt|llm# 每次invoke后手动saveresultchain.invoke({input:什么鬼})messageresult[text]# 注意这里如果不save下一轮还是会失忆memory.save_context({input:什么鬼},{output:message})你可能会想“有没有一个东西让Chain自动加载和保存Memory”答案是在旧版的LLMChain里它内置了memory的调用逻辑。但升级到LangChain 0.1之后官方越来越推荐你显式写这个流程。所以我的建议是别依赖框架帮你调memory自己写一个循环体defrun_loop(user_input):historymemory.load_memory_variables({})responsechain.invoke({input:user_input,history:history})memory.save_context({input:user_input},{output:response})returnresponse这几行代码比任何高级封装都可靠。你可能会问“那ChatMessageHistory呢”它可以用来存储原始消息序列是Memory内部的数据源。比如你想从数据库加载历史就可以先把历史消息填充进ChatMessageHistory对象。但注意ConversationBufferMemory的chat_memory属性默认是InMemoryChatMessageHistory它只在进程内有效服务重启内存就没了。要做持久化你得自己写一个ChatMessageHistory的子类把add_user_message和add_ai_message覆盖成写数据库。别试图直接塞Redis因为LangChain有一个RedisChatMessageHistory类但那个类的序列化格式比较特殊如果你用别的客户端往Redis里写普通字符串它是读不出来的。最后我想把话说明白Memory模块从设计上看是为了解决“LLM无状态”的问题但它本质上是在绕弯。真正的高性能对话应用通常不会在Prompt里堆叠所有历史。你会发现OpenAI的API里后来出了messages数组的分段策略LangChain也跟着做了各种Memory类但这些都是在模拟“记忆”这个功能。我自己更倾向于使用外挂的短期记忆层比如用向量存储只检索相关的历史片段而不是把整段历史全部塞给模型。这样做的好处是无论聊多少轮Prompt长度几乎恒定速度不会变慢。缺点是相关的历史片段可能检索不准需要你调好Embedding和搜索算法。而LangChain的Memory就好比是“全量记忆”适合对话轮数很少的场景比如20轮以内。如果你非要听完我的个人经验记住三条能用简单的就不要用复杂的能用代码控制的就不要靠LLM自动处理能显式加载/保存的就不要依赖隐式魔法。我在生产环境里最后用的是一个自己写的RollingMemory类维护一个双向队列固定长度每条消息存储时顺便打上时间戳拼接Prompt时把时间字段格式化进去。效果意外地好因为模型能看到“三分钟前”和“三天前”的区别不会把太久远的话当作当前状态。LangChain的Memory你可以学可以玩但如果你要做一个能支撑数万用户的对话服务我劝你把那些类当成参考实现而不是最终依赖。好这篇笔记就到这里。下次如果你在调试时发现模型“失忆”先别怀疑参数去打印一下memory.load_memory_variables({})返回的字符串——八成你会看到历史压根没进去。