先讲一件让我印象很深的事。很早之前我给一个demo聊天机器人加记忆,当时用的是LangChain最基础的ConversationBufferMemory。用户第一句说“我叫小明”,机器人正常回复;第二句问“你还记得我叫什么吗”,结果机器人一本正经地答“你没有告诉过我你的名字”。当时我还以为是模型问题,排查了半天才发现,根本原因是那个demo压根没接Memory,每一轮请求对模型来说都是“第一次见面”。
这个问题在LangChain里有个专门的模块去解决,就是这一章要聊的Memory。它解决的事情很聚焦:让Chain在多次调用之间保留、读取并更新对话历史,让模型能“记得”前面说过什么。不管你是刚接触LangChain的初学者,还是已经在做Agent、RAG类应用的老手,Memory这块都是绕不开的基础设施。这一章我会从概念、类型、实操到坑位全部过一遍,重点是怎么选型、怎么把它接进Chain和Agent,以及有哪些我在实测里踩过的雷。
1. 先搞清楚:没有记忆的聊天机器人会怎样
1.1 一个真实翻车现场
先别急着看代码。我上面说的那个demo,其实暴露了一个很本质的问题:LLM本身是没有状态的。你调一次接口,传一段Prompt进去,它根据这段Prompt生成回复,然后就完了。它不保存“你刚才说什么”,更不保存“它刚才答什么”。你连续问十句话,如果每次都是独立的Prompt,模型根本不知道前九句的存在。
这种“无状态”设计对单轮问答没问题,比如“帮我写个正则表达式”这种问题,上下文本来就不需要。但只要是稍微像样一点的对话系统,比如客服机器人、角色扮演、多轮追问、连续的数据分析对话,就必须要做一件事:把之前的对话内容也塞进下一轮的Prompt里。这个动作,落到工程上就是Memory的活。
我见过不少项目:明明用了很好的大模型,但产品体验特别差,用户问“那第二个方案呢”,系统完全不知道“那”指的是哪个方案。这种问题的根源往往不在模型,而在于历史信息压根没传进去。所以理解Memory,本质上是理解“大模型应用如何管理上下文”。
1.2 Memory在LangChain里的定位
LangChain把Memory抽象成Chain执行过程中的一个辅助组件。核心逻辑其实就两步:Chain准备执行之前,Memory调用load_memory_variables把历史记录读出来,以变量形式注入Prompt模板;Chain执行完毕之后,Memory再调用save_context把这一轮的输入和输出存回去。
这个“先读取、后保存”的模式,保证了每一轮对话都能看到之前的内容,同时又不用你手动去拼接历史字符串。BaseMemory这个基类把两个核心方法固定下来,剩下的事情就是你在不同实现之间做选择:是全部保存,还是只保存最近几轮,还是摘要化保存,还是放到向量数据库里按相似度检索。
我强烈建议你在往下看具体类型之前,先把这个“读取-注入-保存”的闭环印在脑子里。后面所有Memory类型的区别,本质上只是“读取什么内容”和“以什么形式保存”的区别。
2. Memory家族盘点:从简单缓存到摘要记忆怎么选
2.1 ConversationBufferMemory:最原始的全量缓存
ConversationBufferMemory是最容易理解的一种:把每一轮对话的输入输出原样拼接,全部缓存下来。它不丢任何内容,这也是它最大的优点和最大的隐患。
优点是好用、直观,调试能力强。你随时可以把它缓存的内容打印出来,看看历史到底存了什么。隐患也在这里:上下文越攒越长。普通对话聊上二三十轮,再加上系统Prompt和别的指令,很容易就把模型上下文窗口撑爆。尤其一些小型开源模型的上下文窗口本身就不大,全量缓存很快就会触发Token超限。
所以我的看法是:ConversationBufferMemory只适合做原型验证和教学场景。你用来理解Memory的工作机制没问题,产品环境我不建议直接上,除非你严格控制总轮数。
2.2 ConversationBufferWindowMemory:限定轮数的滑动窗口
ConversationBufferWindowMemory在Buffer基础上加了一个k参数,意思是你只保留最近k轮对话。超出这个窗口的内容会被丢掉,新的对话会像滑动窗口一样往前滚动。
这个设计思路很直接:绝大多数多轮对话,模型真正需要的是“最近几轮”的上下文,更早的内容往往已经跟当前问题无关了。保留最近的3到5轮,既维持了基本的多轮能力,又不会让Token无限膨胀。
但这里有个明显的取舍:一旦用户在第10轮突然问“我之前提到的那个地址是多少”,而那个地址在第2轮出现,并且已经被滑出窗口,模型就答不上来了。所以滑动窗口适合那些“上下文关联度随时间衰减很快”的场景,比如闲聊助手、短对话任务。做需要长期记忆的产品,就不能只看窗口长度了。
2.3 ConversationTokenBufferMemory:按Token做容量管理
很多人一开始容易把窗口记忆和Token记忆搞混。ConversationTokenBufferMemory不是按轮数截断,而是按Token总数截断。它有一个max_token_limit参数,当缓存的总Token数超过这个上限时,最旧的对话会先被丢弃,直到总量回落到限制以内。
你可能会问:LangChain怎么知道某段文本有多少Token?它自己并没有Tokenizer,所以这个Memory类型需要你传一个LLM实例进去,用它来估算Token数量。这也是它看起来比窗口记忆“更聪明”的地方:同样是截断,窗口记忆死的,Token记忆是根据实际文本长度动态控制的。比如某几轮对话特别长,可能一轮就占掉一半预算,那下一轮进来时就会丢掉更多旧内容。
这个类型适合你对Token成本有比较精细控制需求的场景。不过我个人觉得,它和窗口记忆本质上是同一类思路,都是在“有限空间里保留最近的内容”,只是截断单位不一样。
2.4 ConversationSummaryMemory与SummaryBufferMemory:用摘要换空间
这一对值得认真说,因为它们解决的是“重要信息被截断”的问题。
ConversationSummaryMemory的思路是:不保留完整对话,而是让LLM把已经发生的对话总结成一段摘要,以后每一轮都在这个摘要的基础上追加新的摘要,旧细节被永久压缩。这么做的好处是,哪怕聊到第50轮,模型依然能抓住“用户以前提到过他的公司是做跨境电商的”这类关键信息,而不是被硬生生丢出窗口。
代价也有三点。第一,摘要过程本身要调用LLM,每一轮都做一次摘要更新,Token成本和延迟都会涨。第二,摘要是有损压缩,就像你读一本书的目录不等于读了全书,很多具体细节会丢。第三,对于简单任务来说,用摘要属于杀鸡用牛刀。
ConversationSummaryBufferMemory算是前面所有方案的一个折中:最近的内容用原文保留,保证当前话题的细节完整;只有超过max_token_limit的“更早部分”才会被丢进LLM去生成滚动摘要。这样一来,近期信息不损失,远期信息也不至于彻底丢失,只是被压缩掉边角。
如果让我推荐一个通用默认项,我会说:预算允许的情况下,从ConversationSummaryBufferMemory起步是相对稳的选择。它兼顾了短时细节和长时记忆,是几种Memory里最适合产品落地的。
2.5 另两类不常用的进阶选手
除了上面几个常见类型,还有两个比较“特殊”的Memory,我建议你了解一下,但不一定马上用。
一个是ConversationKGMemory,它的思路是把对话中的实体和关系抽取成知识图谱三元组。比如“用户的名字是XX”会被抽成(用户, 名字, XX)这样的三元组。加载记忆时,它会根据当前输入中出现过的实体去知识图里检索相关事实。这种设计优点是很适合做“事实性问答”,比如“我之前说过我的猫叫什么名字”,但问题在于实体抽取的准确性依赖LLM,而且对普通闲聊场景来说有点重。
另一个是VectorStoreRetrieverMemory,它会先把对话片段写入向量数据库,然后在加载记忆时,把当前输入变成向量,去库里面做语义检索,找出最相关的几条历史记录。这个方案的好处是记忆容量可以做很大,缺点是片段化存储容易丢掉上下文连续性,检索效果高度依赖Embedding模型和向量库配置。如果你的项目本身已经用了向量数据库,这类Memory可以尝试;否则不建议为了用Memory特意给自己加一套向量库。
我整理了一张表,方便你对比选型:
| Memory类型 | 保存策略 | 核心优势 | 主要风险 |
|---|---|---|---|
| ConversationBufferMemory | 全部保存 | 无信息丢失,调试直观 | 历史无限膨胀,易超Token |
| ConversationBufferWindowMemory | 保留最近k轮 | 控制Token简单直接 | 过早内容被丢弃,长程记忆弱 |
| ConversationTokenBufferMemory | 按Token总量截断 | 容量控制更精细 | 长对话一样丢信息 |
| ConversationSummaryMemory | 全程滚动摘要 | 长期信息保留好 | 摘要成本高,细节有损 |
| ConversationSummaryBufferMemory | 近期原文+远期摘要 | 短期细节与长期要点兼顾 | 实现复杂度较高,需调参 |
| ConversationKGMemory | 抽取实体关系三元组 | 适合事实性问答 | 抽取准确率依赖模型 |
| VectorStoreRetrieverMemory | 向量检索历史片段 | 记忆规模大,语义召回 | 片段上下文易断裂 |
3. 实操:在Chain和Agent里接入Memory
3.1 最小可用案例:ConversationChain
聊完理论,直接上代码。最简单的接入方式是用ConversationChain,它内部已经预留了history这个变量,只要你把memory传进去,它会自动完成读取、注入、保存这套流程。
from langchain.chains import ConversationChain from langchain.memory import ConversationBufferMemory # get_llm() 表示你实际使用的模型服务,不限定厂商 llm = get_llm() memory = ConversationBufferMemory() chain = ConversationChain(llm=llm, memory=memory) print(chain.predict(input="我叫小明,是负责前端开发的")) print(chain.predict(input="我叫什么名字?做什么工作?"))你先忽略我这里用get_llm()占位这件事,重点是:第二次predict的时候,因为Memory里已经存了第一轮的输入和输出,模型能看到“我叫小明,是负责前端开发的”这段历史,自然就能正确回答出来。这就是Memory最直观的作用。
我实测下来,第一次跑通这个最小案例是很有成就感的一件事,因为“让模型记住用户的名字”几乎是所有对话应用的起步需求。但这里有个坑:ConversationBufferMemory默认把历史存成一大段字符串,如果后面你换了return_messages=True,历史会以消息列表形式存在,必须配合ChatPromptTemplate和MessagesPlaceholder来使用,不能直接塞进字符串模板,否则会报类型错误。
3.2 自定义Prompt时正确绑定memory_key
很多人用了一段时间后就不满足于ConversationChain那种默认模板了,因为默认Prompt太简陋,根本不能满足业务需求。于是会自己写一个PromptTemplate,结果动不动就报错:Loaded memory has invalid keys,或者直接告诉你Prompt里缺变量。
问题通常出在memory_key没有对齐。Memory对象里有一个memory_key参数,比如默认的"history",它代表的是Memory加载后返回的变量名,这个变量名必须出现在你的Prompt模板里,LangChain才能正确注入。
from langchain.prompts.prompt import PromptTemplate template = """你是一个智能助理。 以下是和用户的对话历史: {history} 当前用户输入:{input} 你的回复:""" prompt = PromptTemplate( template=template, input_variables=["input"], # 动态变量 partial_variables={ "history": "" # 这个变量由Memory提供 }, )我自己踩过的坑是:写完PromptTemplate,忘记给partial_variables里补history,结果每次调用都报“输入变量缺失”。所以如果你自定义了Prompt模板,务必检查三件事:一是Memory的memory_key和模板变量名一致;二是模板里除input以外的变量,要么能在输入里给到,要么通过partial_variables占住;三是如果设了return_messages=True,模板得用MessagesPlaceholder而不是字符串拼接,否则历史消息塞不进去。
3.3 在Agent中使用Memory的硬核建议
Agent用Memory,是这一章里最容易翻车的部分。
很多初学者会想当然地写:
from langchain.agents import AgentExecutor, create_react_agent from langchain.memory import ConversationBufferMemory # 不推荐的写法:直接给AgentExecutor挂memory memory = ConversationBufferMemory(memory_key="chat_history") agent = create_react_agent(llm, tools, prompt) executor = AgentExecutor(agent=agent, tools=tools, memory=memory)这个写法在早期版本偶尔能跑,但在实际复杂工具调用场景中,你会遇到一个很典型的现象:Agent的思考过程被当成普通对话存进了Memory,或者几轮之后Memory里的内容跟AgentExecutor自己维护的intermediate_steps打架,导致历史越来越乱。
原因是,AgentExecutor在执行过程中会生成多步的思考、动作、观察结果,这些步骤本质上是内部推理过程,不是用户和助手之间的正常对话。如果把它们全部丢进Memory,下次加载历史时,模型看到的“对话”里混着一堆工具调用的中间结果,上下文就变得特别脏。
我的建议是分两种做法。
第一种是“Agent在外层套一个带Memory的Chain”。也就是让一个普通的ConversationChain负责跟用户聊天,当它判断需要调用工具时,把控制权转交给一个不带Memory的AgentExecutor,等结果回来后再由ConversationChain把结果作为消息保存。这样,Memory只管“用户说了什么、最终回答了什么”,工具内部过程不进入历史。
第二种是对简单Agent场景,谨慎使用memory_key="chat_history",并手动清理中间步骤。但说实话,这种做法维护成本高,我在项目里一般不推荐。如果你正在做一个需要“长期多轮 + 工具调用”的复杂系统,我更建议你去研究LCEL下的消息历史管理,把那套逻辑用自己的代码控制住,而不是依赖传统AgentExecutor的Memory注入。
4. 与ChatMessageHistory配合的历史持久化
4.1 什么时候需要持久化记忆
到这里为止,上面所有Memory都只存在于程序进程里。服务一重启,记忆全部清空。这对开发调试没问题,但到了产品环境就不行了:用户昨天跟机器人聊到一半,今天回来继续聊,结果机器人又把他当陌生人。让记忆跨重启、跨实例存在,就需要把历史存到外部存储里。
LangChain把这个能力拆成了两部分:Memory负责“如何组织历史内容”,ChatMessageHistory负责“历史内容存在哪里”。无论你用ConversationBufferMemory还是ConversationSummaryBufferMemory,都可以通过chat_memory参数挂一个持久化的ChatMessageHistory子类,让读写历史这件事落到Redis、数据库或者文件里。
4.2 基于Redis和SQL的落地方式
我项目里用得比较多的持久化方案有两个:Redis和SQL数据库。
用RedisChatMessageHistory的典型写法是:
from langchain.memory import ConversationBufferMemory from langchain_community.chat_message_histories import RedisChatMessageHistory history = RedisChatMessageHistory( session_id="user_abc_001", url="redis://localhost:6379/0" ) memory = ConversationBufferMemory( chat_memory=history, memory_key="history", return_messages=True, )这里的关键是session_id。它就是每个用户的标识,你要保证不同用户传不同的session_id,否则A用户说的话会被B用户看到,这是非常严重的线上事故。我在实际项目里曾经踩过这个坑:测试时图省事,所有请求都用一个固定的session_id,结果用户A问了一句“我的银行卡尾号是多少”,用户B的下一轮对话里居然出现了A的历史记录。后来排查出来就是session维度没隔离。
如果要存到SQL里,可以用SQLChatMessageHistory,它会自动建表保存消息。这种方式适合你本身就有关系型数据库、需要跟业务数据一起管理记忆的场景。你只需要传入session_id和数据库连接字符串,剩下的建表操作LangChain会处理好。不过我对它的使用建议是:在连接串、字段命名、生命周期清理这些方面,最好确认一下对应的表结构再上线,因为你自己的DBA不一定熟悉这个自动建出来的表长什么样。
4.3 自定义Memory的完整写法
等你的需求再特殊一点,比如希望从某个外部业务系统拉取用户画像作为历史上下文,或者希望把历史保存到自己公司的内部存储里,那现成的Memory实现就不够用了。你需要继承BaseMemory自己写一个。
自定义Memory的核心是实现两个方法:load_memory_variables负责返回历史变量,save_context负责保存本轮输入输出。
from typing import Any, Dict, List from langchain.memory import BaseMemory class ProfileMemory(BaseMemory): """从用户画像库加载事实,并将对话片段写入记录。""" @property def memory_variables(self) -> List[str]: return ["history"] def __init__(self, user_id: str, client): self.user_id = user_id self.client = client def load_memory_variables(self, inputs: Dict[str, Any]) -> Dict[str, Any]: # 从存储中读取历史记录 history = self.client.get_history(self.user_id) # 你可以在这里对history做任意加工 return {"history": history} def save_context(self, inputs: Dict[str, Any], outputs: Dict[str, Any]) -> None: # inputs里注意取input字段,outputs里注意取response字段 user_message = inputs.get("input", "") assistant_message = outputs.get("response", "") if user_message: self.client.append_message(self.user_id, "user", user_message) if assistant_message: self.client.append_message(self.user_id, "assistant", assistant_message)这里有一个非常重要的细节:save_context收到的inputs和outputs是Chain在调用过程中产生的字典,具体有哪些键,取决于你调用Chain的方式。比如chain.predict(input=...)传进去的键名是input,Chain输出的键往往是response或output。如果你拿不对键名,很可能保存进去的是空字符串,或者报Invalid variables错误。我自己一开始写自定义Memory时就犯过这毛病,保存了半天,load出来全是空。调试方法很简单:在save_context里先把inputs和outputs打印出来看一眼,确认键名再写解析逻辑。
5. 实测中踩过的坑与排查速查表
5.1 历史变量找不到或Prompt报错
这是Memory相关报错里最常见的。现象是运行时报“输入变量缺失”或“Loaded memory has invalid keys”。排查路径基本固定:
第一,确认Memory的memory_key与Prompt模板里的变量名一致。比如Memory设了memory_key="chat_history",Prompt里写的是{history},那肯定对不上。第二,确认你自定义PromptTemplate时,input_variables只放真正的用户输入变量,像history这种由Memory注入的变量要用partial_variables占位。第三,如果你用了return_messages=True,那就意味着历史变量是消息对象列表而不是字符串,你的Prompt必须用MessagesPlaceholder来接收,不能简单插到模板字符串里。
这个坑并不难避开,但一旦出问题,报错信息往往比较迷惑,因为它是在链组装阶段爆出来的,不会直接告诉你“历史变量没绑定上”。所以我的习惯是:先跑一个不带Memory的最小Chain,确认Prompt本身没问题,再挂上Memory,遇到报错就能快速定位是不是变量绑定的问题。
5.2 Token超限与成本失控
使用ConversationBufferMemory时最容易遇到这个问题:刚开始聊得挺好,聊到二十轮以后,突然收到一条超长报错,说输入Token超出了模型上下文上限。原因很简单,全量历史一股脑全进Prompt,总有一天会爆。
成本失控是另一个容易被忽略的点。ConversationSummaryMemory虽然解决了上下文长度问题,但它每一轮都要额外调用一次LLM去更新摘要,摘要的Token消耗是持续叠加的。我实测过一个典型场景:一个客服对话demo,用户聊了十分钟,摘要记忆产生的Token消耗比正常问答本身还高出一大截,账单直接翻倍。
所以我的建议是:先预估你的核心场景大概会聊多少轮,每轮大概多长,再用ConversationTokenBufferMemory或ConversationSummaryBufferMemory把总量控住。不要一上来就全量Buffer,省得后面返工。
5.3 多轮记忆串场与敏感信息残留
前面提到session_id隔离问题,这里再展开强调一下。Memory如果不按用户维度做隔离,就会出现严重的“串场”事故:一个用户的历史被另一个用户看到。凡是产品化部署,必须做到一个用户一套Memory(或一套ChatMessageHistory),并在调用时显式传入用户标识。
敏感信息残留是另一个容易忽视的点。用户可能会在对话中说出手机号、身份证、家庭住址等信息,如果这些都被完整存进Memory并长期保留,本身就是安全风险。我在自己的项目里做过一次“脱敏抽查”,发现历史里躺着用户明文手机号。后来我们的做法是:Memory落地前过一层脱敏逻辑,能不带原文的尽量转化成“用户手机号已登记”这类占位描述。这个工作看起来绕,但在合规和用户体验上都非常必要。
5.4 异步场景的Memory失效
如果你用的是async版本的Chain,比如chain.arun(...),那就要注意Memory的读写是否也走了异步路径。LangChain的BaseMemory提供了aload_memory_variables和asave_context这样带a前缀的异步方法,但在一些链里,异步执行时并不一定正确地调用到Memory的异步方法,或者因为你的自定义Memory只实现了同步方法,导致异步调用时历史读不到、存不上。
我自己排查过的一个诡异现象是:同步调用一切正常,换成异步调用后,模型总是不记得前面的话,最后发现是自定义Memory没有实现异步方法,而异步链默认走异步读取,等于每次读到的历史都是空的。解决方案是把load_memory_variables和save_context的异步版本一起实现。这里给一个经验结论:只要你的服务可能走异步路径,就默认把同步和异步两个版本都写上,别等出了问题再补。
最后再分享一个我个人的习惯:选Memory类型时,我会先写成可配置的,而不是写死在代码里。把memory_type做成一个配置项,上线前跑几组模拟对话,分别看响应效果、Token消耗和延迟,再决定用哪种。因为Memory这个东西,没有绝对最好的方案,只有最适合你场景的方案。不同业务对“记得多少”“忘掉什么”“花多少成本”的优先级完全不同,这个权衡,最好别只靠拍脑袋。