LangChain 源码剖析(八):对话记忆的 “智能管家“_RunnableWithMessageHistory

每一篇文章都短小精悍,不啰嗦。

一、功能定位:给 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"}}
)

五、设计亮点:优雅解决历史管理的痛点

  1. 非侵入式增强:无需修改原Runnable的代码,通过包装实现历史管理,符合 "开放 - 封闭原则";
  2. 存储与逻辑分离get_session_history负责历史存储,RunnableWithMessageHistory负责逻辑,符合 "单一职责原则";
  3. 格式自适应:自动处理字符串、消息对象、字典等多种输入输出,降低集成成本;
  4. 配置驱动:通过history_factory_config灵活定义会话标识参数,适配不同场景。

总结:多轮对话的 "基础设施"

RunnableWithMessageHistory通过封装 "加载历史 - 合并输入 - 保存历史" 的逻辑,为任意Runnable快速赋能多轮对话能力。它的核心价值在于:让开发者无需关注历史管理的细节,只需专注于对话逻辑本身

无论是简单的内存测试,还是复杂的分布式生产环境,这个组件都能通过配置轻松适配,是构建聊天机器人、智能客服等对话系统的关键基础设施。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值