网上讲 LangChain「记忆」的教程,几乎都是同一套话术:创建
InMemoryChatMessageHistory,addMessage用户问题,getMessages拼历史,调模型,把回答也addMessage回去......看完你会背,但转头还是不懂:它到底凭什么"记住"我?文件里那些嵌套花括号是什么意思?为什么换个存储后端代码几乎不用动?这篇不按 demo 文件讲,改沿 「一句话从你嘴里到它嘴里、再到硬盘」 的旅程逐层拆。源码级地看,你会发现记忆的真相既不神秘也不复杂,就是把「下一次要发给模型的东西」想办法留了下来。
一、先讲透反常识:记忆=每次复读+会存档的账本
三个 demo(history-test.mjs / 2 / 3)代码高度雷同,是因为它们本来就是同一件事的三个切面。先把这个底层模型刻进脑子,后面每个代码片段都能对上号:
① 大模型没有记忆。 它每次只干一件事:你给一份消息数组,它基于这份数组生成下一个回复。所谓「能连续聊天」,是因为调用方每次都把历史整份重发给它------也就是「复读」。
② 所以"记忆"这件事 = 你负责复读,别人负责把"要复读的内容"存好。
③ 存好的那份内容,我们叫它"账本"。 账本放内存 → 进程死就没(demo1);账本落硬盘 → 进程死还在(demo2/3)。
于是三个 demo 只剩一个真正的区别:账本存在哪 。而控制"存在哪"的,是 ChatMessageHistory 这一个接口背后的不同实现。下面沿一句话的旅程,把这些实现逐个看穿。
二、四个地基对象:一条消息到底是什么
先认清楚参与这场旅程的四个类型,别被名字唬住:
| 类型 | 它在类体系里的位置 | 白话 |
|---|---|---|
BaseMessage |
所有消息的父类 | 消息的"通用壳子":type(谁说的)+ content(说了啥) |
HumanMessage |
BaseMessage 子类 |
用户消息,type 固定为 human |
AIMessage |
BaseMessage 子类 |
模型消息,type 固定为 ai,会额外带上 id、token 用量等元数据 |
SystemMessage |
BaseMessage 子类 |
系统人设,type 固定为 system |
InMemoryChatMessageHistory 和 FileSystemChatMessageHistory 的父类则都是 BaseListChatMessageHistory ------一个只规定三个方法(addMessage / getMessages / clear)的抽象接口。存储能随便换,全靠这个父类,第八节细说。
三、第一站:一条消息的诞生
旅程从你把「红烧肉怎么做」这六个字打出来开始:
js
const userMessage1 = new HumanMessage("红烧肉怎么做");
别把它想得多玄。new 一下,就是内存里多了一个普通 JS 对象,大概长这样(简化):
js
{
type: "human", // 谁说的
content: "红烧肉怎么做", // 说了啥
additional_kwargs: {}, // 预留的扩展字段
response_metadata: {}, // 响应的元数据(用户消息通常为空)
}
此刻它还只是一段孤零零的数据,和"记忆"没有半毛钱关系。记忆,从它进入账本开始。
四、第二站:addMessage,两种账本在此分道(读源码)
addMessage 是全场最重要的方法,因为它正是「账本记账」的动作。看它之前先明白一个反直觉点:
无论内存版还是文件版,
addMessage本质上都是"往一个数组 push"。 真正的差别只有:push 完之后,要不要把这个数组同步到别的地方。
4.1 内存版:push 完就完事
InMemoryChatMessageHistory 的核心实现大概长这样(它就是用一个实例数组):
js
class InMemoryChatMessageHistory extends BaseListChatMessageHistory {
async addMessage(message) {
this.messages.push(message); // push 进实例数组,完事,没有"然后"
}
async getMessages() {
return this.messages;
}
}
push 完之后这行话睡在实例内存里------程序一退出,进程内存被回收,这句话就没了。这就是 demo1「聊完就忘」的根源,不是玄学,是数组随进程消亡。
4.2 文件版:push 完还要"写档案"(读源码)
FileSystemChatMessageHistory 可就多干了几件事。我把它的真实源码贴出来(@langchain/community/dist/stores/message/file_system.js)逐行看:
js
let store; // ★ 模块级"大档案柜":整个 JSON 文件的缓存,进程内只读一次
async init() {
if (store) return; // 已加载过就不再读盘
store = await this.loadStore(); // 第一次:把整个 JSON 读进内存
}
async loadStore() {
const store = await promises.readFile(this.filePath, "utf-8");
return JSON.parse(store);
}
async addMessage(message) {
await this.init(); // ① 确保档案柜已加载
const messages = await this.getMessages(); // ② 取出当前这个 session 的消息
messages.push(message); // ③ push ------ 和内存版一模一样
const storedMessages = mapChatMessagesToStoredMessages(messages); // ④ 序列化成 {type,data}
store[this.userId][this.sessionId] = { ...store[this.userId][this.sessionId], messages: storedMessages };
await this.saveStore(); // ⑤ 把整棵档案树重写回文件
}
async saveStore() {
await promises.mkdir(dirname(this.filePath), { recursive: true });
await promises.writeFile(this.filePath, JSON.stringify(store)); // 整个文件重写
}
三个和内存版的关键差异,每个都是知识点:
let store是模块级单例 :文件只被readFile读一次,之后所有操作都在内存里那棵树store上进行------所以文件版其实也是"内存 + 落盘"双份 ,只是比 InMemory 多了一道saveStore;- 每次
addMessage都整文件重写 (writeFile(JSON.stringify(store))):几行对话没问题,聊到上千条每次全量写会越来越慢------源码文档注释自己也写了 "For demo and development purposes only"; mapChatMessagesToStoredMessages序列化 :这解释了文件里消息为什么是{ type: "human", data: {...} }那种形态------不是随便 JSON.stringify,而是走了 LangChain 的存储格式(第七节展开)。
4.3 一句话总结第二站
addMessage两边一样是 push;文件版只是在 push 后,把整棵消息树序列化 + 全量写回文件 。await也因此是必须的------文件版每一步都真在碰磁盘。
五、第三站:把账本打包发给模型
记账不是目的,让模型下次能看到才是。看这一行(demo1 / demo2 / demo3 都有):
js
const messages1 = [systemMessage, ...(await history.getMessages())];
它干了三件事,从左往右读:
systemMessage放最前面:人设必须在对话流的最开头,模型才知道"我是谁";...(await history.getMessages()):把账本里当前所有 历史展开拼进去(含刚addMessage的那句用户问题);- 合起来
[system, human, ai, human, ...]:顺序 = 对话发生的顺序。顺序错乱,记忆就乱。
getMessages() 值得多看一眼。内存版直接返回内部数组;文件版则是把 store 里的存储格式反序列化回消息实例 再返回(mapStoredMessagesToChatMessages)。所以你在 demo 里能放心写 msg.type、msg.content------它们已经被还原成 HumanMessage / AIMessage 对象了。
发出去之后,模型返回的不再是你 new 的那种瘦消息,而是一个胖 AIMessage ------它带着 id、response_metadata(token 用量、用的什么模型 qwen-plus)等一堆"回执信息"。这些在第五站开始变得重要。
六、第四站:把回答存回去,为什么不能省
全篇最容易忽略、也最能体现"懂没懂"的一行:
js
await history.addMessage(response1); // ★ 把模型的回答也记回账本
为什么必须存 AI 的回答? 回到第一节的公式:记忆 = 复读。如果账本里只有你的问题、没有模型的回答,那下一轮拼出来的历史是断的------模型只看到"你问了什么",看不到"它上次答了什么",自然接不上话。把 demo 里这行注释掉跑一遍,第二轮回答立刻"失忆",是最直观的实验。
顺便注意:这一步存进去的是那个胖 AIMessage。因为它带元数据,落到文件里也会比 human 消息"胖"------这正是下节文件内容里 AI 消息字段更多的原因。
七、读文件:chat_history.json 到底存了啥
旅程终点是磁盘。用编辑器打开跑 demo2/3 生成的 chat_history.json,真实的缩略结构是这样:
json
{
"": {
"user_session_001": {
"messages": [
{ "type": "human",
"data": { "content": "红烧肉怎么做", "additional_kwargs": {}, "response_metadata": {} } },
{ "type": "ai",
"data": { "id": "chatcmpl-xxx", "content": "哈哈,红烧肉可是......",
"response_metadata": { "model_name": "qwen-plus", "tokenUsage": { "totalTokens": 1012 } },
"tool_calls": [], "usage_metadata": {} } }
]
}
}
}
逐层对号入座(这层理解很值钱):
| JSON 里的位置 | 它是什么 | 来源 |
|---|---|---|
最外层 "": {...} |
userId 这一层 | 构造时没传 userId,源码里默认成 ""(userId ?? "") |
"user_session_001": {...} |
sessionId 这一层 | demo 里 new FileSystemChatMessageHistory({ filePath, sessionId }) |
"messages": [...] |
真正的话语录 | 数组里每条 = 一轮里的一个发言 |
每条消息的 type / data |
LangChain 存储格式 | mapChatMessagesToStoredMessages 序列化的结果,content 在 data 里 |
我最早以为最外层那个
""是个无意义的占位符,直到翻开源码看到this.userId = userId ?? ""才明白------它是 userId 层,默认空串 。真正的层级是userId → sessionId → messages。读源码的价值就在这种"原来如此"的时刻。
八、换存储只改两行的真相:同一接口
现在能解释 demo1 → demo2 为什么只改开头两行、后面业务代码一个字符都不动了:
js
// demo1:内存账本
const history = new InMemoryChatMessageHistory();
// demo2:文件账本(其余代码一模一样)
const filePath = path.join(process.cwd(), "chat_history.json");
const history = new FileSystemChatMessageHistory({ filePath, sessionId: "user_session_001" });
因为两个类都继承自 BaseListChatMessageHistory,承诺了同一套方法签名:addMessage / getMessages / clear。调用方只面向接口编程,不关心底层是数组还是文件。 今天用内存明天换 SQLite/Redis/Milvus,只要实现了这仨方法,业务代码不动。
这正是你该从这三个 demo 带走的最重要的一条设计思想------面向接口,而不是面向实现。
九、重启恢复:为什么第二次跑数字会涨
demo3 的「神奇」其实也很好验证。假设你先跑 demo2 :两轮对话,addMessage 4 次 → 文件里有 4 条消息。
然后再跑 demo3(新进程):
js
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件中恢复了 ${restoredMessages.length} 条历史信息:`);
getMessages()走init()→store还是空的 →readFile把文件读进内存 → 反序列化 → 返回 4 条。打印 4 条;- 接着聊第三轮("需要哪些食材?"),又
addMessage2 次 → 文件从 4 变 6 条。
再跑一次 demo3,会打印 6 条 ,聊完变 8 条。数字每次 +2,证明记忆确实跨进程活着,而且会累积------demo2 负责"从 0 写起",demo3 负责"读存量 + 增量"。这个 +2 实验是检验"文件持久化是否生效"最直接的观察窗口。
十、踩坑:读源码才懂的边界
- 文件版是模块级单例缓存 (源码
let store,init()里if (store) return)。一个进程里如果new了两个指向不同文件的 history 实例,第二个实例不会重新读盘,会共享第一份缓存------多文件场景会踩坑,演示别较真。 - 每次 addMessage 全量重写文件,是 O(文件大小) 的磁盘写。几千条对话后明显变慢------官方注释都写了仅供 demo 使用。
.then(console.error)≠ 错误处理 :demo3 结尾就是这么写的,.then是成功回调,失败不会打印;要用.catch(demo2 是对的)。await也不是装饰------文件版真在读写磁盘,漏掉await时序全乱。- AI 消息"胖"是正常的 :它带着
id/ token 用量 / 模型名等元数据一起被序列化进文件,不是 bug。 - 顺序即记忆 :
[system, ...history]的顺序错了、或漏存了 AI 回答,表现出的症状都一样------模型"失忆"。
十一、总结与升级方向
一句话的完整旅程,收束成一张图:
所以:模型不记事,记事的是你的代码;代码不记事,记事的是那棵被反复序列化、写进文件的 userId → sessionId → messages 树。 所谓给 Agent 加记忆,工程上就是把"下次要复读的内容"交给一个可靠的存储,再用同一套接口随时取回。
想更进一步(对应仓库里更后面的 demo):
- 手工拼
[system, ...history]太原始 → LangChain 官方推荐RunnableWithMessageHistory,自动帮你注入历史; - 历史越长,每次全量重发越贵 → 学 token 截断(
trimMessages+ js-tiktoken) 和 AI 总结压缩旧话; - 想让它"想起一个多月前的细节" → 把账本升级成 Milvus 向量库做按相似度的检索式记忆。
先把本文这句话的旅程亲手走一遍------把第六节那行 addMessage(response) 注释掉跑一次,再对照 chat_history.json,你对"记忆从哪来"的理解,会比看十篇教程都深。