1.2 langchain:长期回话记忆

python 复制代码
import os, json
from typing import Sequence

from langchain_community.chat_models import ChatTongyi
from langchain_core.messages import message_to_dict, messages_from_dict, BaseMessage
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import RunnableWithMessageHistory


# message_to_dict:单个消息对象(BaseMessage类实例) -> 字典
# messages_from_dict:[字典、字典...]  -> [消息、消息...]
# AIMessage、HumanMessage、SystemMessage 都是BaseMessage的子类


class FileChatMessageHistory(BaseChatMessageHistory):
    def __init__(self, session_id, storage_path):
        self.session_id = session_id        # 会话id
        self.storage_path = storage_path    # 不同会话id的存储文件,所在的文件夹路径
        # 完整的文件路径
        self.file_path = os.path.join(self.storage_path, self.session_id)

        # 确保文件夹是存在的
        os.makedirs(os.path.dirname(self.file_path), exist_ok=True)

    def add_messages(self, messages: Sequence[BaseMessage]) -> None:
        # Sequence序列 类似list、tuple
        all_messages = list(self.messages)      # 已有的消息列表
        all_messages.extend(messages)           # 新的和已有的融合成一个list

        # 将数据同步写入到本地文件中
        # 类对象写入文件 -> 一堆二进制
        # 为了方便,可以将BaseMessage消息转为字典(借助json模块以json字符串写入文件)
        # 官方message_to_dict:单个消息对象(BaseMessage类实例) -> 字典
        # new_messages = []
        # for message in all_messages:
        #     d = message_to_dict(message)
        #     new_messages.append(d)

        new_messages = [message_to_dict(message) for message in all_messages]
        # 将数据写入文件
        with open(self.file_path, "w", encoding="utf-8") as f:
            json.dump(new_messages, f)

    @property       # @property装饰器将messages方法变成成员属性用
    def messages(self) -> list[BaseMessage]:
        # 当前文件内: list[字典]
        try:
            with open(self.file_path, "r", encoding="utf-8") as f:
                messages_data = json.load(f)    # 返回值就是:list[字典]
                return messages_from_dict(messages_data)
        except FileNotFoundError:
            return []

    def clear(self) -> None:
        with open(self.file_path, "w", encoding="utf-8") as f:
            json.dump([], f)





model = ChatTongyi(model="qwen3-max")
# prompt = PromptTemplate.from_template(
#     "你需要根据会话历史回应用户问题。对话历史:{chat_history},用户提问:{input},请回答"
# )
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "你需要根据会话历史回应用户问题。对话历史:"),
        MessagesPlaceholder("chat_history"),
        ("human", "请回答如下问题:{input}")
    ]
)

str_parser = StrOutputParser()


def print_prompt(full_prompt):
    print("="*20, full_prompt.to_string(), "="*20)
    return full_prompt


base_chain = prompt | print_prompt | model | str_parser

def get_history(session_id):
    return FileChatMessageHistory(session_id, "./chat_history")

# 创建一个新的链,对原有链增强功能:自动附加历史消息
conversation_chain = RunnableWithMessageHistory(
    base_chain,     # 被增强的原有chain
    get_history,    # 通过会话id获取InMemoryChatMessageHistory类对象
    input_messages_key="input",             # 表示用户输入在模板中的占位符
    history_messages_key="chat_history"     # 表示用户输入在模板中的占位符
)


if __name__ == '__main__':
    # 固定格式,添加LangChain的配置,为当前程序配置所属的session_id
    session_config = {
        "configurable": {
            "session_id": "user_001"
        }
    }

    # res = conversation_chain.invoke({"input": "小明有2个猫"}, session_config)
    # print("第1次执行:", res)
    #
    # res = conversation_chain.invoke({"input": "小刚有1只狗"}, session_config)
    # print("第2次执行:", res)

    res = conversation_chain.invoke({"input": "总共有几个宠物"}, session_config)
    print("第3次执行:", res)

