上一篇我们用"截断"控制住了上下文长度,但也把老消息白白扔掉了。这篇介绍记忆模块第二招------自动总结(Summarization):对话一超长,就让模型把最早那批对话压成一段摘要,只留"最近几轮 + 摘要",信息不丢、上下文不爆。文末附完整可运行代码,系列第三篇预告在最后 🚀
前言
如果你没读过上一篇,先花 30 秒补个前情:大模型无状态,所谓"记忆"就是把历史对话不断塞进上下文。为了让塞得下的内容不被撑爆,上篇讲了截断------对话太长只留最近几条。
但这引出一个新问题:被截断丢掉的老消息,可能是用户十分钟前说的"我喜欢微辣",或者上周说的"我是设计师"。直接丢掉太可惜了。
本篇就是来解决这个问题的第二招:自动总结。
- 这篇文章讲什么:LangChain 项目里两个真实的记忆 demo,如何用 LLM 把"最早的一批老消息"自动压成一段摘要,替代粗暴截断。
- 适合谁看:看过第一篇、或对 LangChain 记忆有基础概念的同学。
- 读完能收获:理解 Summarization 记忆的完整套路------"触发判断 + 拼文本 + LLM 压缩 + 重建历史",以及按条数 / 按 token 两种触发方式怎么选。
- 文末附完整可运行项目代码。
快速回顾:记忆模块走到哪了
上篇我们搭了一条升级路线,本篇给它补上第四格:
text
① 内存记忆 进程内记得住(重启就丢)
② 文件持久化 重启还记得(聊久了会膨胀)
③ 截断 控住长度(但老消息被丢弃)😥
④ 自动总结 老消息不丢,压成摘要(本篇)✅
└─ 下一篇:向量长期记忆,按"语义"召回历史
核心痛点:截断是"丢",总结是"留"
同样是处理"历史太长",两种思路天差地别:
| 做法 | 老消息的下场 | 缺点 |
|---|---|---|
截断 slice(-n) |
❌ 彻底删除 | 信息永久丢失,之后可能要用却没了 |
| 自动总结 | ✅ 压成一段摘要 | 摘要毕竟是"压缩版",细节有损(可接受) |
用一句话概括总结记忆的思路,作者在代码注释里已经写得很妙:
js
// 被截断的数组 -> 字符串拼接 -> ai summarization
也就是:把要"处理"的那批消息拼成一段文本,交给 AI 总结。这本质上是一场"记忆的搬运与压缩",而不是删除。
自动总结的两大步
不管是哪个版本,套路都是固定的两步:
第 1 步 · 判断"该压缩了吗" ------ 历史太长,触发总结(怎么算"太长"?→ 条数 or token 两种口径,下面两个版本分别演示)。
第 2 步 · 压缩并重建历史:
js
// ① 用 LLM 把 messages 压成摘要
const summary = await summarizeHistory(messagesToSummarize);
// ② 清空旧账本
await history.clear();
// ③ 只放回"最近几条"
for (const msg of recentMessages) await history.addMessage(msg);
// ④ 把摘要当作一条 AI 消息追加在末尾,继续接着聊
await history.addMessage(new AIMessage(summary));
逐行拆解:
| 代码 | 作用 |
|---|---|
history.clear() |
先把整段历史清掉(数组整体替换比单条删除干净) |
history.addMessage(recentMessages) |
把最近的真实对话放回来,保证细节准确 |
addMessage(new AIMessage(summary)) |
摘要作为一条 AI 消息放最后,让模型读上下文时"刚读完摘要就轮到新问题" |
💡 为什么用
clear重建而不是原地删中间几条?ChatMessageHistory本质是消息数组,与其删除中间元素再拼接,不如"保留集 → 清空 → 重放"更清晰、不易出错。
下面看两个版本怎么实现"触发判断"。示例对话都是同一个故事线(用户先后说"我叫李四、我是一名设计师、我喜欢艺术和音乐、我擅长 UI/UX 设计"......),共 8 条消息。
版本一:按"条数"触发 ------ summarization-memory.mjs
js
const maxMessages = 6; // 超过 6 条就触发总结
// ...
if (allMessages.length > maxMessages) {
const keepRecent = 2;
// 留最近的 2 条做"现场"保留
const recentMessages = allMessages.slice(-keepRecent);
// 其余最早的 6 条,拿去总结
const messagesToSummarize = allMessages.slice(0, -keepRecent);
const summary = await summarizeHistory(messagesToSummarize);
await history.clear();
for (const msg of recentMessages) await history.addMessage(msg);
await history.addMessage(new AIMessage(summary));
}
逐行拆解:
| 代码 | 作用 |
|---|---|
allMessages.length > maxMessages |
8 条 > 6 → 触发 |
slice(-keepRecent) |
尾部取最近 2 条(最新对话最相关,必须原样保留) |
slice(0, -keepRecent) |
头部 6 条 → 待总结的"旧账" |
history.clear() + 重放 |
见上文的四步重建 |
底层逻辑 :条数触发最直观,但和上篇 slice 截断有同一个毛病------消息长短不一,"6 条"的 token 量可能忽多忽少。所以更稳的触发口径是按 token 数。
别忘了核心函数 summarizeHistory
两个版本都要靠它把消息交给 LLM 压缩:
js
async function summarizeHistory(messages) {
if (messages.length === 0) return "";
// ① 消息数组 -> 一段可读的对话文本
const conversationText = getBufferString(messages, "用户", "助手");
// ② 让 LLM 提炼摘要
const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:
`;
const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
return summaryResponse.content;
}
逐行拆解:
| 代码 | 作用 |
|---|---|
getBufferString(messages, "用户", "助手") |
LangChain 自带工具:把一堆消息对象拼成 用户:...\n助手:... 的纯文本,方便塞进提示词 |
model.invoke([SystemMessage(...)]) |
一次新的独立调用,请 LLM 当"记忆管家"做提炼 |
return summaryResponse.content |
摘要返回,之后作为一条 AIMessage 放回账本 |
💡 关键认知:总结记忆里的"总结",是真实发生的一次 LLM 网络调用,有成本也有耗时。这就是为什么上一步必须控制触发时机,别动不动就总结。
版本二:按"token"触发 ------ summarization-memory2.mjs
这个版本更接近生产:用 js-tiktoken 精确统计 token,同时触发阈值、保留预算都按 token 算。
js
const encoder = getEncoding('cl100k_base');
const maxTokens = 200; // 总 token ≥ 200 就触发总结
const keepRecentTokens = 80; // 最多保留最近 80 token 的原文
const totalTokens = countTokens(allMessages, encoder);
console.log(totalTokens);
if (totalTokens >= maxTokens) {
const recentMessages = [];
let recentTokens = 0;
// 从最新一条往前累计,攒够 80 token 为止(unshift 保持时间顺序)
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; // 预算用尽,跳出
}
}
// 剩下的头部消息交给 LLM 总结
const messagesToSummarize = allMessages.slice(0, allMessages.length - recentMessages.length);
const summary = await summarizeHistory(messagesToSummarize);
// ...clear 后重放 recentMessages + summary,同版本一
}
逐行拆解:
| 代码 | 作用 |
|---|---|
getEncoding('cl100k_base') |
OpenAI 官方 token 编码器,逐字精确"称重" |
encoder.encode(content).length |
单条消息占多少 token |
倒序 for + unshift |
从最新往回贪心累加,直到填满 keepRecentTokens;unshift 保证放回时顺序不乱 |
totalTokens >= maxTokens |
用 token(而非条数)判断是否触发 |
slice(0, len - recentMessages.length) |
去掉保留下来的最近 N 条,剩下的都是"旧账" |
底层逻辑 :token 触发的优点是精确 ------不管消息是长是短,都换算成同一个"货币单位"衡量;缺点是代码稍复杂(多一次遍历累计)。条数触发简单,适合演示;token 触发控的是"钱"(API 计费)和"窗口"(模型上限),适合生产。
⚠️ 避坑提醒(这篇 demo 就真实踩过):代码里最容易翻车的就是命名不一致 ------
getEncoding返回的变量叫encoder,后面计算却写成enc;summarizeHistory被调用却忘记定义。这两个都会直接ReferenceError。仓库里的原稿我已修正为自包含可运行版本,教训是:token 计数器的变量名全文件统一,共用的summarizeHistory要么定义要么 import。
边界与进阶思考
把自动总结玩明白,还要留意几个点:
-
触发阈值 ≠ 上下文总预算 。看版本二:触发线 200 token,重建后账本里其实还有"最近 80 token 原文 + 一段摘要 + 新问题"要一起进上下文。所以生产上
maxTokens要留出keepRecentTokens+ 摘要本身 + 未来几轮的空间,别把触发线顶到窗口上限。 -
可能发生"总结的总结" 。如果某轮总结后,旧的摘要被算进下次的
messagesToSummarize,就会把摘要再压一遍,信息二次损耗。大型对话一般要标记/隔离已总结段,避免重复压缩。 -
总结后依然在内存里 。这两个 demo 用的还是
InMemoryChatMessageHistory------"总结"解决了上下文膨胀,没解决"重启丢失"。要持久化,把历史容器换成上篇的FileSystemChatMessageHistory即可,四步重建的逻辑完全不变(换存储、不换套路)。
现在,记忆模块进化到哪了?
回顾这个系列,我们已经拥有了一套"会管理自己记忆"的 Agent:
| 能力 | 手段 | 解决什么 |
|---|---|---|
| 会话内记忆 | 内存历史 | 无状态 → 记得住当前对话 |
| 跨重启记忆 | 文件历史 | 进程结束 → 记忆不丢 |
| 上下文不爆 | 截断 | 聊太久 → 塞不下 |
| 信息不丢 | 自动总结 | 老消息 → 压成摘要留下 |
🎬 下一篇预告
到目前为止,Agent 的"回忆"还是按顺序一股脑读 的------不管用户问什么,都得从头把历史读一遍。但人的记忆不是这样:你问"我上次说的项目进展",人脑是按语义直接调出那一小段,而不是重读全部人生。
所以记忆模块的终局形态,是把对话向量化 存进 Milvus 这类向量数据库,提问时先做语义检索 召回相关片段------真正的"长期记忆"(仓库里 insert-conversations.mjs / retrieval-memory.mjs 已经写好,下一篇拆给你看,正好配上你在装的那个 Attu 界面 👀)。
觉得这个记忆专题有用?点个赞收藏,催更第三篇~ 你现在的 Agent 处理超长历史,用的是截断、总结还是全量读?评论区聊聊 👏
完整项目代码
- 仓库地址 :gitee.com/dcx2758/ai_...
- 本文项目所在目录 :
ai/agent/memory/demo
快速启动 (项目在仓库子目录,clone 后要 cd 进去):
bash
# 1. 克隆仓库
git clone git@gitee.com:dcx2758/ai_doubao_dcx.git
# (没配 SSH 用:git clone https://gitee.com/dcx2758/ai_doubao_dcx.git)
# 2. 进入子目录并安装依赖
cd ai/agent/memory/demo
npm install
# 3. 新建 .env
.env 配置项(本篇只需前三项,总结要联网调用大模型):
env
OPENAI_API_KEY=你的_API_Key
OPENAI_API_BASE_URL=https://你的代理地址/v1
OPENAI_MODEL_NAME=你的模型名
运行本篇两个 demo (都从 demo 目录执行):
bash
# 按"条数"触发的自动总结
node src/memory/summarization-memory.mjs
# 按"token"触发的自动总结
node src/memory/summarization-memory2.mjs
⚠️ 运行需要能访问配置的大模型接口。若 token 版打印的 token 总数没到 200(8 条短对话可能不够),可把
maxTokens调小(如 120)即可观察到触发总结。
memory 文件夹现状(含后续篇章预览):
text
src/memory
├── truncation-memory.mjs # 第一篇:截断
├── summarization-memory.mjs # 本篇:自动总结(按条数)
├── summarization-memory2.mjs # 本篇:自动总结(按 token,已修正为可运行)
├── insert-conversations.mjs # 下一篇:历史向量化入库 Milvus
└── retrieval-memory.mjs # 下一篇:语义检索召回记忆
依赖版本 :@langchain/core ^1.2.3、@langchain/openai ^1.5.5、langchain ^1.5.10、js-tiktoken ^1.0.21、dotenv ^17.4.2(Node 20.11+,ESM)。