Agent Memory 进阶:用 LLM 总结压缩上下文,告别"金鱼记忆"

19 阅读9分钟

截断是粗暴的丢弃,总结压缩是优雅的提炼——让 AI 自己帮你记住重点。

前言

上一篇 《一文搞懂 Agent Memory 管理》 我们聊了 Memory 的三种截断策略:消息数量截断、Token 数量截断、总结压缩。但上一篇对"总结压缩"只放了一段伪代码,很多同学看完还是不知道怎么落地。

今天这篇就来把总结压缩讲透:

  • 你会收获:
    • 理解总结压缩的核心流程(5 步拆解)
    • 掌握 getBufferString 的用法和作用
    • 学会两种触发方案:按消息数量 vs 按 Token 数量
    • 理解从后往前遍历保留最近消息的算法
    • 知道为什么用 SystemMessage 而不是 HumanMessage 包装总结 prompt

技术栈:LangChain.js + OpenAI + js-tiktoken


目录


一、截断 vs 总结压缩:丢掉 vs 精炼

先回顾一下上篇的两种截断方式:

策略做法类比
消息数量截断slice(-N) 保留最近 N 条整理衣柜,把旧衣服全扔了
Token 数量截断trimMessages 按 token 上限裁剪搬家称重,超重的东西不带
总结压缩旧消息 → LLM 生成摘要 → 替换整理日记,把旧日记浓缩成一句话

截断是无差别丢弃——旧消息不管重不重要,一律删掉。总结压缩是有选择提炼——让 LLM 读一遍旧消息,把核心信息压缩成一段摘要。

一句话记住:截断是"忘掉过去",总结是"浓缩过去"。


二、总结压缩的 5 步流程

不管哪种触发方案,总结压缩的核心流程都是一样的:

┌──────────────┐    ┌──────────────┐    ┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│  1. 判断超限  │ →  │  2. 拆分消息  │ →  │  3. 生成摘要  │ →  │  4. 清空历史  │ →  │  5. 重建历史  │
│  (数量/Token) │    │ (旧 vs 最近) │    │  (LLM 总结)  │    │ (history.    │    │ (最近+摘要)  │
│              │    │              │    │              │    │  clear())    │    │              │
└──────────────┘    └──────────────┘    └──────────────┘    └──────────────┘    └──────────────┘

拆解每一步:

步骤做什么关键代码
1. 判断超限检查消息数量或 token 数是否超过阈值if (allMessages.length > maxMessages)
2. 拆分消息把消息分成"要总结的旧消息"和"要保留的最近消息"slice(0, -keepRecent) + slice(-keepRecent)
3. 生成摘要旧消息 → getBufferString 转文本 → 喂给 LLMawait summarizeHistory(messagesToSummarize)
4. 清空历史把 history 里的消息全部清掉await history.clear()
5. 重建历史先加回最近消息,再加一条摘要消息history.addMessage(new AIMessage(summary))

类比:就像 Claude Code 的 /compact 命令——对话太长时,把旧对话压缩成摘要,释放上下文窗口,但保留关键信息。


三、方案一:按消息数量触发总结

这是最简单的方案,适合快速验证思路。

完整代码

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 是干嘛的?

const conversationText = getBufferString(messages, "用户", "助手");

把消息对象数组转成可读的纯文本:

用户: 我叫李四
助手: 你好李四,很高兴认识你!
用户: 我是一名设计师
助手: 设计师是个很有创造力的职业!你主要做什么类型的设计?

第二个参数 "用户" 替换 HumanMessage,第三个参数 "助手" 替换 AIMessage。

为什么要转? 因为 LLM 不认识 LangChain 的消息对象,它只认纯文本。不转的话,你得手动 messages.map(m => ...) 拼接,getBufferString 帮你做了这件事。

2. 为什么用 SystemMessage 包装?

const summaryResponse = await model.invoke(
  [new SystemMessage(summaryPrompt)]
);

你可能会问:为什么不直接用 HumanMessage?

因为这是单轮任务调用,不是多轮对话。SystemMessage 的语义是"给模型一个任务指令",模型会以任务执行者的身份回复,输出更干净。用 HumanMessage 的话,模型可能还会加"好的,我来帮你总结"之类的客套话。

包装方式模型理解输出风格
SystemMessage"这是一个任务指令"直接输出摘要
HumanMessage"用户在问我问题"可能带客套话

一句话记住:任务指令用 SystemMessage,对话用 HumanMessage。

3. slice 拆分的逻辑

const recentMessages = allMessages.slice(-keepRecent);        // 最后 2 条
const messagesToSummarize = allMessages.slice(0, -keepRecent); // 除了最后 2 条

用 -keepRecent 这个负数索引做分界线:

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 算的,不是按消息条数算的。

完整代码

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);

核心算法拆解:从后往前遍历

这是方案二最精妙的部分,值得单独讲:

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;
  }
}

为什么从后往前? 因为要保留最近的消息。从最后一条开始,逐条往前检查:

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 > NtotalTokens >= maxTokens
保留策略slice(-keepRecent) 按条数从后往前遍历按 token 预算
精确度❌ 不同消息长度差异大✅ 精确控制 token 开销
复杂度⭐ 简单⭐⭐⭐ 需要 tokenizer
适用场景开发调试、快速验证生产环境
额外依赖无js-tiktoken

怎么选?

  • 开发阶段先用方案一,快速验证"总结压缩"的思路
  • 上线前换成方案二,精确控制 token 开销,避免超限报错

六、常见坑 / 易错点

坑 1:忘记 history.clear()

// ❌ 错误:不清空就加新消息,旧消息还在
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 的角色名顺序

// ❌ 错误:角色名写反了
getBufferString(messages, "助手", "用户");
// 输出:助手: 我叫李四  ← 把用户说的话标成了助手

// ✅ 正确:第二个参数对应 HumanMessage,第三个对应 AIMessage
getBufferString(messages, "用户", "助手");

坑 3:Token 计算不一致

// ❌ 错误:判断用一种 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:摘要消息的角色选择

// ❌ 错误:用 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插入到数组开头,配合从后往前遍历保持顺序
/compactClaude Code 的压缩命令,和总结压缩同理

核心代码骨架(5 步)

// 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 截断兜底,再对超出的部分做总结。

项目源码:memory-management-demo

希望这篇文章对你有帮助!有问题欢迎在评论区交流 🔥


💡 思考:你在项目中用过总结压缩吗?遇到过什么坑?欢迎评论区分享!