程序重启后,AI 为什么把你忘了?从 InMemory 到持久化 Memory

摘要: 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 内存虽然没了,但是文件还在。

这就是持久化最核心的区别。


四、filePathsessionId 分别在解决什么问题?

创建 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 的另一个核心问题:

上下文截断。

相关推荐
ZGIAI21 分钟前
客服知识库更新后,怎么批量验收
人工智能·架构
Asize1 小时前
AI 协作开发新范式:我用 SDD 做了个排版 npm 包
前端·人工智能
IT_陈寒3 小时前
用了Proxy才发现以前的JavaScript白写了
前端·人工智能·后端
甲维斯4 小时前
Claude Opus5手搓“NewAPI Plus”首轮成果!
人工智能
程序猿DD4 小时前
分享两个我每天都在用的 Skill,拖进豆包就能跑,限时领 30 天会员
人工智能
阿里云大数据AI技术4 小时前
Agentic Search 2.0:从单轮对话迈向企业级 AI 搜索自动驾驶Agent
人工智能·elasticsearch·agent
东风破_4 小时前
从 messages 数组到 Memory:大模型到底是怎么“记住”上一轮对话的?
人工智能
AEI4 小时前
四年间大模型的演进历程
人工智能·面试·程序员
狂师5 小时前
阿里开源:skill-up,一款Agent Skill 评测工具!
人工智能·开源·agent