我们平时使用 ChatGPT 时,会很自然地进行这样的对话:
用户:这个🍅是啥?
AI:这是番茄。
用户:好吃吗?
AI:......
第二句话里的"好吃吗"其实没有明确说:
番茄好吃吗?
但模型依然能够理解我们问的是番茄。
这很容易让人产生一个错觉:
大模型是不是自己记住了上一轮对话?
实际上并不是。
理解 Memory,最重要的一件事,就是先接受一个事实:
LLM 本身是无状态的(Stateless)。
所谓 Memory,本质上是应用程序在模型外部保存历史信息,然后在下一次调用模型时,把这些信息重新放进 Prompt。
一、LLM 本身没有"上一轮对话"
假设第一次调用模型:
arduino
const response1 = await model.invoke([
new HumanMessage("这个🍅是啥?"),
]);
模型可能回答:
这是番茄。
然后我们进行第二次调用:
arduino
const response2 = await model.invoke([
new HumanMessage("好吃吗?"),
]);
对于第二次请求来说,模型实际上只能看到:
好吃吗?
它并不知道前面讨论过番茄。
因为对于模型 API 来说,两次 invoke() 本质上是两次完全独立的请求:
请求 1
↓
LLM
↓
响应 1
请求 2
↓
LLM
↓
响应 2
请求结束以后,模型不会在内部自动保存这次对话。
所以:
LLM ≠ 自带聊天记忆
如果希望模型记住上下文,就必须由我们的程序负责保存。
二、最简单的 Memory:一个 messages 数组
其实我们之前已经实现过最原始版本的 Memory。
例如:
arduino
const messages = [
new SystemMessage("你是一个美食家"),
new HumanMessage("这个🍅是啥?"),
new AIMessage("这是番茄"),
new HumanMessage("好吃吗?"),
];
然后把整个数组再次传给模型:
arduino
await model.invoke(messages);
模型看到的实际上是:
sql
System:你是一个美食家
Human:这个🍅是啥?
AI:这是番茄
Human:好吃吗?
所以模型自然就知道:
"好吃吗?"
是在继续讨论番茄。
这就是所谓的:
Conversation Memory。
它并没有什么神秘的。
本质仍然只是:
markdown
保存历史消息
↓
下一轮重新取出来
↓
和新消息一起发送给 LLM
因此最简单的 Memory 完全可以写成:
ini
const messages = [];
每轮对话:
ini
messages.push(userMessage);
const response = await model.invoke(messages);
messages.push(response);
就已经拥有最基础的"记忆"能力了。
三、为什么还要 InMemoryChatMessageHistory?
既然数组就能实现 Memory,那么 LangChain 为什么还要提供:
InMemoryChatMessageHistory
原因并不是它拥有某种神奇的 AI 记忆能力。
而是因为:
它把"消息历史管理"从普通数组提升成了一个专门的抽象。
例如创建:
ini
const history = new InMemoryChatMessageHistory();
添加消息:
csharp
await history.addMessage(userMessage);
获取历史:
ini
const messages = await history.getMessages();
过去我们可能自己维护:
scss
messages.push(...)
现在则变成:
scss
history.addMessage(...)
也就是说:
普通数组
↓
ChatMessageHistory
↓
专门负责管理聊天历史
这一步非常重要。
因为后面我们可能不再把消息保存在 JavaScript 内存中,而是保存在:
Redis
数据库
文件
云端存储
但是对于上层业务来说,我们希望调用方式尽量保持一致:
scss
history.addMessage(...)
history.getMessages()
这就是抽象的价值。
四、手写一个 InMemory Memory Demo
来看一个完整例子。
javascript
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import {
HumanMessage,
SystemMessage,
} from "@langchain/core/messages";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
basePath: process.env.OPENAI_API_BASE_URL,
},
});
async function inMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const systemMessage = new SystemMessage(
"你是一个美食家"
);
console.log("[第一轮对话]");
const userMessage1 =
new HumanMessage("这个🍅是啥?");
await history.addMessage(userMessage1);
const messages1 = [
systemMessage,
...(await history.getMessages()),
];
const response1 =
await model.invoke(messages1);
console.log(
`[模型回复] ${response1.content}`
);
await history.addMessage(response1);
console.log("[第二轮对话 基于历史对话]");
const userMessage2 =
new HumanMessage("好吃吗😋?");
await history.addMessage(userMessage2);
const messages2 = [
systemMessage,
...(await history.getMessages()),
];
const response2 =
await model.invoke(messages2);
console.log(
`[模型回复] ${response2.content}`
);
await history.addMessage(response2);
const allMessages =
await history.getMessages();
console.log(
`[所有对话历史]: 一共 ${allMessages.length} 条`
);
allMessages.forEach((message, index) => {
console.log(
`第 ${index + 1} 条: [${message.role}] ${message.content}`
);
});
}
inMemoryDemo()
.catch(console.error)
.finally(() => {
console.log("Done");
});
这段代码最值得理解的不是 API,而是数据到底是怎样流动的。
五、第一轮发生了什么?
首先创建 Memory:
ini
const history =
new InMemoryChatMessageHistory();
此时可以想象成:
ini
history = []
只是实际的数据由 LangChain 帮我们管理。
用户发送第一句话:
arduino
const userMessage1 =
new HumanMessage("这个🍅是啥?");
加入 Memory:
csharp
await history.addMessage(userMessage1);
此时历史大致相当于:
Human:这个🍅是啥?
接下来:
ini
const messages1 = [
systemMessage,
...(await history.getMessages()),
];
最终传给模型的是:
sql
System:你是一个美食家
Human:这个🍅是啥?
模型生成:
AI:这是番茄......
但是这里还有一个非常关键的步骤:
csharp
await history.addMessage(response1);
必须把 AI 的回答也保存下来。
因此此时 Memory 变成:
Human:这个🍅是啥?
AI:这是番茄......
这就是一轮完整对话。
六、第二轮为什么知道"好吃吗"指的是番茄?
第二轮:
arduino
const userMessage2 =
new HumanMessage("好吃吗😋?");
加入历史以后:
Human:这个🍅是啥?
AI:这是番茄......
Human:好吃吗😋?
然后:
ini
const messages2 = [
systemMessage,
...(await history.getMessages()),
];
真正发送给模型的是:
sql
System:你是一个美食家
Human:这个🍅是啥?
AI:这是番茄......
Human:好吃吗😋?
注意:
模型不是"回忆起"了番茄。
而是我们的程序再次把:
这个🍅是啥?
这是番茄......
发送给了它。
所以 Memory 的核心流程可以总结成:
用户消息
↓
写入 Memory
↓
读取历史消息
↓
历史 + 当前问题
↓
发送给 LLM
↓
获得 AI 回复
↓
AI 回复写回 Memory
这才是大模型聊天应用所谓"有记忆"的真正原理。
七、这里还有一个小细节:SystemMessage 没有进入 Memory
Demo 中的:
arduino
const systemMessage =
new SystemMessage("你是一个美食家");
并没有执行:
ini
history.addMessage(systemMessage);
而是每轮请求时重新拼进去:
csharp
[
systemMessage,
...(await history.getMessages())
]
因此最终:
ini
const allMessages =
await history.getMessages();
里面保存的是:
markdown
1. Human:这个🍅是啥?
2. AI:......
3. Human:好吃吗?
4. AI:......
一共四条。
SystemMessage 不在 history 中。
这种做法也很容易理解:
SystemMessage
负责定义 AI 的角色和规则
History
负责保存用户与 AI 的聊天过程
于是每轮请求:
diff
系统规则
+
聊天历史
+
当前问题
组成最终发送给 LLM 的上下文。
八、到这里似乎已经解决 Memory 了,但真正的问题才刚开始
如果聊天只有两三轮:
Human
AI
Human
AI
把所有历史消息都发送给模型完全没问题。
但如果用户连续聊了几百轮呢?
Memory 可能逐渐变成:
erlang
message 1
message 2
message 3
...
message 1000
...
而每次调用模型时,我们都执行:
ini
await model.invoke([
systemMessage,
...allHistory
]);
那么意味着:
每一次请求都要把越来越多的历史重新发送给模型。
于是马上出现两个问题。
1. 上下文窗口有限
模型一次能够处理的 Token 数量并不是无限的。
Prompt、历史消息、RAG 内容、Tool Result、模型输出等,都会占用上下文。
可以把一次 Agent 请求粗略理解成:
diff
System Prompt
+
Conversation Memory
+
RAG Context
+
Tool Result
+
Current Question
+
Model Output
它们共同竞争同一个上下文空间。
所以 Conversation Memory 不可能无限增长。
2. Token 成本越来越高
假设第一轮只有:
100 tokens
第二轮:
200 tokens
几十轮以后可能已经变成:
几千
甚至几万 tokens
而且每次请求都要重新输入这些历史。
于是:
历史越长
↓
Prompt 越长
↓
Token 消耗越高
↓
调用成本越高
↓
响应也可能越来越慢
所以 Memory 管理真正困难的地方并不是:
怎么把消息存进数组。
而是:
哪些信息值得继续放进模型上下文?
九、Memory 的三个核心问题
到这里,我们就可以重新理解所谓的 Memory Management。
它主要解决三个问题:
Memory
├── 上下文大小
├── Token 成本
└── 持久化
十、第一个问题:上下文不能无限增长
最简单的方法就是:
截断。
例如:
ini
const recentMessages =
messages.slice(-4);
只保留最近四条消息。
假设原本:
message1
message2
message3
message4
message5
message6
message7
message8
经过:
scss
slice(-4)
只剩:
message5
message6
message7
message8
这叫做:
Sliding Window / 截断式 Memory。
优点非常明显:
简单
速度快
成本可控
但缺点同样明显。
例如用户很早以前说:
以后代码示例全部使用 JavaScript。
几十轮之后这条消息被截断了。
模型就"忘记"了。
所以截断适合保存:
最近上下文。
但不适合保存真正重要的长期信息。
十一、第二种方案:总结
另一种思路是:
不保存所有原始对话,而是把过去的内容总结。
例如原始对话可能有几十轮:
用户学习 JavaScript
用户正在学习 React
用户不理解闭包
已经解释了作用域
已经解释了词法作用域
用户最终理解了闭包
Memory 可以将它压缩成:
用户使用 JavaScript,
正在学习 React。
此前学习过闭包和词法作用域,
目前已经理解闭包的基本原理。
于是原本几千 Token 的聊天历史:
大量 message
变成一小段:
css
Summary
之后模型接收:
diff
System Prompt
+
Conversation Summary
+
Recent Messages
+
Current Question
这样既能保留较早的信息,又不用把全部原始对话重新发送。
这就是:
Summary Memory。
十二、第三种方案:检索
还有一种更加适合长期 Memory 的方式:
不把所有记忆都塞进 Prompt,而是在需要的时候搜索。
例如长期记忆里保存:
用户使用 JavaScript
用户喜欢先理解暴力解再学习最优解
用户正在学习 React
用户之前学习过 JWT
用户做过 Docker Demo
......
现在用户问:
继续学 JWT。
Memory 系统可以根据当前 Query:
JWT
检索相关记忆:
用户之前学习过 JWT,
做过登录 Token Demo,
主要困惑是 Token 签发和验证流程。
然后只把这些相关内容放进 Prompt。
流程变成:
当前 Query
↓
搜索 Memory
↓
找到相关历史
↓
Relevant Memory
↓
加入 Prompt
↓
LLM
这和 RAG 的思想其实非常接近。
只是:
RAG
检索外部知识
Memory Retrieval
检索与用户、会话、Agent 有关的历史信息
十三、于是 Memory 有三种非常重要的管理思路
可以把目前的知识压缩成:
markdown
Memory 管理
1. 截断
slice(-N)
2. 总结
历史对话 → Summary
3. 检索
Query → 搜索相关历史 → Prompt
它们解决的是不同问题。
截断
保留:
最近发生了什么
总结
保留:
前面的对话整体讲了什么
检索
寻找:
当前问题需要哪些过去的信息
实际系统通常不会只使用一种策略,而可能组合使用:
diff
长期 Memory 检索
+
历史 Summary
+
最近几轮原始消息
最终形成模型真正需要的上下文。
十四、临时记忆和长期记忆
临时记忆
例如:
ini
const history =
new InMemoryChatMessageHistory();
这些消息存在当前程序进程的内存中。
程序运行时:
有
程序退出或者服务重新启动:
没了
因此它更像:
Session Memory / 临时会话记忆。
适合:
当前这次聊天
当前任务
当前 Agent 执行
长期记忆
如果希望用户:
今天退出程序
明天重新打开以后,Agent 仍然知道:
用户之前学过什么
用户有哪些偏好
任务做到哪一步
就不能只依赖:
InMemoryChatMessageHistory
而需要把记忆存入真正的持久化系统,例如:
数据库
Redis
文件
向量数据库
其他持久化存储
因此:
kotlin
Memory
├── Short-term Memory
│ 当前对话上下文
│
└── Long-term Memory
跨 Session 保存的信息
十五、Memory 和 Agent 是什么关系?
理解完 Memory,再来看:
ini
Agent = LLM + Harness
就容易很多。
一个比较完整的 Agent 系统可能包含:
sql
Agent
│
├── LLM
│
├── System Prompt
│
├── Tools
│
├── RAG
│
├── Memory
│
└── Agent Loop
例如一个编程 Agent。
用户说:
go
帮我修改 package.json。
LLM 本身只能生成文本。
但 Harness 可以给它提供:
read_file
write_file
run_command
search_code
这样的工具。
于是模型不只是:
回答问题
还可以:
读取文件
修改代码
执行命令
检查结果
继续修改
这就是 Tool 带来的能力。
十六、RAG、Tool 和 Memory 不要混在一起
这三个概念非常容易混。
可以这样区分。
Tool
解决:
Agent 能做什么?
例如:
搜索网页
运行终端
读取文件
发送邮件
查询数据库
RAG
解决:
Agent 从哪里获得相关知识?
例如用户问:
公司的退款流程是什么?
Agent 可以:
Query
↓
搜索知识库
↓
找到退款文档
↓
加入 Prompt
↓
回答
Memory
解决:
Agent 需要记住什么?
例如:
之前聊了什么
任务执行到了哪里
用户有哪些长期偏好
Agent 已经尝试过哪些操作
所以三者分别可以理解成:
Tool
→ 能力
RAG
→ 外部知识
Memory
→ 历史与状态
它们不是谁必须依赖谁才能存在。
但在复杂 Agent 中,它们通常会一起工作。
十七、为什么 Agent 尤其需要 Memory?
普通聊天可能只有:
sql
User
↓
LLM
↓
Answer
但是 Agent 往往会执行多步任务。
例如:
用户:
帮我修复这个项目的登录 Bug
Agent 可能执行:
Thought
↓
读取 login.ts
Observation
↓
发现 token 解析代码
Thought
↓
继续读取 auth.ts
Observation
↓
发现验证逻辑
Thought
↓
修改代码
Action
↓
运行测试
Observation
↓
测试失败
Thought
↓
继续修改
这就是典型的 Agent Loop。
无论具体框架是否直接叫做 ReAct,它都需要不断维护:
之前做了什么
工具返回了什么
当前任务进展到哪里
下一步应该做什么
这些信息最终都会以某种形式进入:
perl
messages / state
所以对于 Agent 来说:
Memory 不只是"聊天记录"。
它还可能承担:
任务状态
工具调用历史
中间结果
计划
用户偏好
过去经验
十八、重新理解 messages
学到这里以后,再看到:
ini
const messages = [...]
它就不再只是一个简单数组了。
对于现代 LLM 应用来说,messages 实际上就是:
模型当前能够看到的世界。
模型不知道数据库里有什么。
不知道几十轮以前发生了什么。
不知道工具刚才执行过什么。
也不知道用户一个月以前说过什么。
除非应用程序把这些信息重新组织成:
messages
发送给它。
因此一个 Agent 系统非常重要的一件事,就是不断决定:
下一次调用 LLM 时,到底应该让模型看到什么?
最终可能形成:
sql
messages
│
├── System Prompt
│
├── Long-term Memory
│
├── Conversation Summary
│
├── Recent Messages
│
├── RAG Context
│
├── Tool Results
│
└── Current User Message
这其实就是很多 Agent 框架背后的核心问题。
十九、从一个数组,到真正的 Memory 系统
现在重新回头看整个学习过程。
最开始只是:
ini
const messages = [];
然后发现:
模型本身没有记忆
所以:
messages 保存历史
接着使用:
InMemoryChatMessageHistory
把消息管理抽象出来。
但随后又发现:
聊天历史不能无限增长
于是需要:
截断
总结
检索
然后继续发现:
内存里的消息重启就消失
于是又出现:
持久化
长期 Memory
最终进入 Agent 以后,Memory 管理的已经不仅仅是:
HumanMessage
AIMessage
还可能包括:
用户信息
任务状态
Agent 执行轨迹
工具执行结果
历史经验
于是整条知识线就连起来了:
markdown
LLM 是 Stateless
↓
模型不会自动记住历史
↓
手动维护 messages
↓
InMemoryChatMessageHistory
↓
抽象聊天历史管理
↓
历史越来越长
↓
上下文限制 + Token 成本
↓
截断 / 总结 / 检索
↓
临时 Memory / 长期 Memory
↓
成为 Agent 状态管理的重要组成部分
二十、最后记住这几个结论
如果以后再看到 Memory,可以先忘掉各种框架 API,只记住下面几句话。
第一,大模型本身是无状态的。
所谓"记得上一轮",其实是应用程序把上一轮消息再次发送给了模型。
第二,Memory 的最原始形态就是 messages。
css
[ HumanMessage, AIMessage, HumanMessage, AIMessage]
就是最基础的 Conversation Memory。
第三,InMemoryChatMessageHistory 只是把消息历史管理抽象了出来。
它解决的是:
怎么管理 message
而不是自动解决所有 Memory 问题。
第四,真正的 Memory Management 关注的是:
上下文长度
Token 成本
持久化
相关性
第五,Memory 常见的三个处理手段是:
截断
总结
检索
第六,在 Agent 中要区分三个概念:
Tool
→ 让 Agent 能做事情
RAG
→ 给 Agent 找外部知识
Memory
→ 让 Agent 保存历史和状态
而整个 Agent 系统真正不断在做的一件事情,就是:
在有限的上下文窗口里,把当前最有价值的信息组织好,再交给 LLM。
这才是理解 Memory 最核心的一条主线。