先聊一个挺有意思的现象:很多人把 LangChain 用成了“提示词模板库”,觉得它只是把 prompt 拼一拼、调一调模型接口,真正要做带记忆的对话应用时却总是卡住。报错、串号、历史消息丢三落四,最后要么干脆用 Redis 自己存 JSON,要么退回“把聊天记录拼进 system prompt”这种最原始的做法。我自己早期也踩过类似的坑,后来把 LangChain 的执行引擎(LCEL)和里面的持久化状态机制翻了个底朝天,才算真正搞明白“状态提取”这件事到底该怎么做。这篇博文就来拆解一下 LangChain 执行引擎中持久化状态的提取方法,聊聊底层原理、实际选型、完整的代码落地,以及那些文档里不会写、但实战中一定会遇到的坑。
这个内容适合三类人:第一类是刚接触 LangChain 记忆功能、想搞懂 RunnableWithMessageHistory 到底怎么用的人;第二类是已经能跑 demo、但一上生产就碰到并发串号、会话隔离问题的同学;第三类是正在纠结要不要切到 LangGraph 的开发者。看完你至少能搞清楚两件事:LangChain 执行引擎里的“状态”是怎样被提取和注入的,以及如何设计一套能扛住真实业务的持久化方案。
1. 执行引擎里的“状态”到底是个啥
1.1 记忆不只是“多传几轮对话”
很多入门教程会说,记忆就是把之前几轮对话一起发给模型。这句话没有错,但只停留在表面。真正的问题是:每一轮对话之间,程序凭什么知道“上一轮说了什么”? 模型本身是无状态的,它像一个只有短期记忆的陌生人,你每次敲开它的门,它都记不得你是谁。所以需要在外面加一层“外挂记忆”,而这一层记忆就是 LangChain 执行引擎里的状态。
状态这个概念,往细了分有两类:一类是执行状态,比如当前 Agent 执行到第几步、调用了哪个工具、拿到了什么中间结果,这属于“执行引擎的运行时上下文”;另一类是业务状态,比如用户 ID、会话 ID、聊天历史、知识库检索结果,这类状态直接影响模型输出的质量。持久化状态的提取,本质上是把第二类状态从存储介质里拿出来,塞进第一类状态的流转链路中,让每一次执行都能“带着之前的记忆”开始。
我见过很多人把这两类混在一起,导致代码越写越乱。一个清晰的思路是:业务状态负责“记住聊了什么”,执行状态负责“这一步该怎么跑”。在 LangChain 里,RunnableWithMessageHistory 这类封装做的就是两者的桥接。
1.2 LangChain 执行引擎里的状态流转路径
要理解持久化状态,先得理解 LangChain 的执行引擎是怎么跑起来的。LCEL(LangChain Expression Language)是 LangChain 的声明式编程模型,它的核心就是 Runnable。每个 Runnable 都有一个 invoke 方法,输入一个 dict 或消息对象,输出一个结果。Runnable 之间可以用 | 管道符串联,前一个的输出会变成后一个的输入。
在这个管道里,状态是怎么流动的?答案是通过输入字典。比如一个简单的链:
python复制chain = prompt | model | parser
执行 chain.invoke({"question": "你好"}) 时,prompt 需要哪些字段,就从输入字典里取哪些字段。如果你想带上历史消息,就得在输入字典里额外塞一个 chat_history 键,然后让 prompt 的模板去引用它。
这是 LangChain 记忆的精髓:它不自动携带历史,一切都要你自己把历史“提取”出来放进输入字典。 持久化状态的提取,就是这一小步的工程化封装。理解了这个路径,你就不再会问“为什么模型不记得之前的话”,而是会思考“我是从哪里把之前的话捞出来、以什么格式塞回去的”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 持久化方案选型:为什么最后选了它
2.1 三类常见方案对比
“存历史消息”这件事,网上方法很多,我大致归成三类,踩过一轮之后才确定哪种最适合 LangChain 的执行引擎。
| 方案 | 基本思路 | 优点 | 缺点 |
|---|---|---|---|
| 手动拼进 Prompt | 自己把历史消息 JSON 化,存 Redis/MySQL,每次请求先查再拼 | 灵活、不依赖框架 | 容易串号、格式容易错、并发时很痛苦 |
| 使用 LangChain 内置记忆类 | 用 ConversationBufferMemory 等组件管理历史 | 和链集成度高、代码量少 | 版本迭代快、抽象黑盒、调试困难 |
| 使用 RunnableWithMessageHistory | 基于 Session 的持久化封装,自动提取历史 | 和 LCEL 天然契合、接口清晰 | 需要自己写 SessionHistory 的实现类 |
我最后选的是 RunnableWithMessageHistory,原因有两点。第一,它本身就嵌在 LangChain 的执行引擎里,和 LCEL 管道是无缝衔接的,不需要在业务代码里手动处理历史提取,这能避免很多低级错误。第二,它给了足够的自由,存储后端可以自己定义,想存内存、文件、Redis 还是数据库都可以。
2.2 从 LCEL 看执行单元的边界
再往深一层说,选 RunnableWithMessageHistory 还有一个很重要的考量,就是执行单元的边界。在 LCEL 里,一个 Runnable 链是一个完整的执行单元,输入进去、输出出来,中间过程对外部不可见。如果你把“历史提取”塞在链内部,比如在 prompt 模板里调用某个函数去查数据库,那链就变成一个有副作用、不可测试的“脏”单元,不仅没法复用,排查问题的时候也会很痛苦。
RunnableWithMessageHistory 的做法是把“提取历史”放在链的外面,作为 Runnable 的一种包装器。执行单元内部仍然是纯净的:拿到输入的 question,往里塞 chat_history,走 prompt 和 model。历史的加载和保存由包装器负责,链本身不需要关心存储细节。这个设计非常符合“关注点分离”的原则,也让整个执行引擎的状态流转路径变得清晰。
提示:如果以后纠结某个状态到底该放链内还是链外,记住一条经验——能不能被当成纯函数来测,如果能,就放进链内;如果必须依赖外部 IO,就放链外。 用这个标准去判断,绝大多数设计问题都能想明白。
3. 核心细节:RunnableWithMessageHistory 的完整拆解
3.1 拿到历史会话时的关键四要素
第一次用 RunnableWithMessageHistory 的时候,感觉它像一个黑盒子,配置起来照着文档复制就行,但一报错就完全不知道怎么排查。后来我一步步去翻源码,才搞清楚它运作时依赖四个关键要素。
第一个是 get_session_history 回调函数。这是一个由你实现的函数,接收一个 session_id,返回一个 BaseChatMessageHistory 对象。RunnableWithMessageHistory 每次会执行链之前都会调用这个函数,把当前会话的历史捞出来,然后注入到链的输入里。
第二个是 input_messages_key。它用来告诉包装器,输入 dict 里哪个键是你要发给模型的“用户新消息”。比如输入是 {"question": "你好"},那这个参数就填 "question"。
第三个是 history_messages_key。它指定的是“历史消息”要放进输入 dict 的哪个键。默认情况下 LangChain 的 prompt 模板里通常都有一个 chat_history 字段,所以这个参数一般填 "chat_history"。
第四个是 session_id 的传递。它有两种方式:如果你的输入 dict 里本身就带着 session_id 字段,包装器会自动读取;也可以用 config={"configurable": {"session_id": "xxx"}} 这种方式传。我第一次用的时候就是没搞懂参数传递的方式,结果一直报 session_id 找不到的错误。
这四个要素组合起来,就是持久化状态提取的核心机制:通过 session_id 定位存储位置,通过 get_session_history 加载历史,通过 key 配置把历史注入到输入 dict,链内 prompt 模板直接使用。
3.2 写一个可复用的持久化提取函数
理解了机制,写代码就顺理成章了。关键是把 get_session_history 实现得健壮一点,不要在生产环境里用默认的内存版,因为内存版在服务重启后数据就全没了,而且多个 worker 进程之间不共享。
下面是一个基于文件的简单实现,适合本地开发和单机小规模部署:
python复制import json
import os
from pathlib import Path
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.messages import HumanMessage, AIMessage
from typing import List, Dict
class FileChatMessageHistory(BaseChatMessageHistory):
"""基于 JSON 文件的聊天历史存储"""
def __init__(self, file_path: str):
self.file_path = Path(file_path)
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self._messages: List[Dict] = []
self._load()
def _load(self):
if self.file_path.exists():
with open(self.file_path, "r", encoding="utf-8") as f:
data = json.load(f)
self._messages = data.get("messages", [])
def _save(self):
with open(self.file_path, "w", encoding="utf-8") as f:
json.dump({"messages": self._messages}, f, ensure_ascii=False, indent=2)
@property
def messages(self):
# 返回标准消息对象列表
result = []
for msg in self._messages:
if msg["type"] == "human":
result.append(HumanMessage(content=msg["content"]))
elif msg["type"] == "ai":
result.append(AIMessage(content=msg["content"]))
return result
def add_message(self, message):
msg_type = "human" if message.type == "human" else "ai"
self._messages.append({"type": msg_type, "content": message.content})
self._save()
def clear(self):
self._messages = []
self._save()
def get_session_history(session_id: str) -> BaseChatMessageHistory:
"""根据 session_id 提取持久化历史"""
storage_dir = os.getenv("CHAT_HISTORY_DIR", "./chat_history")
return FileChatMessageHistory(os.path.join(storage_dir, f"{session_id}.json"))
这段代码里的核心思路是:把历史消息先序列化成 JSON 存文件,读的时候再反序列化成 LangChain 的消息对象。这样既满足了持久化的需求,又保持了对 LangChain 执行引擎的透明性,prompt 模板拿到的依然是标准的消息列表。
4. 实操过程:完整的记忆持久化实现
4.1 环境准备与初始化
动手之前先把依赖装好。我的建议是直接装最新稳定版,避免老版本里接口不一致的问题。用 Python 3.10 及以上环境执行:
bash复制pip install langchain langchain-openai langchain-core
这里我用的模型是 OpenAI 兼容接口,因为很多开源模型或者国内大模型平台都提供兼容端点,一套代码可以到处切。初始化模型客户端时,把 base_url 配到你的模型网关就行:
python复制from langchain_openai import ChatOpenAI
model = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.7,
base_url="https://your-api-endpoint/v1",
api_key="your-api-key"
)
如果你是本地起服务做测试,也可以先把 api_key 随便填一个占位符,等真正接服务商的时候再替换。这一步的意图是先把链路跑通,别在密钥和网络配置上浪费太多时间。
4.2 完整链路的搭建与运行效果
现在把前面讲的各个部分拼起来。这里我以一个客服问答机器人为例,场景是:用户连续提问,系统需要记得用户之前问过什么、自己回答过什么。
python复制from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
# 1. 定义 prompt 模板
prompt = ChatPromptTemplate.from_messages([
("system", "你是客服助手,请基于已有对话上下文,友好、准确地回答问题。"),
MessagesPlaceholder(variable_name="chat_history"),
("human", "{question}"),
])
# 2. 构建 LCEL 管道
chain = prompt | model
# 3. 用 RunnableWithMessageHistory 包装
chain_with_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="question",
history_messages_key="chat_history",
)
# 4. 调用时传入 session_id
def chat_with_session(session_id: str, question: str):
result = chain_with_history.invoke(
{"question": question},
config={"configurable": {"session_id": session_id}}
)
return result.content
# 测试
print(chat_with_session("user-001", "你好,我的订单一直显示发货中,是什么情况?"))
print(chat_with_session("user-001", "大概要多久才能送到?"))
print(chat_with_session("user-001", "我问的是刚才那个订单,多久可以送到?"))
跑完这三轮,你会发现第二轮“大概要多久才能送到?”如果不带上下文,模型是会懵的,不知道你问的是哪个订单;但配上 RunnableWithMessageHistory 之后,它会自动从上文里提取“发货中的订单”这个实体,然后给出针对性回答。这就是持久化状态提取的价值:它把一次性的“问答”变成了连续的“对话”。
这个链路的执行过程可以这样理解:每次调用时,包装器通过 session_id 找到 user-001 对应的历史文件,把里面的消息转换成 chat_history 列表,和当前 question 一起注入 prompt。模型拿到完整的上下文之后输出回答,包装器再把这个回答以 AI 消息的形式追加到历史文件里。整个流程不需要你在业务代码里手动维护任何历史消息变量。
注意:在实际业务里,session_id 不能简单用用户 ID 一个字段,最好把租户、渠道、会话场景一起拼进去。比如
tenant:channel:user:conversation这种格式,否则不同渠道的会话会互相污染。这块我在后面“常见问题”里会细讲。
5. 常见问题与排查技巧实录
5.1 状态串号:多个会话互相污染
这是上生产之后最容易遇到、也最头疼的问题。表现是用户 A 提问,模型回答里带着用户 B 的历史信息。出现这种问题,99% 的原因是 session_id 传递得不对。
我排查的时候通常按这个顺序来:
- 先看调用端有没有把 config 里的 session_id 传准。
- 再看 get_session_history 是不是对每个 session_id 都生成了独立的存储实例。
- 最后确认存储后端是不是多实例共享的。
一个常见的伪代码级错误是:把 session_id 写死在函数里,或者用一个全局 dict 存 SessionHistory,这样即便你传入不同的 session_id,拿到的也是同一个历史对象。这个坑我用过一个非常简单的验证方法:连续开两个不同 session_id 的会话,问同一个问题,看输出是否相互影响。 如果相互影响,那基本就是存储实例没有按 session_id 隔离。
另一个容易忽略的点是多进程环境下内存存储不共享。比如你用 gunicorn 起 4 个 worker,每个 worker 进程里都有一份内存历史。同一个用户第一次请求打到 worker 1,第二次打到 worker 2,内存里压根没有对方的数据,看起来就像是“记忆被随机清除”。这种场景必须用 Redis 或数据库这种集中式存储。
5.2 历史消息格式与 Token 膨胀
LangChain 的消息对象有严格的类型,HumanMessage 和 AIMessage 不能混用。如果你往历史里塞了字符串,或者把 OpenAI 的 message dict 直接丢进去,执行引擎在拼 prompt 的时候会报错。这个一般看异常栈就能定位,真正让人头疼的是 Token 膨胀问题。
Token 膨胀是指:会话越长,历史消息越多,每次请求发给模型的 token 就越大。到后面不仅响应变慢,费用也会直线上升,甚至超出模型的上下文窗口。持久化状态提取在这里只负责“把历史都捞出来”,但它并没有帮你过滤“哪些历史值得捞”。
我的经验是分两级处理。第一级是数量截断:只保留最近 N 条消息,比如 20 条,超出部分从最早的消息开始丢弃。第二级是内容压缩:如果消息特别长,可以用 LLM 把小模型把历史摘要成一段话,只把摘要和最近几条原话一起发给模型。这套方案适合客服、问答这类对近期信息敏感、对早期信息只需要概况的场景。
实现数量截断时要注意一点,不要把历史在存储的时候就截断了,尽量在 get_session_history 返回时按需截断。原因是存储尽量保留原始完整数据,将来做数据分析、模型微调都还有用;而给模型看的上下文可以灵活裁剪。这个思路我踩过坑之后才固定下来,之前直接在写入时截断,后面想溯源就傻眼了。
5.3 并发写入的数据安全性
文件存储方案在并发量上来之后会有一个隐藏问题:多个请求同时往同一个文件里写消息,会导致数据互相覆盖甚至文件损坏。本地测试时感觉不到,因为 QPS 很低;真实业务一压测就露馅。
解决思路有两个。第一,如果并发量不大(低于每秒几次),可以用文件锁或者简单的加锁机制保证同一 session 的写入串行化。第二,如果并发量高或者需要水平扩展,直接把存储换成 Redis:
python复制from redis import Redis
from langchain_redis import RedisChatMessageHistory
redis_client = Redis.from_url("redis://localhost:6379/0")
def get_session_history(session_id: str):
return RedisChatMessageHistory(
session_id,
redis=redis_client,
ttl=86400 * 7, # 7 天过期
)
Redis 版的 BaseChatMessageHistory 会直接以 Redis 列表的数据结构存储消息,读写性能远高过文件 IO,而且天然支持多进程共享。ttl 参数还可以设置会话过期时间,避免存储无限膨胀。
经验之谈:生产环境直接上 Redis,别在文件存储上浪费太多优化时间。文件方案适合本地开发、调试思路,但撑不起真实业务。存储层从第一天就要按“可替换”的标准来设计,这样后面切换成本会低很多。
6. 从 LangChain 到 LangGraph:持久化状态的下一个形态
6.1 两者在状态管理上的根本区别
聊到这里,估计有同学会想到最近很热的 LangGraph。很多人问 LangChain 和 LangGraph 到底有什么区别,其实从执行引擎的角度看,区别非常清晰:LangChain 的执行引擎是“线性管道”,状态只能从前往后流;LangGraph 的执行引擎是“图”,状态可以在任意节点之间流转,还支持循环。
这意味着 LangChain 的持久化状态提取,本质上是在“链外”做文章——通过包装器把历史塞回管道入口,属于一种相对轻量的方案。而 LangGraph 把状态揉进了执行引擎本身,它有一个显式的 StateGraph,所有节点共享一个状态对象,并且支持 checkpoint 机制,把每一步的状态都持久化下来。
用 LangGraph 之后,“追溯历史”这件事变成了引擎的默认能力,不需要你像 LangChain 里那样手动实现 get_session_history,也不需要关心历史消息是怎么灌回 prompt 的。只要配置好 checkpointer,图执行到一半挂了都能恢复,这在多轮 Agent 任务中特别有用。
6.2 什么时候值得迁移
虽然 LangGraph 听起来更强大,但并不是所有场景都值得立刻迁移。我的建议是:如果你的业务主要是“单轮问答 + 对话记忆”,那 LangChain 的 RunnableWithMessageHistory 完全够用,代码简单、排障容易,没必要为了追新把架构搞复杂。但如果你的场景是“多步 Agent 任务 + 中间状态需要恢复 + 有分支跳转”,比如一个需要多次调用工具、根据中间结果决定下一步的复杂 Agent,那 LangGraph 的状态模型会省心非常多。
我自己的一个感觉是:LangChain 像流程代码里的函数调用,你明确知道每一步干什么;LangGraph 更像一个状态机,它的边界、循环、跳转都是显式的。选型没有绝对的好坏,核心看你的状态变更复杂度。一旦状态变更超过三四个节点,LangChain 的线性模型会越来越别扭,这时候就是迁往 LangGraph 的信号。
6.3 个人体会:先把“持久化状态”这件事理解透
说到底,不管是 LangChain 还是 LangGraph,最核心的能力要求是你对“状态从哪来、往哪去、怎么存、怎么取”有一个清晰认知。框架只是工具,如果对执行引擎的状态流转路径理解不透彻,换到任何框架都会踩到一样的坑。
我个人在实际开发里的一个体会是:动手写代码之前,先画一张状态流转草图——哪个环节产生状态,哪个环节消费状态,哪个环节负责持久化。不需要什么专业工具,纸笔或者白板都行。把这张图画清楚之后,用 LangChain 还是 LangGraph 其实就是个填空题。
最后再分享一个小技巧:给 get_session_history 加上日志打印。 别小看这个操作,它能让你在联调时直观看到每次请求到底提取了多少条历史、历史消息的内容是什么。这比对着错误栈猜半天要高效得多。日志格式可以参考这样:
python复制def get_session_history(session_id: str):
print(f"[SESSION] {session_id} 开始提取历史...")
history = RedisChatMessageHistory(session_id, redis=redis_client)
print(f"[SESSION] {session_id} 提取到 {len(history.messages)} 条历史消息")
return history
日志打出来之后,很多诡异的问题一眼就能看出来:是历史没存进去,还是提取的时候把 session_id 搞混了,还是消息格式不对。排查效率能提升好几个档次。这个方法不挑框架,你以后用 LangGraph 也一样适用。
