066、LangChain的Memory模块详解
我上周调一个对话机器人的时候,被同一个坑折磨了三个小时——多轮对话里模型死活记不住用户十分钟前说的“我住在杭州”,每轮都像第一次见面。我第一反应是模型上下文窗口不够,换了更长的提示词模板,没用。后来把整个Chain的输入输出打出来,才发现LangChain的ConversationBufferMemory默认只存了当前这轮的输入输出,之前的历史早就被丢掉了。那一刻我才真正意识到,Memory模块不是“给模型加记忆”,而是“给Prompt拼历史字符串”,很多人对它的理解从根上就偏了。
先看最基础的ConversationBufferMemory。它做的事情简单到令人发指:把每一轮对话的Human和AI消息按顺序存进一个列表,然后用的时候全部拼成一段文本塞进Prompt。代码长这样:
fromlangchain.memoryimportConversationBufferMemory memory=ConversationBufferMemory()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)//2+1memory=ConversationTokenBufferMemory(llm=None,max_token_limit=200,# 自定义计数函数,绕开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 memory=ConversationBufferMemory(return_messages=True)defload_memory(state):# 别直接memory.load_memory_variables({}),因为要跟用户消息合并return{"history":memory.load_memory_variables({})["history"]}chain=RunnableLambda(load_memory)|prompt|llm# 每次invoke后手动saveresult=chain.invoke({"input":"什么鬼"})message=result["text"]# 注意,这里如果不save,下一轮还是会失忆memory.save_context({"input":"什么鬼"},{"output":message})你可能会想“有没有一个东西,让Chain自动加载和保存Memory?”答案是在旧版的LLMChain里,它内置了memory的调用逻辑。但升级到LangChain 0.1之后,官方越来越推荐你显式写这个流程。所以我的建议是,别依赖框架帮你调memory,自己写一个循环体:
defrun_loop(user_input):history=memory.load_memory_variables({})response=chain.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({})返回的字符串——八成你会看到,历史压根没进去。