LLM 记忆管理三剑客:截断、总结、检索,彻底搞懂 Agent 的 Memory 系统

LLM 记忆管理三剑客:截断、总结、检索,彻底搞懂 Agent 的 Memory 系统

大模型是无状态的------每次对话都像第一次见面。要让 AI 记住你说过的话,就需要 Memory 系统。但简单地把所有对话塞进 prompt 会触发两个问题:上下文窗口爆了,Token 账单爆了。本文从 LangChain 的 InMemoryChatMessageHistory 出发,逐级深入三种存储方式(内存 / 文件 / 向量数据库)和三种管理策略(截断 / 总结 / 检索),附完整代码演示和 token 精确计算。建议收藏后动手实践。


一、为什么需要 Memory?

1.1 大模型是无状态的

arduino 复制代码
LLM 的本质:无状态

  第一次对话:
  用户:我叫李四
  模型:你好李四,很高兴认识你!

  第二次对话(不带历史):
  用户:我叫什么名字?
  模型:抱歉,我不知道您的名字。
  → 大模型根本不记得刚才说过什么!

  第二次对话(带上历史):
  输入:[
    {role: "user", content: "我叫李四"},
    {role: "assistant", content: "你好李四,很高兴认识你!"},
    {role: "user", content: "我叫什么名字?"}
  ]
  模型:您叫李四。
  → 因为历史消息被塞进了 prompt,模型"看到了"之前的对话
arduino 复制代码
大模型 = 无状态的函数

  每次调用 model.invoke(messages):
  → 模型只看 messages 数组里的内容
  → 模型不记得上次调用了什么
  → 就像鱼的记忆------每次都是全新的

  所以我们需要 Memory:
  → 把每次对话保存下来
  → 下次调用时拼到 prompt 里
  → 模型就"记住"了

1.2 Agent 的完整公式

ini 复制代码
Agent = LLM + Harness(Tool + RAG + Memory + ...)

  ┌──────────────────────────────────────────────────────────┐
│                    Agent 架构图                           │
│                                                          │
│  ┌──────────────────────────────────────────────────┐     │
│  │  LLM(大脑)                                     │     │
│  │  → 理解意图                                      │     │
│  │  → 生成回答                                      │     │
│  │  → 决策是否调用工具                               │     │
│  └──────────────────┬───────────────────────────────┘     │
│                     │                                    │
│  ┌──────────────────▼───────────────────────────┐        │
│  │  Harness(框架)                              │        │
│  │                                               │        │
│  │  ┌──────────┐  ┌──────────┐  ┌──────────┐    │        │
│  │  │  Tool    │  │  RAG     │  │  Memory  │    │        │
│  │  │  调用工具 │  │  检索知识 │  │  对话记忆 │    │        │
│  │  │  干活    │  │  注入prompt│ │  持续对话│    │        │
│  │  └──────────┘  └──────────┘  └──────────┘    │        │
│  │                                               │        │
│  │  → 不只是回答问题,而是真正"干活"              │        │
│  └───────────────────────────────────────────────┘        │
│                                                          │
│  Tool:让模型能调用外部工具(查天气、发邮件、写代码)     │
│  RAG:基于 query 从向量数据库检索相关知识,放入 prompt   │
│  Memory:保存对话历史,让模型记住上下文                   │
│                                                          │
│  Tool 和 RAG 都依赖 Memory------没有记忆就无法连续对话       │
└──────────────────────────────────────────────────────────┘

1.3 Memory 的三大挑战

javascript 复制代码
简单的 chatMessage 数组不够,面临三大挑战:

  ┌──────────────────────────────────────────────────────────┐
│  ① 上下文窗口大小                                          │
│  → 模型有 token 上限(如 128k、200k)                     │
│  → 对话越多,消息越长,越容易超出窗口                      │
│  → 超出窗口模型就处理不了了                                │
└──────────────────────────────────────────────────────────┘

  ┌──────────────────────────────────────────────────────────┐
