☰
LangChain Memory记忆机制实战:从无状态到多轮对话的完整指南
2026/10/22 9:10:01 网站建设 项目流程

我最早做聊天机器人时,最怕用户来一句"你刚刚不是已经说过了吗",我只能让用户把背景重新交代一遍。后来我才意识到,这不是模型不行,而是应用的Memory 记忆机制没搭好。LLM 本身是无状态的,每次请求在模型眼里都是第一次见面,LangChain 里的 Memory 模块,解决的正是把"第一次见面"变成"老熟人"这件事。这篇内容我打算把用 LangChain 做记忆机制的完整思路、代码样例和踩坑记录都写出来,适合正在做智能客服、私人助手、知识库问答这类应用的开发者参考,也适合那些刚接触 LLM 工程的初学者用来建立记忆体系的概念框架。

1. 先把"记忆"这个词拆开:AI 的记忆不只是聊天记录

1.1 没有记忆的 AI,体验为什么像"金鱼"

做过对话应用的开发者基本都体会过那种尴尬:用户花了大段文字交代了自己的身份、需求和偏好,结果模型在下一轮回复里完全当成陌生人处理。比如用户说"我的订单号是 A12345,帮我查一下物流状态",AI 回复了物流信息,用户再追问"那如果我要改收件地址呢",AI 可能反问"请问您的订单号是多少"。

这个问题的根不在模型能力,而在于应用层没有做上下文管理。大多数大模型 API 的调用是纯粹的"输入一段文本,输出一段文本",模型自身不保存任何跨请求状态。所谓让 AI 记住你,说白了就是我们在应用层把之前的对话、事实、偏好找回来,在每次调用模型之前,把它们重新拼进 Prompt 里。

这也解释了为什么记忆机制看起来简单,但做起来有很多细节。你可以把它理解为两个人聊天时,一方需要不断翻阅聊天记录和对方名片,才能在对话中表现得像"记得你"。翻得太多,速度慢还费钱;翻得太少,又显得健忘。LangChain 的 Memory 相关模块,本质上是帮你把"翻记录"这件事工程化。

1.2 三层记忆模型

我习惯把 AI 的记忆拆成三层,这三层对应不同的存储位置和使用方式。

短期会话记忆(Session Memory)指的是最近几轮对话的原始记录,通常直接塞进 Prompt 的对话历史区域。它解决的是"刚才聊到哪儿了"的问题。由于语言模型对上下文长度有硬性限制,短期记忆不能无限增长,需要配合窗口或裁剪策略。

摘要记忆(Summary Memory)是把早期长对话压缩成一段结构化摘要。当对话超过一定轮数,原始记录太占空间,继续全部塞进上下文既不经济也可能被截断。此时用摘要记忆保留关键信息,比如用户身份、核心诉求、已经确认的结论。摘要记忆解决的是"几个小时后还能不能对上号"的问题。

长期事实记忆(Long-term Memory)一般存在向量数据库、键值存储或关系型数据库里,包括用户画像、历史偏好、过往关键事件。它不追求把每句话都存下来,而是提取出可以复用的"事实"。在每次对话开始前,系统根据当前问题召回相关记忆片段,注入 Prompt。长期记忆解决的是"下次再来 AI 还认不认识你"的问题。

三个层次不是互相替代,而是同时配合。只用短期记忆,对话稍长就会超出上下文窗口;只用摘要记忆,细节会丢;只靠长期记忆,连"刚才"都记不住。实际项目里,三者的使用比例和触发规则需要根据应用场景去调。

1.3 业务场景决定你要做几层

做智能客服时,用户画像和历史订单是长期记忆,最近三到五轮对话是短期记忆,一通会话结束后可能还需要生成一份服务摘要。做知识库问答时,对话历史的比重可以放低,重点是把用户常问的问题、已反馈过的答案存入长期记忆,用来做推荐和追问。做陪伴型聊天或私教类应用时,摘要记忆变得尤其重要,因为这类产品最需要"懂我",而"懂"来自于对长期闲聊内容的持续归纳。

