你有没有遇到过这种情况:跟 AI 聊了几轮之后,它突然问你"你叫什么名字"------而这个问题你第一轮就告诉过它了。
这不是 AI 故意装傻,而是因为大模型本质上是一个"金鱼脑袋" 。每一次调用大模型,都是一次独立的、无状态的请求。模型本身不会记得你上一轮说了什么,也不会主动保存任何历史信息。
那为什么我们平时用 ChatGPT 的时候,它好像记得住上下文呢?原因很简单:前端把所有的对话历史都打包塞进了每次请求里。每次你发一条新消息,前端都会把之前的"用户说 + 助手说"全部拼在一起,作为上下文一起发给模型。
这就是 Memory(记忆)要做的事情------在模型之外,帮它把对话历史存下来,下次再喂给它。
一、Memory 是什么?为什么它如此重要?
在 LangChain 的体系里,Agent 的完整形态可以理解为:
Agent = LLM + 工具(Tool)+ 检索增强(RAG)+ 记忆(Memory)+ 其他
模型本身负责"思考"和"生成",但它需要外部组件来帮助它完成更复杂的任务:
- 工具(Tool) :让模型不只是回答问题,还能执行操作,比如查天气、发邮件、调接口。
- RAG(检索增强生成) :根据用户的提问,从外部知识库(比如向量数据库)中检索相关信息,拼接到提示词里,让模型基于这些信息回答。
- Memory:保存对话历史,让模型在多轮对话中"记得"之前说过什么。
简单来说,Memory 解决的核心问题是:大模型记不住,我们帮它记。
二、最基础的 Memory:用数组存对话
在开始用 LangChain 的封装之前,我们先理解最朴素的做法。
假设你要让模型记住两轮对话,最直接的方式就是维护一个数组:
javascript
php
const messages = [];
// 第一轮
messages.push({ role: 'user', content: '我叫李四' });
// 调用模型,拿到回复后存起来
messages.push({ role: 'assistant', content: '你好李四!' });
// 第二轮
messages.push({ role: 'user', content: '我叫什么名字?' });
// 再次调用模型时,把整个 messages 数组传进去
每次请求都把整个数组发给模型,模型就能"看到"之前的对话了。
这个思路没问题,但存在几个明显的短板:
- 存在哪里? 数组存在内存里,程序一重启就丢了。
- 怎么管理? 对话越来越多,数组越来越大,每次请求都带全部历史,token 开销会爆炸。
- 怎么截断? 上下文窗口是有限的(比如 200k token),历史太长就得想办法丢掉一部分。
LangChain 提供了一套完善的 Memory 管理方案,帮我们解决这些问题。
三、InMemoryChatMessageHistory:把对话存在内存里
LangChain 中有一个基础类叫 InMemoryChatMessageHistory,它的作用就是在内存中维护一个对话历史数组 ,并提供标准的增删改查接口。
来看一个完整的例子:
javascript
php
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: {
baseURL: process.env.OPENAI_BASE_URL,
}
});
这段代码做了三件事:
- 加载环境变量(
dotenv/config),把 API Key 等敏感信息放在.env文件里。 - 初始化一个 ChatOpenAI 模型实例,指定模型名称、API Key、温度和自定义 Base URL。
- 从
@langchain/core中导入消息类和历史记录类。
关于 temperature 参数:它控制模型回答的随机程度。0 表示每次都给出最确定的答案,数值越高回答越多样、越"有创意"。
接下来我们创建历史记录实例并开始对话:
javascript
javascript
async function inMemoryDemo() {
// 创建一个内存历史记录实例
const history = new InMemoryChatMessageHistory();
// 系统提示词:设定 AI 的角色
const systemMessage = new SystemMessage(
"你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧"
);
// 第一轮:用户提问
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);
}
这里有几个关键点需要解释:
1. SystemMessage 和 HumanMessage 的区别
LangChain 中的消息分为几种类型:
- SystemMessage :系统提示词,用来设定 AI 的角色、行为准则和回答风格。这个消息通常放在对话的最前面,且不属于"对话历史"的一部分------它更像是一个"人设说明书"。
- HumanMessage:用户发送的消息。
- AIMessage :AI 返回的回复(在代码中,
model.invoke()返回的就是 AIMessage 实例)。
2. addMessage() 的作用
每一次调用 history.addMessage(),都是在往内部的 messages 数组里追加一条记录。这个数组就是 Memory 的核心载体。
3. getMessages() 的作用
获取当前存储的所有消息。注意,systemMessage 是单独定义的,没有通过 addMessage() 存入 history------因为系统提示词通常不需要被"记住",它每次都应该是固定的。
4. 为什么要把 response 也 add 进去?
如果不把 AI 的回复存入 history,那么下一轮对话时,模型只能看到用户的历史消息,却看不到自己之前说过什么------这就像一个人只记得别人说了什么,却不记得自己怎么回答的,对话依然是不完整的。
继续看第二轮对话:
javascript
typescript
// 第二轮:基于历史记录继续问
const userMessage2 = new HumanMessage("好吃吗?");
await history.addMessage(userMessage2);
const messages2 = [systemMessage, ...(await history.getMessages())];
const response2 = await model.invoke(messages2);
await history.addMessage(response2);
console.log(`助手:${response2.content}`);
// 打印所有历史记录
const allMessages = await history.getMessages();
console.log(`共保存了${allMessages.length}条对话`);
allMessages.forEach((msg, index) => {
const type = msg.type;
const prefix = type === 'human' ? '用户' : '助手';
console.log(`${index + 1}. [${prefix}]: ${msg.content.substring(0, 50)}...`);
});
}
第二轮中,history.getMessages() 返回的数组已经包含了第一轮的"用户问题 + AI 回答",所以模型能够结合上下文来回答"好吃吗?"这个问题------它知道上一轮讨论的是"今天吃的什么",自然能接上话。
InMemory 的优缺点:
| 优点 | 缺点 |
|---|---|
| 实现简单,上手快 | 程序重启后数据全部丢失 |
| 读写速度快 | 无法在多进程/多服务间共享 |
| 适合开发测试和短期对话 | 内存占用会随对话增长 |
四、FileSystemChatMessageHistory:把对话存到文件里
InMemory 的方式虽然方便,但数据没法持久化。如果你的应用需要重启后还能恢复之前的对话,就需要把历史记录存到外部存储中。
LangChain 提供了 FileSystemChatMessageHistory(旧版本叫 FileChatMessageHistory),可以把对话历史保存到本地文件里。
javascript
javascript
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system';
import path from 'node:path';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.API_BASE_URL,
}
});
与 InMemory 版本的区别在于,FileSystemChatMessageHistory 的构造函数需要两个额外参数:
javascript
ini
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001";
const history = new FileSystemChatMessageHistory({
filePath, // 存储文件的位置
sessionId, // 会话标识
});
sessionId 的作用 :同一个文件里可以存储多个不同会话的历史记录,sessionId 用来区分不同的对话。比如用户 A 和用户 B 的对话可以存在同一个文件中,通过不同的 sessionId 来隔离。
使用方式与 InMemory 完全一致:
javascript
ini
async function fileHistoryDemo() {
const history = new FileSystemChatMessageHistory({ filePath, sessionId });
const systemMessage = new SystemMessage('你是一个友好、幽默的做菜助手');
// 第一轮
const userMessage1 = new HumanMessage("红烧肉怎么做?");
await history.addMessage(userMessage1);
const messages1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(messages1);
await history.addMessage(response1);
// 第二轮
const userMessage2 = new HumanMessage("好吃吗?");
await history.addMessage(userMessage2);
const messages2 = [systemMessage, ...(await history.getMessages())];
const response2 = await model.invoke(messages2);
await history.addMessage(response2);
}
文件持久化的最大价值在于:恢复历史
假设程序重启了,或者用户第二天重新打开对话,我们可以直接从文件中读取之前的历史记录,而不需要用户重新说一遍:
javascript
javascript
async function restoreHistory() {
// 从文件系统恢复记忆
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件中恢复了${restoredMessages.length}条历史信息`);
// 第三轮对话:基于恢复的历史继续
const userMessage3 = new HumanMessage("需要哪些食材?");
await restoredHistory.addMessage(userMessage3);
const messages3 = [systemMessage, ...(await restoredHistory.getMessages())];
const response3 = await model.invoke(messages3);
await restoredHistory.addMessage(response3);
}
这段代码演示了一个非常实用的场景:程序重启后,从文件加载历史记录,继续与用户对话。用户完全感觉不到 AI 曾经"失忆"过。
文件存储的局限性:
- 适合单机应用,不适合分布式部署。
- 文件会不断变大,需要配合截断策略。
- 并发写入时可能存在文件锁问题。
五、上下文窗口管理:截断(Truncation)
不管是存在内存还是文件里,对话历史都会随着时间推移越来越长。而大模型的上下文窗口是有限的 ------比如某些模型支持 200k token,但超过这个限制就无法处理。
更关键的是,token 是要花钱的。每次请求携带的历史越长,消耗的 token 越多,成本越高。
所以我们需要对 Memory 进行管理。LangChain 提供了三种主流策略:截断、总结、检索 。这里先讲最直接的截断(Truncation) 。
5.1 按消息数量截断
最简单的方式就是只保留最近 N 条消息,把更早的丢掉。
javascript
javascript
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
async function messageCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 4;
// 模拟 8 条对话历史
const messages = [
{ type: 'human', content: '我叫李四' },
{ type: 'ai', content: '你好李四!' },
{ type: 'human', content: '我是一名设计师' },
{ type: 'ai', content: '设计师很有创造力!' },
{ type: 'human', content: '我喜欢艺术和音乐' },
{ type: 'ai', content: '艺术和音乐能激发灵感。' },
{ type: 'human', content: '我擅长 UI/UX 设计' },
{ type: 'ai', content: 'UI/UX 设计非常重要!' },
];
for (const msg of messages) {
if (msg.type === 'human') {
await history.addMessage(new HumanMessage(msg.content));
} else {
await history.addMessage(new SystemMessage(msg.content));
}
}
let allMessages = await history.getMessages();
// 只保留最后 4 条
const trimmedMessages = allMessages.slice(-maxMessages);
console.log(`保留消息数量:${trimmedMessages.length}`);
console.log('保留的消息:', trimmedMessages.map(
m => `${m.constructor.name}: ${m.content}`).join('\n'));
}
slice(-maxMessages) 是 JavaScript 数组的原生方法,表示从数组末尾取 N 个元素。这里的逻辑是:丢掉最早的消息,保留最近的消息。
这种方式的优点是实现简单,但缺点是没有考虑消息的长度。一条很长的消息和一条很短的消息占用同样"名额",显然不够精细。
5.2 按 Token 数量截断
更精确的做法是按 token 数量截断------保留尽可能多的最近消息,直到总 token 数不超过设定的阈值。
LangChain 提供了 trimMessages 函数来帮助我们完成这个任务:
javascript
javascript
import { trimMessages } from '@langchain/core/messages';
import { getEncoding } from 'js-tiktoken';
// 计算一组消息的总 token 数
function countTokens(messages, encoder) {
let total = 0;
for (const msg of messages) {
const content = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
total += encoder.encode(content).length;
}
return total;
}
async function tokenCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxTokens = 100;
// ... 添加同样的 8 条消息 ...
let allMessages = await history.getMessages();
const enc = getEncoding('cl100k_base'); // OpenAI 的编码器
const trimmedMessages = await trimMessages(allMessages, {
maxLength: maxTokens,
tokenCounter: async (msgs) => countTokens(msgs, enc),
strategy: 'latest', // 保留最新的消息
});
}
这里有几个新概念:
Token 是什么?
Token 是大模型处理文本的基本单位。它不是字符,也不是单词,而是一个介于两者之间的概念。比如"你好"可能是 2 个 token,"Hello"可能是 1 个 token。不同模型使用不同的 Tokenizer(分词器),所以计算方式也不一样。
getEncoding('cl100k_base') 是什么?
cl100k_base 是 OpenAI 目前使用的编码方案(用于 GPT-4、GPT-3.5-turbo 等模型)。getEncoding 函数返回一个编码器实例,调用 encode(text) 可以把文本拆分成 token 数组,.length 就是 token 数量。
trimMessages 的工作原理:
它使用二分查找 策略:从最新的消息开始,逐步向前尝试包含更多消息,直到总 token 数刚好不超过 maxLength 为止。因为消息数量与 token 总数是单调递增的(消息越多 token 越多),所以可以用二分查找来高效定位"能塞下多少条消息"。
strategy: 'latest' 的含义:
表示优先保留最新的消息。这是大多数场景下的合理选择------因为越早的对话对当前问题的影响越小。
六、三种 Memory 管理策略的对比
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 截断(Truncation) | 对话轮次有限,早期信息不重要 | 实现简单、计算开销小 | 可能丢失重要上下文 |
| 总结(Summarization) | 长对话,需要保留关键信息 | 压缩信息密度高 | 总结本身消耗 token,可能丢失细节 |
| 检索(Retrieval) | 超长对话,信息分散 | 按需召回相关历史 | 实现复杂,需要向量数据库 |
截断是最基础的管理手段,适合大多数场景。总结和检索会在后续文章中详细展开。
七、总结
这篇文章我们梳理了 LangChain 中 Memory 管理的核心概念和实践:
- 大模型是无状态的,Memory 的本质是在模型外部帮它"记住"对话历史。
- InMemoryChatMessageHistory 是最简单的实现,适合开发和测试,但数据无法持久化。
- FileSystemChatMessageHistory 把历史存到文件中,支持程序重启后恢复对话,通过
sessionId区分不同会话。 - 上下文窗口管理 是 Memory 的必修课。按消息数量截断(
slice)实现简单但不够精细;按 token 数量截断(trimMessages)更精确,通过二分查找在 token 预算内保留尽可能多的最近消息。 - Memory 管理的三种主流策略:截断、总结、检索,各有适用场景。
在实际开发中,Memory 的选择取决于你的应用场景:
- 简单的聊天机器人:InMemory + 截断就够了。
- 需要用户长期记忆的应用:文件持久化 + 定期截断。
- 企业级多用户系统:可能需要数据库存储 + 向量检索。
下一篇我们会深入聊总结(Summarization) ------当对话太长无法直接截断时,如何让 AI 自己"总结"历史,把精华压缩成简短的上下文。