LangChain.js 对话 Memory 实战:History 持久化、截断与摘要压缩
- 前言
- [1. 项目目标与整体方案](#1. 项目目标与整体方案)
-
- [1.1 要解决的不是"保存文本",而是"构造下一次提示词"](#1.1 要解决的不是“保存文本”,而是“构造下一次提示词”)
- [1.2 三类 History 与三种长度管理策略](#1.2 三类 History 与三种长度管理策略)
- [2. 从零初始化项目与依赖边界](#2. 从零初始化项目与依赖边界)
-
- [2.1 初始化、安装与环境变量](#2.1 初始化、安装与环境变量)
- [2.2 统一创建模型:配置与职责分离](#2.2 统一创建模型:配置与职责分离)
- [3. 进入代码前必须理解的基础概念](#3. 进入代码前必须理解的基础概念)
-
- [3.1 Message:带角色的上下文单元](#3.1 Message:带角色的上下文单元)
- [3.2 Token:为什么不能只按"消息条数"思考](#3.2 Token:为什么不能只按“消息条数”思考)
- [4. History 怎么存:内存、文件与会话恢复](#4. History 怎么存:内存、文件与会话恢复)
-
- [4.1 `InMemoryChatMessageHistory`:最快的短期记忆起点](#4.1
InMemoryChatMessageHistory:最快的短期记忆起点) - [4.2 `FileSystemChatMessageHistory`:让会话跨进程延续](#4.2
FileSystemChatMessageHistory:让会话跨进程延续)
- [4.1 `InMemoryChatMessageHistory`:最快的短期记忆起点](#4.1
- [5. History 太长了怎么办:两种截断策略](#5. History 太长了怎么办:两种截断策略)
-
- [5.1 按消息数量截断:`slice(-4)` 的边界与用途](#5.1 按消息数量截断:
slice(-4)的边界与用途) - [5.2 按 Token 截断:让预算与模型窗口对齐](#5.2 按 Token 截断:让预算与模型窗口对齐)
- [5.1 按消息数量截断:`slice(-4)` 的边界与用途](#5.1 按消息数量截断:
- [6. Summary 总结压缩:用有限上下文保留长期事实](#6. Summary 总结压缩:用有限上下文保留长期事实)
-
- [6.1 压缩原则:旧消息变摘要,最近消息保留原文](#6.1 压缩原则:旧消息变摘要,最近消息保留原文)
- [6.2 把旧消息变成可读的摘要](#6.2 把旧消息变成可读的摘要)
- [6.3 以 Token 为阈值保留最近上下文](#6.3 以 Token 为阈值保留最近上下文)
- [7. 串联完整业务逻辑:可运行的摘要型文件 Memory](#7. 串联完整业务逻辑:可运行的摘要型文件 Memory)
- [8. 选型复盘与常见陷阱](#8. 选型复盘与常见陷阱)
-
- [8.1 按业务复杂度组合策略](#8.1 按业务复杂度组合策略)
- [8.2 上线前检查清单](#8.2 上线前检查清单)
- 总结
前言
一个聊天机器人能够回答当前问题,并不代表它真正具备"记忆"。例如用户先说"我叫李四,是一名 UI/UX 设计师",下一轮再问"这对我的工作有什么帮助?",模型若没有拿到前面的上下文,根本不知道"这"指什么。对话 Memory 的本质不是给模型增加永久大脑,而是在每次请求模型前,挑选并组织应该放进上下文窗口的历史消息。
直接把所有聊天记录一直塞给模型看似简单,实际会很快遇到三个问题:进程重启后数据丢失;上下文窗口和调用成本不断膨胀;太长的输入还可能稀释模型对当前问题的注意力。本文以一个"做菜助手"为例,完整走通 LangChain.js 中的对话记忆:先保存 History,再持久化到文件,最后通过消息截断、Token 截断和摘要压缩管理长度。
1. 项目目标与整体方案
1.1 要解决的不是"保存文本",而是"构造下一次提示词"
对话 Memory 有两个职责:一是记录 用户与助手的消息,二是在下一轮提问时把合适的记录与系统提示一起交给模型。前者解决状态保存,后者决定模型实际能"记住"什么。因此,history.addMessage() 之后还必须通过 history.getMessages() 取回记录,并与 SystemMessage 组装成消息数组后再调用 model.invoke()。
本项目的业务流程可以概括为:
text
用户输入
↓
HumanMessage 写入当前会话 History
↓
读取 History,并按策略管理上下文长度
├─ 少量消息:原样保留
├─ 只需最近上下文:截断旧消息
└─ 既要长期信息又要控制成本:旧消息摘要 + 最近消息原样保留
↓
SystemMessage + 可用 History → ChatOpenAI.invoke()
↓
AIMessage 写回 History / 文件
↓
下一轮对话可恢复并继续理解上下文
Memory 是应用层的上下文管理策略。 模型本身不会因为上一轮回答过就自动保留状态;每一次 API 调用都是独立的,状态由应用主动传入。
1.2 三类 History 与三种长度管理策略
整个方案可拆成"存在哪里"和"过长后怎么办"两个维度。二者并不冲突:文件 History 同样需要截断或摘要,内存 History 也可以先用于验证摘要逻辑。
| 维度 | 方案 | 核心价值 | 主要代价 | 适用场景 |
|---|---|---|---|---|
| 存储 | InMemoryChatMessageHistory |
调用简单、读写快 | 进程退出即丢失 | 本地调试、单次任务 |
| 存储 | FileSystemChatMessageHistory |
JSON 文件可跨进程恢复 | 不适合高并发服务 | Demo、单机工具 |
| 长度 | slice(-4) |
规则直观、零额外模型调用 | 会丢弃早期关键信息 | 短任务、多轮追问 |
| 长度 | trimMessages() |
按 Token 预算控制请求 | 需要可靠的 Token 计数 | 有明确上下文预算的生产调用 |
| 长度 | Summary 压缩 | 保留早期事实与最近细节 | 多一次模型调用,摘要会有损 | 长会话、个性化助手 |
2. 从零初始化项目与依赖边界
2.1 初始化、安装与环境变量
先创建 Node.js 项目。题目中的 Milvus SDK 是后续将长期记忆做成向量检索时的基础依赖;本篇的 History、截断和摘要示例并不会直接调用 Milvus。把这个边界说清楚很重要:安装了向量数据库 SDK,不等于当前的短期对话记忆已经实现了语义检索。
bash
npm init -y
# 初始化 Milvus 与环境变量能力,为后续向量长期记忆预留入口
pnpm i @zilliz/milvus2-sdk-node dotenv
# 运行本文的 History、模型调用、文件存储和 Token 统计还需要这些包
pnpm i @langchain/core @langchain/openai @langchain/community js-tiktoken
各依赖的职责如下。
| 依赖 | 用途 | 为什么需要 |
|---|---|---|
dotenv |
从 .env 读取密钥和模型配置 |
避免把 API Key 写死在源码中 |
@langchain/core |
Message、History、trimMessages() 等核心抽象 |
用结构化消息替代手写字符串拼接 |
@langchain/openai |
ChatOpenAI 模型适配器 |
负责把消息数组发送给兼容接口的聊天模型 |
@langchain/community |
FileSystemChatMessageHistory |
提供本地 JSON 文件的聊天记录存储 |
js-tiktoken |
本地 Token 编码与计数 | 让"最多保留多少上下文"可量化 |
@zilliz/milvus2-sdk-node |
Milvus 向量数据库客户端 | 用于后续从海量长期记忆中按语义召回信息 |
在项目根目录新建 .env,并将它加入 .gitignore。MODEL_NAME、OPENAI_API_KEY 与 OPENAI_BASE_URL 分别描述模型名、访问凭证和兼容服务地址;若使用官方默认地址,可按实际服务要求省略 OPENAI_BASE_URL。
dotenv
MODEL_NAME=gpt-4o-mini
OPENAI_API_KEY=你的密钥
OPENAI_BASE_URL=https://你的兼容接口地址/v1
2.2 统一创建模型:配置与职责分离
下面这段代码只做一件事:创建一个可接受 BaseMessage[] 的聊天模型。temperature: 0 让演示更稳定,便于观察 Memory 是否真正影响了回答;它不负责保存任何对话。
javascript
import 'dotenv/config'; // 模块加载时读取 .env,填充 process.env
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,
},
});
这里要区分两类配置:modelName、temperature 是模型行为参数;apiKey、baseURL 是连接参数。把密钥留在环境变量中,既避免泄露,也让同一套代码可切换不同模型服务。
3. 进入代码前必须理解的基础概念
3.1 Message:带角色的上下文单元
聊天模型不是只接收一段普通字符串,而是接收按顺序排列、带角色的消息。LangChain.js 用不同的 Message 类表达语义:SystemMessage 放规则和角色设定,HumanMessage 表示用户输入,AIMessage 表示模型回复。模型据此区分"应该遵守的规则""用户说过什么""自己已经回答过什么"。
javascript
import {
SystemMessage,
HumanMessage,
AIMessage,
} from '@langchain/core/messages';
const systemMessage = new SystemMessage(
'你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧。'
);
const userMessage = new HumanMessage('红烧肉怎么做?');
const assistantMessage = new AIMessage('先准备五花肉、冰糖和生抽......');
SystemMessage 通常是稳定的应用配置,不应混入某个用户会话的可变记录;而用户与助手的成对消息属于 History。每轮调用的典型输入顺序应是:系统消息 → 既有历史 → 当前用户消息。这样既能固定助手边界,又能让上下文按时间从旧到新展开。
3.2 Token:为什么不能只按"消息条数"思考
模型处理的不是汉字数或字符数,而是 Token。一个 Token 可以是一个英文单词片段、汉字或标点组合;不同模型的分词器和消息封装开销也不同。因此,"保留 4 条消息"并不能推出固定成本:4 条很短的寒暄可能只占几十 Token,4 段长代码却可能占满上下文。
上下文预算 = 模型窗口上限 − 系统提示 − 本轮输入 − 需要预留的输出空间。 History 的上限应基于这份预算设定,而不是盲目取一个很大的常量。
js-tiktoken 的 getEncoding('cl100k_base') 可用于本地估算文本 Token。但它只与采用相同或相近编码的模型较匹配,且纯文本计数没有覆盖每条消息的角色、工具调用等协议开销。在临近模型上限的生产场景,应使用模型提供的计数能力或保留安全余量。
4. History 怎么存:内存、文件与会话恢复
4.1 InMemoryChatMessageHistory:最快的短期记忆起点
InMemoryChatMessageHistory 把 BaseMessage 对象保存在当前 Node.js 进程内存中。它不是普通数组,而是统一了 addMessage()、getMessages() 和 clear() 等异步接口,方便将来替换为文件、Redis 或数据库实现。
下面的两轮对话展示了最重要的写入时机:调用模型前写入用户消息,调用成功后写入 AI 回复。若漏掉后一步,下一轮模型只能看见用户连续提问,却看不到自己之前给过什么答案。
javascript
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
async function inMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const systemMessage = new SystemMessage(
'你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧。'
);
// 第一轮:先写入用户问题,再把"规则 + 历史"交给模型
await history.addMessage(new HumanMessage('今天吃了什么?'));
const firstInput = [systemMessage, ...(await history.getMessages())];
const firstReply = await model.invoke(firstInput);
await history.addMessage(firstReply); // 回复也必须进入 History
// 第二轮的"好吃吗"能关联第一轮,关键在于 firstReply 已被保存
await history.addMessage(new HumanMessage('好吃吗?'));
const secondInput = [systemMessage, ...(await history.getMessages())];
const secondReply = await model.invoke(secondInput);
await history.addMessage(secondReply);
return history.getMessages();
}
这里的展开运算符 ... 不是装饰:getMessages() 返回一个数组,而 model.invoke() 需要扁平的消息数组。[systemMessage, ...(await history.getMessages())] 会把系统消息放在第一位,并保持历史原有顺序。只要进程重启,这个实例及其中所有消息都会消失,所以它更像一次会话期间的工作内存。
4.2 FileSystemChatMessageHistory:让会话跨进程延续
文件型实现把记录序列化为 JSON。filePath 决定文件位置,sessionId 将不同会话隔离;同一个文件可以保存多个会话。第一段程序写入"红烧肉怎么做"和后续追问,第二段程序即使在重新启动后,也能用相同的 filePath 与 sessionId 读回记录并继续第三轮对话。
javascript
import path from 'node:path';
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
async function continueConversation() {
const history = new FileSystemChatMessageHistory({
// process.cwd() 是当前执行目录;path.join 保证跨平台拼接路径
filePath: path.join(process.cwd(), 'chat_history.json'),
sessionId: 'user_session_001', // 相同 ID 才能恢复同一段对话
});
const restoredMessages = await history.getMessages();
console.log(`恢复了 ${restoredMessages.length} 条历史消息`);
const systemMessage = new SystemMessage('你是专业的做菜助手。');
await history.addMessage(new HumanMessage('做红烧肉需要哪些食材?'));
// getMessages() 此时包含磁盘恢复的消息和本轮新问题
const reply = await model.invoke([
systemMessage,
...(await history.getMessages()),
]);
await history.addMessage(reply);
console.log(reply.content);
}
FileSystemChatMessageHistory 在读取时会把 JSON 中的存储格式还原为 HumanMessage、AIMessage 等对象,因此业务代码不需要自己判断 JSON 中的角色字段。它适合教学、脚本和单机开发;多个 Node.js 进程同时改写同一 JSON 文件时,仍可能产生竞争和覆盖。线上多实例应用应换成具备并发控制、访问隔离与生命周期管理的 Redis 或数据库,而不是把本地文件当作共享存储。
5. History 太长了怎么办:两种截断策略
5.1 按消息数量截断:slice(-4) 的边界与用途
最直接的做法是在请求模型前仅保留最后 maxMessages 条消息:
javascript
const maxMessages = 4;
const allMessages = await history.getMessages();
// -4 表示从倒数第 4 条开始取到末尾;history 本身不被修改
const recentMessages = allMessages.slice(-maxMessages);
const input = [systemMessage, ...recentMessages];
const reply = await model.invoke(input);
slice(-4) 返回新数组,不会删掉 history 中的旧数据。这一点很实用:可以先按最近消息调用模型,仍把完整 History 留给日志、审计或后续摘要处理。它的不足也很明确:截断单位是"条",不是成本;并且如果从一条 AIMessage 开始,模型可能看到没有对应问题的回答。实践中至少应让 maxMessages 保持偶数并按"用户问题 + 助手回答"的轮次保存,或者采用下一节的角色约束。
5.2 按 Token 截断:让预算与模型窗口对齐
trimMessages() 是 LangChain 提供的消息裁剪工具。strategy: 'last' 表示从末尾优先保留最新上下文;startOn: 'human' 要求裁剪结果从用户消息开始,避免遗留孤立的助手回复;allowPartial: false 则避免把一条消息从中间切断。注意,当前版本的合法策略名是 first 与 last,不是 latest。
javascript
import { getEncoding } from 'js-tiktoken';
import { trimMessages } from '@langchain/core/messages';
// 只计算正文 Token;实际调用还应为消息协议与输出预留安全余量
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 buildTokenLimitedHistory(history) {
const encoder = getEncoding('cl100k_base');
const allMessages = await history.getMessages();
try {
return await trimMessages(allMessages, {
maxTokens: 100,
tokenCounter: (messages) => countTokens(messages, encoder),
strategy: 'last', // 从最新消息向前保留
startOn: 'human', // 避免以一条孤立的助手回答开头
allowPartial: false,
});
} finally {
// 编码器可能持有 WASM 资源,使用完主动释放
encoder.free();
}
}
这段代码的核心不在 100 这个数字,而在计数器、保留方向和消息边界 三项配置。tokenCounter 可以接收同步或异步函数,输入是完整消息数组;不能只遍历内容却忘记 total += ...,否则计数恒为 0,裁剪形同虚设。若系统消息没有存入 History,应像前文那样在裁剪后再拼到首位,并把它的 Token 成本计入总预算。
| 对比项 | 按条数 slice(-4) |
按 Token trimMessages() |
|---|---|---|
| 上限依据 | 消息条数 | 模型输入预算 |
| 实现复杂度 | 很低 | 需提供 Token 计数器 |
| 成本可控性 | 较弱 | 较强 |
| 角色边界 | 需要自行保证 | 可用 startOn、allowPartial 约束 |
| 早期信息 | 直接丢弃 | 同样会丢弃,只是更精确 |
6. Summary 总结压缩:用有限上下文保留长期事实
6.1 压缩原则:旧消息变摘要,最近消息保留原文
截断会遗忘早期内容。如果用户在第一轮说出职业和偏好,几十轮后又问"按我的工作背景推荐工具",只保留最近 4 条消息显然不够。摘要压缩的思路是把旧消息交给 LLM 提炼为短文本,将原始细节替换为这份摘要,同时保留最近若干轮的原文。
text
原始 History:旧消息 1 ... 旧消息 N | 最近消息 1 ... 最近消息 M
↓
LLM 总结旧消息的事实、偏好、结论
↓
压缩后 History:历史摘要 | 最近消息 1 ... 最近消息 M
摘要不是无损压缩。它很适合保留"姓名、职业、已确认偏好、已经做出的决定"等稳定事实,却不适合承担精确报价、法律措辞、完整代码、工具调用 ID 等不可改写的数据。此类信息应保存到结构化数据库或原始日志中;摘要只是服务当前对话的高密度上下文。
6.2 把旧消息变成可读的摘要
getBufferString() 会将 Message 数组按角色渲染成普通文本,例如"用户:我叫李四\n助手:你好李四"。再把它置入 SystemMessage,让模型执行单一的总结任务。提示词要明确要求保留哪些信息,否则模型会把无关客套也写进摘要。
javascript
import { SystemMessage, getBufferString } from '@langchain/core/messages';
async function summarizeHistory(messages) {
if (messages.length === 0) return '';
// Message 对象转为带角色标签的文本,模型才能正确理解说话者
const conversationText = getBufferString(messages, '用户', '助手');
const prompt = `请总结以下历史对话,只保留用户身份、偏好、约束、已确认结论和未完成事项;
不要编造信息,使用简洁的中文条目。
${conversationText}
历史摘要:`;
const response = await model.invoke([new SystemMessage(prompt)]);
return String(response.content);
}
这次 model.invoke() 的输入只有一条 SystemMessage,目的不是继续和用户聊天,而是让模型充当压缩器。返回的 AIMessage.content 才是后续要写回 History 的摘要文本。由于一次总结也要花费 Token 和延迟,不应每新增一条消息就总结;应在总消息数或总 Token 超过阈值时触发。
6.3 以 Token 为阈值保留最近上下文
与其固定"保留最近 2 条",更稳妥的方案是给最近原文预留一段 Token 预算,例如 keepRecentTokens = 80。实现时从末尾逆向扫描:若当前消息加入后仍不超标,就通过 unshift() 放回数组开头,从而在最终数组中恢复"旧 → 新"的自然时间顺序。
javascript
function takeRecentMessagesByTokens(messages, encoder, keepRecentTokens) {
const recentMessages = [];
let recentTokens = 0;
// 从最新消息逆向挑选,优先保证当前问题及其近邻上下文
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const content =
typeof message.content === 'string'
? message.content
: JSON.stringify(message.content);
const messageTokens = encoder.encode(content).length;
if (recentTokens + messageTokens > keepRecentTokens) break;
recentMessages.unshift(message); // 逆序扫描,插到开头以恢复时间顺序
recentTokens += messageTokens;
}
return recentMessages;
}
messagesToSummarize 可通过 allMessages.slice(0, allMessages.length - recentMessages.length) 得到。这里隐含一个前提:recentMessages 是原数组末尾连续的一段;上面的逆向循环在第一次超标时立即 break,正好保证了这一点。实际产品还可增加"从用户消息开始"的对齐逻辑,避免摘要边界切开一轮问答。
7. 串联完整业务逻辑:可运行的摘要型文件 Memory
前面的片段分别处理了模型、存储、计数与摘要。下面把它们串成一个完整流程:每次用户发言后,先写入文件 History;若总 Token 超标,则把旧消息压缩为摘要、保留最近原文并重建 History;随后才组装系统消息和上下文调用模型,最后把回答写回文件。这样下一次启动时仍可从同一 sessionId 恢复状态。
javascript
import 'dotenv/config';
import path from 'node:path';
import { ChatOpenAI } from '@langchain/openai';
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system';
import {
AIMessage,
HumanMessage,
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 },
});
const systemMessage = new SystemMessage(
'你是一个友好、专业的做菜助手。回答要结合用户已说明的偏好。'
);
function getContent(message) {
// 多模态消息的 content 可能不是字符串;统一成可计数、可摘要的文本
return typeof message.content === 'string'
? message.content
: JSON.stringify(message.content);
}
function countTokens(messages, encoder) {
return messages.reduce(
(total, message) => total + encoder.encode(getContent(message)).length,
0
);
}
async function summarizeHistory(messages) {
if (messages.length === 0) return '';
const conversationText = getBufferString(messages, '用户', '助手');
const response = await model.invoke([
new SystemMessage(`请压缩以下对话。仅保留用户资料、偏好、约束、已确认结论和待办事项;不确定的信息不要补充。
${conversationText}
历史摘要:`),
]);
return String(response.content);
}
function takeRecentMessagesByTokens(messages, encoder, tokenBudget) {
const recentMessages = [];
let usedTokens = 0;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const tokens = encoder.encode(getContent(message)).length;
if (usedTokens + tokens > tokenBudget) break;
recentMessages.unshift(message); // 保持输出仍按"旧 → 新"排列
usedTokens += tokens;
}
return recentMessages;
}
async function compactHistoryIfNeeded(history) {
const encoder = getEncoding('cl100k_base');
try {
const allMessages = await history.getMessages();
const maxHistoryTokens = 200;
const keepRecentTokens = 80;
if (countTokens(allMessages, encoder) <= maxHistoryTokens) return;
const recentMessages = takeRecentMessagesByTokens(
allMessages,
encoder,
keepRecentTokens
);
const oldMessages = allMessages.slice(
0,
allMessages.length - recentMessages.length
);
const summary = await summarizeHistory(oldMessages);
await history.clear();
// 时间顺序必须是:过去的摘要 → 最近原文,而不是反过来
if (summary) {
await history.addMessage(new AIMessage(`历史对话摘要:${summary}`));
}
for (const message of recentMessages) {
await history.addMessage(message);
}
} finally {
encoder.free();
}
}
async function chat(sessionId, userText) {
const history = new FileSystemChatMessageHistory({
filePath: path.join(process.cwd(), 'chat_history.json'),
sessionId,
});
// 1. 当前输入先入库,确保压缩和本轮模型调用都能看见它
await history.addMessage(new HumanMessage(userText));
// 2. 过长时压缩旧上下文;短会话会直接跳过,不额外调用摘要模型
await compactHistoryIfNeeded(history);
// 3. 固定系统规则与当前可用历史一起调用聊天模型
const reply = await model.invoke([
systemMessage,
...(await history.getMessages()),
]);
// 4. 保存模型回答,让下一轮拥有完整的问答闭环
await history.addMessage(reply);
return reply.content;
}
chat('user_session_001', '我擅长 UI/UX 设计,适合做什么菜谱 App?')
.then((answer) => console.log(`助手:${answer}`))
.catch(console.error);
这份代码有两个刻意的设计点。第一,摘要在 history.clear() 后先写入,再写最近消息,保证模型看到的语义顺序是"较早事实 → 最近上下文 → 当前对话"。第二,压缩发生在模型调用之前,控制的是输入而不是输出;模型回答之后仍会新增一条记录,下一轮进入函数时会再次检查阈值。
如果每个对话回合都非常长,要为"当前用户输入、系统消息、模型输出"预留更大的空间,而不是把 maxHistoryTokens 设为模型窗口的全部。若要接入 Milvus,可将经过确认的长期事实向量化存入集合,在每轮根据当前问题检索少量相关片段,再与上述短期 History 一起组成上下文;这属于检索型长期记忆,与文件 History 的顺序型短期记忆形成互补。
8. 选型复盘与常见陷阱
8.1 按业务复杂度组合策略
没有一种 Memory 方案适用于所有应用。临时脚本可以只用内存 History;个人工具需要文件持久化;客服、助手或 Agent 的长对话通常应采用"持久化存储 + Token 预算 + 摘要压缩"的组合。检索型长期记忆适合跨很久仍可能被问起的大量知识,但不能替代最近对话,因为语义检索不保证返回连续的对话过程。
| 业务需求 | 推荐组合 | 原因 |
|---|---|---|
| 单次命令行演示 | 内存 + slice(-4) |
最少代码即可验证上下文连续性 |
| 本地多轮聊天 | 文件 History + Token 截断 | 重启可恢复,成本上限可控 |
| 长会话个人助手 | 持久化 + 摘要压缩 + 最近原文 | 同时保留用户事实与当前语境 |
| 海量跨会话知识 | 短期 History + Milvus 检索 | 只召回与当前问题相关的长期片段 |
8.2 上线前检查清单
- 不要把密钥或聊天记录提交到仓库。
.env与chat_history.json都可能包含敏感内容,应加入.gitignore,并根据业务设置加密、脱敏和过期删除策略。 - 不要只存用户消息。 缺少
AIMessage会使后续模型重复回答或误解指代关系;一轮问答必须闭环写入。 - 不要把"摘要"当作原始事实。 摘要可能遗漏细节,重要数据需要结构化保存,并允许用户查看、更正或删除。
- 不要用错误的 Token 计数做成本控制。 计数函数必须累计每条消息,Token 编码要尽量匹配目标模型,并为系统提示和输出留余量。
- 不要让多个服务实例共写一个 JSON 文件。 文件存储适合开发与单机环境;并发生产服务需要专门的共享存储与会话隔离。
总结
对话 Memory 的关键不在于"把聊天内容存起来",而在于每轮请求前以正确顺序构造可控的上下文。InMemoryChatMessageHistory 适合快速验证记忆闭环,FileSystemChatMessageHistory 能让同一 sessionId 在重启后继续对话;当记录增长时,slice(-4) 提供最简单的近期截断,trimMessages() 则把控制粒度提升到 Token 预算。对于既要保留早期用户事实、又要维持当前语境的长会话,可将旧消息压缩为摘要,并原样保留最近消息。生产实现还必须关注 Token 计数准确性、角色边界、摘要有损性、敏感数据与并发存储问题。只有把存储、选择和压缩作为一个整体设计,Memory 才能既让模型"记得住",又不会失控地吞噬上下文和成本。