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

截断是粗暴的丢弃,总结压缩是优雅的提炼------让 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 截断兜底,再对超出的部分做总结。

项目源码:memory-management-demo

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


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

相关推荐
RisunJan3 小时前
后端高频面试题与解答
面试·职场和发展
数智启示录7 小时前
PostgreSQL 计划缓存实战(第 10 篇):预编译 SQL 前五次都快,第六次为什么可能变慢
经验分享·sql·缓存·postgresql·面试
windliang9 小时前
Agent Loop 的 while 循环什么时候开始不够用
人工智能·面试·开源
不要打扰75610 小时前
2027国网提前批宣讲、面试、预报名、内部笔试时间一览
面试·职场和发展
YHHLAI10 小时前
TypeScript 高级工具类型面试指南
ubuntu·面试·typescript
众人皆醒我独醉13 小时前
Kubernetes GPU 调度与管理的完整机制——从节点上架到 Pod 拿到 GPU
面试·kubernetes·gpu
程序员-Benothing15 小时前
MySQL 中的 Log Buffer 是什么?它有什么作用?
数据库·mysql·面试
Phil3231 天前
开源一个 Windows 实时面试助手:听译、置顶提词、按需生成英文稿
面试·github
汉堡大王95271 天前
面试官:讲讲归并排序 —— 为什么它能在面试里反复出现?
前端·javascript·面试