摘要: Memory 能让模型在多轮对话中记住上下文,但如果历史只存在内存里,程序一重启就会全部丢失。本文从 InMemory 出发,通过文件持久化完整走一遍"保存历史、关闭程序、重新恢复并继续对话"的过程。
上一篇我们已经搞清楚了一件事:
大模型本身并不会自动记住上一轮对话,所谓 Memory,本质上是程序把历史消息保存下来,在下一次调用模型时重新发送给它。
最简单的实现是:
ini
const history = new InMemoryChatMessageHistory();
每轮对话:
ini
await history.addMessage(userMessage);
const messages = await history.getMessages();
const response = await model.invoke(messages);
await history.addMessage(response);
只要程序还在运行,这套逻辑完全没有问题。
但是如果我们现在把 Node.js 程序停掉,再重新运行,会发生什么?
Memory 没了。
这就引出了 Memory 的另一个核心问题:
聊天历史到底存在哪里?
一、InMemory 的问题不是不能记,而是记不久
InMemoryChatMessageHistory 里的 InMemory 已经说明了它的存储位置:
bash
JavaScript 程序运行
↓
内存中存在 history
↓
程序结束
↓
进程内存被释放
↓
history 消失
例如用户已经聊了两轮:
Human:这个🍅是啥?
AI:这是番茄......
Human:好吃吗?
AI:......
这些消息都保存在:
bash
history
里面。
但它们实际上只是当前 Node.js 进程中的 JavaScript 对象。
程序一旦结束:
Ctrl + C
这些对象自然也就不存在了。
下一次重新:
node xxx.mjs
创建:
scss
new InMemoryChatMessageHistory()
得到的是一份全新的 Memory。
所以:
InMemoryChatMessageHistory
解决的是:
一次程序运行期间,如何管理多轮聊天历史。
它并没有解决:
程序重启之后,怎么继续使用以前的聊天历史。
二、真正的聊天应用不能每重启一次就失忆
假设我们正在开发一个 AI 聊天应用。
用户今天和 AI 聊了很多内容。
晚上服务器因为部署新版本重启。
第二天用户继续问:
那我昨天说的那个方案呢?
如果 Memory 只存在进程内存里:
markdown
服务器重启
↓
历史清空
↓
模型只看到当前问题
AI 根本不知道"昨天那个方案"是什么。
所以聊天历史不能只存在:
程序内存
还必须进入某种可以长期保存数据的地方。
例如:
文件
数据库
Redis
云端存储
......
这就是:
Memory 持久化。
三、先用文件理解"持久化"到底是什么意思
为了把这个过程看清楚,我们先不急着接数据库。
直接把聊天历史写进一个 JSON 文件。
这里使用:
FileSystemChatMessageHistory
例如:
javascript
import path from "node:path";
import {
FileSystemChatMessageHistory,
} from "@langchain/community/stores/message/file_system";
然后确定聊天记录保存的位置:
arduino
const filePath = path.join(
process.cwd(),
"chat_history.json"
);
此时:
markdown
聊天历史
↓
chat_history.json
程序结束以后,JavaScript 内存虽然没了,但是文件还在。
这就是持久化最核心的区别。
四、filePath 和 sessionId 分别在解决什么问题?
创建 History:
arduino
const history = new FileSystemChatMessageHistory({
filePath,
sessionId: "user_session_001",
});
这里出现了两个非常重要的东西:
filePath
sessionId
先看 filePath。
arduino
const filePath = path.join(
process.cwd(),
"chat_history.json"
);
它回答的问题是:
历史消息存到哪里?
例如:
bash
项目目录/chat_history.json
而:
ini
const sessionId = "user_session_001";
回答的是另一个问题:
这是谁的对话?
因为真实系统显然不可能只有一个用户。
假设:
css
用户 A
用户 B
用户 C
他们都在使用同一个 AI 应用。
Memory 必须能够区分:
css
A 的聊天历史
B 的聊天历史
C 的聊天历史
于是我们需要某种标识:
sessionId
可以先简单理解成:
markdown
sessionId
↓
找到属于这个会话的聊天历史
所以:
filePath
→ 数据在哪里
sessionId
→ 这份数据属于哪个会话
这是两个不同的问题。
五、第一轮对话:先把用户消息保存下来
创建一个 SystemMessage:
arduino
const systemMessage = new SystemMessage(
"你是一个美食家"
);
然后第一轮用户提问:
arduino
const userMessage1 =
new HumanMessage("这个🍅是啥?");
先写入 History:
csharp
await history.addMessage(userMessage1);
此时历史大致可以理解为:
Human:这个🍅是啥?
然后读取历史:
ini
const messages1 = [
systemMessage,
...await history.getMessages(),
];
真正发送给模型的是:
sql
System:你是一个美食家
Human:这个🍅是啥?
调用模型:
ini
const response1 =
await model.invoke(messages1);
模型回答之后:
csharp
await history.addMessage(response1);
于是第一轮完整历史变成:
Human:这个🍅是啥?
AI:这是番茄......
注意这里和上一篇讲的 InMemory Memory 完全一样。
区别仅仅在于:
bash
以前
history → JavaScript 内存
现在
history → 文件
上层使用方式依然是:
scss
history.addMessage(...)
history.getMessages()
这就是为什么要把"聊天历史管理"抽象成 History。
六、第二轮依然是同样的流程
用户继续问:
arduino
const userMessage2 =
new HumanMessage("好吃吗😋?");
写进去:
csharp
await history.addMessage(userMessage2);
此时历史:
Human:这个🍅是啥?
AI:这是番茄......
Human:好吃吗😋?
然后:
ini
const messages2 = [
systemMessage,
...await history.getMessages(),
];
调用:
ini
const response2 =
await model.invoke(messages2);
最后:
csharp
await history.addMessage(response2);
到这里,我们得到:
Human
AI
Human
AI
四条完整的聊天历史。
并且和 InMemory 最大的区别出现了:
这些消息已经落到文件里了。
即使现在结束程序,文件仍然存在。
七、真正关键的实验:把程序关掉
到这里其实还不能证明"持久化"真的成功了。
因为程序一直运行时:
内存 History
和:
文件 History
从使用体验上看差别并不明显。
真正应该做的是:
markdown
运行程序
↓
聊两轮
↓
关闭程序
然后重新启动另一个程序。
这时候再看历史还能不能恢复。
这才是这个 Demo 最重要的部分。
八、重新启动程序以后,不再创建一份"空白记忆"
第二个程序里仍然使用:
ini
const filePath = path.join(
process.cwd(),
"chat_history.json"
);
const sessionId = "user_session_001";
然后:
ini
const restoredHistory =
new FileSystemChatMessageHistory({
filePath,
sessionId,
});
注意:
程序虽然重新启动了
但是:
filePath 没变
sessionId 没变
因此程序可以重新找到之前保存的聊天历史。
接下来:
ini
const restoredMessages =
await restoredHistory.getMessages();
此时获取到的不再是:
css
[]
而是之前已经保存的:
Human:这个🍅是啥?
AI:......
Human:好吃吗?
AI:......
这就是:
恢复 Memory。
九、恢复之后还能继续第三轮对话
现在用户再次询问:
arduino
const userMessage3 =
new HumanMessage("有什么营养价值");
依旧按照相同流程:
csharp
await restoredHistory.addMessage(
userMessage3
);
然后:
ini
const messages3 = [
systemMessage,
...await restoredHistory.getMessages(),
];
模型真正看到的是:
sql
System:你是一个美食家
Human:这个🍅是啥?
AI:......
Human:好吃吗?
AI:......
Human:有什么营养价值?
所以即使程序已经经历过一次关闭和重新启动,模型仍然能继续之前的话题。
原因当然不是:
LLM 把昨天的聊天记住了
而是:
markdown
第一次程序运行
↓
聊天记录写入文件
↓
程序结束
↓
文件仍然存在
↓
第二次程序运行
↓
根据 filePath + sessionId
恢复聊天历史
↓
重新发送给 LLM
模型所谓的"长期记住",依然发生在模型外部。
十、所以"持久化 Memory"到底比 InMemory 多了什么?
现在可以把两者放在一起:
markdown
InMemoryChatMessageHistory
用户聊天
↓
历史保存在进程内存
↓
程序关闭
↓
历史消失
而持久化以后:
markdown
FileSystemChatMessageHistory
用户聊天
↓
历史写入文件
↓
程序关闭
↓
文件继续存在
↓
重新启动
↓
读取文件
↓
恢复聊天历史
因此:
Memory 是否持久化,和模型没有直接关系,本质是应用层的数据存储问题。
这也是理解 Memory 时一个很重要的转变。
前面我们关注的是:
模型怎么获得上一轮上下文?
现在开始关注:
这些上下文保存在哪里?
十一、为什么还需要 sessionId?
继续往真实应用推一步。
假设系统中同时有:
张三
李四
王五
如果所有人的消息全部混进同一份 History:
张三:我喜欢 JavaScript
李四:我喜欢 Python
王五:我正在学 React
下一次张三回来以后,显然不能把其他人的历史也发送给模型。
因此 Memory 不只是:
保存消息
还必须做到:
消息属于谁
或者更准确一点:
消息属于哪个会话
所以真实系统一般会存在类似:
userId
conversationId
threadId
sessionId
这样的标识。
最终数据关系可能类似:
session_001
├── message 1
├── message 2
└── message 3
session_002
├── message 1
├── message 2
└── message 3
调用模型之前,根据当前会话找到:
对应的 History
再发送给模型。
十二、文件 Memory 不是终点
这个 Demo 使用文件,并不意味着真实 AI 应用就应该把所有聊天记录都塞进一个 JSON 文件。
这里使用文件最重要的目的,是把:
Memory 持久化的完整过程
看清楚。
真实项目往往还需要考虑:
多用户
并发写入
查询效率
权限
数据量
分布式部署
过期策略
备份
于是存储可能进一步变成:
PostgreSQL
Redis
MongoDB
云数据库
......
但无论底层换成什么,核心模型没有改变:
markdown
用户消息
↓
持久化存储
↓
根据 session / conversation 查询
↓
恢复 History
↓
构造本次上下文
↓
调用 LLM
↓
模型回复
↓
再次持久化
这才是一个真正聊天系统中的 Memory。
十三、但解决持久化以后,一个更麻烦的问题出现了
现在我们已经能够做到:
今天聊天
↓
保存
明天回来
↓
恢复
一个月之后回来
↓
历史仍然存在
听起来很好。
但如果一个用户真的持续聊了几个月:
erlang
message 1
message 2
message 3
...
message 500
...
message 5000
难道每次调用模型,我们都执行:
ini
const messages = [
systemMessage,
...await history.getMessages(),
];
await model.invoke(messages);
把几千条消息全部重新发送给模型吗?
显然又不行。
所以 Memory 持久化解决了:
历史怎么不丢。
接下来还必须解决:
历史太多怎么办。
这就进入 Memory Management 的另一个核心问题:
上下文截断。