│  ② Token 开销                                              │
│  → 每次调用都要把所有历史消息算进 token                     │
│  → 对话越长,每次调用越贵                                  │
│  → 200k context window 的模型 ≠ 每次都传 200k token       │
│  → 成本会随着对话长度线性增长                              │
└──────────────────────────────────────────────────────────┘

  ┌──────────────────────────────────────────────────────────┐
│  ③ 持久化                                                  │
  → 内存存储 → 程序重启就没了                                │
  → 用户换设备、刷新页面 → 对话消失                          │
  → 需要文件 / 数据库持久化                                  │
│  → 多用户场景需要 sessionId 区分不同会话                   │
└──────────────────────────────────────────────────────────┘
arduino 复制代码
Memory 的两层设计:

  存储逻辑(存在哪里):
  → 内存(In-Memory):临时,重启即失
  → 文件(File System):持久化,本地文件
  → 数据库(Vector DB):长期记忆,可检索

  管理逻辑(怎么管理):
  → 截断(Truncation):只留最近 N 条 / N 个 token
  → 总结(Summarization):旧消息用 AI 压缩成摘要
  → 检索(Retrieval):按语义从向量数据库检索相关历史

  ReAct 执行流程:
  messages 数组 → Memory 管理 → 喂给 LLM → 结果写回 Memory

二、短期记忆:InMemoryChatMessageHistory

2.1 基本用法

arduino 复制代码
InMemoryChatMessageHistory = 内存中的消息历史管理器

  它的本质:
  → 一个被封装过的数组
  → 提供 addMessage / getMessages / clear 等方法
  → 把"手动维护 messages 数组"的模式抽象成了类
javascript 复制代码
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 消息类型

go 复制代码
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 文件记忆的优势

ini 复制代码
FileSystemChatMessageHistory = 文件存储的消息历史

  相比内存记忆的优势:
  → 持久化:程序重启不丢失
  → 跨会话:重新运行能恢复历史
  → 多用户:通过 sessionId 区分不同会话
  → 简单:不需要数据库,JSON 文件即可

3.2 写入对话

javascript 复制代码
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 恢复对话

javascript 复制代码
// 重新运行程序,从文件恢复历史
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('对话已保存到文件');
}
javascript 复制代码
文件存储的结构(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 记忆分层

arduino 复制代码
不同存储方式的定位:

  ┌──────────────────────────────────────────────────────────┐
│              记忆三层架构                                  │
│                                                          │
│  短期记忆(In-Memory)                                    │
│  → 当前 Agent 正在进行的对话                               │
│  → 速度最快,无 I/O                                       │
│  → 程序重启即失                                           │
│                                                          │
│  中期记忆(File System)                                  │
│  → 最近几次的对话                                         │
│  → JSON 文件持久化                                        │
│  → 跨会话恢复                                             │
│  → 适合 session 级别的记忆                                │
│                                                          │
│  长期记忆(Vector DB / Milvus)                           │
│  → 所有历史对话的语义向量                                 │
│  → 按相似度检索相关历史                                   │
│  → 真正的"长期记忆"                                       │
│  → 结合 RAG 使用                                          │
└──────────────────────────────────────────────────────────┘

四、截断策略:只留最近的消息

4.1 为什么需要截断?

yaml 复制代码
对话越来越长的问题:

  对话 10 轮 → 20 条消息 → 2000 token → 还能接受
  对话 100 轮 → 200 条消息 → 20000 token → 有点贵了
  对话 1000 轮 → 2000 条消息 → 200000 token → 爆窗口了

  解决方案:截断(Truncation)
  → 只保留最近的 N 条消息
  → 旧消息直接丢弃
  → 最简单、最直接的策略

  类比:人的短期记忆
  → 你记得昨天吃了什么
  → 你可能不记得上个月某天吃了什么
  → 太久远的就忘了

4.2 消息数量截断

scss 复制代码
最简单的截断方式:按消息数量截断

  原理:
  → 设定 maxMessages(最多保留几条)
  → 用 slice(-maxMessages) 截取最后 N 条
  → 旧消息直接扔掉
javascript 复制代码
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}`)
  );
}
markdown 复制代码
截断效果(保留最近 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,不是条数
javascript 复制代码
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}`)
  );
}
ini 复制代码
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) 找到截断点
arduino 复制代码
js-tiktoken:精确计算 token

  token ≠ 字 ≠ 字符
  → "你好" 可能是 2 个 token(中文)
  → "hello" 可能是 1 个 token(英文)
  → 不同模型的 token 编码方式不同

  cl100k_base:
  → GPT-3.5 / GPT-4 使用的编码
  → 最常用的编码方式

  为什么要精确计算 token?
  → 控制成本:每次调用花多少钱心里有数
  → 控制窗口:确保不超过模型上限
  → 截断策略:精确控制留下多少