所以别急着写代码,先想清楚产品需要哪几层记忆。经常有人一上来就堆一堆记忆组件,结果 Prompt 里塞满了无用的历史,又贵又慢。记忆的核心不是"存得越多越好",而是"在合适的时候把合适的信息拿出来"。

2. LangChain 内置 Memory 组件:哪些能用,哪些已经过时

2.1 内置记忆类型横向对比

LangChain 从早期版本开始就提供了一批 Memory 类,名字非常直观。我列一个表说明它们的核心逻辑,方便你做选型参考。

组件名称核心逻辑适合场景主要问题
ConversationBufferMemory保存全部对话原始消息短会话、调试阶段无限增长,很快爆上下文和预算
ConversationBufferWindowMemory只保留最近 k 轮原始消息客服闲聊、轻量对话窗口外的关键信息直接丢失
ConversationSummaryMemory持续生成对话摘要长对话、需要保留全局信息摘要本身消耗 token,且细节会失真
ConversationSummaryBufferMemory前段摘要 + 后段原始消息混合长对话同时兼顾细节配置较复杂,摘要更新逻辑要盯紧
ConversationEntityMemory抽取实体并维护实体属性用户画像简单场景实体抽取不稳定,中文场景尤其容易翻车
VectorStoreRetrieverMemory按语义相似度召回历史片段知识问答、长期记忆召回需要额外搭向量库,有召回粒度问题

从实践角度看,ConversationBufferMemory是很多人初学时最先接触的组件,也是最快翻车的组件。它在 Demo 里看着一切正常,一旦对话超过十几轮,每次请求携带的 token 数量就会飞速上涨,接着就是延迟增加、成本增加,甚至模型开始"遗忘"早期信息。ConversationBufferWindowMemory稍微好一点,但窗口截断很粗暴,用户在第 20 轮提到的偏好,到了第 21 轮可能就丢了。

2.2 Token 成本的账必须自己算

我拿一个常见场景来算笔账,你就知道为什么不能无脑用 Buffer 了。假设你的对话平均每轮用户输入 150 token,AI 回复 350 token,合计 500 token。第一次请求的 Prompt 只有当前这轮内容;第十次请求如果带上前 9 轮全部消息,就需要额外携带大约 4500 token 的历史;第五十次请求时,历史部分已经来到 24500 token。

如果只算输入价格,500 token 和 25000 token 的 Prompt 成本差距大概是 50 倍。如果你的应用有几千个日活用户,每人每天聊几十轮,这个差距就是决定项目能不能盈利的量级。所以做记忆机制的第一个原则是:历史消息必须设上限,不能用"全部"当默认策略。要么用窗口截断,要么用摘要压缩,要么两者结合。

2.3 旧版 Memory 类和新版 Runnable API 的取舍

在 LangChain 早期版本里,比较常见的写法是ConversationChain(llm=llm, memory=memory),然后在链内自动维护消息历史。这套 API 在简单场景下很顺,但接入多链路、多工具、复杂 Prompt 模板时就失控了,因为历史消息的注入时机和格式不够透明。

新版本更推荐把消息历史当作 Runnable 流程中的一个外部依赖来管理,典型工具是RunnableWithMessageHistory。它不再隐性地维护记忆,而是让你明确提供"根据 session_id 获取历史对象"的方法,历史消息注入的位置也由你在 Prompt 模板里显式控制。这种设计对调试更友好,也方便把历史存储换成 Redis、数据库或者任何自定义方案。

我的建议是:新项目直接用 Runnable 这套思路,别再抱着旧 Chain 写法不放。旧 Memory 类不是完全不能用,但你得清楚它是怎么注入历史、什么时候清理状态,否则出了问题很难排查。

3. 从代码层面跑通真正的多轮记忆

3.1 用 RunnableWithMessageHistory 搭一个带记忆的对话入口