这段代码复用了前一个例子的核心逻辑,但将历史存储从内存(InMemoryChatMessageHistory升级为文件持久化(FileChatMessageHistory,使对话历史能够在程序重启后依然保留。


1. 核心变化:自定义 FileChatMessageHistory

python 复制代码
class FileChatMessageHistory(BaseChatMessageHistory):
    def __init__(self, session_id, storage_path):
        self.session_id = session_id
        self.storage_path = storage_path
        self.file_path = os.path.join(self.storage_path, self.session_id)
        os.makedirs(os.path.dirname(self.file_path), exist_ok=True)
  • 每个会话的历史被保存为 单独的一个文件 ,文件名即为 session_id,存放在 storage_path 目录下。
  • 这样,即使程序退出,下次启动时仍能加载之前的历史消息。

关键方法:

  • add_messages :将新消息追加到已有历史中,并使用 message_to_dictBaseMessage 对象转成字典,再以 JSON 格式写入文件。
  • messages(属性) :从文件读取 JSON 数据,用 messages_from_dict 还原为 BaseMessage 对象列表。
  • clear:清空文件(重置历史)。

2. 链的构建与记忆增强

python 复制代码
def get_history(session_id):
    return FileChatMessageHistory(session_id, "./chat_history")

conversation_chain = RunnableWithMessageHistory(
    base_chain,
    get_history,
    input_messages_key="input",
    history_messages_key="chat_history"
)
  • 与之前唯一不同的是 get_history 返回的是 FileChatMessageHistory 实例,而非内存存储。
  • 其余部分(提示模板、模型、解析器)完全一致。

3. 执行情况分析(重点)

python 复制代码
if __name__ == '__main__':
    session_config = {"configurable": {"session_id": "user_001"}}
    # 前两次调用被注释掉了
    res = conversation_chain.invoke({"input": "总共有几个宠物"}, session_config)
    print("第3次执行:", res)

分两种场景:

场景一:程序首次运行(历史文件不存在)
  • FileChatMessageHistory.messages 读取文件时抛出 FileNotFoundError,返回空列表 []

  • 因此 chat_history 占位符为空。

  • 模型收到的提示只有系统消息和当前用户问题,没有历史记录。

  • 结果 :模型无法知道"宠物数量"的上下文,会回复类似:

    复制代码
    您还没有告诉我任何宠物的信息,请问您有多少只宠物呢?
场景二:之前已经运行过前两次调用(历史文件已存在)
  • 文件 ./chat_history/user_001 中已保存了两次对话记录:

    • 用户:小明有2个猫
    • 助手:(记录确认)
    • 用户:小刚有1只狗
    • 助手:(记录确认)
  • 第三次调用时,历史被完整加载,模型能从中提取出"2 只猫 + 1 只狗"。

  • 结果 :模型回答:

    复制代码
    总共有 3 只宠物。

4. 关键技术总结

技术点 说明
自定义 BaseChatMessageHistory 通过继承基类,可以轻松实现任何后端存储(文件、Redis、MySQL 等)。
message_to_dict / messages_from_dict LangChain 提供的序列化工具,确保消息对象的完整还原(包含角色、内容、额外元数据)。
文件持久化 将历史保存为 JSON 文件,方便调试和迁移,适合单机开发环境。
@property 装饰器 messages 方法变成属性访问,保持与 InMemoryChatMessageHistory 一致的接口。
健壮性处理 messages 方法中捕获 FileNotFoundError 并返回空列表,避免程序崩溃。

5. 面试拓展提问

Q:如果生产环境,你会把历史存在哪里?

A :文件存储不适合高并发,我会使用 RedisRedisChatMessageHistory)或 PostgreSQL (配合 AsyncPostgresChatMessageHistory),通过 langchain_community.chat_message_histories 中的官方实现,只需更换 get_history 返回的对象即可,业务代码零改动。

Q:FileChatMessageHistory 存在并发写入问题吗?

A:是的,多线程/多进程同时写入同一文件会导致数据损坏。在单机单进程环境下可行;若需并发,应加锁或改用支持并发的数据库后端。