4.4 截断策略的优缺点

arduino 复制代码
┌──────────────────────────────────────────────────────────┐
│              截断策略的优缺点                              │
│                                                          │
│  优点:                                                   │
│  ✅ 简单高效,实现成本低                                   │
│  ✅ token 截断精度高                                       │
│  ✅ 不需要额外调用模型(零成本)                           │
│  ✅ 适合短对话、快速对话场景                               │
│                                                          │
│  缺点:                                                   │
│  ❌ 旧消息直接丢失,不可逆                                 │
│  ❌ 可能丢失重要的早期信息                                 │
│  ❌ 用户说"我刚才提到的那件事" → 模型不知道是哪件事       │
│  ❌ 长期对话体验差                                        │
│                                                          │
│  适用场景:                                               │
│  → 简单问答机器人                                         │
│  → 短对话任务                                             │
│  → 对历史记忆要求不高的场景                               │
└──────────────────────────────────────────────────────────┘

五、总结策略:用 AI 压缩历史

5.1 为什么需要总结?

erlang 复制代码
截断策略的问题:旧消息直接丢了

  用户:我叫李四,是一名 UI/UX 设计师,喜欢艺术和音乐
  ...(10 轮对话后)...
  用户:你还记得我是做什么的吗?
  模型:抱歉,我们的对话历史中没有提到您的职业。
  → 因为早期消息被截断掉了!

  解决方案:总结(Summarization)
  → 旧消息不直接丢
  → 让 AI 把旧消息总结成一段摘要
  → 摘要 + 最近消息一起喂给模型
  → 既省 token,又保留核心信息

5.2 消息数量触发的总结

复制代码
总结策略的核心思路:

  当消息数量超过阈值时:
  → 旧消息(前 N 条) → AI 总结 → 摘要
  → 最近消息(后 K 条) → 原样保留
  → 摘要 + 最近消息 → 组成新的历史
  → 既省 token,又保留核心信息
javascript 复制代码
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);
css 复制代码
总结前后对比:

  总结前(8 条消息,约 200 token):
  用户:我叫李四
  助手:你好李四,很高兴认识你!
  用户:我是一名设计师
  助手:设计师是个很有创造力的职业!你主要做什么类型的设计?
  用户:我喜欢艺术和音乐
  助手:艺术和音乐都是很好的爱好,它们能激发创作灵感。
  用户:我擅长 UI/UX 设计
  助手:UI/UX 设计非常重要,好的用户体验能让产品更成功!

  总结后(3 条消息,约 80 token):
  助手:[对话摘要] 用户名叫李四,是一名设计师,擅长 UI/UX 设计,
       喜欢艺术和音乐。助手对用户的职业和爱好表示赞赏。
  用户:我擅长 UI/UX 设计
  助手:UI/UX 设计非常重要,好的用户体验能让产品更成功!

  → token 减少约 60%
  → 核心信息保留
  → 最近对话保持完整

5.3 getBufferString 工具