这里给一个最小可运行示例,整体思路是:先用 ChatPromptTemplate 定义带history占位符的 Prompt,再用 LangChain 的RunnableWithMessageHistory包裹模型链,让每次调用都能自动带上对应 session 的消息历史。

import os from langchain_core.chat_history import InMemoryChatMessageHistory from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_openai import ChatOpenAI model = ChatOpenAI(model="your-model-name", temperature=0.3) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一位有记忆能力的助手,请利用对话历史来理解用户背景,并自然作答。"), MessagesPlaceholder(variable_name="history"), ("human", "{input}"), ]) chain = prompt | model history_store = {} def get_session_history(session_id: str): if session_id not in history_store: history_store[session_id] = InMemoryChatMessageHistory() return history_store[session_id] conversation = RunnableWithMessageHistory( runnable=chain, get_session_history=get_session_history, input_messages_key="input", history_messages_key="history", ) result = conversation.invoke( {"input": "我叫小王,最近在研究记忆机制"}, config={"configurable": {"session_id": "user-001"}}, ) print(result.content)

这段代码里最关键的是get_session_history函数。每次调用时框架会拿着session_id找到对应的历史对象,然后自动把消息序列填充到history占位符中。第一次调用时历史为空,第二次开始模型就能看到上一轮的对话了。

需要留意的是history_store这个字典只是演示用,它放在进程内存里,服务一重启全没了。多实例部署时也不能这么写,否则不同机器上的用户会拿到不同的历史。生产环境至少要换成 Redis 这类共享存储。

3.2 session_id 是记忆的"抽屉分隔线"

很多人第一次看到configurable: {"session_id": "..."}会忽略它的重要性。session_id 决定了哪些消息属于同一个会话。它可以是 user_id、设备号、或者"user-时间戳"这样的组合值,关键是不能让不同会话的消息混在一起,否则就是数据串台的严重事故。

实际项目中我通常会把 session_id 设计为多个维度的组合:用户身份、场景类型、客户端实例。比如用户 A 在 App 内和网页端的对话应该分成不同 session,否则两边互相污染。又比如客服坐席场景中,同一个用户可能在多个会话里穿插提问,单靠 user_id 做 session_id 会导致历史混乱。

另外还要注意 ID 的字符安全,session_id 经常出现在日志、数据库索引、URL 参数里,包含特殊字符时可能带来意外问题。我一般建议统一做一层简单归一化,比如格式化为业务标识_用户标识_时间戳。

3.3 限制历史消息体积:窗口裁剪加摘要

对话一长,历史体积必然失控,因此我习惯在入口处做两层控制:第一层是滑动窗口,只保留最近 N 轮原始消息;第二层是把更早的消息压缩成摘要。

最简单的实现是直接在 get_session_history 返回自定义历史类时做窗口截断:

class WindowedHistory: def __init__(self, max_messages: int = 8): self.messages = [] self.max_messages = max_messages def add_message(self, message) -> None: self.messages.append(message) if len(self.messages) > self.max_messages: self.messages.pop(0) def get_session_history(session_id: str): if session_id not in history_store: history_store[session_id] = WindowedHistory(max_messages=8) return history_store[session_id]

这个类每次只保留最近 8 轮消息,超过便从头部丢弃。它的问题是粗暴,如果用户在第 6 轮报了订单号,第 9 轮还在说这个订单,模型就拿不到最初的信息了。所以我一般只在记忆压力不大的场景下单独用窗口,更完整的方案是窗口加摘要混合,把早期关键事实提炼出来放进系统 Prompt。

摘要可以用模型循环对历史做归纳:每次达到窗口上限时,把窗口外的历史喂给模型生成一段摘要,下一次调用时带着前次摘要和新窗口内容。LangChain 自带的 summary buffer 思路也是这个,但你最好自己控制摘要的生成时机,避免每轮调用都触发一次模型总结,白白烧钱。

3.4 把历史存储换到 Redis

进程内存的存储方式只能用于学习和单机 Demo。生产环境里至少要用 Redis 做历史存储,既解决多实例共享问题,也能顺手设置过期时间。

