对话一长上下文就爆?LangChain.js Memory 三种淘汰策略(截断 / 总结 / 检索)实战

上一篇我们给 Agent 配了 memory:把每轮对话记进账本、落盘、重启能捞回来。可账本越攒越大后,新的麻烦来了------每次请求都要把整本账本重发给模型

  • 聊到几百轮,一次性塞给模型的文本超长,上下文窗口装不下
  • token 是计费的,历史越长越贵;响应还跟着变慢。

这一篇就解决这一个问题:账本太满怎么办? 仓库 demo/src/memory/ 下五个 demo 手把手实现了三种淘汰策略------截断(Truncation)/ 总结(Summarization)/ 检索(Retrieval) ,其中检索策略会把记忆升级成 Milvus 向量数据库,让 Agent 做到"按相关性想起很久以前的细节"。

一、先立住两个概念:存账与管账

之前把记忆拆成过两层,这里再次强调,因为它决定你读下面代码的心态:

复制代码
存储逻辑(账本存在哪)    内存 / 文件 / 向量库
管理逻辑(账本太满怎么办) 截断 / 总结 / 检索

两个维度可以任意组合demo/src/memory/ 这五个文件全部在讲"管理逻辑",外加把账本搬进向量库的"新存储":

文件 策略 一句话
truncation-memory.mjs 截断 按条数 slice + 按 token trimMessages
summarization-memory.mjs 总结 超条数 → 让 AI 压缩旧话
summarization-memory2.mjs 总结(token版) 超 token 数 → 压缩,逻辑更精确(但有个笔误,第六节讲)
insert-conversations.mjs 前置步骤 把对话 embedding 后写入 Milvus
retrieval-memory.mjs 检索 提问 → 向量搜最相关 → 增强回答 → 回写

三个策略的直觉先建立起来:

  • 截断:假设"最新 = 最相关",砍掉最旧的,省事但可能误伤重要旧话;
  • 总结:旧话别扔,让模型压成一句话梗概留着,保住骨架、丢细节,但多花一次模型调用;
  • 检索 :旧话全留,提问时按语义相似度只捞"最像"的几段------最接近人脑,但要上向量库。

二、策略一:截断,丢掉最旧的话

对应文件 truncation-memory.mjs。它一口气写了两种截断,从"简单版"到"生产版"。

2.1 按消息条数截断:一个 slice 就够

js 复制代码
const history = new InMemoryChatMessageHistory();
const maxMessages = 4;
// ...把 8 条消息依次 addMessage 进账本...

const trimmedMessages = allMessages.slice(-maxMessages);  // 只留最后 4 条
console.log(`保留消息数量:${trimmedMessages.length}`);

slice(-4) 大白话:从队尾往前数 4 条,前面全砍 。极端简单,但它有个明显缺陷------它按"条"算,不按"重"算。一条 5000 字的回答和一句"嗯"都算 1 条,显然不公平。生产上更应该按 token 算。

2.2 按 token 数截断:trimMessages + js-tiktoken

token 是模型计费/窗口的单位,一个词或几个汉字算 1 个 token。要精确控制就得先能数出每条消息多少个 token ,这里用 OpenAI 官方分词器 js-tiktoken

js 复制代码
import { getEncoding } from 'js-tiktoken';   // OpenAI 官方分词器
import { trimMessages } from '@langchain/core/messages';

const enc = getEncoding("cl100k_base");      // 编码方案要跟模型匹配

function countTokens(messages, encoder) {    // 给一组消息数 token
  let total = 0;
  for (const msg of messages) {
    const content = typeof msg.content === 'string' ? msg.content
                    : JSON.stringify(msg.content);   // content 可能是数组,兜底
    total += encoder.encode(content).length;
  }
  return total;
}

const trimmedMessages = await trimMessages(allMessages, {
  maxTokens: 100,                                     // token 预算
  tokenCounter: async (msgs) => countTokens(msgs, enc),  // 怎么数,自定义
  strategy: "last",                                   // 从最后往前留
});
console.log(`总token 数量:${countTokens(trimmedMessages, enc)}`);

trimMessages 是 LangChain 内置的按 token 预算裁剪工具,三个参数各一句话:

参数 白话
maxTokens 这次最多放行多少 token
tokenCounter 由你告诉它"怎么数 token"(不同模型分词器不一样)
strategy: "last" 从最后(最新)往前留,保住最新上下文

截断的代价:省了钱、不爆窗口,但砍掉的部分可能正好埋着用户的某个关键设定------"我叫李四"如果被砍了,下轮它又不认识你了。于是有了策略二。