vbscript 复制代码
getBufferString:消息数组 → 格式化字符串

  作用:
  → 将 messages 数组转换成人类可读的字符串
  → 方便传给 AI 做总结
  → 可以自定义 human 和 ai 的前缀

  输入:
  [
    HumanMessage("你好"),
    AIMessage("你好!有什么可以帮你?")
  ]

  输出(默认):
  "Human: 你好
  AI: 你好!有什么可以帮你?"

  输出(自定义前缀):
  getBufferString(messages, "用户", "助手")
  "用户: 你好
  助手: 你好!有什么可以帮你?"

5.4 Token 触发的总结

复制代码
更精确的方式:按 token 数量触发总结

  思路:
  → 设定 maxTokens(总 token 上限)
  → 设定 keepRecentTokens(保留最近多少 token)
  → 超过上限时触发总结
  → 旧消息总结,最近消息保留
javascript 复制代码
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 总结策略的优缺点

erlang 复制代码
┌──────────────────────────────────────────────────────────┐
│              总结策略的优缺点                              │
│                                                          │
│  优点:                                                   │
│  ✅ 保留核心信息,不丢失重要上下文                         │
│  ✅ token 效率高(压缩比可达 60-80%)                     │
│  ✅ 适合中长对话                                          │
│  ✅ 用户体验比纯截断好                                    │
│                                                          │
│  缺点:                                                   │
│  ❌ 需要额外调用 AI → 额外成本                            │
│  ❌ 摘要可能丢失细节(信息有损压缩)                       │
│  ❌ 总结质量依赖模型能力                                   │
│  ❌ 摘要本身也会增长(多轮总结后摘要越来越长)            │
│                                                          │
│  适用场景:                                               │
│  → 中长对话(10-100 轮)                                  │
│  → 需要保留核心上下文的对话机器人                         │
│  → 客服、助手类应用                                       │
└──────────────────────────────────────────────────────────┘

六、检索策略:语义化长期记忆

6.1 为什么需要检索?

erlang 复制代码
总结策略的问题:摘要越来越长

  第一次总结:100 token 摘要
  第二次总结:摘要 + 新消息 → 新摘要 → 150 token
  第三次总结:更长的摘要 → 200 token
  → 摘要本身也会膨胀
  → 时间越久,信息越模糊

  终极解决方案:检索(Retrieval)
  → 所有历史消息存入向量数据库
  → 每次对话时,用 query 检索最相关的历史
  → 只把相关的历史消息塞进 prompt
  → 类似 RAG,但检索的是对话历史而不是外部知识

6.2 检索型 Memory 的架构

arduino 复制代码
检索型 Memory 架构:

  ┌──────────────────────────────────────────────────────────┐
│  用户输入:"我上次说的那个项目怎么样了?"                  │
│                                                          │
│  ① 向量化                                                 │
│  → 把用户输入转换成 embedding 向量                        │
│                                                          │
│  ② 检索                                                   │
│  → 在向量数据库中搜索最相似的历史消息                      │
│  → 返回 Top-K 相关的历史对话片段                          │
│                                                          │
│  ③ 组装 prompt                                            │
│  → SystemMessage + 相关历史 + 当前对话 + 用户输入          │
│                                                          │
│  ④ 调用模型                                               │
│  → 模型看到相关历史 → 回答准确                            │
│                                                          │
│  ⑤ 存储新对话                                             │
│  → 本轮对话也存入向量数据库                                │
│  → 供未来检索使用                                         │
└──────────────────────────────────────────────────────────┘

  类比:人的长期记忆
  → 你不会每时每刻都记着所有事
  → 被问到某个话题时,相关记忆才会浮现
  → 这就是检索的本质

6.3 三种策略对比

python 复制代码
┌──────────────────────────────────────────────────────────────┐
│          三种 Memory 管理策略对比                            │
│                                                              │
│          截断           总结              检索               │
│  ───────────────────────────────────────────────────         │
│  原理    保留最近 N 条   AI 压缩旧消息     向量相似度检索     │
│  成本    零额外成本      每次总结调 AI     向量化 + 检索     │
│  信息    丢失早期信息    保留核心摘要      保留相关细节       │
│  复杂度  极低           中等              较高               │
│  适合    短对话         中长对话          超长对话 / 长期记忆 │
│  类比    短期记忆       工作记忆          长期记忆           │
│  工具    slice / trim   model.invoke      向量数据库         │
│                                                              │
│  实际项目中通常组合使用:                                     │
│  → 最近 N 条:完整保留                                      │
│  → 更早的:总结压缩                                          │
│  → 更早的:存入向量数据库,需要时检索                        │
│  → 分层管理 = 短期 + 中期 + 长期                             │
└──────────────────────────────────────────────────────────────┘

