每一篇文章都短小精悍,不啰嗦。
一、功能定位:给 Runnable 装上 "对话记忆"
在聊天机器人、客服系统等场景中,多轮对话是核心需求 —— 用户不会每次都重复历史信息,系统需要记住之前说过什么。RunnableWithMessageHistory
就是为解决这个问题而生的组件:它像一个 "智能管家",给原本只能处理单次输入的Runnable
(如大模型调用链)装上 "记忆功能",自动管理对话历史的加载、合并和保存。
核心价值:
- 自动维护上下文:无需手动拼接历史消息,系统会自动将当前输入与历史对话合并
- 适配多种存储:支持任意
BaseChatMessageHistory
实现(内存、Redis、数据库等),灵活应对开发 / 生产环境 - 兼容多种输入输出格式:无论是纯消息列表、带消息的字典,还是字符串,都能正确处理
生活类比:
就像你去咖啡店点单,店员(RunnableWithMessageHistory
)会记住你之前的喜好(历史消息):
- 第一次你说 "要一杯拿铁"(输入),店员记下 "顾客要拿铁"(保存历史);
- 第二次你说 "再来一杯"(当前输入),店员会自动理解为 "再来一杯拿铁"(合并历史)。
二、核心架构:四层协作的 "记忆管理系统"
1. 继承关系:
class RunnableWithMessageHistory(RunnableBindingBase):
继承RunnableBindingBase
,因此具备 "包装其他 Runnable 并增强其功能" 的能力(类似之前讲的 "装饰器模式")。
2. 核心成员变量:
这些变量构成了 "记忆管家" 的核心工具:
成员变量 | 作用 | 类比 |
---|---|---|
get_session_history | 函数:根据会话标识(如 session_id)获取对应的消息历史存储 | 钥匙:打开对应顾客的 "记忆抽屉" |
input_messages_key | 输入字典中,当前消息所在的键(如 "question") | 标签:标记当前输入在字典中的位置 |
output_messages_key | 输出字典中,模型回复所在的键(如 "answer") | 标签:标记回复在字典中的位置 |
history_messages_key | 输入字典中,历史消息应填充的键(如 "history") | 标签:标记历史消息应放在输入的哪个位置 |
history_factory_config | 会话标识的配置规范(如需要哪些参数才能找到会话,如 user_id+conv_id) | 钥匙规格:规定需要哪些信息才能打开 "记忆抽屉" |
3. 架构流程图:
用户输入 → 加载历史消息(_enter_history) → 合并历史与当前输入 → 调用原Runnable → 保存新消息到历史(_exit_history) → 返回结果
↑ ↑ ↑
└─ 从get_session_history获取历史 └─ 原Runnable处理合并后的输入 └─ 将当前输入和回复存入历史
三、关键流程:"记忆" 的完整生命周期
以一次invoke
调用为例,看看 "记忆" 是如何工作的:
1. 初始化:打造专属 "记忆管家"
chain_with_history = RunnableWithMessageHistory(
chain, # 原Runnable(如"prompt | llm")
get_session_history=get_by_session_id, # 获取会话历史的函数
input_messages_key="question", # 输入字典中当前消息的键
history_messages_key="history", # 输入字典中历史消息应填充的键
)
- 这里的
chain
是处理单次对话的链(如 "根据问题和历史生成回复"); get_by_session_id
负责根据session_id
找到对应的消息历史存储(如内存中的字典、Redis 等)。
2. 调用前:加载并合并历史(_enter_history)
当调用chain_with_history.invoke(input, config={"configurable": {"session_id": "123"}})
时:
- 第一步:通过
_merge_configs
方法,用session_id=123
调用get_session_history
,拿到该会话的历史消息(如[HumanMessage("你好"), AIMessage("你好!有什么可以帮你?")]
); - 第二步:
_enter_history
将历史消息与当前输入合并。例如,当前输入是{"question": "什么是余弦?"}
,合并后会变成{"question": "什么是余弦?", "history": [历史消息]}
,传给原chain
。
3. 调用中:原 Runnable 处理合并后的输入
原chain
(如prompt | llm
)收到的是包含历史的完整输入,因此能生成结合上下文的回复(如 "余弦是三角函数的一种,之前我们聊到过三角形...")。
4. 调用后:保存新消息到历史(_exit_history)
回复生成后,_exit_history
会自动将当前输入消息(如HumanMessage("什么是余弦?")
)和模型回复(如AIMessage("余弦是...")
)存入历史存储,供下一次调用使用。
四、技术细节:适配复杂场景的 "弹性设计"
1. 输入输出格式的灵活适配
现实中,Runnable
的输入输出格式可能五花八门,这个类通过两个方法解决兼容性问题:
_get_input_messages
:将各种输入格式(字符串、单条消息、消息列表、带消息的字典)统一转换成list[BaseMessage]
。例如:
-
- 字符串
"什么是余弦?"
→ 转成[HumanMessage(content="什么是余弦?")]
; - 字典
{"question": "什么是余弦?"}
→ 提取"question"
对应的值并转换。
- 字符串
-
_get_output_messages
:将各种输出格式(字符串、单条消息、消息列表、带消息的字典)统一转换成list[BaseMessage]
,以便存入历史。例如:- 字符串
"余弦是..."
→ 转成[AIMessage(content="余弦是...")]
; - 字典
{"answer": "余弦是..."}
→ 提取"answer"
对应的值并转换。
- 字符串
2. 会话标识的灵活配置(history_factory_config)
默认情况下,用session_id
作为唯一标识查找会话历史,但实际场景可能需要更复杂的标识(如user_id + conversation_id
)。通过history_factory_config
可以自定义需要的参数:
# 示例:用user_id和conversation_id作为标识
history_factory_config=[
ConfigurableFieldSpec(id="user_id", annotation=str, ...),
ConfigurableFieldSpec(id="conversation_id", annotation=str, ...),
]
此时调用时需传入config={"configurable": {"user_id": "456", "conversation_id": "789"}}
;
_merge_configs
会校验参数是否匹配,并传给get_session_history
获取对应历史。
3. 历史与输入的合并策略
根据history_messages_key
是否设置,有两种合并逻辑:
设置了 history_messages_key(如 "history"):历史消息会单独放在输入字典的该键下(如{"history": [历史], "question": "当前问题"}
),适合原 Runnable 需要显式使用历史的场景(如 prompt 中有{history}
变量);
- 未设置:历史消息会直接拼在当前消息前(如
[历史消息..., current_message]
),适合原 Runnable 直接处理消息列表的场景(如大模型直接接收消息列表)。
四、实际场景:从开发到生产的适配
场景 1:开发环境(内存存储)
用InMemoryChatMessageHistory
快速测试多轮对话:
# 内存存储会话历史
store = {}
def get_session_history(session_id: str) -> BaseChatMessageHistory:
if session_id not in store:
store[session_id] = InMemoryChatMessageHistory()
return store[session_id]
# 构建带历史的链
chain = prompt | llm # prompt含MessagesPlaceholder(variable_name="history")
chain_with_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="question",
history_messages_key="history",
)
# 第一次调用
chain_with_history.invoke({"question": "你好"}, config={"configurable": {"session_id": "1"}})
# 第二次调用(自动带上历史)
chain_with_history.invoke({"question": "还记得我刚才说什么吗?"}, config={"configurable": {"session_id": "1"}})
场景 2:生产环境(Redis 存储)
换成RedisChatMessageHistory
实现持久化:
from langchain_community.chat_message_histories import RedisChatMessageHistory
def get_session_history(session_id: str) -> BaseChatMessageHistory:
return RedisChatMessageHistory(
session_id=session_id,
redis_url="redis://localhost:6379/0", # 连接Redis
)
# 其余代码与开发环境一致,无需修改chain_with_history
场景 3:多参数标识(user_id + conversation_id)
适用于一个用户有多个对话的场景:
def get_session_history(user_id: str, conversation_id: str) -> BaseChatMessageHistory:
key = f"{user_id}:{conversation_id}"
return RedisChatMessageHistory(session_id=key, redis_url="redis://localhost:6379/0")
chain_with_history = RunnableWithMessageHistory(
chain,
get_session_history,
input_messages_key="question",
history_messages_key="history",
history_factory_config=[
ConfigurableFieldSpec(id="user_id", annotation=str, ...),
ConfigurableFieldSpec(id="conversation_id", annotation=str, ...),
],
)
# 调用时需传入两个参数
chain_with_history.invoke(
{"question": "继续刚才的话题"},
config={"configurable": {"user_id": "u123", "conversation_id": "c456"}}
)
五、设计亮点:优雅解决历史管理的痛点
- 非侵入式增强:无需修改原
Runnable
的代码,通过包装实现历史管理,符合 "开放 - 封闭原则"; - 存储与逻辑分离:
get_session_history
负责历史存储,RunnableWithMessageHistory
负责逻辑,符合 "单一职责原则"; - 格式自适应:自动处理字符串、消息对象、字典等多种输入输出,降低集成成本;
- 配置驱动:通过
history_factory_config
灵活定义会话标识参数,适配不同场景。
总结:多轮对话的 "基础设施"
RunnableWithMessageHistory
通过封装 "加载历史 - 合并输入 - 保存历史" 的逻辑,为任意Runnable
快速赋能多轮对话能力。它的核心价值在于:让开发者无需关注历史管理的细节,只需专注于对话逻辑本身。
无论是简单的内存测试,还是复杂的分布式生产环境,这个组件都能通过配置轻松适配,是构建聊天机器人、智能客服等对话系统的关键基础设施。