三、策略二:总结,把旧话压成摘要

对应文件 summarization-memory.mjs(按条数触发)和 summarization-memory2.mjs(按 token 触发)。核心思路:

被裁掉的旧话不扔,交给 AI 压成一句话摘要放进账本------丢了细节,但保住了"你们之前聊过啥"的梗概。

3.1 让 AI 干活的函数:拼串 → 提问 → 拿摘要

js 复制代码
async function summarizeHistory(messages) {
  if (messages.length === 0) return "";
  // ① 把消息对象数组拼成一段可读文本(Human 显示为"用户")
  const conversationText = getBufferString(messages, "用户", "助手");
  console.log(conversationText);

  // ② 用一个"总结指令"去调模型
  const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
    ${conversationText}
    总结:`;
  const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
  return summaryResponse.content;
}

getBufferString(messages, "用户", "助手") 是 LangChain 提供的小工具:把一堆消息对象拼成 用户: xxx\n助手: xxx\n... 这样的字符串------模型只能吃文本,得先把对象"翻译"成人话。

3.2 主流程:超限 → 分两拨 → 清账 → 重建

js 复制代码
const maxMessages = 6;
// ...8 条消息入账本...
if (allMessages.length > maxMessages) {   // 账本超过 6 条,触发
  const keepRecent = 2;
  const recentMessages = allMessages.slice(-keepRecent);        // ① 最近 2 条,原样留
  const messagesToSummarize = allMessages.slice(0, -keepRecent);// ② 其余 6 条,拿去压缩

  const summary = await summarizeHistory(messagesToSummarize);  // ③ AI 出摘要

  await history.clear();                     // ④ 清空账本
  for (const msg of recentMessages) {        // ⑤ 把最近的 2 条放回去
    await history.addMessage(msg);
  }
  await history.addMessage(new AIMessage(summary));  // ⑥ 把"摘要"也当一条消息放回去
}

关键在 ⑥:摘要被塞成一条 AIMessage,跟真实对话混在账本里。之后每次发模型,它都能看到"前面那么长对话的本质是:李四是个设计师,喜欢艺术音乐"这种一句话梗概。

3.3 token 版(summarization-memory2.mjs):单位换成 token

条数版看的是 length > maxMessages,token 版改成看 token 总量,并且"最近保留多少"也从"留 2 条"升级成"从后往前凑满 80 token":

js 复制代码
const maxTokens = 200;        // 总 token 超 200 触发总结
const keepRecentTokens = 80;  // 从最新往前,攒够 80 token 的原样留下
// ... 从 allMessages 尾部往前累加,直到 recentTokens + msgTokens > 80 就停 ...

然后同样 clear() → 放回 recent → 塞入 AIMessage(summary)。逻辑和条数版一模一样,只是判断和保留的单位更精细

⚠️ 注意:这个文件里有个运行时会报错的笔误 ------第 37 行定义的是 const encoder = getEncoding('cl100k_base'),但后面循环里用的却是 enc.encode(...)enc 根本没定义)。跑起来会抛 ReferenceError: enc is not defined。改成 encoder 即可。

总结的代价 :每次触发多一次模型调用(出摘要),且层层摘要会失真------聊 1000 轮时,第 1 轮的细节早就被压缩没了。想要"细节还在",得用策略三。

四、策略三:检索,向量库按"像不像"捞旧账

截断靠"时间近",总结靠"全压缩",检索则完全不同:旧账全留着,每次提问只按语义相似度捞最相关的几段 。这需要两个新东西:embedding(把文字变向量)和向量数据库(存向量 + 算相似度)。对应 insert-conversations.mjs(写入)和 retrieval-memory.mjs(检索问答)。

4.1 准备:Docker 起 Milvus + 连上

bash 复制代码
docker compose -f ./milvus-standalone-docker-compose.yml up -d   # 监听 19530

装 GUI 工具 Attu 可以可视化看集合数据(相当于 Milvus 的 Navicat)。然后装依赖、连接:

bash 复制代码
pnpm i @zilliz/milvus2-sdk-node @langchain/openai dotenv
js 复制代码
import { MilvusClient, DataType, MetricType, IndexType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const COLLECTION_NAME = 'conversations';   // 集合 ≈ MySQL 的表
const VECTOR_DIM = 1024;                   // 向量维度,必须和 embedding 模型对齐

// embedding:把文字变成 1024 维向量
const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: 'text-embedding-v3',
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  dimension: VECTOR_DIM,
});

const client = new MilvusClient({ address: 'localhost:19530' });

一句话讲清 embedding:语义相近的句子,变成的向量方向也相近。所以「我的职业是软件工程师」和「你是做什么工作的」余弦相似度会很高------机器靠这个"想起来"。

4.2 建集合:给对话设计"档案表"

js 复制代码
await client.createCollection({
  collection_name: COLLECTION_NAME,
  fields: [
    { name: "id", data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
    { name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM }, // 向量(检索靠它)
    { name: 'content', data_type: DataType.VarChar, max_length: 5000 },   // 对话原文
    { name: 'round',  data_type: DataType.Int64 },                        // 第几轮
    { name: 'timestamp', data_type: DataType.VarChar, max_length: 100 },  // 时间戳(字符串)
  ],
});

// 检索必须两步走:先建索引,再 loadCollection
await client.createIndex({
  collection_name: COLLECTION_NAME,
  field_name: 'vector',                     // 给哪个字段建索引
  index_type: IndexType.IVF_FLAT,           // 索引算法
  metric_type: MetricType.COSINE,           // 相似度算法:余弦
});
await client.loadCollection({ collection_name: COLLECTION_NAME });

💡 Milvus 没有独立的 Date/DateTime 类型,时间戳用 VarChar 字符串存 ISO 时间即可。vector 字段必须有索引 + 集合必须 load 进内存才能 search,漏一个都会报错。

4.3 写入:每条对话 = 原文 + 向量

js 复制代码
const conversations = [{
  id: 'conv_001',
  content: '用户:我叫赵六,是一名数据科学家\n助手:很高兴认识你!',
  round: 1, timestamp: new Date().toISOString(),
}, /* ...共 5 条... */];

const conversationData = await Promise.all(
  conversations.map(async (conv) => ({
    ...conv,
    vector: await embeddings.embedQuery(conv.content),  // 原文 -> 1024 维向量
  }))
);
await client.insert({ collection_name: COLLECTION_NAME, data: conversationData });

注意字段设计里没有多余的 AI 元数据,就是最朴素的「原文 + 向量 + 轮次 + 时间」------存整轮对话当一条记忆,方便检索后整段拿给模型。

4.4 检索:把问题也变成向量去"捞"

retrieval-memory.mjs 的核心函数:

js 复制代码
async function retrieveRelevantConversations(query, k = 2) {
  try {
    const queryVector = await getEmbedding(query);   // ① 问题 → 向量
    const searchResult = await client.search({
      collection_name: COLLECTION_NAME,
      vector: queryVector,
      limit: k,                                        // ② 只要最像的 k 段
      metric_type: MetricType.COSINE,
      output_fields: ['id', 'content', 'round', 'timestamp'],
    });
    return searchResult.results;                       // ③ 相关历史数组
  } catch (err) {
    console.error('检索对话时出错', err.message);
    return [];                                         // 失败降级:空历史,别中断
  }
}

4.5 检索增强问答 + 回写:完整闭环

js 复制代码
async function retrievalMemoryDemo() {
  await client.connectPromise;
  const questions = ["我之前提到的机器学习项目进展如何?", "我的职业是什么?"];

  for (let i = 0; i < questions.length; i++) {
    const input = questions[i];
    const userMessage = new HumanMessage(input);

    // ① 检索最相关的 2 段历史
    const retrieved = await retrieveRelevantConversations(input, 2);
    let relevantHistory = '';
    if (retrieved.length > 0) {
      relevantHistory = retrieved.map((conv, idx) =>
        `[历史对话 ${idx + 1}] 轮次:${conv.round}\n${conv.content}`).join('\n');
    } else {
      console.log('未找到相关历史对话');
    }

    // ② 有相关历史 → 拼进上下文;没有 → 只发当前问题
    const contextMessages = relevantHistory
      ? [new HumanMessage(`相关历史对话:\n${relevantHistory}\n\n用户问题:${input}`)]
      : [userMessage];

    // ③ 交给模型
    const response = await model.invoke(contextMessages);
    console.log(response.content);

    // ④ 把这一轮也写回 Milvus ------ 记忆越用越厚
    const conversationText = `用户:${input}\n助手: ${response.content}`;
    const convId = `conv_${Date.now()}_${i + 1}`;
    const convVector = await getEmbedding(conversationText);
    await client.insert({ collection_name: COLLECTION_NAME,
      data: [{ id: convId, content: conversationText, vector: convVector,
                round: i + 1, timestamp: new Date().toISOString() }] });
  }
}

为什么这套能"想起你"? 问「我的职业是什么?」→ 向量搜 → 命中历史里那句「我的职业是软件工程师」→ 连上下文一起喂给模型 → 它真的答得出来,而不是反问。这就是"检索式长期记忆"和截断/总结最大的不同:它不是把历史删掉或压扁,而是每次按需取最相关的一段,细节永远在库里。

五、三选一还是组合用

没有银弹,按场景取舍:

策略 核心假设 保什么 丢什么 额外成本 适用
截断 最新=最相关 最新上下文 旧细节 极低 短会话、兜底保险
总结 梗概够用 全局大纲 细节、易失真 每次触发多一次模型调用 中长会话压缩
检索 相似=相关 任意相关细节 检索不到的 向量库+embedding 工程 长期记忆、懂你的 Agent

生产里通常是组合 :会话内用 trimMessages 截断兜底上限 + 定期把旧会话总结成"记忆快照";快照(或关键对话)embedding 后进 Milvus 做长期检索库。仓库 readme 里提到的 codex 思路就是这个方向------每聊 20 条触发一次总结,摘要与精华入库,下次先检索再回答。

六、踩坑清单

  1. encoder 定义却用 enc (summarization-memory2.mjs)→ ReferenceError,改成 encoder。自己写时统一变量名。
  2. 按条数截断不公平 → 生产用 token(trimMessages + tiktoken),并确认编码方案跟模型一致(cl100k_base 对应 GPT-4 系)。
  3. embedding 模型与 VECTOR_DIM 必须对齐 → 你声明 1024 维,模型输出不是 1024 就写入报错;换 embedding 模型要同步改维度。
  4. Milvus 要"先建索引 → 再 loadCollection"才能 search → 顺序漏了或没 load,search 报 collection not loaded
  5. Milvus 没有日期类型 → 时间用 VarChar 存 ISO 字符串,别等 Date/DateTime 字段。
  6. embedding/模型都走同一个 OPENAI_BASE_URL 中转 → 国内环境用中转域名,dimension、模型名要和厂商一致。
  7. 检索失败要降级retrieveRelevantConversations 里 catch 返回 [],宁可当无历史对话也别崩。
  8. AI 消息比 human 消息"胖" → 会带着 id/token 用量等元数据,序列化入库、数 token 时都要考虑(content 也可能是数组,数 token 前先判断)。

七、总结:从记住到会挑着记

回到那两句话:

存储逻辑回答"记忆放哪",管理逻辑回答"太满怎么办"。

  • 上篇的账本解决了「记住」------现在它已经会落盘、会按 sessionId 分人、重启能捞回;
  • 这一篇的三种策略解决「满」------截断省成本、总结保梗概、检索留细节并"按需想起";
  • 当检索策略把账本搬进 Milvus,记忆就从"一条条存"进化成"随时捞最相关的一段"。

五个 demo(truncation-memorysummarization-memory(+token版) → insert-conversationsretrieval-memory)就是一条从"会淘汰"到"会搜索"的进阶线。搭配前面的 InMemory/文件记忆,一个"越聊越懂你"的 Agent memory 模块就完整了:短期会话靠截断和总结省 token,长期关系靠向量库记住那些"很久以前说过的事"。

相关推荐
雪芽蓝域zzs2 小时前
第三十二节:部门组织管理(el‑tree 组织树
前端·javascript·vue.js
leoZ2312 小时前
第 7 篇:进阶——校验、联动、列表页
开发语言·前端·javascript·vue.js·人工智能·目标检测·ecmascript
VeryCool2 小时前
别吹了,依赖图像识别的GPT‑6 Astra永远快不起来
前端·javascript·aigc
AC赳赳老秦3 小时前
农产品公开数据应用:OpenClaw 抓取农产品价格、产销公开数据,实现农产品行情动态监测
java·c语言·javascript·python·php·deepseek·openclaw
全栈技术负责人3 小时前
大模型流式输出核心技术:智能 Markdown 渲染引擎方案
前端·javascript·vue.js
雪芽蓝域zzs4 小时前
第三十四节:用户新增编辑弹窗增加 el‑tree‑select 部门选择器
前端·javascript·vue.js
巴勒个啦4 小时前
如何设计一个高可复用的前端组件库:从API设计到文档自动化
javascript
mONESY4 小时前
一句话是怎么被大模型记住的?——拆解 LangChain.js 消息从诞生到落盘的完整旅程
javascript
mONESY4 小时前
给大模型装上记忆:Agent 的 Memory 模块,从 InMemory 到文件到 Milvus 向量数据库
javascript