LangChain.js对话记忆实战:内存存储与文件持久化方案
做 AI 应用做得越久越觉得对话记忆是个绕不开的坎。你写一个问答助手用户问一句“我叫小明”AI 答一句“你好小明”过了几轮用户又问“我叫什么名字”AI 一脸茫然地回一句“我并不知道你的名字”。这不是模型笨而是 LLM 的 API 本身是无状态的——每次调用都是一次全新的请求它根本不知道几分钟前你说了什么。要解决这个问题应用层就得自己搭一套“对话记忆”体系。在 LangChain.js 这条技术栈上对话记忆体系按落盘方式可以拆成三个层次内存存储、文件持久化、数据库/向量检索。这篇文章是系列第一篇先把最直观、也最常用的前两层讲透——基于内存的 BufferMemory 和基于文件的持久化方案。适合刚接触 LangChain.js、被“AI 记不住上下文”困扰的开发者照着文章把 Demo 跑通你对记忆体系的理解会比看十篇文档都扎实。1. 先搞清楚为什么AI应用必须自己管记忆1.1 无状态的API决定了“失忆”是常态大模型的对话能力再强底层业务也是“你把 prompt 发给服务端服务端返回 completion”。模型不会把历次请求的数据存下来也不会主动维护你的用户状态。这意味着从产品角度看AI 应用需要像数据库一样自己记录“这个用户之前聊了什么”并在下一次请求时把相关历史重新包装进 prompt。这个包装动作就是记忆系统的核心职责。LangChain.js 的记忆模块之所以存在就是帮你完成两件事组装历史上下文和维护历史上下文。我见过不少新手直接硬拼字符串把上一轮的 user 输入和 AI 输出用换行符拼进下一轮 prompt。短期 Demo 没问题但一旦涉及多角色消息、工具调用、持久化恢复字符串方案就崩了。LangChain.js 把这件事抽象得很干净你只要理解 Memory 和 ChatMessageHistory 两个概念后续扩展检索、摘要、数据库存储都有清晰路径。1.2 三层记忆模型先分清楚再选技术实践中我会把对话记忆分成三个层级对应不同体量的项目记忆层级载体存活周期典型场景短期内存层BufferMemory / BufferWindowMemory进程运行期间单轮会话内的多轮聊天持久化层文件、Redis、SQLite跨进程重启单用户体验连续性、服务冷启动恢复检索层向量数据库 Embedding长期海量知识库问答、大规模用户长期记忆内存层是地基解决“同一段对话里 AI 能不能接住上下文”的问题持久化层解决“对话隔了几天、服务重启以后 AI 还认不认识你”的问题检索层解决“历史太多塞不进 prompt”的问题。大部分团队其实用前两层就已经比裸调 API 强非常多第三层属于进阶优化别一上来就搞复杂。1.3 为什么先从内存存储和文件持久化入手一个单机部署的客服机器人、一个内部工具助手、一个自己玩的智能体 Demo文件持久化已经完全够用。数据库和向量检索虽然强但对大多数项目属于过度设计——你连内存层都没有跑熟直接上向量库只会被复杂度淹死。所以这个系列的第一篇先把地基打牢。我会用 Node.js 环境实际操作一遍把概念、代码、踩坑放在一起讲。你照着敲一遍比自己读十页文档印象深得多。2. 记忆的容器ChatMessageHistory 与消息角色2.1 记忆不是字符串而是一串结构化的消息很多初学者以为对话记忆就是把历史问答拼成一个长字符串塞进 prompt。这确实是最简单粗暴的做法但 LangChain.js 做得更规矩记忆底层是一个“消息历史容器” ChatMessageHistory里面装的是一系列BaseMessage子类型。LangChain.js 的核心消息角色有四类SystemMessage系统指令通常常驻 prompt告诉模型它是谁、该怎么回答HumanMessage用户输入AIMessage模型回复ToolMessage工具调用结果在 Agent 场景中使用为什么角色这么重要因为最终给 LLM API 发送的本质上就是一个按角色区分内容的messages数组。模型靠角色区分哪些话是用户说的、哪些是自己说的、哪些是系统设置。保存记忆时是什么角色恢复记忆时还得是什么角色。存错了角色模型就分不清上下文归属回答质量会明显下降。2.2 InMemoryChatMessageHistory最底层的内存容器在langchain/core包里有一个非常直白的容器专门负责在内存里放这些消息。import { InMemoryChatMessageHistory } from langchain/core/memory; import { HumanMessage, AIMessage } from langchain/core/messages; const history new InMemoryChatMessageHistory(); await history.addUserMessage(你好我叫小明是一名前端工程师。); await history.addMessage(new AIMessage(你好小明很高兴认识你。)); const messages await history.getMessages(); console.log(messages.length); // 2 console.log(messages[0].getType()); // humanaddUserMessage是便捷方法内部相当于new HumanMessage({ content: ... })。getMessages()返回的就是BaseMessage[]你可以直接把它传给支持 messages 数组的模型调用接口。这个容器本身不直接对接模型它的价值在于给 Memory 类提供“存放历史”的能力。真正要对话时你得自己把 messages 塞进 prompt这就太累了——所以 LangChain.js 在容器之上封装了 Memory 类。2.3 从历史容器到 Memory自动化的关键一步Memory 类解决两个问题第一它知道从哪里读历史内部维护一个chatHistory属性默认就是InMemoryChatMessageHistory实例第二它知道把历史放到 prompt 的哪个变量里这个变量名由memoryKey指定。打个比方ChatMessageHistory是你的仓库Memory 则是仓库管理员。你要什么货历史上下文管理员直接帮你按固定格式搬到 prompt 的指定货架变量上对话结束它又把新货放回仓库。接下来要讲的BufferMemory就是这样一个“自动管家”。3. 内存存储用 BufferMemory 跑通第一条对话记忆3.1 最小可用代码三行配置让AI记住人名BufferMemory的行为非常直白把所有往来的对话消息全部记住每次组装 prompt 时把完整历史原样返回。它最常见的搭档是ConversationChain——一个把 prompt、LLM、记忆串起来的便捷入口。import { ChatOpenAI } from langchain/openai; import { ConversationChain } from langchain/chains; import { BufferMemory } from langchain/memory; const model new ChatOpenAI({ modelName: gpt-4o-mini, temperature: 0, }); const memory new BufferMemory(); const chain new ConversationChain({ llm: model, memory: memory, }); const first await chain.call({ input: 你好我叫小明是一名前端工程师。 }); console.log(first.response); const second await chain.call({ input: 你还记得我做什么工作吗 }); console.log(second.response);第二次调用时ConversationChain会自动把上一轮的用户输入和 AI 回复从 memory 中取出拼接进 prompt。gpt-4o-mini这类模型看到完整历史后就能正确回答“小明是前端工程师”。验证一下记忆是否真的生效把second替换成“我叫什么名字”如果模型能说出“小明”说明内存记忆已经工作。这是你搭的所有记忆系统里最基础也最直观的一条链路。3.2 memoryKey 与 returnMessages两个绕不开的配置BufferMemory有两个参数很容易被忽略但实际开发中逃不掉。第一个是memoryKey。它决定历史内容在 prompt 变量中以什么名字暴露。ConversationChain的默认模板里历史变量叫history所以不设置也能跑通。如果你自己写 prompt 模板就必须让memoryKey和模板变量名一致const memory new BufferMemory({ memoryKey: history, });第二个是returnMessages。默认false时history是一个拼好的字符串适合直接渲染到文本模板里设为true时history变成BaseMessage[]数组适合传给原生支持多角色消息的模型接口。const memory new BufferMemory({ returnMessages: true, memoryKey: history, }); const vars await memory.loadMemoryVariables({}); console.log(Array.isArray(vars.history)); // truereturnMessages: true是通往进阶玩法比如MessagesPlaceholder动态注入历史的入口但入门阶段先用默认字符串模式就好链路更短、更好排查问题。3.3 内存存储的边界要清楚它的代价BufferMemory最大的优点是零成本、零外部依赖、代码直观。但它有两个硬伤。第一进程重启等于失忆。你在终端跑完对话CtrlC 退出再node index.js启动AI 完全不记得刚才聊过什么。原因很简单历史只存在于进程内存里进程没了历史也没了。第二对话越长prompt 越膨胀。BufferMemory会百分百保留全部历史。聊 30 轮后每次请求都要带上 60 条消息token 成本直线上升最终触达模型上下文窗口上限直接报错。所以内存存储适合短会话、原型验证、单用户开发调试。一旦你的应用要长时间运行、要面对真实用户就必须引入下一层的控制手段——窗口滑动或者再下一层的文件持久化。4. 窗口滑动BufferWindowMemory 在长对话中的取舍4.1 为什么不能一直记下去token窗口是硬约束LLM 的 context length 是有限的所有历史消息都会计入 token 消耗。无限增长的记忆必然导致两个问题一是请求超限直接报错二是账单越来越难看。我自己实测过一个简单的问答助手连续聊 40 轮后prompt 里光历史就有上万 token。用gpt-4o-mini还好换更贵的模型费用会先于体验出问题。所以任何生产级记忆方案都必须回答一个问题历史太多时丢掉哪些4.2 BufferWindowMemory只保留最近k轮BufferWindowMemory的答案很朴素只保留最近k轮对话更旧的一律丢弃。这个k是“轮数”而不是“消息条数”每一轮通常包含一条用户消息和一条 AI 回复所以最终保留的消息条数大约是2k条。import { BufferWindowMemory } from langchain/memory; const memory new BufferWindowMemory({ k: 2, memoryKey: history, }); const chain new ConversationChain({ llm: model, memory: memory, }); await chain.call({ input: 第一轮我叫小明 }); await chain.call({ input: 第二轮我住在上海 }); await chain.call({ input: 第三轮我养了一只猫 }); const vars await chain.memory.loadMemoryVariables({}); console.log(vars.history);跑完三轮后再看vars.history你只能看到最近两轮的内容“住在上海”和“养猫”第一轮“我叫小明”已经被丢弃。这个行为可以通过loadMemoryVariables直观验证是排查记忆问题时最常用的调试手段。4.3 两种内存策略的对比维度BufferMemoryBufferWindowMemory保留范围全部历史最近 k 轮适合场景短会话、调试、原型长会话、资源受限主要风险token 超限、费用飙升早期关键信息丢失4.4 k 值怎么选我的经验我自己的习惯是k3~5在客服问答、产品答疑场景表现最好。k1时模型基本失去短期上下文能力k10以上对多数场景有点浪费。如果某些重要信息比如用户姓名、偏好必须在整场对话中保持不要只依赖窗口——下一章的持久化方案才是正解。还有一个细节实际生产里“窗口裁剪”往往是记忆体系的默认第一道闸门因为它代价最小、保留的又是最关键的近期语境。持久化解决“跨会话”窗口解决“本节会话内不爆炸”两者不冲突反而常常组合使用。5. 文件持久化把记忆从进程里搬进磁盘5.1 先复现一个痛点进程一停全忘了用第 3 章的 Demo 跑一段对话输入“我叫小明喜欢吃川菜”AI 正常回应。然后 CtrlC 退出进程重新运行再问“我喜欢吃什么”AI 大概率回答“我不知道我们好像第一次聊天。”这就是纯粹内存记忆的局限。服务重启、代码热更新、断点调试、部署新版本都会摧毁内存里的历史。对真实用户来说“聊着聊着 AI 把我忘了”是最糟糕的体验之一。要让记忆跨过进程边界就必须给它一个落盘通道。5.2 思路让 chatHistory 指向一个会落盘的容器LangChain.js 对记忆持久化的处理很灵活。BufferMemory内部依赖chatHistory属性而这个属性默认是InMemoryChatMessageHistory。只要把它替换成一个“读写文件的消息历史容器”持久化就自动完成了。好消息是 LangChain 提供了BaseListChatMessageHistory这个抽象基类你只需要实现几个方法getMessages()、addMessage()、clear()。剩下的组装逻辑全部由框架处理。5.3 FileChatMessageHistory一个可复用的文件存储类下面这个类是我项目里一直在用的版本代码不长但覆盖了加载、追加、清空、容错四件事import { BaseListChatMessageHistory } from langchain/core/memory; import { HumanMessage, AIMessage, SystemMessage } from langchain/core/messages; import { existsSync, readFileSync, writeFileSync } from node:fs; const MESSAGE_CLASS_MAP { human: HumanMessage, ai: AIMessage, system: SystemMessage, }; export class FileChatMessageHistory extends BaseListChatMessageHistory { constructor(filePath) { super(); this.filePath filePath; this.messages []; this.loadFromFile(); } loadFromFile() { if (!existsSync(this.filePath)) { this.messages []; return; } try { const raw JSON.parse(readFileSync(this.filePath, utf-8)); this.messages raw.map((item) { const MessageClass MESSAGE_CLASS_MAP[item.type] || HumanMessage; return new MessageClass(item.content); }); } catch (err) { console.error(读取记忆文件失败已重置记忆: ${err.message}); this.messages []; } } saveToFile() { const raw this.messages.map((msg) ({ type: msg.getType(), content: msg.content, })); writeFileSync(this.filePath, JSON.stringify(raw, null, 2), utf-8); } async getMessages() { return this.messages; } async addMessage(message) { this.messages.push(message); this.saveToFile(); } async clear() { this.messages []; this.saveToFile(); } }几个设计细节解释一下loadFromFile在构造函数里执行所以new FileChatMessageHistory(a.json)的那一刻历史就已经从磁盘恢复到内存。saveToFile在每次addMessage后调用保证每一条新消息都即时落盘不用等进程退出。解析时用了MESSAGE_CLASS_MAP做角色还原human还原成HumanMessage、ai还原成AIMessage。遇到不认识的角色类型兜底用HumanMessage宁可让它变成普通文本也不能让整个进程崩溃。读取时对 JSON 解析做了 try/catch。记忆文件损坏不应该成为服务启动失败的借口重置成空数组是最好的容错策略。5.4 把文件存储接入 BufferMemory接入过程比想象中简单——把chatHistory参数传进去就行import { BufferMemory } from langchain/memory; import { FileChatMessageHistory } from ./fileHistory.js; const memory new BufferMemory({ chatHistory: new FileChatMessageHistory(./conversation.json), });测试方法分两步。第一步启动一个脚本连续对话输入“我叫小明住在杭州”然后退出进程。第二步重新启动脚本问“我还告诉你什么信息了”如果 AI 能答出“你叫小明住在杭州”说明文件持久化已经生效。此时打开conversation.json你会看到类似这样的内容[ { type: human, content: 你好我叫小明是一名前端工程师。 }, { type: ai, content: 你好小明很高兴认识你。 }, { type: human, content: 你还记得我做什么工作吗 }, { type: ai, content: 你是一名前端工程师。 } ]这个文件就是记忆的实体形态。它对人类可读、方便调试、容易迁移甚至可以直接交给脚本做统计分析。6. 一个完整Demo带记忆的Node.js问答助手6.1 环境准备Node.js 版本建议 18 以上因为后面会用readline/promises低版本不支持。创建项目并安装依赖mkdir langchain-memory-demo cd langchain-memory-demo npm init -y npm install langchain langchain/openai langchain/core设置 API Key。Linux/macOS 用export OPENAI_API_KEYsk-你的keyWindows 用户用set OPENAI_API_KEYsk-你的key6.2 完整的两个文件项目目录下新建fileHistory.js内容就用 5.3 节的FileChatMessageHistory类。再新建index.js内容如下import { ChatOpenAI } from langchain/openai; import { ConversationChain } from langchain/chains; import { BufferMemory } from langchain/memory; import readline from node:readline/promises; import { FileChatMessageHistory } from ./fileHistory.js; const model new ChatOpenAI({ modelName: gpt-4o-mini, temperature: 0, }); const memory new BufferMemory({ chatHistory: new FileChatMessageHistory(./conversation.json), }); const chain new ConversationChain({ llm: model, memory: memory, }); const rl readline.createInterface({ input: process.stdin, output: process.stdout, }); console.log(带记忆的问答助手已启动输入 exit 退出。); while (true) { const input await rl.question(你: ); if (input exit) { break; } const res await chain.call({ input }); console.log(AI: ${res.response}); } rl.close();注意因为每条消息在addMessage时已经写盘所以退出进程前不需要额外“保存”操作。就算你直接 CtrlC 强制终止记忆也已经完整地躺在conversation.json里。6.3 跑起来验证效果第一轮启动你: 我叫小明住在杭州养了一只叫团团的猫 AI: 你好小明很高兴认识你你的猫团团听起来很可爱。 你: exit重新运行node index.js直接问你: 你还记得关于我的哪些信息 AI: 你叫小明住在杭州还养了一只叫团团的猫。到这里一个跨进程存活的记忆系统就完整跑通了。整个过程不需要数据库、不需要向量库就是文件读写加上 LangChain 的抽象。6.4 多用户隔离别让所有人共用一份记忆Demo 里所有对话写进同一个conversation.json真实应用里绝对不能这么干。两个用户同时聊天会互相污染对方的记忆。最简单的方案是按用户区分文件路径。比如给每个用户一个sessionIdfunction createMemoryForUser(userId) { return new BufferMemory({ chatHistory: new FileChatMessageHistory(./memory/${userId}.json), }); }这样每个用户拥有独立的记忆文件互不干扰。文件目录需要提前创建好或者用fs.mkdirSync自动补目录。等用户量级上来再考虑把文件换成 Redis、SQLite 或 Postgres但原理完全一致——都是给“记忆”换一个不同的存储后端。7. 踩坑记录我从这套体系里学到的几件事7.1 并发写入会让文件互相覆盖这是我实际踩过的坑。两个对话请求几乎同时到达A 进程读到文件里只有 1 条消息B 进程也读到只有 1 条消息然后各自追加、各自写盘。后写入的会把先写入的覆盖掉最终文件里只剩 2 条而不是 4 条。Node.js 单进程内解决这个问题不难在FileChatMessageHistory内部维护一个 promise 链让每次写盘串行执行async addMessage(message) { this.messages.push(message); this.writeQueue this.writeQueue.then(() this.saveToFile()); this.writeQueue this.writeQueue.catch(() {}); }多进程/多实例部署下文件方案本身就是不安全的建议直接上 Redis 或 SQLite。这也是为什么说文件持久化适合单机场景。7.2 文件持久化不等于无限记忆很多人以为把历史存进文件就能把十年聊天记录全存住反复查询。这里有个误区文件只是把消息保存下来但模型调用时还是会一次性把所有历史塞进 prompttoken 照样爆炸。正解是组合使用文件持久化负责跨会话恢复窗口裁剪负责控制单次请求体量。你可以给FileChatMessageHistory加一个maxMessages字段保存时只保留最后 N 条saveToFile() { const limited this.messages.slice(-100); const raw limited.map((msg) ({ type: msg.getType(), content: msg.content, })); writeFileSync(this.filePath, JSON.stringify(raw, null, 2), utf-8); }我先提醒一句如果保存时截断了启动恢复时也要同样截断否则内存里的消息数量和文件不一致后续窗口计算会混乱。7.3 简单JSON序列化会丢字段FileChatMessageHistory只保存了type和content两个字段这对HumanMessage、AIMessage、SystemMessage足够。但一旦涉及工具调用消息会携带tool_call_id、name、additional_kwargs等附加字段简单 JSON 序列化会把它们全部丢掉。我在一个 Agent 项目里踩过把带工具调用的历史恢复后模型收到缺字段的AIMessage直接报序列化错误。结论是如果你的消息类型已经超出“普通对话”就不要依赖手写 JSON 方案改用 LangGraph 的 Checkpointer 或者直接落数据库它们对多角色消息的保真度要高得多。7.4 一个隐蔽问题记忆文件越写越大目录越来越乱每个用户一个文件跑一个月后memory/目录下可能躺着几万个 JSON 文件。单文件本身不大但目录元数据会成为负担而且清理过期会话也不方便。我的做法是加一层日期分目录memory/2025/06/15/{userId}.json配合定时任务清理 30 天前的目录。文件数量被摊开到多个目录管理起来清爽很多。另外文件名不要直接用用户 ID容易产生非法字符建议用 UUID 或对用户 ID 做一次哈希。说到底文件持久化适合从小项目起步、快速验证业务阶段。当你的应用开始有真实流量和并发要求时应该顺势把存储后端换成 SQLite单机蜕变后成本极低或 Redis多实例共享。但无论换什么存储FileChatMessageHistory这套“容器 落盘”的思路都不会变——理解透了迁移成本很低。我自己从纯内存方案迁移到文件方案时的最大感受是能明显感觉到产品从“玩具”变成了“可用”。用户刷新页面、隔天回来AI 还记得他是谁体验完全是两回事。下一篇我会接着聊窗口裁剪和记忆摘要的组合玩法把“既保留关键信息、又不撑爆 token”这件事讲透。建议你先亲手跑一遍这个 Demo让 AI 记住你再说别的。