在使用 ChatGPT 这类应用时,我们习惯了连续对话:
我叫小明。
我喜欢吃西红柿。
那你推荐几个适合我的菜?
模型似乎能够记住之前的信息。
但实际上,大语言模型本身并没有真正的"记忆"。
每一次调用,本质都是:
markdown
messages
↓
LLM
↓
response
模型只会根据当前传入的消息生成回复。
那么连续聊天是如何实现的?
答案是:
应用程序保存历史消息,并在下一次请求时重新发送给模型。
这就是 Memory 系统存在的原因。
本文通过一个简单 Demo,逐步实现:
手动维护 messages
↓
InMemoryChatMessageHistory
↓
FileSystemChatMessageHistory
理解 LangChain 中 Memory 的设计思想。
一、为什么大模型需要 Memory?
先看一次普通调用:
arduino
const response = await model.invoke(
"你好"
);
模型收到:
用户:
你好
返回:
助手:
你好,有什么可以帮助你的?
调用结束后,这次信息不会自动保存。
如果下一次:
arduino
const response = await model.invoke(
"我叫什么?"
);
模型并不知道。
因为它没有看到:
用户:
我叫小明
这条消息。
所以连续对话实际上是:
markdown
应用保存历史消息
↓
下一次调用重新传入
↓
模型根据完整上下文生成回答
例如:
第一次:
makefile
SystemMessage:
你是一个助手
HumanMessage:
我叫小明
模型返回:
makefile
AIMessage:
你好,小明
应用保存:
makefile
SystemMessage
HumanMessage:
我叫小明
AIMessage:
你好,小明
第二次用户输入:
我叫什么?
实际发送给模型:
makefile
SystemMessage
HumanMessage:
我叫小明
AIMessage:
你好,小明
HumanMessage:
我叫什么?
所以模型才能回答:
你叫小明。
二、最原始的 Memory:messages 数组
理解了原理后,最简单的实现就是维护一个数组。
ini
const messages = [];
每次用户输入:
arduino
messages.push(
new HumanMessage(
"我叫小明"
)
);
调用模型:
ini
const response =
await model.invoke(messages);
保存 AI 回复:
ini
messages.push(response);
最终:
yaml
[
SystemMessage,
HumanMessage:
我叫小明,
AIMessage:
你好,小明
]
下一轮:
arduino
messages.push(
new HumanMessage(
"我叫什么?"
)
);
再次:
arduino
model.invoke(messages)
模型就拥有了上下文。
对于简单 Demo,这没有问题。
但是进入真实应用:
css
用户 A:
1000 轮聊天
用户 B:
500 轮聊天
用户 C:
200 轮聊天
问题开始出现:
- 每个用户的历史如何隔离?
- 消息如何保存?
- 如何恢复历史?
- 如何替换存储方式?
如果所有地方都直接操作:
scss
messages.push()
代码会越来越难维护。
所以需要一个抽象。
三、InMemoryChatMessageHistory:抽象聊天历史管理
LangChain 提供:
ini
const history =
new InMemoryChatMessageHistory();
它负责管理聊天消息。
添加消息:
csharp
await history.addMessage(
new HumanMessage(
"我叫小明"
)
);
获取历史:
ini
const messages =
await history.getMessages();
然后:
arduino
model.invoke(messages);
它和数组有什么区别?
底层其实类似:
markdown
messages 数组
↓
ChatMessageHistory
但是区别在于:
数组只是数据结构。
而 History 是一个聊天历史管理抽象。
它定义了一套统一操作:
scss
addMessage()
getMessages()
例如,一个真实聊天流程可以封装成:
javascript
const history =
new InMemoryChatMessageHistory();
const systemMessage =
new SystemMessage(
"你是一个友好的助手"
);
async function chat(input) {
// 用户消息
const userMessage =
new HumanMessage(input);
// 保存用户消息
await history.addMessage(
userMessage
);
// 获取历史消息
const messages =
await history.getMessages();
// 调用模型
const response =
await model.invoke([
systemMessage,
...messages
]);
// 保存 AI 回复
await history.addMessage(
response
);
// 返回结果
return response.content;
}
调用:
javascript
console.log(
await chat("我喜欢吃西红柿")
);
console.log(
await chat("我喜欢吃什么?")
);
执行流程:
第一次:
bash
HumanMessage:
我喜欢吃西红柿
↓
history 保存
↓
发送给模型
↓
AIMessage 保存
第二次:
makefile
HumanMessage:
我喜欢吃什么?
↓
history 中已有:
HumanMessage:
我喜欢吃西红柿
AIMessage:
记住了,你喜欢吃西红柿
↓
一起发送给模型
这里有一个重要设计:
SystemMessage 不属于聊天历史
SystemMessage:
你是一个友好的助手
作用:
定义模型行为。
而 History:
保存:
HumanMessage
AIMessage
也就是用户和模型之间真实发生的交流。
所以一次模型调用结构通常是:
css
[ SystemMessage, 历史消息, 当前用户消息]
四、InMemory 的问题:程序关闭后怎么办?
现在:
scss
new InMemoryChatMessageHistory()
已经可以完成聊天。
但是它有一个限制:
它存在于:
Node.js 进程内存
例如:
程序运行:
用户:
我喜欢吃西红柿
AI:
记住了
内存:
css
[ HumanMessage, AIMessage]
关闭程序:
进程结束
历史消失。
再次启动:
scss
new InMemoryChatMessageHistory()
得到:
css
[]
所以 Memory 还有一个问题:
历史消息应该保存多久?
这就是生命周期问题。
五、FileSystemChatMessageHistory:让 Memory 持久化
如果希望:
程序关闭后还能继续聊天。
消息就不能只存在内存。
需要持久化存储。
例如:
arduino
const history =
new FileSystemChatMessageHistory({
filePath:
"./chat_history.json",
sessionId:
"user_001"
});
现在:
scss
history.addMessage()
不再只是:
perl
数组 push
而是:
写入文件
生成:
chat_history.json
保存:
json
{
"user_001": {
"messages": [
{
"type": "human",
"content": "我喜欢吃西红柿"
},
{
"type": "ai",
"content": "记住了,你喜欢吃西红柿"
}
]
}
}
注意:
业务代码没有变化。
仍然:
scss
history.addMessage()
history.getMessages()
变化的是:
底层存储。
这就是抽象层的价值:
markdown
业务代码
↓
ChatMessageHistory
↓
具体实现
InMemory
FileSystem
Redis
Database
六、sessionId:解决多用户隔离问题
持久化以后,又出现新的问题:
多个用户怎么办?
例如:
用户 A:
我喜欢吃西红柿
用户 B:
我喜欢吃牛肉
如果没有隔离:
所有消息会混在一起。
所以需要:
sessionId
例如:
vbnet
sessionId: "user_001"
表示:
用户001 的聊天空间
另一个用户:
vbnet
sessionId: "user_002"
拥有自己的历史。
最终:
json
{
"user_001": {
"messages": []
},
"user_002": {
"messages": []
}
}
七、总结:Memory 的本质
通过这一条主线,我们实现:
messages 数组
↓
InMemoryChatMessageHistory
↓
FileSystemChatMessageHistory
理解了 Memory 的核心设计。
Memory 并不是:
让模型真正拥有记忆。
而是:
应用程序负责保存、管理和恢复消息历史,让模型在每次调用时获得需要的上下文。
其中:
存储方式
决定:
消息保存在哪里。
例如:
arduino
InMemory
FileSystem
Redis
Database
生命周期
决定:
消息存在多久。
例如:
进程生命周期
文件生命周期
数据库生命周期
sessionId
决定:
不同用户、不同会话如何隔离。
到这里,我们解决的是:
Memory 如何保存消息。
但是新的问题出现:
如果用户聊天几百轮:
yaml
1000 条消息
是不是每次都:
arduino
model.invoke([
...messages
])
?
显然不是。
下一阶段需要解决:
Memory 中越来越多的历史消息,应该如何管理?
这会进入:
- 消息截断(Truncation)
- 历史总结(Summarization)
- 语义检索(Retrieval Memory)