1. 为什么第六篇要聊记忆管理
写 LangChain 系列写到第六篇,前面的内容大多在讲"怎么把大模型接进来""怎么把提示词组织好""怎么做输出解析"。这些是骨架,但真正把一个 AI 应用从 Demo 推向可用状态,还差一块关键拼图——记忆管理。
你可能有过这种体验:写了个聊天机器人,单轮对话效果不错,但只要用户多问几句,它就"失忆"了。上一轮刚说过"我家猫叫年糕",下一轮问它"年糕几岁了",它一脸茫然。这不是模型不行,而是你的应用根本没把上下文当回事。
LangChain 对记忆这块提供了非常完整的封装,从最简单的缓存式记忆到窗口记忆、摘要记忆、向量检索记忆,甚至组合式记忆,基本覆盖了不同业务场景的需求。但它的记忆模块也是最容易被误用的一部分——很多人直接把ConversationBufferMemory挂上去,结果上下文越攒越长,Token 费用飙升,响应越来越慢,最后还得回头重构。
这篇就把 LangChain 记忆模块从原理到实战完整拆一遍,重点回答三个问题:不同记忆模式分别解决什么问题、怎么组合使用、工程落地有哪些坑。
2. 五种记忆模式的核心原理与选型逻辑
很多人一上来就问"LangChain 用哪种记忆最好",这个问题本身就有问题。记忆方案没有绝对好坏,只有适不适合场景。
2.1 ConversationBufferMemory:最朴素的全量缓存
这是最容易理解的模式——把整个对话历史原封不动地塞进 Prompt。实现逻辑很简单,每轮对话后追加一条记录,下次请求时把全部历史拼接进消息列表。
from langchain.memory import ConversationBufferMemory from langchain.chains import ConversationChain from langchain_openai import ChatOpenAI memory = ConversationBufferMemory(return_messages=True) chain = ConversationChain( llm=ChatOpenAI(), memory=memory, ) chain.invoke({"input": "你好,我叫小明"}) chain.invoke({"input": "我的名字是什么?"})它最大的优点就是零信息丢失,每一轮的内容都原样保留,模型能看到完整的对话脉络。但缺点同样明显:对话一长,Token 消耗线性增长,很快把上下文窗口塞满。实测中连续聊二十轮左右,普通模型的上下文窗口就开始紧张,更别说中间涉及长文档或代码片段。
所以我的建议是:只用于短会话或内部调试,生产环境基本不要直接用。
2.2 ConversationWindowMemory:给记忆加个窗口
窗口记忆本质上是全量缓存的"受限版",它只保留最近 K 轮对话。K 是开发者手动指定的窗口大小,超过的部分直接丢弃。
from langchain.memory import ConversationWindowMemory memory = ConversationWindowMemory( k=6, return_messages=True, )这个方案的思路很直接:短时记忆天然就有衰减特性,人类聊天的参照物也大多是最近几轮。窗口记忆可以严格约束 Token 上限,让每次请求的开销可控可预测。
代价是远距离信息被硬切掉。用户可能在第十轮提到"我在上海工作",聊到第三十轮你问他在哪个城市,它完全想不起来。这种记忆模式适合那些"上下文时效性很强"的场景,比如客服会话里用户刚提交的订单号、当前正在排查的问题,但对需要跨长会话保持关键信息的场景无能为力。
2.3 ConversationSummaryMemory:用摘要替代原文
它的思路是用一个 LLM 定期对历史对话做摘要,用摘要替换原始对话历史,从而压缩 Token。
听上去很美好,实际使用中要注意它是"一次性全部压缩"还是"逐层滚动压缩"。LangChain 默认实现是用摘要链把新对话和历史摘要拼接后再做一次新的摘要,等于每次都在做收敛。
from langchain.memory import ConversationSummaryMemory from langchain_openai import ChatOpenAI memory = ConversationSummaryMemory( llm=ChatOpenAI(), return_messages=True, )在实际项目中,纯摘要模式有很明显的两个痛点:
一是延迟和成本。每一轮对话都需要先调用一次 LLM 做摘要,再调用一次 LLM 做正式回答,等于把调用次数翻倍。对于低延迟要求的场景,这个开销不可接受。
二是信息失真。摘要天然是压缩过程,细节一定会丢。比如用户说过"我住在朝阳区,离公司地铁 40 分钟",摘要里可能只留下"用户住朝阳区",通勤相关细节就丢了。所以摘要模式适合"用户只关心结论性信息"的场景,不适合对细节有要求的场景。
2.4 ConversationSummaryBufferMemory:摘要与缓存混搭
官方文档里我最推荐的一个基础模式就是这个,它本质上是窗口记忆和摘要记忆的组合:最近 N 轮对话保留原文,这部分保证细节不丢;更早的对话压缩成摘要,这部分保证长期关键信息还在。
from langchain.memory import ConversationSummaryBufferMemory from langchain_openai import ChatOpenAI memory = ConversationSummaryBufferMemory( llm=ChatOpenAI(), max_token_limit=2000, return_messages=True, )max_token_limit是触发压缩的阈值。当消息列表的总 Token 数超过这个值,系统会把最早的消息移入摘要区,用 LLM 重新生成一份包含旧摘要的浓缩摘要。
这个模式在绝大多数真实项目中够用了,尤其是聊天类应用。它的核心优势在于:兼顾了近期细节和远期要点,同时 Token 总量可控。
2.5 VectorStoreRetrieverMemory:把记忆做成检索库
严格来说,前面几种模式都属于"全量参与"或"部分参与"式的记忆,模型每次都要处理所有记忆内容。而向量检索记忆是另一种思路:只把相关信息捞回来。
from langchain.memory import VectorStoreRetrieverMemory from langchain_chroma import Chroma from langchain_openai import OpenAIEmbeddings vector_store = Chroma( embedding_function=OpenAIEmbeddings(), collection_name="memory" ) memory = VectorStoreRetrieverMemory( retriever=vector_store.as_retriever(search_kwargs={"k": 4}), return_messages=True, )每条对话记录在写入时被向量化存储,查询请求到来时,先对当前输入做相似度检索,把和当前问题最相关的历史记录取出来注入 Prompt。
官方示例用的是固定向量库存储全局记忆,但生产级应用一般还会给记忆打标签,比如按用户ID隔离、按会话ID隔离、按时间衰减权重。否则不同用户之间的对话会互相污染,检索精度直线下降。这个后面实战环节细说。
3. 实战:客服助手多级记忆的完整设计
记忆方案不能拍脑袋选。拿一个实际项目举例——我做过一个客服问答助手,用户会在这里咨询套餐、报修、查订单。这类场景的特点是:
- 用户可能在一次会话里先查订单,再问退款流程,然后报故障。
- 用户可能隔天回来继续咨询同一个问题。
- 客服需要知道用户当前会话上下文,也最好知道这个用户的历史偏好。
3.1 业务需求拆解
我先把需求拆成了三级记忆:
短期工作记忆:本次会话内最近几轮的原始上下文。用于保持对话一致性和连贯性,比如用户刚说过的订单号、刚刚确认的地址。
长期偏好记忆:跨会话保留。用户是否老客户、常用收货地址、偏好的沟通方式、历史投诉记录。用于个性化服务。
业务事实记忆:用户在这个平台创建过的订单、报修工单、历史发票。这部分不应该靠对话记忆去压缩,而是从业务数据库里检索,是结构化事实。
所以整个记忆架构不是"选一种模式",而是三层结构混合。纯靠 LangChain 自带记忆模块也覆盖不了全部,它解决的是前两层,第三层通常通过检索增强生成来完成。
3.2 基于 SummaryBufferMemory 的短期记忆实现
短期会话记忆我选了ConversationSummaryBufferMemory,理由很直接:客服会话中,用户往往会在最近几轮给出操作性的具体内容,这些信息必须保持原文精度。而前面聊到的"客史背景"信息——比如"我是你们的老客户了"这种背景,被压缩成摘要完全够用。
from langchain.memory import ConversationSummaryBufferMemory from langchain_openai import ChatOpenAI memory = ConversationSummaryBufferMemory( llm=ChatOpenAI(model="gpt-4o-mini", temperature=0), max_token_limit=1500, memory_key="chat_history", return_messages=True, )这里有个设置细节值得展开:memory_key。如果你在 Prompt 里用的变量名是chat_history,这个 key 必须和 Prompt 里的占位符一致,否则内容传不进去。另外return_messages=True表示以消息对象列表的形式返回,而不是纯字符串,这样和 ChatOpenAI 接口的消息格式更兼容。
temperature=0也是我特意设的。摘要用的 LLM 不需要创造性,需要的是稳定、忠实、可复现的压缩结果,温度调高反而容易凭空补出不存在的信息。
3.3 长期记忆的向量化与用户隔离
长期偏好记忆我用的方案是 LangChain 的VectorStoreRetrieverMemory,但做了两层包装:
第一层,在 store 的 metadata 里记录user_id和session_id,检索时强制过滤用户。第二层,实现了一个记忆写入函数,把对话中提取出的"人属性"信息写成结构化条目。
注意这里的"结构化"不是严格意义上的数据库 Schema,而是形如{"key": "shipping_address", "value": "北京朝阳区xx路xx号", "source": "user_said"}的字典条目。逻辑是收到一条用户消息后,先抽取关键信息,再向量化存入向量库。
import uuid from datetime import datetime from langchain_chroma import Chroma from langchain_openai import OpenAIEmbeddings from langchain.schema import Document class UserMemoryStore: def __init__(self, user_id: str): self.user_id = user_id self.store = Chroma( embedding_function=OpenAIEmbeddings(), collection_name="user_memory", ) def add_memory(self, text: str, metadata: dict = None): base_meta = { "user_id": self.user_id, "created_at": datetime.now().isoformat(), } if metadata: base_meta.update(metadata) doc = Document(page_content=text, metadata=base_meta) self.store.add_documents([doc]) def retrieve(self, query: str, k: int = 5): docs = self.store.similarity_search( query, k=k, filter={"user_id": self.user_id}, ) return [d.page_content for d in docs]这层的关键不是 LangChain 语法,而是filter参数。很多人在生产环境踩的第一个坑就是忘了加用户过滤,导致 A 用户的记忆串到 B 用户身上。单独看代码每一步都正确,但整体语义就错了。
3.4 三级记忆如何拼接进 Prompt
记忆模块设计好了,下一步是怎么把它们组合进最终 Prompt。我之前看到不少人的做法是直接把记忆拼进 system prompt 的开头,比如"以下是用户的历史信息:..."。这种做法有问题:历史信息和当前对话混在一起,模型容易把历史当现行指令,还容易在历史中"找到"一些并不适用于当前场景的内容。
我更推荐的做法是分区注入:
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder prompt = ChatPromptTemplate.from_messages([ ("system", """你是一个客服助手。 本次会话内的重要信息(注意保持简洁): {scratchpad} 用户的长期偏好(参考即可): {long_term_memory} 请基于以上信息,结合当前对话内容回答用户问题。"""), MessagesPlaceholder(variable_name="chat_history"), ("human", "{input}"), ])变量分区的作用是让模型清晰地知道:哪些是"必须严格遵守的现状",哪些是"仅供参考的历史背景"。
4. "长上下文"与"精确召回"的平衡艺术
我在实际项目里反复确认过一件事:记忆设计本质上是资源分配。上下文窗口是有限的、Token 成本是真实的、检索精度是需要调试的。如果只想加个记忆功能,半小时能跑通 Demo;但要让它在生产环境里稳定跑几周不出问题,就要处理取舍问题。
4.1 Token 预算的硬约束
任何 AI 应用,Token 预算永远是最现实的问题。你需要在前期就明确"每次请求最多花多少 Token 在历史上"。
比如我用gpt-4o-mini做客服,模型上下文是 128K,但一次请求我不会让历史占用超过 4000 Token。因为 4000 Token 足够覆盖大约 10 到 15 轮客服对话的原文摘要,再多边际收益就明显下降了——用户一般不会关注 20 轮之前的细节。
所以max_token_limit不要按"上下文窗口有多大"来设,要按"业务上有用的历史有多长"来设。窗口 128K 不代表你要填满 128K,服务端处理时间长、费用高、模型注意力还会被稀释。
4.2 向量检索的精度控制
向量检索不是万能的。我用 Chroma 加 OpenAI Embedding 实测下来,语义相近的表达有时会召回风马牛不相及的内容。比如用户问"退款要多久",可能召回的是"退货流程",尽管"退款"和"退货"语义不完全一致。
控制精度有几个实操手段:
- 降低 k 值:默认 k=4 或 5 就够,k 越大噪声越大。
- 加时间衰减:把超过 30 天的记忆自动降权或清理,历史记忆过期后价值极低。
- 加内容类型过滤:比如只召回"用户主动告知的个人信息",过滤掉"用户问候语"等垃圾记忆条目。
我见过一个项目,向量库里存了大量"你好""谢谢"之类的寒暄语,检索时这些内容频繁被召回,白白占用 Token,还带偏了模型的重心。后来加了规则过滤——长度小于 4 个字的记忆不写入,立刻好转。
4.3 摘要与检索的联动
还有一个容易忽略的点:摘要和检索往往是联动的。比如长期记忆走向量化,但一次会话结束后,可以把整场对话的摘要作为一条独立记忆写入向量库。下次用户再来,检索出的不是零散的只言片语,而是一段"上一轮沟通摘要"。
# 会话结束后的收尾逻辑 def close_conversation(session_id: str, transcript: str, user_id: str): summary = summarizer.run(transcript) # 使用摘要链 store = UserMemoryStore(user_id) store.add_memory( text=f"会话摘要({session_id}):{summary}", metadata={"type": "session_summary", "session_id": session_id} )这个设计在后续很多次对话中发挥了关键作用。用户隔了三天再来,一开口提到"上次说的那个问题",模型能通过向量检索把上一轮会话摘要拉出来,瞬间接上话题。
5. 记忆系统的可观测性与调优记录
记忆本身是不可见的——它藏在 Context 里,直接影响模型输出质量,但很难直接"调试"。很多时候你发现模型回答不对,但根本不确定是 Prompt 问题、模型问题、还是记忆注入内容被污染。所以记忆系统的可观测性设计,是工程落地中绝不能省的一环。
5.1 给记忆加日志和回放
我在项目中用的方案是:每次请求都记录一份"当前上下文的快照"。具体来说,把最终发送给模型的消息体完整存下来,包括 system、历史摘要、向量检索结果、用户最新输入。存成 JSON 行日志,方便事后排查。
import json, logging logger = logging.getLogger("memory_audit") def log_memory_context(user_id, prompt_messages): record = { "user_id": user_id, "timestamp": datetime.now().isoformat(), "messages": [ {"role": m.type, "content": m.content} for m in prompt_messages ], } logger.info(json.dumps(record, ensure_ascii=False))有了日志之后,很多问题一下子就看明白了:比如"模型为什么会回答错了?看一眼系统消息里的摘要历史,发现摘要把关键细节压缩掉了","为什么用户说不知道这个信息?看一眼向量检索结果,发现召回的根本不是用户说的那件事"。
5.2 记忆质量监控指标
记忆模块不像模型指标那样有现成的 EVAL 工具,我自己常用的几个粗粒度指标是:
记忆写入量:每轮对话产生多少条记忆记录。如果某段时间写入量暴增,往往说明抽取逻辑出现了重复写入或者把无关内容也存了进去。
检索 Top 命中率:人工抽样看前 k 条检索结果,有多少条是"和用户问题真正相关的"。这个不用全量做,抽 30 条人工看一遍就能发现常见问题。
Token 历史占比:历史 Token 数占整次请求 Token 数的比例。如果这个比例长期超过 70%,说明记忆膨胀严重,要么压缩阈值设得太大,要么窗口太长。
用户反馈率:最终模型的回答是否被用户点踩、是否触发转人工。这个指标滞后但有说服力,如果点踩率上升,优先怀疑记忆注入带来的上下文污染。
5.3 实战中踩过的几个坑
这里整理几个我在 LangChain 记忆模块中踩过的典型问题,也是我后来在代码评审中重点检查的地方。
坑一:多个链共享同一个 memory 对象
LangChain 的ConversationChain会在每轮调用后自动更新 memory。如果你把同一个 memory 传给两个不同的链,它们会互相覆盖对方的对话历史,最终上下文乱套。解决方法是给不同链分配独立 memory,或者用memory.save_context手动管理写入时机。
坑二:历史消息直接塞入 system prompt
ConversationBufferMemory默认return_messages=False时,返回的是字符串拼接。如果你直接把这个字符串塞进 system,格式控制和角色边界就崩了。正确做法是设置return_messages=True,并配合MessagesPlaceholder把历史消息作为独立的消息对象传入。
坑三:摘要记忆的压缩循环里没有排除系统指令
摘要 LLM 在处理输入时,如果我给的 system 指令本身带了大量业务规则,摘要 LLM 会把规则内容也理解成需要摘要的对话内容,导致规则被压缩进"用户历史"。这是个隐蔽问题。解决方法是摘要链的输入里,只拼接对话历史,不要带入系统级 Prompt。
坑四:向量库命名空间冲突
多个环境共用同一个向量库时,如果 collection 名相同,数据会互相污染。我在测试环境吃过这个亏:测试数据跑进了生产库,客服回答里出现了"测试订单 xxxx"的内容。现在强制按环境和用户双重隔离,测试库和生产库物理分离。
坑五:memory_key 不匹配
LangChain 的memory_key默认是history,但很多人在自定义 Prompt 时写的是chat_history。不匹配的时候,LangChain 表面不报错,但模型接收不到历史。这个问题排查起来非常浪费时间,建议一开始就统一命名,并且在代码里显式声明。
5.4 调优经验:从"通用配置"到"业务定制"
最后聊一点调优心得。LangChain 官方示例里每个 memory 模式的参数看起来都很"够用",但你一定不能照搬,要结合业务拆解后再定。
比如ConversationWindowMemory的k值,官方文档用的是 6,但客服机器人可能需要 10,因为用户和客服要来回确认地址、订单号,近 10 轮内可能还没完成一个完整操作。而一个百科问答机器人,用户问完就走,k=3 就够了。
再比如摘要触发的max_token_limit,客服场景 1500 是起步值。如果偏小,每聊几轮就触发一次摘要,摘要本身频繁更新反而损失信息;如果偏大,Token 浪费明显。我的一般经验是:先设一个值,跑两天看日志,统计实际触发的平均消息轮数,再按业务容忍度调整。
6. 从记忆到"主动记忆":一个值得探索的方向
记忆系统的目标不应该只是"记住用户说过的话",更进一步的是"知道该记住什么、该忘掉什么"。我在最近的一个项目中尝试了一种简单的"主动记忆"模式,效果不错,分享给你作为扩展方向。
具体做法是为对话系统加一个独立的后台进程,每隔一段时间扫描向量库中长期无人检索的记忆条目,按时间衰减评分自动归档或删除。这样可以防止记忆库无限膨胀,同时降低检索噪声。
另外,还有一个思路是给记忆条目打"置信度"。比如用户亲口说的地址是高水平置信度,从对话里推断出的职业是中置信度,模型猜出来的内容是低置信度。高置信度条目优先被检索和应用,低置信度条目只在明确需要时才参考。加上这个机制后,客服侧的回答明显更谨慎了,减少了"我猜你是..."这种结论性错误。
LangChain 的记忆模块是一个基础工具箱,它能帮你快速构建记忆能力,但真正的竞争力在于你如何围绕业务去设计记忆的分层、写入、检索与衰减策略。这些设计不是靠模型能力解决的,而是靠工程判断。希望这篇能把你在记忆模块上的试错时间压缩一些,少走几个我走过的弯路。