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