七、分层 Memory 架构实战

7.1 完整架构

arduino 复制代码
生产级 Agent 的 Memory 架构:

  ┌──────────────────────────────────────────────────────────┐
│  Prompt(发送给模型)                                     │
│  ├── SystemMessage(系统指令)                             │
│  ├── 长期记忆摘要(可选)                                 │
│  ├── 检索到的相关历史(来自向量数据库)                   │
│  ├── 总结后的历史摘要                                     │
│  └── 最近 N 条消息(完整保留)                             │
└──────────────────────────────────────────────────────────┘
                           │
                           │ 写入
                           ▼
  ┌──────────────────────────────────────────────────────────┐
│  Memory 分层存储                                          │
│                                                          │
│  第一层:短期记忆(In-Memory)                            │
│  → 最近 10-20 条消息                                      │
│  → 完整保留                                               │
│  → 速度最快                                               │
│                                                          │
│  第二层:中期记忆(总结)                                 │
│  → 更早的消息被 AI 总结压缩                               │
│  → 作为摘要加入 prompt                                    │
│  → 定期重新总结                                           │
│                                                          │
│  第三层:长期记忆(向量数据库)                           │
│  → 所有历史消息存入 Milvus / Supabase                     │
│  → 需要时按语义检索                                       │
│  → 真正的"永久记忆"                                       │
└──────────────────────────────────────────────────────────┘

7.2 触发时机

markdown 复制代码
什么时候触发 Memory 管理?

  → 每次用户发送消息后
  → 调用模型之前
  → 先检查 token 数量
  → 超过阈值 → 触发截断 / 总结
  → 同时检索相关长期记忆

  流程:
  用户消息 → 加入 Memory → 计算 token 数
    │
    ├─→ 超过 maxTokens?
    │     ├─ 是 → 总结旧消息 + 保留最近
    │     └─ 否 → 直接使用
    │
    ├─→ 需要长期记忆?
    │     └─ 是 → 向量检索相关历史
    │
    ▼
  组装 messages → 调用模型 → 结果写回 Memory

7.3 与 /compact 和 /clear 的关系

bash 复制代码
用户可以手动触发 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 消息类型属性

go 复制代码
每个消息对象的核心属性:

  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 知识体系图

scss 复制代码
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 又不丢关键信息的记忆系统。


如果这篇文章对你有帮助,欢迎点赞收藏

相关推荐
今日无bug28 分钟前
大模型的回答是怎么一个字一个字蹦出来的?前端流式输出解析
前端·llm·agent
简单风1 小时前
别再让 Agent 光会表演:能过编译、过 Schema、过检查的,才叫真正落地
agent
beyond谚语1 小时前
六、LangChain——StrOutputParser和JsonOutputParser
langchain·rag·ollama
阿里云云原生1 小时前
评估:从黄金指标到 Rubric丨AgentLoop 数据飞轮实践(三)
agent
.唉2 小时前
05. LangGraph 深度解析:从基础概念到高级应用全指南
agent·langgraph
AI程序员2 小时前
MCP Apps 能直接返回 HTML,为什么还需要 A2UI?
人工智能·agent·mcp
luckystar513~3 小时前
Hermes实战 :skill编写 + skill治理
人工智能·agent·智能体·hermes·实战专栏
海天一色y3 小时前
构建智能Agent的记忆系统:三层记忆管理器设计
agent·memory
桃西西呀5 小时前
《牛来》票房涨 1000 倍是真的吗?——2026暑期档 124 亿里的数学
人工智能·数据分析·llm