import os from langchain_redis import RedisChatMessageHistory def get_session_history(session_id: str): return RedisChatMessageHistory( session_id=session_id, redis_url=os.getenv("REDIS_URL", "redis://localhost:6379"), ttl=3600, # 1 小时无访问自动过期 )

如果你不想引入额外依赖,也可以自己实现一个历史类,把消息序列化成 JSON 写入数据库,每次读取时反序列化。核心接口只需要两个方法:messages属性和add_message方法,LangChain 的框架层并不会特别要求你必须继承某个基类。对已有系统的侵入程度很小,这是我把历史存储独立出来的原因。

4. 真正"记住用户":三层长期记忆的组合方案

4.1 为什么单个记忆组件撑不起长期记忆

很多人以为把对话历史存进数据库就是长期记忆,其实不对。原始对话记录只能算"日志",不是"记忆"。如果用户三个月回来说"上次你说可以打折,这次怎么不行了",系统得能从一大摞聊天记录里精准找到当时的承诺、原因和条件,这已经不是简单的读取历史能做到的,而是需要检索、归纳和画像维护。

单个组件的问题在于:Buffer 类只解决短时上下文;Summary 类能压缩信息但没有跨会话长期存储;VectorStore 能按语义召回但不保证召回的是关键事实。真正的长期记忆需要一条完整链路:过滤、抽取、存储、召回、更新、遗忘。

4.2 一套可落地的三层架构

我目前用得比较顺的组合方案是这样的:短期保留最近 N 轮原始消息,中期保留按会话生成的摘要,长期存储用户画像和可复用事实。每次用户提问时,系统并行从摘要库和向量库召回相关信息,和最近对话一起拼进 Prompt。

def build_memory_prompt(user_id: str, user_query: str) -> str: recent_messages = recent_session_store.load(user_id, limit=8) profile = user_profile_store.load(user_id) related_memories = memory_retriever.invoke(user_query, top_k=5) memory_text = "\n".join( f"[过往事实] {m.page_content}" for m in related_memories ) prompt = f"用户画像:{profile}\n与当前问题相关的过往事实:\n{memory_text}\n" prompt += f"最近对话:\n{recent_messages}\n" prompt += f"当前问题:{user_query}" return prompt

这段思路的重点是把不同来源的信息分段拼装,而不是一股脑塞在一起。用户画像里放的是稳定信息,比如"用户是学生,偏好性价比商品,上次购买日期是某月某日";过往事实里放的是从历史中抽取出来的片段,比如"用户曾询问过某个型号的保修政策";最近对话则保持原样,让模型知道上下文。

这套架构的好处是每一层都有明确的更新逻辑。会话结束后,用模型对新对话做一次摘要,把新事实合并进用户画像;向量库则定期摄入重要的历史片段。这样既不丢核心信息,又控制了每次 Prompt 的体积。

4.3 实体记忆的翻车现场

LangChain 的ConversationEntityMemory能自动抽取实体并维护属性,听起来很适合做用户画像,但实际用下来坑不少。中文环境下实体抽取对模型依赖很高,同一个"小王"在上下文里可能被识别成多个实体,代词指代很容易张冠李戴。我以前做过一次测试,用户先说"我喜欢看悬疑片",后说"其实我也爱科幻片",实体记忆直接把两个类型都挂在同一个实体上,结果并没有问题;但一旦用户表达的是对某个品牌的负面评价,实体记忆却把"品牌名"和"喜欢"关联在一起,画像就偏了。

所以我的建议是:实体记忆更适合做辅助信号,不要作为用户画像的唯一来源。真正要维护的结构化画像应该由你定义的业务字段决定,比如年龄段、偏好、购买意向,然后通过人工设计的抽取逻辑从对话中提取。把完全不可控的自动抽取结果直接当作事实存储,迟早会给你惹麻烦。

4.4 记忆更新的频率与冲突策略

记忆不是写入后永远不变,用户会改偏好,会换需求,旧信息必须能过期。我在设计用户画像时会为每个字段加一个"最后更新时间"和"置信度"。置信度低的记忆默认不作为强约束条件,只有在后续对话中被多次确认后才升级为高置信状态。

比如用户某天提到"我不吃辣",但两周后又说"今天试了家川菜还不错"。此时系统不应该盲目用新信息覆盖旧信息,而应该记录为"口味变化待确认"。更稳妥的做法是同时保留两条事实和各自的时间戳,让模型在综合判断时考虑时间因素。这个思路和搜索引擎的排序很像,时效性、相关性、可信度三者要平衡。

5. 真实项目里踩过的 Memory 深坑和我的应对习惯

5.1 无限累积导致长聊后 token 暴涨

我最早做某个客服 Demo 时,用了最简单的 Buffer 记忆。前几轮还很正常,聊到 20 轮左右,响应开始变慢,再往后直接报上下文超限错误。排查时发现每次请求的 Prompt 已经从几百 token 涨到两万多 token,整个流程就是带着全部历史在跑。

那次之后我养成了习惯:每次调用模型前,在日志里打一行 token 统计,观察历史消息的体积曲线。一旦发现某个 session 的历史持续增长,就说明裁剪策略失效了。现在我在所有带记忆的链上都默认加窗口限制,即使当时看起来用不到。这个习惯救我很多次。

5.2 多用户"串台":记忆挂到了别人头上

还有一次线上问题更严重:两个测试账号竟然看到了彼此的对话记录。排查到最后,发现是某处代码把 ChatMessageHistory 对象放在了全局变量里,没有按 session_id 分离实例。A 用户和 B 用户共用同一个历史对象,消息互相追加,AI 在回复 B 时能看到 A 的前几轮对话。

这类问题在单机开发时不容易暴露,因为测试用户一般只有一两个。一旦并发量上来,或者在容器里跑多个副本,全局缓存和进程内存的隔离问题会非常明显。现在我要求自己做到:能用共享存储就不用本地变量,必须用本地缓存时也一定按 session_id 做键隔离,并加过期清空机制,防止内存泄漏。

5.3 敏感信息留痕的问题

记忆机制把对话存下来,也意味着把用户说过的所有内容都留了下来。真实场景里,用户可能随口报出手机号、验证码、账号密码、身份证号这类敏感数据。如果这些信息被原封不动写进长期记忆,无论对用户还是对开发者都是一种风险。

我的处理原则是:入库之前先做一轮脱敏和过滤。可以用正则识别常见敏感格式,也可以让模型在生成用户画像摘要时自动忽略敏感片段。比如手机号保留前三位和后两位,中间打星号;验证码、卡号直接不记;对话中的明文密码一律清理。另外,长期记忆存储要严格控制访问权限,不能因为某个接口需要读历史就把整个记忆库暴露出去。

5.4 记忆的"遗忘"与过期策略

长期记忆必须有删除机制,这是很多人忽略的。用户有可能要求清空记忆,也有可能某条记忆过时后产生了误导。我的做法是给每条长期记忆加上过期时间(TTL),同时提供主动清除入口。

在 Prompt 里也可以显式告诉模型:"用户画像信息可能已经过时,如果对话中出现与之矛盾的内容,以当前对话为准。"这句话虽然简单,却能避免模型顶着旧画像硬答。我见过不少应用在用户明确说"我现在不吃辣了"之后,依然因为旧画像而推荐辣味菜品,问题的根不在模型理解能力,而在记忆更新逻辑没有给新信息足够的优先级。

把记忆机制做好,本质上是在做一套信息生命周期管理。存什么、留多久、怎么召回、何时更新、何时遗忘,每一步都要有明确的规则。我在实际项目里最深的体会是:记忆系统不是一个开箱即用的组件,而是需要结合业务认真设计的工程模块。先把这三层架构搭起来,再针对自己的场景去调整细节,你会明显感觉到 AI 应用从"功能可用"到"体验像人"的跨越。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询