你有没有经历过这样的崩溃时刻:跟 AI 助手聊得好好的,它突然一脸茫然地问你“你说的人是谁?”不是你说话含糊,而是对话接口本身没有记忆。LangChain.js 里解决这个问题的整套机制,就是对话记忆体系。今天我们先把基础中的基础讲透:内存存储怎么做,文件持久化怎么落地,以及两者如何组合,让 AI 在服务重启后也记得住你聊过什么。
这套内容适合两类人。一类是刚接触 LangChain.js,想搞明白记忆到底怎么接的新手;另一类是已经写过几个 Agent,但被上下文搞到头大的老手。如果你是后者,可以重点看文件持久化那节——很多线上问题,都是因为没有把“会话历史存储”和“记忆变量注入”分开设计。
1. 先把记忆这件事拆清楚:会话状态到底丢在了哪一环
1.1 LLM 的真实面目:每一次请求都是一次“新生”
大模型本身没有记忆,它只有输入窗口。你调用一次接口,它就根据你这次 prompt 里的内容生成回复;这次调用结束,刚才说的话就没了。下一轮你继续问它,它开启的是另一个全新回合,除非你手动把上一轮对话原文再塞进 prompt。
所以“记忆”这个能力,本质上不在模型里,而在我们自己的代码里。你每次请求之前,要先去一个地方把历史消息捞出来,拼好上下文,再交给模型。这个过程在 LangChain.js 里被封装成了记忆体系。
拿生活类比:每轮对话都像一次面试。面试官原本不认识你,你需要把简历递过去它才知道你叫什么、之前聊过什么。LangChain.js 的对话记忆,就是帮你自动整理和递简历的那个人。你聊得越多,简历越厚,但面试官能记住的仍然只有你递给它的那一部分。
这个认知非常重要。很多刚入门的人以为“接了记忆”之后模型就能永不忘事,其实不是。只要历史消息没有被真正加载进当前请求,模型照样失忆。
1.2 一套记忆体系里至少有三个角色
LangChain.js 的“对话记忆”不是一个魔法开关,而是一条清晰的流水线。我建议你把它拆成三层来理解:
| 层级 | 代表组件 | 职责 |
|---|---|---|
| 消息存储层 | ChatMessageHistory、Redis 存储、文件存储 | 保存原始消息序列,按顺序记录每一轮人类和 AI 的发言 |
| 记忆封装层 | BufferMemory | 决定如何从存储里取出历史,并转换成模型可用的 prompt 变量 |
| 消费注入层 | ConversationChain、自定义 Prompt | 把渲染好的历史变量放回模板,提交给模型 |
这三层各干各的事,但很多踩坑的案例都是把它们混在一起。
最常见的问题是什么?是你以为把消息往内存数组里 push 一下就完事了。结果下一个请求来了,你忘了把数组内容拼进 prompt,模型照样什么都不知道。又或者在服务重启后,内存数组清空了,历史丢得一干二净。所以先理清楚:存储归存储,注入归注入,持久化归持久化。
2. 内存存储:最快跑通,但一重启就清零
2.1 先认识档案柜:ChatMessageHistory
LangChain.js 里最基础的消息存储类是ChatMessageHistory,它把消息保存在当前 Node.js 进程的内存里。用法很直接:
import { ChatMessageHistory } from "langchain/stores/message/in_memory"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; const history = new ChatMessageHistory(); await history.addUserMessage("我叫小明,家里养了一只猫叫煤球。"); await history.addAIChatMessage("好的,我已经记住了:你叫小明,猫叫煤球。");你可以把它理解成一个专门存放消息的档案柜。它提供的主要方法也很直白:
addUserMessage(content):往历史里加入一条用户消息。addAIChatMessage(content):往历史里加入一条 AI 消息。getMessages():按顺序取回全部消息。clear():清空档案柜。
这个类本身不具备“注入 prompt”的能力,它只是存储。你想让它真正影响模型,还得把它和记忆封装类配合起来。
脚本里可以这样直接看到内部效果:
const messages = await history.getMessages(); console.log(messages); // [HumanMessage, AIMessage]如果你只是写一个 Node 脚本,手动把历史拼接好就行。但一旦进入多轮对话、多用户并发,再手动拼就很容易乱,这时候需要BufferMemory。
2.2 让它自动工作:BufferMemory 接进 ConversationChain
BufferMemory是 LangChain.js 里最经典的完整上下文记忆封装。它会从你指定的chatHistory里读取历史,然后在模型调用前后自动完成两件事:调用前把历史注入为 prompt 变量,调用后把这一轮新的输入和输出存回历史。
具体代码可以这样写:
import { ChatOpenAI } from "@langchain/openai"; import { ConversationChain } from "langchain/chains"; import { BufferMemory } from "langchain/memory"; const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.2, }); const memory = new BufferMemory({ memoryKey: "history", returnMessages: false, }); const chain = new ConversationChain({ llm, memory, }); const first = await chain.call({ input: "我养了一只猫,叫煤球。", }); const second = await chain.call({ input: "煤球今天早上吐了,你说我要不要马上去医院?", });第一次调用时,模型会回答第一句话。第二次调用时,BufferMemory已经把第一轮的用户消息和 AI 回复拼成一段历史,注入到ConversationChain的默认模板里。模型看起来就像“记得”之前聊过什么。
这里不需要手动调用saveContext,ConversationChain在 call 完成后会自动把输入和输出保存进 memory。你可以直接把 memory 理解成一个会自动整理的随身笔记。
2.3 为什么参数名要拆成 input、output、memory 三个 Key
很多新手在这里被绕晕,其实拆开看就清楚了:
inputKey:标记一次调用里的“用户输入”字段。chain.call({ input: "..." })默认 key 就是input。outputKey:标记模型返回结果里的“输出”字段。ConversationChain默认返回response,所以这里要对应。memoryKey:历史变量在 prompt 模板里叫什么名字。默认模板用的是history,你改别的地方就要同时在模板里保持一致。returnMessages:为true时,历史变量是一组BaseMessage对象;为false时,历史变量是格式化后的字符串。
一句话总结:memory 想知道“这一轮的输入从哪里取、输出从哪里取、历史往哪里放”。这三个 key 对上,记忆才完整。
2.4 内存存储的边界:进程重启 = 失忆
用ChatMessageHistory和BufferMemory的默认组合,所有消息都只存在于当前进程内存里。脚本跑完、进程退出、服务发布、代码热更新,记忆就没了。
本地调试没问题,但要真做一个能用的机器人,这一步远远不够。这也是文件持久化出现的原因:把消息从内存搬到磁盘,让记忆在进程重启后还能恢复。
3. 文件持久化:给记忆找一个不随进程消失的“存档点”
3.1 为什么先选文件而不是直接上 Redis 或数据库
我见过不少人一上来就接 Redis,其实顺序反了。本地开发、单机小项目、原型验证阶段,文件持久化是最划算的方案:不需要额外启动服务,不增加部署依赖,代码也足够透明,方便你观察模型到底看见了什么历史。
文件格式我推荐 JSONL,也就是每一行一条 JSON 消息,而不是把一大堆消息塞进一个 JSON 数组。原因有三个:
- 追加写入快。每次只需要往文件尾部追加一行,不需要重写整个文件。
- 顺序天然保留。对话历史本身就是时间顺序,逐行追加符合直觉。
- 易读易调试。打开文件一眼就能看到人机双方说了什么,排查 prompt 问题的时候特别有用。
等真正上了生产、有多个实例、需要按用户高并发读写时,再换 SQLite、PostgreSQL 或者 Redis 也不迟。文件持久化的价值是让你先理解“存储层”该做什么,而不是一上来就被基础设施绑架。
3.2 用 JSONL 文件版实现一个 ChatMessageHistory
LangChain.js 内置了内存版的ChatMessageHistory,但没有内置一个适合所有场景的文件版。好在它留下了清晰的抽象基类BaseListChatMessageHistory,我们只要实现几个核心方法,就能写一个自己的文件版存储。
下面这个类就是我实际项目里会用到的简化版本:
import { BaseListChatMessageHistory } from "@langchain/core/chat_history"; import { AIMessage, HumanMessage, SystemMessage } from "@langchain/core/messages"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; function serializeMessage(message) { return { type: message.getType(), content: message.content, additional_kwargs: message.additional_kwargs ?? {}, }; } function deserializeMessage(json) { const base = { content: json.content, additional_kwargs: json.additional_kwargs ?? {}, }; switch (json.type) { case "human": return new HumanMessage(base); case "ai": return new AIMessage(base); case "system": return new SystemMessage(base); default: throw new Error(`不支持的消息类型: ${json.type}`); } } export class JsonlChatMessageHistory extends BaseListChatMessageHistory { constructor(filePath) { super(); this.filePath = filePath; this.messages = []; this.loaded = false; } async ensureLoaded() { if (this.loaded) return; try { const raw = await readFile(this.filePath, "utf-8"); this.messages = raw .split("\n") .filter((line) => line.trim() !== "") .map((line) => deserializeMessage(JSON.parse(line))); } catch (error) { if (error.code !== "ENOENT") throw error; this.messages = []; } this.loaded = true; } async getMessages() { await this.ensureLoaded(); return this.messages; } async addMessage(message) { await this.ensureLoaded(); this.messages.push(message); await mkdir(dirname(this.filePath), { recursive: true }); const line = JSON.stringify(serializeMessage(message)); await writeFile(this.filePath, line + "\n", { flag: "a" }); } async clear() { this.messages = []; await writeFile(this.filePath, "", { flag: "w" }); } }这里有三个关键设计可以说明一下。
第一,ensureLoaded用懒加载方式读文件,避免必须写一个 async 构造函数。文件不存在的时候,按空历史处理;文件第一次创建时,目录也会自动建好。
第二,addMessage只追加一行,不重写整个文件。这是 JSONL 文件持久化的核心收益。每次调用写完,历史就落盘了。
第三,我们只序列化了type、content、additional_kwargs三个字段。对普通文本对话来说完全够用。如果你的消息里包含工具调用、复杂tool_calls、多模态内容,建议直接用 LangChain 内置的messageToJson和mapStoredMessageToChatMessage做序列化,避免自己丢字段。
3.3 把文件版存储接进 BufferMemory,只需要改一行
有了上面这个类,替换内存版存储很容易:
import { BufferMemory } from "langchain/memory"; import { JsonlChatMessageHistory } from "./fileChatHistory.js"; const chatHistory = new JsonlChatMessageHistory("./data/user-123.jsonl"); const memory = new BufferMemory({ chatHistory, memoryKey: "history", returnMessages: false, });之后还是正常用ConversationChain。每轮对话结束,BufferMemory 会把最新的用户消息和 AI 回复写到user-123.jsonl。进程重启后,再创建同一个文件的JsonlChatMessageHistory,历史就自动恢复了。
文件里的内容大致长这样:
{"type":"human","content":"我养了一只猫,叫煤球。","additional_kwargs":{}} {"type":"ai","content":"好的,我记住啦,煤球是你的猫。","additional_kwargs":{}}这里有个容易被忽略的点:文件存储的“持久化”不等于“自动隔离”。如果所有用户都共用同一个文件,那对话历史就乱套了。所以生产上至少要做到一个会话或一个用户一个文件。
4. 完整可跑示例:做一个“重启也不失忆”的多轮对话服务
4.1 初始化项目
先建一个空目录,初始化 Node.js 项目并安装依赖:
npm init -y npm install langchain @langchain/core @langchain/openai express然后设置模型 API Key。本地调试我习惯在运行命令前注入环境变量:
export OPENAI_API_KEY="你的Key"项目结构保持简单:
my-memory-demo/ src/ fileChatHistory.js index.js data/fileChatHistory.js就是上一节那个JsonlChatMessageHistory,直接粘进去。data目录用来放各个会话的历史文件。
4.2 写一个带 session 隔离的 Express 接口
接着写src/index.js。这里我特意用sessionId作为维度,为每个会话创建独立的历史文件,避免不同用户之间串话。
import express from "express"; import { ChatOpenAI } from "@langchain/openai"; import { ConversationChain } from "langchain/chains"; import { BufferMemory } from "langchain/memory"; import { JsonlChatMessageHistory } from "./fileChatHistory.js"; const app = express(); app.use(express.json()); const llm = new ChatOpenAI({ model: "gpt-4o-mini", temperature: 0.2, }); const VALID_SID = /^[a-zA-Z0-9_-]{1,64}$/; app.post("/chat", async (req, res) => { const { sessionId, message } = req.body; if (!sessionId || !message) { return res.status(400).json({ error: "sessionId 和 message 不能为空" }); } if (!VALID_SID.test(sessionId)) { return res.status(400).json({ error: "sessionId 格式不合法" }); } const chatHistory = new JsonlChatMessageHistory(`./data/${sessionId}.jsonl`); const memory = new BufferMemory({ chatHistory, memoryKey: "history", inputKey: "input", outputKey: "response", returnMessages: false, }); const chain = new ConversationChain({ llm, memory, }); const result = await chain.call({ input: message }); res.json({ reply: result.response }); }); app.listen(3000, () => { console.log("对话服务已启动: http://localhost:3000"); });代码里做了一次 sessionId 校验,目的是防止用户把sessionId传成../../xxx这类路径,导致读写到别的目录。这个坑在真实项目里是真的会出现的,不要觉得多此一举。
启动服务:
node src/index.js然后用 curl 模拟两轮对话:
curl -X POST http://localhost:3000/chat \ -H "Content-Type: application/json" \ -d '{"sessionId":"user-1","message":"我养了一只猫,叫煤球。"}' curl -X POST http://localhost:3000/chat \ -H "Content-Type: application/json" \ -d '{"sessionId":"user-1","message":"煤球今天早上吐了,我该怎么办?"}'第二步时,模型能根据第一步的对话做出更自然、更有上下文的判断。你可以把服务 Ctrl+C 停掉,再重新启动,继续发第三条消息:
curl -X POST http://localhost:3000/chat \ -H "Content-Type: application/json" \ -d '{"sessionId":"user-1","message":"我刚刚跟你说过我家猫叫什么名字吗?"}'只要user-1.jsonl文件还在,重启后的服务依然能和刚才的对话接上。这就是“文件持久化”的价值:记忆不再依赖进程存活。
4.3 关键链路回看:一次请求里内存和文件怎么配合
每次/chat请求,程序都会按 sessionId 创建一个文件历史对象。这个对象在内存里维护一份消息数组,同时也负责把新消息落到磁盘。
当模型要回答时,BufferMemory 会调用getMessages(),把文件里所有历史消息读出来,格式化成字符串塞进 prompt。模型回答完成后,BufferMemory 会调用addMessage,分别把用户消息和 AI 消息追加到文件末尾。
所以这一套组合的本质是:内存数组负责快速读写,磁盘文件负责跨进程保存。两者通过同一个历史对象协同。你不需要在业务代码里手动维护拼接逻辑,LangChain.js 的记忆层帮你做完了。
5. 常见问题与排查技巧实录
5.1 高频问题速查表
这条路上我踩过的坑不少,整理成表格给你,省得你再去搜索引擎里来回翻。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 第 2 轮还记得,进程重启后不记得 | 用的还是默认内存ChatMessageHistory,没有换成文件或数据库存储 | 检查是否传入了chatHistory,并确认文件路径是稳定的 |
模型回答里出现[object Object] | BufferMemory设置了returnMessages: true,但 prompt 模板期望的是字符串 | 改成returnMessages: false,或者用支持消息数组的 ChatPromptTemplate |
| 多个用户之间互相串话 | 全局共用了同一个BufferMemory或同一个ChatMessageHistory | 按 sessionId 每次创建独立的 history 实例 |
| 历史越长,响应越慢,最后报 token 超限 | BufferMemory是全量记忆,不会自动裁剪 | 后续需要滑动窗口记忆或摘要记忆;临时方案是主动clear() |
| 并发写同一个文件后丢消息 | 多个请求同时追加写同一个.jsonl文件 | 单实例低并发可以先接受;上生产换成数据库,或在写入层加互斥锁 |
| 读文件时某行 JSON 坏了 | 进程在写文件时崩溃,留下半行脏数据 | 启动时跳过坏行并单独备份,生产环境用事务型数据库 |
5.2 两个我自己踩过的坑
第一个坑:调试时总觉得“模型记忆有问题”,最后发现是 memory 里的内容根本不是我想要的历史。别猜,直接把 memory 加载出来的变量打印出来看。
const vars = await memory.loadMemoryVariables({ attention: "打出来看看" }); console.log(JSON.stringify(vars, null, 2));这一步能让你看到模型真正“看到”的历史是什么格式。是字符串、对象数组,还是已经被截断过的内容,一眼就知道问题出在哪。这个习惯我后来一直保留着,尤其是换新模型、换模板的时候特别管用。
第二个坑:测试文件持久化时,老是从上一次测试的历史开始续写,导致模型“不正常”。我一度以为是代码有 bug。其实是因为测试用同一个文件,历史一直累积。代码本身没错,但测试环境和生产环境混用了同一个数据目录。
解决方式也很简单:测试会话用独立 sessionId,或者每次测试前清空data目录。更优雅的方法是给JsonlChatMessageHistory加一个clear()的调用入口,测试脚本里在开始前先清一次。
6. 这套方案的边界与我的下一步安排
到目前为止,我们解决的是“历史能存下来、能读回去”的问题。文件持久化在实际工程里仍然有边界:单机、低并发、纯文本场景还好,一旦多个 Node 实例同时读写同一批文件,或者单条会话历史超过模型上下文窗口,这个方案就会吃力。
我个人的处理原则是:先用文件存储跑通业务,确认记忆链路没有问题了,再把JsonlChatMessageHistory的接口替换成 SQLite 或 PostgreSQL 实现。替换时业务代码几乎不用动,因为 BufferMemory 依赖的始终是BaseListChatMessageHistory的那几个方法。
下一期我打算把话题集中在“上下文爆炸”上,讲清楚滑动窗口记忆和摘要记忆怎么接入,以及它们各自适合的场景。就我实际开发体验来说,记忆体系最忌讳一开始就想一步到位接个大而全的组件,先把最小链路打通,比什么都重要。