LLM 记忆管理三剑客:截断、总结、检索,彻底搞懂 Agent 的 Memory 系统
大模型是无状态的——每次对话都像第一次见面。要让 AI 记住你说过的话,就需要 Memory 系统。但简单地把所有对话塞进 prompt 会触发两个问题:上下文窗口爆了,Token 账单爆了。本文从 LangChain 的 InMemoryChatMessageHistory 出发,逐级深入三种存储方式(内存 / 文件 / 向量数据库)和三种管理策略(截断 / 总结 / 检索),附完整代码演示和 token 精确计算。建议收藏后动手实践。
一、为什么需要 Memory?
1.1 大模型是无状态的
LLM 的本质:无状态
第一次对话:
用户:我叫李四
模型:你好李四,很高兴认识你!
第二次对话(不带历史):
用户:我叫什么名字?
模型:抱歉,我不知道您的名字。
→ 大模型根本不记得刚才说过什么!
第二次对话(带上历史):
输入:[
{role: "user", content: "我叫李四"},
{role: "assistant", content: "你好李四,很高兴认识你!"},
{role: "user", content: "我叫什么名字?"}
]
模型:您叫李四。
→ 因为历史消息被塞进了 prompt,模型"看到了"之前的对话
大模型 = 无状态的函数
每次调用 model.invoke(messages):
→ 模型只看 messages 数组里的内容
→ 模型不记得上次调用了什么
→ 就像鱼的记忆——每次都是全新的
所以我们需要 Memory:
→ 把每次对话保存下来
→ 下次调用时拼到 prompt 里
→ 模型就"记住"了
1.2 Agent 的完整公式
Agent = LLM + Harness(Tool + RAG + Memory + ...)
┌──────────────────────────────────────────────────────────┐
│ Agent 架构图 │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ LLM(大脑) │ │
│ │ → 理解意图 │ │
│ │ → 生成回答 │ │
│ │ → 决策是否调用工具 │ │
│ └──────────────────┬───────────────────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────────────┐ │
│ │ Harness(框架) │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │ Tool │ │ RAG │ │ Memory │ │ │
│ │ │ 调用工具 │ │ 检索知识 │ │ 对话记忆 │ │ │
│ │ │ 干活 │ │ 注入prompt│ │ 持续对话│ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ │ │ │
│ │ → 不只是回答问题,而是真正"干活" │ │
│ └───────────────────────────────────────────────┘ │
│ │
│ Tool:让模型能调用外部工具(查天气、发邮件、写代码) │
│ RAG:基于 query 从向量数据库检索相关知识,放入 prompt │
│ Memory:保存对话历史,让模型记住上下文 │
│ │
│ Tool 和 RAG 都依赖 Memory——没有记忆就无法连续对话 │
└──────────────────────────────────────────────────────────┘
1.3 Memory 的三大挑战
简单的 chatMessage 数组不够,面临三大挑战:
┌──────────────────────────────────────────────────────────┐
│ ① 上下文窗口大小 │
│ → 模型有 token 上限(如 128k、200k) │
│ → 对话越多,消息越长,越容易超出窗口 │
│ → 超出窗口模型就处理不了了 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ ② Token 开销 │
│ → 每次调用都要把所有历史消息算进 token │
│ → 对话越长,每次调用越贵 │
│ → 200k context window 的模型 ≠ 每次都传 200k token │
│ → 成本会随着对话长度线性增长 │
└──────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ ③ 持久化 │
→ 内存存储 → 程序重启就没了 │
→ 用户换设备、刷新页面 → 对话消失 │
→ 需要文件 / 数据库持久化 │
│ → 多用户场景需要 sessionId 区分不同会话 │
└──────────────────────────────────────────────────────────┘
Memory 的两层设计:
存储逻辑(存在哪里):
→ 内存(In-Memory):临时,重启即失
→ 文件(File System):持久化,本地文件
→ 数据库(Vector DB):长期记忆,可检索
管理逻辑(怎么管理):
→ 截断(Truncation):只留最近 N 条 / N 个 token
→ 总结(Summarization):旧消息用 AI 压缩成摘要
→ 检索(Retrieval):按语义从向量数据库检索相关历史
ReAct 执行流程:
messages 数组 → Memory 管理 → 喂给 LLM → 结果写回 Memory
二、短期记忆:InMemoryChatMessageHistory
2.1 基本用法
InMemoryChatMessageHistory = 内存中的消息历史管理器
它的本质:
→ 一个被封装过的数组
→ 提供 addMessage / getMessages / clear 等方法
→ 把"手动维护 messages 数组"的模式抽象成了类
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('[第一轮对话]');
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); // 加入 AI 回复
// ========== 第二轮对话 ==========
console.log('[第二轮对话 基于历史记录]');
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}\n`);
// ========== 查看所有消息 ==========
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)}...`);
});
}
inMemoryDemo().catch(console.error);
2.2 消息类型
LangChain 中有三种核心消息类型:
┌──────────────────────────────────────────────────────────┐
│ 三种消息类型 │
│ │
│ HumanMessage(用户消息) │
│ → type: 'human' │
│ → 来自用户的输入 │
│ → new HumanMessage('你好') │
│ │
│ AIMessage(AI 消息) │
│ → type: 'ai' │
│ → 模型生成的回答 │
│ → model.invoke() 返回的就是 AIMessage │
│ → new AIMessage('你好!有什么可以帮你?') │
│ │
│ SystemMessage(系统消息) │
│ → type: 'system' │
│ → 给模型的系统指令、人设设定 │
│ → new SystemMessage('你是一个做菜助手') │
│ │
│ ToolMessage(工具消息) │
│ → type: 'tool' │
│ → 工具调用的返回结果 │
│ → 配合 Tool Call 使用 │
└──────────────────────────────────────────────────────────┘
每个消息对象都有:
→ type:消息类型(human / ai / system / tool)
→ content:消息内容(字符串或数组)
→ 其他元信息(name、tool_call_id 等)
2.3 内存记忆的局限
InMemoryChatMessageHistory 的问题:
① 不持久
→ 程序重启 → 历史全没了
→ 刷新页面 → 对话消失
→ 只适合临时会话
② 无上限
→ 对话无限增长
→ 迟早爆上下文窗口
→ Token 成本越来越高
③ 单会话
→ 一个实例对应一个会话
→ 多用户需要多实例管理
三、持久化记忆:FileSystemChatMessageHistory
3.1 文件记忆的优势
FileSystemChatMessageHistory = 文件存储的消息历史
相比内存记忆的优势:
→ 持久化:程序重启不丢失
→ 跨会话:重新运行能恢复历史
→ 多用户:通过 sessionId 区分不同会话
→ 简单:不需要数据库,JSON 文件即可
3.2 写入对话
import 'dotenv/config';
import { ChatOpenAI } from '@langchain/openai';
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import path from 'node:path';
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 fileHistoryDemo() {
const filePath = path.join(process.cwd(), 'chat_history.json');
const sessionId = 'user_session_001'; // 多用户场景的会话标识
const systemMessage = new SystemMessage(
'你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧'
);
// 创建文件记忆实例
const history = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
// ========== 第一轮对话 ==========
console.log('[第一轮对话]');
const userMessage1 = new HumanMessage('红烧肉怎么做');
await history.addMessage(userMessage1);
const messages1 = [systemMessage, ...(await history.getMessages())];
const response1 = await model.invoke(messages1);
await history.addMessage(response1);
console.log(`用户:${userMessage1.content}\n`);
console.log(`助手:${response1.content}\n`);
// ========== 第二轮对话 ==========
console.log('[第二轮对话 基于历史记录]');
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}\n`);
// ========== 查看所有消息 ==========
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)}...`);
});
}
fileHistoryDemo().catch(console.error);
3.3 恢复对话
// 重新运行程序,从文件恢复历史
async function restoreAndContinue() {
const filePath = path.join(process.cwd(), 'chat_history.json');
const sessionId = 'user_session_001';
const systemMessage = new SystemMessage(
'你是一个友好、幽默的做菜助手,喜欢分享美食和烹饪技巧'
);
// 从文件中恢复历史
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId,
});
const restoredMessages = await restoredHistory.getMessages();
console.log(`从文件中恢复了 ${restoredMessages.length} 条历史信息:`);
restoredMessages.forEach((msg, index) => {
const type = msg.type;
const prefix = type === 'human' ? '用户' : '助手';
console.log(`${index + 1}. [${prefix}]: ${msg.content.substring(0, 50)}...`);
});
// 继续第三轮对话
console.log('[第三轮对话]');
const userMessage3 = new HumanMessage('需要哪些食材?');
await restoredHistory.addMessage(userMessage3);
const messages3 = [systemMessage, ...(await restoredHistory.getMessages())];
const response3 = await model.invoke(messages3);
await restoredHistory.addMessage(response3);
console.log(response3.content);
console.log('对话已保存到文件');
}
文件存储的结构(chat_history.json):
{
"user_session_001": [
{ "type": "human", "content": "红烧肉怎么做" },
{ "type": "ai", "content": "红烧肉的做法..." },
{ "type": "human", "content": "好吃吗?" },
{ "type": "ai", "content": "当然好吃..." },
...
],
"user_session_002": [ ... ]
}
→ sessionId 作为 key
→ 每个会话独立存储
→ JSON 格式,人类可读
3.4 记忆分层
不同存储方式的定位:
┌──────────────────────────────────────────────────────────┐
│ 记忆三层架构 │
│ │
│ 短期记忆(In-Memory) │
│ → 当前 Agent 正在进行的对话 │
│ → 速度最快,无 I/O │
│ → 程序重启即失 │
│ │
│ 中期记忆(File System) │
│ → 最近几次的对话 │
│ → JSON 文件持久化 │
│ → 跨会话恢复 │
│ → 适合 session 级别的记忆 │
│ │
│ 长期记忆(Vector DB / Milvus) │
│ → 所有历史对话的语义向量 │
│ → 按相似度检索相关历史 │
│ → 真正的"长期记忆" │
│ → 结合 RAG 使用 │
└──────────────────────────────────────────────────────────┘
四、截断策略:只留最近的消息
4.1 为什么需要截断?
对话越来越长的问题:
对话 10 轮 → 20 条消息 → 2000 token → 还能接受
对话 100 轮 → 200 条消息 → 20000 token → 有点贵了
对话 1000 轮 → 2000 条消息 → 200000 token → 爆窗口了
解决方案:截断(Truncation)
→ 只保留最近的 N 条消息
→ 旧消息直接丢弃
→ 最简单、最直接的策略
类比:人的短期记忆
→ 你记得昨天吃了什么
→ 你可能不记得上个月某天吃了什么
→ 太久远的就忘了
4.2 消息数量截断
最简单的截断方式:按消息数量截断
原理:
→ 设定 maxMessages(最多保留几条)
→ 用 slice(-maxMessages) 截取最后 N 条
→ 旧消息直接扔掉
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
async function messageCountTruncation() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 4; // 最多保留 4 条消息
// 模拟 8 条历史消息(4 轮对话)
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 AIMessage(msg.content));
}
}
// 调用模型前截断
let allMessages = await history.getMessages();
const trimmedMessages = allMessages.slice(-maxMessages);
console.log(`保留消息数量:${trimmedMessages.length}`);
console.log('保留的消息:');
trimmedMessages.forEach(m =>
console.log(` ${m.constructor.name}: ${m.content}`)
);
}
截断效果(保留最近 4 条):
原始 8 条:
1. HumanMessage: 我叫李四 ← 被丢弃
2. AIMessage: 你好李四... ← 被丢弃
3. HumanMessage: 我是一名设计师 ← 被丢弃
4. AIMessage: 设计师是个... ← 被丢弃
5. HumanMessage: 我喜欢艺术和音乐 ← 保留
6. AIMessage: 艺术和音乐... ← 保留
7. HumanMessage: 我擅长 UI/UX 设计 ← 保留
8. AIMessage: UI/UX 设计... ← 保留
优点:简单,O(1) 操作
缺点:按条数不精确——短消息和长消息都算 1 条
4.3 Token 数量截断
更精确的方式:按 token 数量截断
为什么按 token 更精确?
→ 一条短消息可能只有 5 个 token
→ 一条长消息可能有 500 个 token
→ 按条数截断可能差 100 倍
→ 上下文窗口的限制是 token,不是条数
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import { HumanMessage, AIMessage, 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; // token 上限
const encoder = getEncoding('cl100k_base'); // GPT 系列的编码方式
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 AIMessage(msg.content));
}
}
const allMessages = await history.getMessages();
// LangChain 内置的 trimMessages 工具
const trimmedMessages = await trimMessages(allMessages, {
maxTokens: maxTokens,
tokenCounter: async (msgs) => countTokens(msgs, encoder),
strategy: 'last', // 保留最近的
});
const totalTokens = countTokens(trimmedMessages, encoder);
console.log(`截断后总 token 数量:${totalTokens}`);
console.log('保留的消息:');
trimmedMessages.forEach(m =>
console.log(` ${m.constructor.name}: ${m.content}`)
);
}
trimMessages 的工作原理:
输入:8 条消息,总 token = 200,maxTokens = 100
从最后一条往前数,直到不超过 maxTokens:
第 8 条:30 token → 累计 30 ✓ 保留
第 7 条:15 token → 累计 45 ✓ 保留
第 6 条:25 token → 累计 70 ✓ 保留
第 5 条:15 token → 累计 85 ✓ 保留
第 4 条:40 token → 累计 125 ✗ 超过了,停
→ 保留第 5-8 条(85 token)
→ 丢弃第 1-4 条
内部使用二分查找优化效率
→ 不需要一条条遍历
→ O(log n) 找到截断点
js-tiktoken:精确计算 token
token ≠ 字 ≠ 字符
→ "你好" 可能是 2 个 token(中文)
→ "hello" 可能是 1 个 token(英文)
→ 不同模型的 token 编码方式不同
cl100k_base:
→ GPT-3.5 / GPT-4 使用的编码
→ 最常用的编码方式
为什么要精确计算 token?
→ 控制成本:每次调用花多少钱心里有数
→ 控制窗口:确保不超过模型上限
→ 截断策略:精确控制留下多少
4.4 截断策略的优缺点
┌──────────────────────────────────────────────────────────┐
│ 截断策略的优缺点 │
│ │
│ 优点: │
│ ✅ 简单高效,实现成本低 │
│ ✅ token 截断精度高 │
│ ✅ 不需要额外调用模型(零成本) │
│ ✅ 适合短对话、快速对话场景 │
│ │
│ 缺点: │
│ ❌ 旧消息直接丢失,不可逆 │
│ ❌ 可能丢失重要的早期信息 │
│ ❌ 用户说"我刚才提到的那件事" → 模型不知道是哪件事 │
│ ❌ 长期对话体验差 │
│ │
│ 适用场景: │
│ → 简单问答机器人 │
│ → 短对话任务 │
│ → 对历史记忆要求不高的场景 │
└──────────────────────────────────────────────────────────┘
五、总结策略:用 AI 压缩历史
5.1 为什么需要总结?
截断策略的问题:旧消息直接丢了
用户:我叫李四,是一名 UI/UX 设计师,喜欢艺术和音乐
...(10 轮对话后)...
用户:你还记得我是做什么的吗?
模型:抱歉,我们的对话历史中没有提到您的职业。
→ 因为早期消息被截断掉了!
解决方案:总结(Summarization)
→ 旧消息不直接丢
→ 让 AI 把旧消息总结成一段摘要
→ 摘要 + 最近消息一起喂给模型
→ 既省 token,又保留核心信息
5.2 消息数量触发的总结
总结策略的核心思路:
当消息数量超过阈值时:
→ 旧消息(前 N 条) → AI 总结 → 摘要
→ 最近消息(后 K 条) → 原样保留
→ 摘要 + 最近消息 → 组成新的历史
→ 既省 token,又保留核心信息
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import {
SystemMessage,
HumanMessage,
AIMessage,
getBufferString,
} from '@langchain/core/messages';
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,
},
});
// 总结历史消息
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;
}
async function summarizationMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 6;
const keepRecent = 2; // 保留最近 2 条
// 模拟 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 AIMessage(msg.content));
}
}
let allMessages = await history.getMessages();
console.log(`原始消息数量:${allMessages.length}`);
if (allMessages.length > maxMessages) {
// 拆分:旧消息用于总结,最近消息保留
const recentMessages = allMessages.slice(-keepRecent);
const messagesToSummarize = allMessages.slice(0, -keepRecent);
console.log(`将被总结的消息数量:${messagesToSummarize.length}`);
// AI 总结旧消息
const summary = await summarizeHistory(messagesToSummarize);
// 清空历史,重新组装
await history.clear();
for (const msg of recentMessages) {
await history.addMessage(msg);
}
// 把摘要作为一条 AI 消息加入历史
await history.addMessage(new AIMessage(`[对话摘要] ${summary}`));
const newMessages = await history.getMessages();
console.log(`总结后消息数量:${newMessages.length}`);
newMessages.forEach(m => {
console.log(`${m.constructor.name}: ${m.content.substring(0, 60)}...`);
});
}
}
summarizationMemoryDemo().catch(console.error);
总结前后对比:
总结前(8 条消息,约 200 token):
用户:我叫李四
助手:你好李四,很高兴认识你!
用户:我是一名设计师
助手:设计师是个很有创造力的职业!你主要做什么类型的设计?
用户:我喜欢艺术和音乐
助手:艺术和音乐都是很好的爱好,它们能激发创作灵感。
用户:我擅长 UI/UX 设计
助手:UI/UX 设计非常重要,好的用户体验能让产品更成功!
总结后(3 条消息,约 80 token):
助手:[对话摘要] 用户名叫李四,是一名设计师,擅长 UI/UX 设计,
喜欢艺术和音乐。助手对用户的职业和爱好表示赞赏。
用户:我擅长 UI/UX 设计
助手:UI/UX 设计非常重要,好的用户体验能让产品更成功!
→ token 减少约 60%
→ 核心信息保留
→ 最近对话保持完整
5.3 getBufferString 工具
getBufferString:消息数组 → 格式化字符串
作用:
→ 将 messages 数组转换成人类可读的字符串
→ 方便传给 AI 做总结
→ 可以自定义 human 和 ai 的前缀
输入:
[
HumanMessage("你好"),
AIMessage("你好!有什么可以帮你?")
]
输出(默认):
"Human: 你好
AI: 你好!有什么可以帮你?"
输出(自定义前缀):
getBufferString(messages, "用户", "助手")
"用户: 你好
助手: 你好!有什么可以帮你?"
5.4 Token 触发的总结
更精确的方式:按 token 数量触发总结
思路:
→ 设定 maxTokens(总 token 上限)
→ 设定 keepRecentTokens(保留最近多少 token)
→ 超过上限时触发总结
→ 旧消息总结,最近消息保留
import { getEncoding } from 'js-tiktoken';
async function tokenBasedSummarization() {
const history = new InMemoryChatMessageHistory();
const encoder = getEncoding('cl100k_base');
const maxTokens = 200; // 总 token 上限
const keepRecentTokens = 80; // 保留最近消息的 token
// ... 填充消息 ...
let allMessages = await history.getMessages();
const totalTokens = countTokens(allMessages, encoder);
if (totalTokens >= maxTokens) {
// 从后往前收集,直到达到 keepRecentTokens
const recentMessages = [];
let recentTokens = 0;
for (let i = allMessages.length - 1; i >= 0; i--) {
const msg = allMessages[i];
const content = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content);
const msgTokens = encoder.encode(content).length;
if (recentTokens + msgTokens <= keepRecentTokens) {
recentMessages.unshift(msg);
recentTokens += msgTokens;
} else {
break;
}
}
const messagesToSummarize = allMessages.slice(
0,
allMessages.length - recentMessages.length
);
const summary = await summarizeHistory(messagesToSummarize);
// 清空并重新组装
await history.clear();
for (const msg of recentMessages) {
await history.addMessage(msg);
}
await history.addMessage(new AIMessage(`[对话摘要] ${summary}`));
}
}
5.5 总结策略的优缺点
┌──────────────────────────────────────────────────────────┐
│ 总结策略的优缺点 │
│ │
│ 优点: │
│ ✅ 保留核心信息,不丢失重要上下文 │
│ ✅ token 效率高(压缩比可达 60-80%) │
│ ✅ 适合中长对话 │
│ ✅ 用户体验比纯截断好 │
│ │
│ 缺点: │
│ ❌ 需要额外调用 AI → 额外成本 │
│ ❌ 摘要可能丢失细节(信息有损压缩) │
│ ❌ 总结质量依赖模型能力 │
│ ❌ 摘要本身也会增长(多轮总结后摘要越来越长) │
│ │
│ 适用场景: │
│ → 中长对话(10-100 轮) │
│ → 需要保留核心上下文的对话机器人 │
│ → 客服、助手类应用 │
└──────────────────────────────────────────────────────────┘
六、检索策略:语义化长期记忆
6.1 为什么需要检索?
总结策略的问题:摘要越来越长
第一次总结:100 token 摘要
第二次总结:摘要 + 新消息 → 新摘要 → 150 token
第三次总结:更长的摘要 → 200 token
→ 摘要本身也会膨胀
→ 时间越久,信息越模糊
终极解决方案:检索(Retrieval)
→ 所有历史消息存入向量数据库
→ 每次对话时,用 query 检索最相关的历史
→ 只把相关的历史消息塞进 prompt
→ 类似 RAG,但检索的是对话历史而不是外部知识
6.2 检索型 Memory 的架构
检索型 Memory 架构:
┌──────────────────────────────────────────────────────────┐
│ 用户输入:"我上次说的那个项目怎么样了?" │
│ │
│ ① 向量化 │
│ → 把用户输入转换成 embedding 向量 │
│ │
│ ② 检索 │
│ → 在向量数据库中搜索最相似的历史消息 │
│ → 返回 Top-K 相关的历史对话片段 │
│ │
│ ③ 组装 prompt │
│ → SystemMessage + 相关历史 + 当前对话 + 用户输入 │
│ │
│ ④ 调用模型 │
│ → 模型看到相关历史 → 回答准确 │
│ │
│ ⑤ 存储新对话 │
│ → 本轮对话也存入向量数据库 │
│ → 供未来检索使用 │
└──────────────────────────────────────────────────────────┘
类比:人的长期记忆
→ 你不会每时每刻都记着所有事
→ 被问到某个话题时,相关记忆才会浮现
→ 这就是检索的本质
6.3 三种策略对比
┌──────────────────────────────────────────────────────────────┐
│ 三种 Memory 管理策略对比 │
│ │
│ 截断 总结 检索 │
│ ─────────────────────────────────────────────────── │
│ 原理 保留最近 N 条 AI 压缩旧消息 向量相似度检索 │
│ 成本 零额外成本 每次总结调 AI 向量化 + 检索 │
│ 信息 丢失早期信息 保留核心摘要 保留相关细节 │
│ 复杂度 极低 中等 较高 │
│ 适合 短对话 中长对话 超长对话 / 长期记忆 │
│ 类比 短期记忆 工作记忆 长期记忆 │
│ 工具 slice / trim model.invoke 向量数据库 │
│ │
│ 实际项目中通常组合使用: │
│ → 最近 N 条:完整保留 │
│ → 更早的:总结压缩 │
│ → 更早的:存入向量数据库,需要时检索 │
│ → 分层管理 = 短期 + 中期 + 长期 │
└──────────────────────────────────────────────────────────────┘
七、分层 Memory 架构实战
7.1 完整架构
生产级 Agent 的 Memory 架构:
┌──────────────────────────────────────────────────────────┐
│ Prompt(发送给模型) │
│ ├── SystemMessage(系统指令) │
│ ├── 长期记忆摘要(可选) │
│ ├── 检索到的相关历史(来自向量数据库) │
│ ├── 总结后的历史摘要 │
│ └── 最近 N 条消息(完整保留) │
└──────────────────────────────────────────────────────────┘
│
│ 写入
▼
┌──────────────────────────────────────────────────────────┐
│ Memory 分层存储 │
│ │
│ 第一层:短期记忆(In-Memory) │
│ → 最近 10-20 条消息 │
│ → 完整保留 │
│ → 速度最快 │
│ │
│ 第二层:中期记忆(总结) │
│ → 更早的消息被 AI 总结压缩 │
│ → 作为摘要加入 prompt │
│ → 定期重新总结 │
│ │
│ 第三层:长期记忆(向量数据库) │
│ → 所有历史消息存入 Milvus / Supabase │
│ → 需要时按语义检索 │
│ → 真正的"永久记忆" │
└──────────────────────────────────────────────────────────┘
7.2 触发时机
什么时候触发 Memory 管理?
→ 每次用户发送消息后
→ 调用模型之前
→ 先检查 token 数量
→ 超过阈值 → 触发截断 / 总结
→ 同时检索相关长期记忆
流程:
用户消息 → 加入 Memory → 计算 token 数
│
├─→ 超过 maxTokens?
│ ├─ 是 → 总结旧消息 + 保留最近
│ └─ 否 → 直接使用
│
├─→ 需要长期记忆?
│ └─ 是 → 向量检索相关历史
│
▼
组装 messages → 调用模型 → 结果写回 Memory
7.3 与 /compact 和 /clear 的关系
用户可以手动触发 Memory 管理:
/compact → 总结压缩
→ 手动触发一次总结
→ 把当前对话压缩成摘要 + 最近消息
→ 类似"整理记忆"
/clear → 清空记忆
→ 清除所有对话历史
→ 重新开始新对话
→ 类似"失忆"
自动管理 + 手动管理相结合
→ 自动:token 超限时自动总结
→ 手动:用户主动 /compact 或 /clear
八、核心工具速查
8.1 LangChain Memory API
| 工具 | 作用 | 所在包 |
|---|---|---|
| InMemoryChatMessageHistory | 内存消息历史 | @langchain/core/chat_history |
| FileSystemChatMessageHistory | 文件消息历史 | @langchain/community/stores/message/file_system |
| HumanMessage | 用户消息 | @langchain/core/messages |
| AIMessage | AI 消息 | @langchain/core/messages |
| SystemMessage | 系统消息 | @langchain/core/messages |
| ToolMessage | 工具返回消息 | @langchain/core/messages |
| trimMessages | 按 token 裁剪消息 | @langchain/core/messages |
| getBufferString | 消息数组转字符串 | @langchain/core/messages |
| getEncoding | token 编码器 | js-tiktoken |
8.2 消息类型属性
每个消息对象的核心属性:
msg.type → 'human' | 'ai' | 'system' | 'tool'
msg.content → 消息内容(字符串或数组)
msg.name → 名称(可选)
msg.tool_call_id → 工具调用 ID(ToolMessage 用)
构造方式:
new HumanMessage(content)
new AIMessage(content)
new SystemMessage(content)
new ToolMessage({ content, tool_call_id })
九、总结
9.1 知识体系图
LLM Memory 管理
│
├── 为什么需要 Memory
│ ├── LLM 是无状态的(每次都是新的)
│ ├── Agent = LLM + Harness(Tool + RAG + Memory)
│ └── 三大挑战:上下文窗口 / Token 开销 / 持久化
│
├── 存储方式(存在哪里)
│ ├── In-Memory(短期)
│ │ ├── InMemoryChatMessageHistory
│ │ ├── addMessage / getMessages / clear
│ │ └── 临时会话,重启即失
│ ├── File System(中期)
│ │ ├── FileSystemChatMessageHistory
│ │ ├── sessionId 多用户隔离
│ │ └── JSON 文件持久化,跨会话恢复
│ └── Vector DB(长期)
│ ├── Milvus / Supabase pgvector
│ └── 语义检索相关历史
│
├── 管理策略(怎么管理)
│ ├── 截断(Truncation)
│ │ ├── 按消息数量:slice(-N)
│ │ ├── 按 token 数量:trimMessages + js-tiktoken
│ │ ├── 零额外成本,简单高效
│ │ └── 缺点:旧消息直接丢失
│ ├── 总结(Summarization)
│ │ ├── 旧消息 AI 总结 → 摘要
│ │ ├── 最近消息原样保留
│ │ ├── getBufferString 消息转字符串
│ │ ├── 压缩比 60-80%
│ │ └── 缺点:额外 AI 调用成本,有损压缩
│ └── 检索(Retrieval)
│ ├── 向量数据库存储所有历史
│ ├── 按语义检索 Top-K 相关历史
│ ├── 类似 RAG,但检索的是对话历史
│ └── 真正的长期记忆
│
├── 分层架构
│ ├── 短期:最近 N 条完整保留
│ ├── 中期:更早消息总结压缩
│ ├── 长期:所有消息存入向量数据库
│ └── 自动管理 + 手动 /compact /clear
│
└── 核心工具
├── trimMessages:内置裁剪工具
├── getBufferString:消息转字符串
├── getEncoding:token 编码(cl100k_base)
└── LangChain 四种消息类型
9.2 一句话总结
大模型是无状态的,Memory 系统让它"记住"对话。Memory 分为两层设计:存储方式(内存 / 文件 / 向量数据库)决定记忆保存在哪里,管理策略(截断 / 总结 / 检索)决定记忆怎么被使用。截断最简单但丢信息,总结用 AI 压缩历史保留核心但有额外成本,检索通过向量相似度找到相关记忆是终极方案。生产级 Agent 通常采用分层架构——最近消息完整保留、中期消息总结压缩、长期消息存入向量数据库按需检索,三者配合实现既省 token 又不丢关键信息的记忆系统。
如果这篇文章对你有帮助,欢迎点赞和收藏!