
1. 先搞清楚一件事多轮对话为什么需要消息管理我最初接触LangChain时以为聊天机器人就是把提示词拼好调一次大模型接口返回答案就完事了。等真做了带上下文的产品才发现多轮对话的消息管理才是决定项目能不能从demo走向实用的关键。大家最容易踩的坑是这么想的既然我调LLM时要带上历史对话那我就在请求前把历史拼到prompt里。投影到代码上就是开一个全局列表每次把用户输入和模型输出append进去下次请求时把这个列表整体传入。这个方案在单机测试时确实能跑但一旦涉及用户隔离、服务重启、会话切换就会暴露出一堆问题列表存在内存里应用一重启全没了所有用户共用一份全局历史A说的话会被B看到session一旦切换历史就找不回来了。我当时要解决的核心需求很简单每个用户有独立的会话每次对话后即使服务重启历史依然能完整保留。在此基础上还希望能继续使用LangChain原生的消息机制而不是自己再造一套和框架脱节的轮子。基于这个目标我最终选择了文件持久化方案也就是给LangChain实现一个自定义的文件消息历史类把每一轮对话实时落盘。这篇文章会完整记录我当时的设计思路、踩坑经历和最终能跑通的代码。适合正在做LangChain多轮对话、RAG问答系统或者想把轻量级记忆从内存升级到持久化的开发者参考。2. 消息管理方案选型为什么不直接用数据库偏偏选文件做技术选型时我第一个想到的方案其实是SQLite。它也是持久化存储关系模型清晰读写也有标准API。后来为什么放弃转而用文件方案核心原因是在LangChain这套框架里消息本身就是一个个对象直接序列化成JSON存文件比拆字段存数据库表要自然得多。2.1 内存方案适合demo不适合任何真实产品纯内存方案就不展开了只提一个必须知道的结论ConversationBufferMemory这类内存记忆只能在单个进程的存活期内生效。如果你用Flask或FastAPI起了多进程服务不同进程之间连内存里的历史都无法共享。我最初用全局列表实现的时候还遇到过更尴尬的情况——测试时开了热重载代码一改服务一重启所有测试会话全部归零前面对话直接断掉。但这不等于内存方案一无是处。它的优点是极其轻量读取没有IO开销适合做单元测试、临时验证prompt效果的场景。我现在的做法是本地联调用内存所谓上了生产要能存下来的场合换成文件或数据库实现。2.2 数据库方案功能强但对轻量场景过重SQLite、Redis、MongoDB都被我试过。SQLite需要维护表结构要把消息角色、内容、时间戳、会话ID拆开建表Redis适合当作缓存但持久化需要额外配置RDB或AOFMongoDB文档模型确实贴合消息这种结构但引入一个完整数据库服务对个人项目或小型内部工具来说成本不小。我承认数据库在并发控制、查询能力上碾压文件方案但LangChain场景里大多数时候查询消息只是按会话ID全量读出连条件查询都很少用。这种使用模式文件方案完全够用还省掉一层依赖。2.3 文件方案的真正优势和LangChain消息模型天然契合选文件方案最根本的原因是LangChain的消息对象本身就可以无损序列化成JSON。LangChain底层提供了_message_to_dict和messages_from_dict这对工具函数前者把消息对象HumanMessage、AIMessage、SystemMessage转换成字典后者把字典还原成消息对象。这意味着我只需要把消息列表转成JSON字符串写入文件读取时再反序列化回来就能完整恢复对话历史不需要手工做字段映射。再加上文件方案肉眼可见的轻量性不需要额外依赖、不需要数据库服务、文件本身就是可读的JSON排查问题可以直接打开看。对于单机部署的LangChain应用、内部知识库问答系统、个人助理机器人这个方案是性价比很高的选择。3. LangChain消息历史机制深度拆解在动手写代码之前需要先理解LangChain里消息历史的两个概念BaseChatMessageHistory和RunnableWithMessageHistory。这两个东西一个负责数据存储一个负责把历史状态接入到调用链路里不搞懂它们代码即使能跑也是瞎蒙。3.1 BaseChatMessageHistory抽象类究竟是干什么的LangChain抽象了所有存消息的类基类就是BaseChatMessageHistory。它定义了几个核心能力messages属性返回消息列表add_message方法追加一条消息clear方法清空历史这几个接口是所有消息历史实现都必须提供的。LangChain官方自带不少实现比如InMemoryChatMessageHistory就是把消息存在列表里。还有针对特定数据库的实现。我写文件持久化版本本质上就是继承这个基类把add_message和messages属性接到文件读写上这样LangChain的其他组件无需感知底层存储方式也无缝复用。这里有个细节值得注意messages属性返回的是list[BaseMessage]也就是LangChain标准消息对象列表不是字典列表也不是字符串。这一步很关键因为后续传入prompt模板时LangChain靠消息对象来判断角色和内容如果传成普通字符串prompt组装就会出错。3.2 消息对象的序列化与反序列化细节我最初图简单想自己把消息搞成字典再存成JSON写了下面这样的逻辑{ type: human, content: 你好 }起初能用但一旦对话里出现AIMessage里的额外字段比如带function call的参数、带tool_call_id或者出现了SystemMessage之外的工具消息这种手写序列化就漏字段了。LangChain的_message_to_dict会把消息的完整字段都保留下来包括type、content、additional_kwargs、tool_call_id等还原时messages_from_dict会按type重建出对应消息类一点都不丢。所以实操中我建议序列化一律用官方工具函数不要自己造格式。这算是我做完整个项目后最想强调的一个点。3.3 RunnableWithMessageHistory怎么把历史串起来理解了存储层再看调用层。LangChain的RunnableWithMessageHistory包装器的作用是在每次invoke时自动完成三件事从历史存储里取出当前会话的旧消息拼接到prompt里把这条链路传给大模型获取结果再把人类输入和AI输出都追加到历史存储中。整个流程对业务代码透明。它有一个核心参数是get_session_history回调接收一个session_id返回一个消息历史对象。正是因为有了这个回调我才可以实现每个会话ID对应一个独立文件的设计实现多用户隔离。4. 完整实现基于文件持久化的历史消息类到这里可以上代码了。我给了两个方案基础版只实现文件读写进阶版加上时间窗口裁剪。先看基础版。4.1 核心代码自定义FileChatMessageHistory类环境准备很简单装好 langchain-core 即可。我的实现如下import json import os from typing import List from langchain_core.chat_history import BaseChatMessageHistory from langchain_core.messages import BaseMessage, _message_to_dict, messages_from_dict class FileChatMessageHistory(BaseChatMessageHistory): def __init__(self, session_id: str, storage_dir: str ./chat_data): self.session_id session_id self.storage_dir storage_dir self.file_path os.path.join(storage_dir, f{session_id}.json) os.makedirs(storage_dir, exist_okTrue) self._load_from_disk() def _load_from_disk(self) - None: if os.path.exists(self.file_path): try: with open(self.file_path, r, encodingutf-8) as f: data json.load(f) self._messages messages_from_dict(data) except (json.JSONDecodeError, KeyError): # 文件损坏时备份后重新开始绝对不要让单条坏数据拖垮整个会话 backup_path self.file_path .corrupt os.rename(self.file_path, backup_path) self._messages [] else: self._messages [] property def messages(self) - List[BaseMessage]: return self._messages def add_message(self, message: BaseMessage) - None: self._messages.append(message) self._save_to_disk() def clear(self) - None: self._messages [] self._save_to_disk() def _save_to_disk(self) - None: data [_message_to_dict(m) for m in self._messages] with open(self.file_path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)解释几个关键点os.makedirs(storage_dir, exist_okTrue)保证存储目录不存在时自动创建避免第一次写入时报FileNotFoundError。序列化用_message_to_dict和messages_from_dict这个之前说过是唯一靠谱的转换方式。还有写文件时机我选择的是每次add_message立即落盘而不是批量攒着写这样即使进程在任意时刻崩溃最多只丢最后一条还在内存中的消息之前的对话全部安全。4.2 接入LangChain调用链路跑通多轮对话存储类写好后接入调用链路就很简单了from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder from langchain_core.runnables.history import RunnableWithMessageHistory from langchain_openai import ChatOpenAI prompt ChatPromptTemplate.from_messages([ (system, 你是一个乐于助人的助手请根据对话历史和用户问题回答。), MessagesPlaceholder(variable_namehistory), (human, {question}), ]) llm ChatOpenAI(modelgpt-4o-mini, temperature0.3) chain prompt | llm def get_session_history(session_id: str) - FileChatMessageHistory: return FileChatMessageHistory(session_id, storage_dir./chat_data) chat_with_history RunnableWithMessageHistory( chain, get_session_history, input_messages_keyquestion, history_messages_keyhistory, ) # 第一次提问 result1 chat_with_history.invoke( {question: 我叫张三我的公司是做智能客服系统的}, config{configurable: {session_id: user_001}}, ) print(result1.content) # 第二次提问模型应该还记得上面的自我介绍 result2 chat_with_history.invoke( {question: 我叫什么我的公司做什么}, config{configurable: {session_id: user_001}}, ) print(result2.content) # 换一个用户提问历史互相独立 result3 chat_with_history.invoke( {question: 我叫什么}, config{configurable: {session_id: user_002}}, ) print(result3.content)这段代码跑通后打开./chat_data/user_001.json能看到完整的对话记录。第二次提问时模型能正确回答你叫张三换用户后又能正确回答我不知道这就说明多轮会话和用户隔离都已经生效了。4.3 时间窗口裁剪解决消息无限膨胀问题基础版有个非常现实的隐患对话轮次一多消息文件会越来越大传给大模型的上下文也会跟着膨胀。一旦超过模型的上下文窗口请求直接报错。所以我在生产版本里加了一个时间窗口裁剪逻辑只保留最近N轮或最近N条消息。class WindowedFileChatMessageHistory(FileChatMessageHistory): def __init__(self, session_id: str, storage_dir: str ./chat_data, window_size: int 20): super().__init__(session_id, storage_dir) self.window_size window_size def add_message(self, message: BaseMessage) - None: self._messages.append(message) if len(self._messages) self.window_size: self._messages self._messages[-self.window_size:] self._save_to_disk()这里的思路是保留最近20条消息超出部分直接丢弃。这里有个取舍意识裁剪方案会丢失早期的关键信息比如用户在一开始设置的偏好。如果这种信息很重要应该配合长期记忆做摘要。4.4 与RAG场景的集成回答多个热词里rag多轮对话怎么设计很多人做RAG时会忽略一个细节RAG的多轮对话除了普通聊天历史还需要处理检索内容的注入方式。我接RAG时的设计是把文件持久化历史作为基础上下文底座RAG检索到的文档片段以额外信息的形式注入prompt然后大模型结合两部分生成答案。伪代码如下所示rag_prompt ChatPromptTemplate.from_messages([ (system, 以下是从知识库检索到的参考资料\n{context}\n请基于资料回答。), MessagesPlaceholder(variable_namehistory), (human, {question}), ]) def build_rag_chain(retriever): def retrieve_and_format(request): docs retriever.invoke(request[question]) context \n\n.join([doc.page_content for doc in docs]) return {context: context, question: request[question]} llm ChatOpenAI(modelgpt-4o-mini) qa_chain ( retrieve_and_format | rag_prompt | llm ) return RunnableWithMessageHistory( qa_chain, get_session_history, input_messages_keyquestion, history_messages_keyhistory, )RAG场景里还有一个常见问题检索器每次都用用户当前的问题去检索但也应该考虑把上几轮的问题也作为检索词的一部分否则它支持哪些功能这种代词式追问会检索不到有效文档。我建议把最近2轮的用户问题拿出来拼接一轮检索查询效果会好很多。核心思路是持久化历史不等于简单的消息流水账它也是RAG检索链路的输入来源。5. 实操心得容易踩的4个坑和排查方法代码部分讲完了下面这部分是我在实际运行中踩过的坑。这些坑如果不在代码里规避平时可能不出现一旦流量上来或者操作频繁就会间歇性发现问题。5.1 并发写入竞态条件文件持久化最大的弱点是并发写同一文件。如果两个请求同时往同一个session的文件写数据就可能出现写入覆盖导致其中一条或几条消息丢失。典型场景是用户在网页上同时点击了两次发送按钮或者浏览器自动重试请求后端就可能并发处理同一个会话。我当时的第一版代码没处理这个问题结果是偶发丢消息。后来加了如下处理import threading thread_lock threading.RLock() def _save_to_disk(self) - None: with thread_lock: data [_message_to_dict(m) for m in self._messages] temp_path self.file_path .tmp with open(temp_path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) os.replace(temp_path, self.file_path)这里做了两件事加线程锁防止同一进程内的并发写入写入时先写临时文件再原子替换避免写一半的时候另一个进程读到损坏文件。5.2 文件损坏与恢复策略我在测试时做过一个暴力实验把某个session的JSON文件用编辑器打开故意删掉几个字符保存。再次调用时代码立刻抛异常。我突然意识到文件持久化如果没有任何容错设计一个文件的小损坏就会导致整个会话彻底不可用。这就是我在4.1的代码里加了损坏备份逻辑的原因读取出错时先把损坏文件重命名为.corrupt后缀再从空消息开始会话。这样用户虽然会丢失历史但至少服务不会中断。如果对数据安全要求更高还可以做定期备份。5.3 消息类型兼容性ToolMessage的序列化陷阱LangChain消息类型比想象中多除了基础的HumanMessage、AIMessage、SystemMessage还有用于工具调用的ToolMessage以及用于链式传递的AIMessageChunk。我在做Agent时发现AIMessage里可能带tool_calls字段必须完整序列化否则还原后Agent就无法继续。官方工具函数_message_to_dict能处理这些但前提是序列化和反序列化要配对使用不能自己手写格式。否则等出了事再来排查会非常痛苦。5.4 上下文窗口限制与文件大小的关系这个问题我在4.3里提过对话无边界增长最终会导致LLM调用超出上下文限制。裁剪是最直接的解法但裁剪方案会丢早期信息。更高级的方案是用摘要代替裁剪对话满N轮后让LLM把之前对话总结成摘要后续只保留摘要加最近N轮。这个方案成本略高但保留了长程关键信息。6. 关于LangGraph同一个问题的新解法写到这里必须提一下LangGraph。很多人在搜索 LangChain和LangGraph的区别、什么时候该用LangGraph我在完成文件持久化项目后也认真研究过这个问题结论很清晰它们不是替代关系而是适用场景不同。LangChain的RunnableWithMessageHistory是线性链路的便捷方案适合用户发消息→组装上下文→LLM回复→存历史这种简单闭环。而LangGraph更擅长有分支、循环、多角色协作的复杂状态流。更重要的是LangGraph内置了checkpointer机制它可以持久化整个状态快照支持断点续跑和状态回滚这就是Agent类应用持久化的更正式解。如果用LangGraph做多轮对话持久化思路不是自定义History类而是配置一个SqliteSaver或MemorySaver作为检查点器LangGraph会自动在每个节点执行后保存状态from langgraph.checkpoint.sqlite import SqliteSaver with SqliteSaver.from_conn_string(:memory:) as checkpointer: graph workflow.compile(checkpointercheckpointer) config {configurable: {thread_id: user_001}} result graph.invoke({messages: [HumanMessage(content你好)]}, config)那么文件持久化方案是不是就过时了我的判断是如果你的需求是轻量、单机、线性对话文件方案更快更简单依赖少代码一眼就能看懂如果要做复杂Agent工作流或者需要分布式部署LangGraph自带的状态持久化机制更值得投入。工具选型最终是权衡问题不是堆新技术的问题。7. 后续还可以怎么扩展做完文件持久化之后我紧接着做了几个使用频率很高的扩展简单记录一下给大家做个参考。第一个改动是存储格式升级。文件JSON在小规模数据下非常好用但会话数量上来以后单独文件多找起来麻烦。我把存储目录按日期分了子目录./chat_data/20250101/user_001.json这样日志清理时按目录批次删除脚本也简单。第二个改动是增加消息检索功能。有时候需要知道用户在这个会话里第一次提到某个关键词是什么时候但在JSON文件里全量扫描不现实。我给每条消息加了一个单调递增的序号和时间戳需要用的时候直接在写文件时同步打印日志或者用轻量SQLite做索引。聊到这份上其实就滑回了数据库方案。第三个改动是缓存加速。文件方案读IO相对慢但对话历史只有在每次请求开始时全量读一次。如果对延迟敏感可以在内存里维护一个LRU缓存只对频繁活跃的session减少磁盘读取。第四个经验是关于安全合规的。消息内容涉及用户隐私持久化时需要注意存储位置权限控制、文件访问日志、敏感信息脱敏。这个没有固定方案但做生产部署一定不能省。我在自己的项目里最终把文件JSON 窗口裁剪 线程锁 原子写这套组合跑了好几个月稳定性完全够用。它不像重型的数据库方案那样体面但胜在逻辑简单、问题好查、部署环境要求低——作为中小型项目的记忆方案我觉得是特别值得推荐的一个起点。如果再让我重新做一遍我会先说这句话先想清楚你的对话系统是线性的、还是带分支的想清楚再选记忆方案。如果只是线性聊天文件持久化已经够了不要过早引入重框架。