一句话是怎么被大模型记住的?——拆解 LangChain.js 消息从诞生到落盘的完整旅程

网上讲 LangChain「记忆」的教程,几乎都是同一套话术:创建 InMemoryChatMessageHistoryaddMessage 用户问题,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

InMemoryChatMessageHistoryFileSystemChatMessageHistory 的父类则都是 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));  // 整个文件重写
}

三个和内存版的关键差异,每个都是知识点:

  1. let store 是模块级单例 :文件只被 readFile 读一次,之后所有操作都在内存里那棵树 store 上进行------所以文件版其实也是"内存 + 落盘"双份 ,只是比 InMemory 多了一道 saveStore
  2. 每次 addMessage 都整文件重写writeFile(JSON.stringify(store))):几行对话没问题,聊到上千条每次全量写会越来越慢------源码文档注释自己也写了 "For demo and development purposes only"
  3. mapChatMessagesToStoredMessages 序列化 :这解释了文件里消息为什么是 { type: "human", data: {...} } 那种形态------不是随便 JSON.stringify,而是走了 LangChain 的存储格式(第七节展开)。

4.3 一句话总结第二站

addMessage 两边一样是 push;文件版只是在 push 后,把整棵消息树序列化 + 全量写回文件await 也因此是必须的------文件版每一步都真在碰磁盘。

五、第三站:把账本打包发给模型

记账不是目的,让模型下次能看到才是。看这一行(demo1 / demo2 / demo3 都有):

js 复制代码
const messages1 = [systemMessage, ...(await history.getMessages())];

它干了三件事,从左往右读:

  1. systemMessage最前面:人设必须在对话流的最开头,模型才知道"我是谁";
  2. ...(await history.getMessages()):把账本里当前所有 历史展开拼进去(含刚 addMessage 的那句用户问题);
  3. 合起来 [system, human, ai, human, ...]:顺序 = 对话发生的顺序。顺序错乱,记忆就乱。

getMessages() 值得多看一眼。内存版直接返回内部数组;文件版则是把 store 里的存储格式反序列化回消息实例 再返回(mapStoredMessagesToChatMessages)。所以你在 demo 里能放心写 msg.typemsg.content------它们已经被还原成 HumanMessage / AIMessage 对象了。

发出去之后,模型返回的不再是你 new 的那种瘦消息,而是一个AIMessage ------它带着 idresponse_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 序列化的结果,contentdata

我最早以为最外层那个 "" 是个无意义的占位符,直到翻开源码看到 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 条
  • 接着聊第三轮("需要哪些食材?"),又 addMessage 2 次 → 文件从 4 变 6 条

再跑一次 demo3,会打印 6 条 ,聊完变 8 条。数字每次 +2,证明记忆确实跨进程活着,而且会累积------demo2 负责"从 0 写起",demo3 负责"读存量 + 增量"。这个 +2 实验是检验"文件持久化是否生效"最直接的观察窗口。

十、踩坑:读源码才懂的边界

  1. 文件版是模块级单例缓存 (源码 let storeinit()if (store) return)。一个进程里如果 new两个指向不同文件的 history 实例,第二个实例不会重新读盘,会共享第一份缓存------多文件场景会踩坑,演示别较真。
  2. 每次 addMessage 全量重写文件,是 O(文件大小) 的磁盘写。几千条对话后明显变慢------官方注释都写了仅供 demo 使用。
  3. .then(console.error) ≠ 错误处理 :demo3 结尾就是这么写的,.then 是成功回调,失败不会打印;要用 .catch(demo2 是对的)。await 也不是装饰------文件版真在读写磁盘,漏掉 await 时序全乱。
  4. AI 消息"胖"是正常的 :它带着 id / token 用量 / 模型名等元数据一起被序列化进文件,不是 bug。
  5. 顺序即记忆[system, ...history] 的顺序错了、或漏存了 AI 回答,表现出的症状都一样------模型"失忆"。

十一、总结与升级方向

一句话的完整旅程,收束成一张图:

sequenceDiagram participant U as 你 participant M as new HumanMessage(...) participant H as history.addMessage participant F as 序列化+文件/内存 participant LLM as model.invoke U->>M: "红烧肉怎么做" 变成消息对象(type/content) M->>H: addMessage → push 进数组 H->>F: 文件版:{type,data} 序列化 + 整树写盘 U->>LLM: [system, ...getMessages()] 拼历史整包重发 LLM-->>U: 返回胖 AIMessage(带 id/token 元数据) U->>H: addMessage(AI回答) → 回填账本 Note over F: 下次新进程 getMessages 又从文件捞回 → demo3 数字 +2

所以:模型不记事,记事的是你的代码;代码不记事,记事的是那棵被反复序列化、写进文件的 userId → sessionId → messages 树。 所谓给 Agent 加记忆,工程上就是把"下次要复读的内容"交给一个可靠的存储,再用同一套接口随时取回。

想更进一步(对应仓库里更后面的 demo):

  • 手工拼 [system, ...history] 太原始 → LangChain 官方推荐 RunnableWithMessageHistory,自动帮你注入历史;
  • 历史越长,每次全量重发越贵 → 学 token 截断(trimMessages + js-tiktoken)AI 总结压缩旧话;
  • 想让它"想起一个多月前的细节" → 把账本升级成 Milvus 向量库做按相似度的检索式记忆。

先把本文这句话的旅程亲手走一遍------把第六节那行 addMessage(response) 注释掉跑一次,再对照 chat_history.json,你对"记忆从哪来"的理解,会比看十篇教程都深。

相关推荐
mONESY1 小时前
给大模型装上记忆:Agent 的 Memory 模块,从 InMemory 到文件到 Milvus 向量数据库
javascript
橘子星1 小时前
给 Agent 装上有记忆的"脑子"(一):用 LangChain 打通内存记忆、文件持久化与上下文截断
javascript
光影少年1 小时前
从输入URL到页面渲染,React/RN 整体加载流程
前端·javascript·react native·react.js·前端框架
兔子零10241 小时前
我给 Pi Coding Agent 做了一个桌面控制台:Pi-Harness
前端·javascript·后端
橘子星1 小时前
给 Agent 装上有记忆的"脑子"(二):对话太长会撑爆上下文?用"自动总结"给记忆瘦身
javascript
橘子星1 小时前
给 Agent 装上有记忆的"脑子"(三):把对话存进向量库,让 Agent 拥有"长期记忆"
javascript·人工智能
runningshark2 小时前
Lecture: The ‘Why & How‘ Principle: Moving Beyond Simple Statements
开发语言·前端·javascript
雪芽蓝域zzs2 小时前
第三十一节:角色管理页面 + 权限分配树形弹窗
前端·javascript·vue.js
暖焰核心2 小时前
C++模板进阶——特化全解
javascript·c++·jquery