LangChain.js Agent Memory 实战(上):从内存对话、文件持久化到截断与摘要
大模型本身是无状态的。
第一次请求时告诉模型"我叫李四",第二次只问"我擅长什么",如果第二次请求里没有再次携带第一轮内容,模型并不会天然记得"李四"是谁。我们平时感受到的连续对话,本质上是应用程序保存了历史消息,并在下一次调用模型时把这些消息重新放进上下文。
如果把 Agent 简化成下面这个结构:
text
Agent = LLM + Harness(Tool + RAG + Memory + ...)
那么 Memory 就是 Harness 中负责"记住并管理过去信息"的部分。Tool 的执行结果、RAG 检索到的资料、用户与 AI 的往来消息,最终都可能进入模型上下文,因此也都会受到上下文窗口和调用成本的约束。
本文使用 LangChain.js,从最简单的内存消息历史开始,逐步实现:
- 用
InMemoryChatMessageHistory维护多轮对话; - 用
FileSystemChatMessageHistory持久化并恢复会话; - 按消息条数和 Token 数截断上下文;
- 把较早的对话总结成摘要,同时保留最近消息。
下篇再把这条路线延伸到 Milvus:不再把全部历史塞给模型,而是按照当前问题检索相关记忆。
一、先建立 Memory 的整体认识
Memory 可以从两个维度理解。
1. 存储逻辑:消息放在哪里
- 内存:读写直接,但进程退出后数据消失;
- 文件:可以跨进程恢复,适合演示持久化;
- 数据库:适合保存更多长期数据,并支持后续查询或检索。
2. 管理逻辑:哪些消息进入上下文
- 截断:只保留最近若干条消息;
- 总结:把较早消息压缩成一段摘要;
- 检索:根据当前问题找出语义相关的历史消息。
存储和管理不是一回事。把所有消息保存到文件或数据库,并不代表每次都应该把它们全部发送给模型。可以完整保存长期历史,但每次只选择其中一部分作为当前上下文。
从使用时间上还可以把记忆分成两类:
- 短期记忆:当前会话中最近的消息;
- 长期记忆:跨会话保存的文件、摘要或向量数据库记录。
Agent 在 ReAct 执行过程中同样需要持续维护 messages:用户输入、AI 返回以及工具执行结果会沿着执行流程不断追加,Memory 则负责这些消息的保存与取用。本文的示例集中在用户和 AI 对话;当 Agent 调用工具时,工具结果还可以表示为 ToolMessage,管理原则并没有改变。
本文先解决短期记忆及其压缩问题。
二、准备模型和环境变量
示例采用 ESM 模块,主要依赖如下:
json
{
"dependencies": {
"@langchain/community": "^1.1.29",
"@langchain/core": "^1.2.11",
"@langchain/openai": "^1.5.13",
"dotenv": "^17.4.2",
"js-tiktoken": "^1.0.21"
}
}
环境变量至少需要提供模型名称、API Key 和服务地址:
dotenv
MODEL_NAME=你的聊天模型名称
OPENAI_API_KEY=你的API-Key
OPENAI_BASE_URL=你的模型服务地址
通过 dotenv/config 加载变量,再创建一个温度为 0 的聊天模型:
js
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
后面的示例都会复用这段配置。
三、消息不是普通字符串:认识三种角色
LangChain.js 用消息对象表示一次对话中的不同角色:
js
import {
HumanMessage,
AIMessage,
SystemMessage,
} from "@langchain/core/messages";
SystemMessage:定义助手身份和总体行为;HumanMessage:用户发送的内容;AIMessage:模型返回的内容。
例如:
js
const systemMessage = new SystemMessage(
"你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧"
);
const userMessage = new HumanMessage("你今天吃的什么?");
消息对象不只有 content。从文件恢复的 AI 消息还可能带有模型名称、Token 用量、结束原因等响应元数据。不过在构造对话上下文时,最关键的仍然是消息角色、顺序和正文。
四、第一版 Memory:在内存中维护消息历史
InMemoryChatMessageHistory 把原本需要手动管理的消息数组封装成一个历史记录对象。它提供三个最常用的操作:
addMessage(message):添加一条消息;getMessages():按顺序取出全部消息;clear():清空历史。
下面实现两轮连续对话。第二轮只问"好吃吗?",如果模型能够理解它在追问第一轮内容,就说明历史上下文已经生效。
js
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,
},
});
async function inMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const systemMessage = new SystemMessage(
"你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧"
);
console.log("[第 1 轮对话]");
const userMessage1 = new HumanMessage("你今天吃的什么?");
await history.addMessage(userMessage1);
const messages1 = [
systemMessage,
...(await history.getMessages()),
];
const response1 = await model.invoke(messages1);
console.log(`助手:${response1.content}\n`);
await history.addMessage(response1);
console.log("[第 2 轮对话]");
const userMessage2 = new HumanMessage("好吃吗?");
await history.addMessage(userMessage2);
const messages2 = [
systemMessage,
...(await history.getMessages()),
];
const response2 = await model.invoke(messages2);
console.log(`助手:${response2.content}\n`);
await history.addMessage(response2);
const allMessages = await history.getMessages();
console.log(`共保存 ${allMessages.length} 条消息`);
allMessages.forEach((message, index) => {
const prefix = message.type === "human" ? "用户" : "助手";
console.log(
`${index + 1}.[${prefix}]:${message.content.substring(0, 50)}...`
);
});
}
inMemoryDemo()
.catch(console.error)
.finally(() => console.log("done"));
1. 一轮调用到底发生了什么
每一轮都遵循相同流程:
text
创建 HumanMessage
↓
写入 history
↓
SystemMessage + history 中的全部消息
↓
model.invoke(messages)
↓
得到 AIMessage
↓
把 AIMessage 写回 history
最容易漏掉的是最后一步。model.invoke() 只返回本轮的 AIMessage,不会自动替应用维护历史。如果没有执行 history.addMessage(response),下一轮上下文里就只有用户消息,没有上一轮的 AI 回答。
2. 为什么 SystemMessage 没有放进 history
这里把系统消息作为固定配置,每次调用时临时放在消息数组最前面:
js
const messages = [systemMessage, ...(await history.getMessages())];
因此,history 只维护用户与助手的往来内容,系统角色不会被重复追加。
3. 消息条数不等于对话轮数
两轮完整对话通常包含四条消息:
text
HumanMessage → AIMessage → HumanMessage → AIMessage
所以 allMessages.length 表示的是消息条数 ,不是轮数。完整问答成对保存时,轮数才可以近似理解为 allMessages.length / 2。一旦加入工具消息、系统摘要或不完整问答,这种除以二的算法也不再成立。
4. 这一版的边界
它已经让模型拥有连续对话能力,但历史只存在于当前 Node.js 进程中。应用停止后重新启动,内存对象会被重新创建,过去的消息随之消失。
五、把消息持久化到 JSON 文件
要跨进程恢复历史,可以把 InMemoryChatMessageHistory 换成 FileSystemChatMessageHistory:
js
import { FileSystemChatMessageHistory }
from "@langchain/community/stores/message/file_system";
import path from "node:path";
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001";
const history = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
两个参数分别解决两个问题:
filePath决定历史写入哪个 JSON 文件;sessionId决定读写文件中的哪一个会话。
同一个文件可以按照会话 ID 区分消息。实际应用中,可以为不同用户或不同会话分配不同的 sessionId。
需要注意,process.cwd() 表示启动 Node.js 命令时所在的工作目录,不是当前模块文件所在目录。换一个目录启动脚本,最终得到的文件路径也会变化。
1. 写入两轮对话
文件历史的使用方式与内存历史几乎一致:
js
async function fileHistoryDemo() {
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001";
const history = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
const systemMessage = new SystemMessage(
"你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧"
);
const userMessage1 = new HumanMessage("红烧肉怎么做?");
await history.addMessage(userMessage1);
const response1 = await model.invoke([
systemMessage,
...(await history.getMessages()),
]);
await history.addMessage(response1);
const userMessage2 = new HumanMessage("好吃吗?");
await history.addMessage(userMessage2);
const response2 = await model.invoke([
systemMessage,
...(await history.getMessages()),
]);
await history.addMessage(response2);
}
变化只发生在存储层:addMessage() 会把可序列化的消息内容写入文件,getMessages() 会把文件中的记录重新还原成 HumanMessage、AIMessage 等对象。
JSON 中保存的不只是纯文本,大致会包含下面这些层次:
json
{
"user_session_001": {
"messages": [
{
"type": "human",
"data": {
"content": "红烧肉怎么做?",
"additional_kwargs": {},
"response_metadata": {}
}
},
{
"type": "ai",
"data": {
"content": "模型生成的回答",
"response_metadata": {
"tokenUsage": {
"promptTokens": 34,
"completionTokens": 941,
"totalTokens": 975
}
}
}
}
]
}
}
真实记录里还可能有 AI 消息 ID、模型名称、工具调用和更完整的用量信息。应用不必手动解析这些字段,继续通过 getMessages() 读取即可。
2. 重启后恢复并继续第三轮
只要 filePath 与 sessionId 保持一致,新创建的历史对象就能读取之前的记录:
js
async function restoreHistoryDemo() {
const filePath = path.join(process.cwd(), "chat_history.json");
const sessionId = "user_session_001";
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件中恢复 ${restoredMessages.length} 条历史消息`);
restoredMessages.forEach((message, index) => {
const prefix = message.type === "human" ? "用户" : "助手";
console.log(
`${index + 1}.[${prefix}]:${message.content.substring(0, 50)}...`
);
});
const systemMessage = new SystemMessage(
"你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧"
);
const userMessage3 = new HumanMessage("需要哪些食材?");
await restoredHistory.addMessage(userMessage3);
const response3 = await model.invoke([
systemMessage,
...(await restoredHistory.getMessages()),
]);
console.log(`助手:${response3.content}`);
await restoredHistory.addMessage(response3);
console.log("对话已经保存到文件中");
}
"需要哪些食材?"没有明确说明是哪道菜,但恢复出来的上下文中已经包含"红烧肉怎么做",模型便可以沿着原话题继续回答。
持久化解决了"重启后丢失"的问题,却带来了另一个问题:文件会越来越大,每次传给模型的上下文也会越来越长。因此,接下来需要把完整保存 和有限使用分开处理。
六、最直接的上下文管理:按消息条数截断
先准备八条消息,即四轮对话:
js
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 设计非常重要,好的用户体验能让产品更成功!" },
];
把普通对象转换成 LangChain 消息并写入历史:
js
for (const message of messages) {
if (message.type === "human") {
await history.addMessage(new HumanMessage(message.content));
} else {
await history.addMessage(new AIMessage(message.content));
}
}
如果只保留最后四条消息,JavaScript 的 slice() 就足够了:
js
const maxMessages = 4;
const allMessages = await history.getMessages();
const trimmedMessages = allMessages.slice(-maxMessages);
得到的内容是:
text
HumanMessage: 我喜欢艺术和音乐
AIMessage: 艺术和音乐都是很好的爱好,它们能激发创作灵感。
HumanMessage: 我擅长 UI/UX 设计
AIMessage: UI/UX 设计非常重要,好的用户体验能让产品更成功!
这种方式简单、直观,而且一定能限制消息数量。但消息正文有长有短,四条消息可能只有几十个 Token,也可能非常长。因此,"保留四条"并不能直接代表"控制在某个 Token 预算内"。
此外,截断位置还要注意消息边界。如果截断结果以孤立的 AI 回答开头,模型看到的上下文会缺少对应问题。示例中的数据刚好以完整的一问一答为单位保留,实际管理时也应留意这一点。
七、更贴近上下文预算:按 Token 数截断
js-tiktoken 可以按照指定编码计算文本 Token 数,LangChain.js 的 trimMessages 则负责从消息数组中保留符合预算的一段内容。
1. 计算消息正文的 Token
js
import { getEncoding } from "js-tiktoken";
function countTokens(messages, encoder) {
let total = 0;
for (const message of messages) {
const content = typeof message.content === "string"
? message.content
: JSON.stringify(message.content);
total += encoder.encode(content).length;
}
return total;
}
这里兼容了两种 content:
- 字符串正文直接编码;
- 非字符串正文先转成 JSON 字符串再编码。
需要准确理解这个函数的统计口径:它精确计算的是所选编码下的消息正文 Token 数 ,并没有额外计算角色标识、消息封装等可能产生的开销。因此它非常适合演示和自定义裁剪,但日志里的数字不要直接等同于模型服务最终返回的完整 promptTokens。
2. 使用 trimMessages 保留最近消息
js
import { trimMessages } from "@langchain/core/messages";
const encoder = getEncoding("cl100k_base");
const maxTokens = 100;
const trimmedMessages = await trimMessages(allMessages, {
maxTokens,
tokenCounter: async (messages) => countTokens(messages, encoder),
strategy: "last",
});
const totalTokens = countTokens(trimmedMessages, encoder);
console.log(`总 Token 数量:${totalTokens}`);
三个配置项各自承担明确职责:
maxTokens: 100:裁剪后的正文 Token 上限;tokenCounter:告诉trimMessages如何计算一组消息的 Token;strategy: "last":优先保留最近的消息。
对前面的八条中文消息执行后,会保留最后四条,按当前统计方式共 78 个 Token:
text
我喜欢艺术和音乐
艺术和音乐都是很好的爱好,它们能激发创作灵感。
我擅长 UI/UX 设计
UI/UX 设计非常重要,好的用户体验能让产品更成功!
与固定条数相比,Token 裁剪能更直接地对应上下文预算。不过无论按条数还是按 Token 截断,被移除的旧信息都彻底离开了本轮上下文。用户早先说过自己的姓名、职业,一旦它们被裁掉,模型便无法继续利用这些事实。
八、让旧信息不直接消失:对历史进行摘要
摘要式 Memory 的核心思路是:
text
较早消息 → 拼接成文本 → 交给模型总结 → 保存摘要
最近消息 → 保留原文
这样既压缩了上下文,又没有简单丢弃所有旧信息。
1. 把消息数组转换成对话文本
getBufferString 可以把消息数组拼接成带角色前缀的字符串:
js
import { getBufferString, SystemMessage } from "@langchain/core/messages";
async function summarizeHistory(messages) {
if (messages.length === 0) return "";
const conversationText = getBufferString(messages, "用户", "助手");
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;
const summaryResponse = await model.invoke([
new SystemMessage(summaryPrompt),
]);
return summaryResponse.content;
}
例如,一组对象消息会被转换成类似下面的文本:
text
用户: 我叫李四
助手: 你好李四,很高兴认识你!
用户: 我是一名设计师
助手: 设计师是个很有创造力的职业!
模型接收到明确的总结要求后,只返回摘要正文。
"消息数组 → 对话文本 → 总结提示词 → 模型摘要"是一个按顺序执行的线性流程,用 LangChain.js 就可以把它组织起来,这里不需要再引入图结构的工作流。
2. 按消息数量触发摘要
先看最容易理解的一版:历史超过六条消息时,把最近两条保留原文,其余消息交给模型总结。
js
const maxMessages = 6;
const allMessages = await history.getMessages();
if (allMessages.length > maxMessages) {
const keepRecent = 2;
const recentMessages = allMessages.slice(-keepRecent);
const messagesToSummarize = allMessages.slice(0, -keepRecent);
const summary = await summarizeHistory(messagesToSummarize);
await history.clear();
await history.addMessage(
new SystemMessage(`以下是之前对话的摘要:${summary}`)
);
for (const message of recentMessages) {
await history.addMessage(message);
}
}
压缩后的顺序非常重要:
text
旧对话摘要
最近一条 HumanMessage
最近一条 AIMessage
摘要描述的是较早历史,所以应该放在最近消息之前。它也不应伪装成助手刚刚说出的一条普通回答,因此这里使用 SystemMessage 表明它是供后续推理参考的历史说明。
3. 按 Token 数触发并划分摘要区间
固定消息条数仍然无法反映正文长度。进一步改造后,可以在总 Token 达到阈值时触发摘要,并从后向前累计最近消息,直到用完为近期上下文预留的 Token 预算。
js
import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import {
HumanMessage,
AIMessage,
SystemMessage,
getBufferString,
} from "@langchain/core/messages";
import { getEncoding } from "js-tiktoken";
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
temperature: 0,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
function countTokens(messages, encoder) {
let total = 0;
for (const message of messages) {
const content = typeof message.content === "string"
? message.content
: JSON.stringify(message.content);
total += encoder.encode(content).length;
}
return total;
}
async function summarizeHistory(messages) {
if (messages.length === 0) return "";
const conversationText = getBufferString(messages, "用户", "助手");
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;
const response = await model.invoke([
new SystemMessage(summaryPrompt),
]);
return response.content;
}
async function summarizationMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const encoder = getEncoding("cl100k_base");
const maxTokens = 200;
const keepRecentTokens = 80;
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 message of messages) {
if (message.type === "human") {
await history.addMessage(new HumanMessage(message.content));
} else {
await history.addMessage(new AIMessage(message.content));
}
}
const allMessages = await history.getMessages();
const totalTokens = countTokens(allMessages, encoder);
if (totalTokens >= maxTokens) {
const recentMessages = [];
let recentTokens = 0;
for (let index = allMessages.length - 1; index >= 0; index--) {
const message = allMessages[index];
const content = typeof message.content === "string"
? message.content
: JSON.stringify(message.content);
const messageTokens = encoder.encode(content).length;
if (recentTokens + messageTokens <= keepRecentTokens) {
recentMessages.unshift(message);
recentTokens += messageTokens;
} else {
break;
}
}
const splitIndex = allMessages.length - recentMessages.length;
const messagesToSummarize = allMessages.slice(0, splitIndex);
const summary = await summarizeHistory(messagesToSummarize);
await history.clear();
await history.addMessage(
new SystemMessage(`以下是之前对话的摘要:${summary}`)
);
for (const message of recentMessages) {
await history.addMessage(message);
}
}
}
summarizationMemoryDemo().catch(console.error);
这段逻辑可以拆成四步。
第一步:判断是否需要压缩
js
if (totalTokens >= maxTokens) {
// 开始摘要
}
历史没有达到 200 个正文 Token 时,不需要额外调用模型生成摘要。
按本文 countTokens() 的统计方式,这八条示例消息在 cl100k_base 下合计为 136 个 Token,因此保留 maxTokens = 200 时不会触发摘要,这是预期结果。若想用这组数据直接观察摘要流程,可以临时把阈值改为 120:
js
const maxTokens = 120;
在真实对话中仍应根据希望保留的上下文规模设置阈值,而不是为了触发分支固定使用 120。
第二步:从后向前保留最近消息
js
for (let index = allMessages.length - 1; index >= 0; index--) {
// 只要累计值不超过 keepRecentTokens,就继续保留
}
遍历方向从最新消息开始,所以近期内容拥有更高优先级。使用 unshift() 而不是 push(),是为了在逆序遍历后仍维持原来的时间顺序。
第三步:切出需要摘要的较早历史
js
const splitIndex = allMessages.length - recentMessages.length;
const messagesToSummarize = allMessages.slice(0, splitIndex);
消息数组因此被分成连续的前后两段,不会重复也不会遗漏:前半段生成摘要,后半段保留原文。
第四步:用"摘要 + 最近消息"重建上下文
js
await history.clear();
await history.addMessage(
new SystemMessage(`以下是之前对话的摘要:${summary}`)
);
for (const message of recentMessages) {
await history.addMessage(message);
}
清空的只是当前上下文中的历史对象。重建之后,模型看到的是压缩后的旧信息和未经压缩的近期对话。
这里还有一个边界:如果某一条最近消息自身就超过 keepRecentTokens,循环会立即停止,recentMessages 可能为空,那么全部消息都会进入摘要区间。这个结果符合当前算法,但设置预算时需要意识到它。
九、截断与摘要应该怎么选
| 方案 | 保留方式 | 优点 | 代价或限制 |
|---|---|---|---|
| 固定条数截断 | 最近 N 条消息 | 最简单、执行快 | 不直接对应 Token 预算,旧信息直接丢失 |
| Token 截断 | Token 预算内的最近消息 | 更贴近上下文长度控制 | 仍会丢失被裁剪的信息 |
| 消息数触发摘要 | 旧消息摘要 + 最近消息原文 | 容易理解,能保留旧信息要点 | 消息长度不一致时触发时机不够精细 |
| Token 触发摘要 | 按 Token 划分摘要区与近期区 | 同时控制长度并保留核心信息 | 需要额外调用一次模型生成摘要 |
这几种方法不是互斥关系。一个清晰的演进路线是:
text
先完整保存历史
↓
上下文较短时直接使用
↓
达到阈值后,对较早消息生成摘要
↓
继续保留最近原始消息
如果历史进一步增长,仅靠一段持续累积的摘要仍然不够灵活。当前问题可能只与很久以前的一小段对话有关,把所有长期信息都揉进一个摘要,不一定能精确取回那段内容。
这就引出了下一种 Memory:把历史对话转成向量存入 Milvus,并在每一轮按照当前问题检索最相关的记录。下篇将完整拆解"写入长期记忆---语义检索---注入上下文---保存新记忆"的闭环。