上一篇我们给 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 条触发一次总结,摘要与精华入库,下次先检索再回答。
六、踩坑清单
encoder定义却用enc(summarization-memory2.mjs)→ReferenceError,改成encoder。自己写时统一变量名。- 按条数截断不公平 → 生产用 token(
trimMessages+tiktoken),并确认编码方案跟模型一致(cl100k_base对应 GPT-4 系)。 - embedding 模型与
VECTOR_DIM必须对齐 → 你声明 1024 维,模型输出不是 1024 就写入报错;换 embedding 模型要同步改维度。 - Milvus 要"先建索引 → 再 loadCollection"才能 search → 顺序漏了或没 load,search 报
collection not loaded。 - Milvus 没有日期类型 → 时间用 VarChar 存 ISO 字符串,别等 Date/DateTime 字段。
- embedding/模型都走同一个
OPENAI_BASE_URL中转 → 国内环境用中转域名,dimension、模型名要和厂商一致。 - 检索失败要降级 →
retrieveRelevantConversations里 catch 返回[],宁可当无历史对话也别崩。 - AI 消息比 human 消息"胖" → 会带着 id/token 用量等元数据,序列化入库、数 token 时都要考虑(
content也可能是数组,数 token 前先判断)。
七、总结:从记住到会挑着记
回到那两句话:
存储逻辑回答"记忆放哪",管理逻辑回答"太满怎么办"。
- 上篇的账本解决了「记住」------现在它已经会落盘、会按 sessionId 分人、重启能捞回;
- 这一篇的三种策略解决「满」------截断省成本、总结保梗概、检索留细节并"按需想起";
- 当检索策略把账本搬进 Milvus,记忆就从"一条条存"进化成"随时捞最相关的一段"。
五个 demo(truncation-memory → summarization-memory(+token版) → insert-conversations → retrieval-memory)就是一条从"会淘汰"到"会搜索"的进阶线。搭配前面的 InMemory/文件记忆,一个"越聊越懂你"的 Agent memory 模块就完整了:短期会话靠截断和总结省 token,长期关系靠向量库记住那些"很久以前说过的事"。