截断是粗暴的丢弃,总结压缩是优雅的提炼------让 AI 自己帮你记住重点。
前言
上一篇 《一文搞懂 Agent Memory 管理》 我们聊了 Memory 的三种截断策略:消息数量截断、Token 数量截断、总结压缩。但上一篇对"总结压缩"只放了一段伪代码,很多同学看完还是不知道怎么落地。
今天这篇就来把总结压缩讲透:
- 你会收获:
- 理解总结压缩的核心流程(5 步拆解)
- 掌握
getBufferString的用法和作用 - 学会两种触发方案:按消息数量 vs 按 Token 数量
- 理解从后往前遍历保留最近消息的算法
- 知道为什么用
SystemMessage而不是HumanMessage包装总结 prompt
技术栈:LangChain.js + OpenAI + js-tiktoken
目录
- [一、截断 vs 总结压缩:丢掉 vs 精炼](#一、截断 vs 总结压缩:丢掉 vs 精炼 "#%E4%B8%80%E6%88%AA%E6%96%AD-vs-%E6%80%BB%E7%BB%93%E5%8E%8B%E7%BC%A9%E4%B8%A2%E6%8E%89-vs-%E7%B2%BE%E7%82%BC")
- [二、总结压缩的 5 步流程](#二、总结压缩的 5 步流程 "#%E4%BA%8C%E6%80%BB%E7%BB%93%E5%8E%8B%E7%BC%A9%E7%9A%84-5-%E6%AD%A5%E6%B5%81%E7%A8%8B")
- 三、方案一:按消息数量触发总结
- [四、方案二:按 Token 数量触发总结](#四、方案二:按 Token 数量触发总结 "#%E5%9B%9B%E6%96%B9%E6%A1%88%E4%BA%8C%E6%8C%89-token-%E6%95%B0%E9%87%8F%E8%A7%A6%E5%8F%91%E6%80%BB%E7%BB%93")
- 五、两种方案对比
- [六、常见坑 / 易错点](#六、常见坑 / 易错点 "#%E5%85%AD%E5%B8%B8%E8%A7%81%E5%9D%91--%E6%98%93%E9%94%99%E7%82%B9")
- 总结
一、截断 vs 总结压缩:丢掉 vs 精炼
先回顾一下上篇的两种截断方式:
| 策略 | 做法 | 类比 |
|---|---|---|
| 消息数量截断 | slice(-N) 保留最近 N 条 |
整理衣柜,把旧衣服全扔了 |
| Token 数量截断 | trimMessages 按 token 上限裁剪 |
搬家称重,超重的东西不带 |
| 总结压缩 | 旧消息 → LLM 生成摘要 → 替换 | 整理日记,把旧日记浓缩成一句话 |
截断是无差别丢弃 ------旧消息不管重不重要,一律删掉。总结压缩是有选择提炼------让 LLM 读一遍旧消息,把核心信息压缩成一段摘要。
一句话记住:截断是"忘掉过去",总结是"浓缩过去"。
二、总结压缩的 5 步流程
不管哪种触发方案,总结压缩的核心流程都是一样的:
scss
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 1. 判断超限 │ → │ 2. 拆分消息 │ → │ 3. 生成摘要 │ → │ 4. 清空历史 │ → │ 5. 重建历史 │
│ (数量/Token) │ │ (旧 vs 最近) │ │ (LLM 总结) │ │ (history. │ │ (最近+摘要) │
│ │ │ │ │ │ │ clear()) │ │ │
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
拆解每一步:
| 步骤 | 做什么 | 关键代码 |
|---|---|---|
| 1. 判断超限 | 检查消息数量或 token 数是否超过阈值 | if (allMessages.length > maxMessages) |
| 2. 拆分消息 | 把消息分成"要总结的旧消息"和"要保留的最近消息" | slice(0, -keepRecent) + slice(-keepRecent) |
| 3. 生成摘要 | 旧消息 → getBufferString 转文本 → 喂给 LLM |
await summarizeHistory(messagesToSummarize) |
| 4. 清空历史 | 把 history 里的消息全部清掉 | await history.clear() |
| 5. 重建历史 | 先加回最近消息,再加一条摘要消息 | history.addMessage(new AIMessage(summary)) |
类比 :就像 Claude Code 的 /compact 命令------对话太长时,把旧对话压缩成摘要,释放上下文窗口,但保留关键信息。
三、方案一:按消息数量触发总结
这是最简单的方案,适合快速验证思路。
完整代码
javascript
import 'dotenv/config';
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, "用户", "助手");
// 第二步:组装总结 prompt
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:
`;
// 第三步:调用 LLM 生成摘要
const summaryResponse = await model.invoke(
[new SystemMessage(summaryPrompt)]
);
return summaryResponse.content;
}
// ========== 主流程 ==========
async function summarizationMemoryDemo() {
const history = new InMemoryChatMessageHistory();
const maxMessages = 6; // 超过 6 条就触发总结
const keepRecent = 2; // 保留最近 2 条
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();
// 第一步:判断是否超限
if (allMessages.length > maxMessages) {
// 第二步:拆分消息
const recentMessages = allMessages.slice(-keepRecent); // 最近 2 条
const messagesToSummarize = allMessages.slice(0, -keepRecent); // 旧的 6 条
// 第三步:生成摘要
const summary = await summarizeHistory(messagesToSummarize);
// 第四步:清空历史
await history.clear();
// 第五步:重建历史(最近消息 + 摘要)
for (const msg of recentMessages) {
await history.addMessage(msg);
}
await history.addMessage(new AIMessage(summary)); // 摘要作为最后一条
// 验证结果
const newMessages = await history.getMessages();
console.log(`压缩后消息数量:${newMessages.length}`);
for (const mes of newMessages) {
console.log(`${mes.constructor.name}: ${mes.content}`);
}
}
}
summarizationMemoryDemo().catch(console.error);
逐行拆解关键部分
1. getBufferString 是干嘛的?
javascript
const conversationText = getBufferString(messages, "用户", "助手");
把消息对象数组转成可读的纯文本:
makefile
用户: 我叫李四
助手: 你好李四,很高兴认识你!
用户: 我是一名设计师
助手: 设计师是个很有创造力的职业!你主要做什么类型的设计?
第二个参数 "用户" 替换 HumanMessage,第三个参数 "助手" 替换 AIMessage。
为什么要转? 因为 LLM 不认识 LangChain 的消息对象,它只认纯文本。不转的话,你得手动 messages.map(m => ...) 拼接,getBufferString 帮你做了这件事。
2. 为什么用 SystemMessage 包装?
javascript
const summaryResponse = await model.invoke(
[new SystemMessage(summaryPrompt)]
);
你可能会问 :为什么不直接用 HumanMessage?
因为这是单轮任务调用 ,不是多轮对话。SystemMessage 的语义是"给模型一个任务指令",模型会以任务执行者的身份回复,输出更干净。用 HumanMessage 的话,模型可能还会加"好的,我来帮你总结"之类的客套话。
| 包装方式 | 模型理解 | 输出风格 |
|---|---|---|
SystemMessage |
"这是一个任务指令" | 直接输出摘要 |
HumanMessage |
"用户在问我问题" | 可能带客套话 |
一句话记住 :任务指令用 SystemMessage,对话用 HumanMessage。
3. slice 拆分的逻辑
javascript
const recentMessages = allMessages.slice(-keepRecent); // 最后 2 条
const messagesToSummarize = allMessages.slice(0, -keepRecent); // 除了最后 2 条
用 -keepRecent 这个负数索引做分界线:
ini
allMessages: [msg0, msg1, msg2, msg3, msg4, msg5, msg6, msg7]
↑ ↑
-keepRecent 末尾
slice(-2) → [msg6, msg7] ← recentMessages(保留)
slice(0, -2) → [msg0, msg1, msg2, msg3, msg4, msg5] ← messagesToSummarize(送去总结)
四、方案二:按 Token 数量触发总结
生产环境推荐这个方案,因为LLM 的上下文窗口是按 token 算的,不是按消息条数算的。
完整代码
javascript
import 'dotenv/config';
import { InMemoryChatMessageHistory } from '@langchain/core/chat_history';
import {
SystemMessage,
HumanMessage,
AIMessage,
getBufferString,
} from '@langchain/core/messages';
import { ChatOpenAI } from '@langchain/openai';
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,
}
});
// ========== 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; // encode 返回 token 数组,.length 取数量
}
return total;
}
// ========== 总结函数(和方案一一样) ==========
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 encoder = getEncoding('cl100k_base'); // OpenAI 的 tokenizer
const maxTokens = 200; // 总 token 上限
const keepRecentTokens = 80; // 最近消息的 token 预算
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 totalTokens = countTokens(allMessages, encoder);
console.log(`总 token 数量:${totalTokens}`);
// 第一步:判断是否超限
if (totalTokens >= maxTokens) {
// 第二步:从后往前遍历,按 token 预算保留最近消息
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); // unshift 插入到数组开头,保持顺序
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));
const newMessages = await history.getMessages();
console.log(`压缩后消息数量:${newMessages.length}`);
const newTokens = countTokens(newMessages, encoder);
console.log(`压缩后 token 数量:${newTokens}`);
}
}
summarizationMemoryDemo().catch(console.error);
核心算法拆解:从后往前遍历
这是方案二最精妙的部分,值得单独讲:
javascript
const recentMessages = [];
let recentTokens = 0;
for (let i = allMessages.length - 1; i >= 0; i--) {
const msg = allMessages[i];
const msgTokens = encoder.encode(msg.content).length;
if (recentTokens + msgTokens <= keepRecentTokens) {
recentMessages.unshift(msg); // 插入到数组开头
recentTokens += msgTokens;
} else {
break;
}
}
为什么从后往前? 因为要保留最近的消息。从最后一条开始,逐条往前检查:
ini
allMessages: [msg0(10t), msg1(15t), msg2(12t), msg3(8t), msg4(20t), msg5(25t)]
↑ ↑
keepRecentTokens = 80 从这里开始往前
遍历过程:
i=5: msg5(25t), recentTokens=25 ≤ 80 ✅ → recentMessages=[msg5]
i=4: msg4(20t), recentTokens=45 ≤ 80 ✅ → recentMessages=[msg4, msg5]
i=3: msg3(8t), recentTokens=53 ≤ 80 ✅ → recentMessages=[msg3, msg4, msg5]
i=2: msg2(12t), recentTokens=65 ≤ 80 ✅ → recentMessages=[msg2, msg3, msg4, msg5]
i=1: msg1(15t), recentTokens=80 ≤ 80 ✅ → recentMessages=[msg1, msg2, msg3, msg4, msg5]
i=0: msg0(10t), recentTokens=90 > 80 ❌ → break!
最终:
recentMessages = [msg1, msg2, msg3, msg4, msg5] ← 保留
messagesToSummarize = [msg0] ← 送去总结
unshift 的作用 :因为是从后往前遍历的,unshift 把每条消息插到数组开头,保证最终顺序是从旧到新,不会颠倒。
一句话记住 :从后往前装,装不下就停,unshift 保顺序。
五、两种方案对比
| 维度 | 方案一:按消息数量 | 方案二:按 Token 数量 |
|---|---|---|
| 触发条件 | messages.length > N |
totalTokens >= maxTokens |
| 保留策略 | slice(-keepRecent) 按条数 |
从后往前遍历按 token 预算 |
| 精确度 | ❌ 不同消息长度差异大 | ✅ 精确控制 token 开销 |
| 复杂度 | ⭐ 简单 | ⭐⭐⭐ 需要 tokenizer |
| 适用场景 | 开发调试、快速验证 | 生产环境 |
| 额外依赖 | 无 | js-tiktoken |
怎么选?
- 开发阶段先用方案一,快速验证"总结压缩"的思路
- 上线前换成方案二,精确控制 token 开销,避免超限报错
六、常见坑 / 易错点
坑 1:忘记 history.clear()
javascript
// ❌ 错误:不清空就加新消息,旧消息还在
for (const msg of recentMessages) {
await history.addMessage(msg);
}
await history.addMessage(new AIMessage(summary));
// 结果:旧消息 + 最近消息 + 摘要,比原来还多!
// ✅ 正确:先清空再重建
await history.clear();
for (const msg of recentMessages) {
await history.addMessage(msg);
}
await history.addMessage(new AIMessage(summary));
坑 2:getBufferString 的角色名顺序
javascript
// ❌ 错误:角色名写反了
getBufferString(messages, "助手", "用户");
// 输出:助手: 我叫李四 ← 把用户说的话标成了助手
// ✅ 正确:第二个参数对应 HumanMessage,第三个对应 AIMessage
getBufferString(messages, "用户", "助手");
坑 3:Token 计算不一致
javascript
// ❌ 错误:判断用一种 tokenizer,计算用另一种
const totalTokens = countTokens(allMessages, encoderA); // cl100k_base
if (totalTokens >= maxTokens) {
const msgTokens = encoderB.encode(content).length; // 用了别的编码器
}
// ✅ 正确:全程用同一个 encoder
const encoder = getEncoding('cl100k_base');
// 判断和计算都用 encoder
坑 4:摘要消息的角色选择
javascript
// ❌ 错误:用 HumanMessage 存摘要
await history.addMessage(new HumanMessage(summary));
// 下次对话时模型会以为这是用户说的
// ✅ 正确:用 AIMessage 存摘要,表示这是 AI 总结的内容
await history.addMessage(new AIMessage(summary));
总结
核心概念速查表
| 概念 | 一句话 |
|---|---|
getBufferString |
把消息对象数组转成 角色: 内容 格式的纯文本 |
SystemMessage |
任务指令,不是对话,模型直接执行不废话 |
encoder.encode() |
文本 → token 数字数组,.length 取数量 |
slice(0, -N) |
从开头到倒数第 N 个之前,拿到旧消息 |
slice(-N) |
从倒数第 N 个到末尾,拿到最近消息 |
unshift |
插入到数组开头,配合从后往前遍历保持顺序 |
/compact |
Claude Code 的压缩命令,和总结压缩同理 |
核心代码骨架(5 步)
javascript
// 1. 判断超限
if (totalTokens >= maxTokens) {
// 2. 拆分:从后往前保留最近消息
const recentMessages = []; // 从后往前装
const messagesToSummarize = allMessages.slice(0, -recentMessages.length);
// 3. 总结:旧消息 → getBufferString → LLM 摘要
const summary = await summarizeHistory(messagesToSummarize);
// 4. 清空 + 5. 重建
await history.clear();
recentMessages.forEach(m => history.addMessage(m));
await history.addMessage(new AIMessage(summary));
}
一句话记住
截断是"忘掉过去",总结是"浓缩过去"------让 AI 帮你记住重点,比你自己决定丢什么更聪明。
结尾
总结压缩是 Agent Memory 管理中最"聪明"的策略,但也是最贵的(需要额外 LLM 调用)。实际项目中,往往截断 + 总结压缩组合使用:先用 token 截断兜底,再对超出的部分做总结。
希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥
💡 思考:你在项目中用过总结压缩吗?遇到过什么坑?欢迎评论区分享!