给 Agent 装上有记忆的"脑子"(二):对话太长会撑爆上下文?用"自动总结"给记忆瘦身

上一篇我们用"截断"控制住了上下文长度,但也把老消息白白扔掉了。这篇介绍记忆模块第二招------自动总结(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 从最新往回贪心累加,直到填满 keepRecentTokensunshift 保证放回时顺序不乱
totalTokens >= maxTokens 用 token(而非条数)判断是否触发
slice(0, len - recentMessages.length) 去掉保留下来的最近 N 条,剩下的都是"旧账"

底层逻辑 :token 触发的优点是精确 ------不管消息是长是短,都换算成同一个"货币单位"衡量;缺点是代码稍复杂(多一次遍历累计)。条数触发简单,适合演示;token 触发控的是"钱"(API 计费)和"窗口"(模型上限),适合生产

⚠️ 避坑提醒(这篇 demo 就真实踩过):代码里最容易翻车的就是命名不一致 ------getEncoding 返回的变量叫 encoder,后面计算却写成 encsummarizeHistory 被调用却忘记定义。这两个都会直接 ReferenceError。仓库里的原稿我已修正为自包含可运行版本,教训是:token 计数器的变量名全文件统一,共用的 summarizeHistory 要么定义要么 import。


边界与进阶思考

把自动总结玩明白,还要留意几个点:

  1. 触发阈值 ≠ 上下文总预算 。看版本二:触发线 200 token,重建后账本里其实还有"最近 80 token 原文 + 一段摘要 + 新问题"要一起进上下文。所以生产上 maxTokens 要留出 keepRecentTokens + 摘要本身 + 未来几轮的空间,别把触发线顶到窗口上限。

  2. 可能发生"总结的总结" 。如果某轮总结后,旧的摘要被算进下次的 messagesToSummarize,就会把摘要再压一遍,信息二次损耗。大型对话一般要标记/隔离已总结段,避免重复压缩。

  3. 总结后依然在内存里 。这两个 demo 用的还是 InMemoryChatMessageHistory------"总结"解决了上下文膨胀,没解决"重启丢失"。要持久化,把历史容器换成上篇的 FileSystemChatMessageHistory 即可,四步重建的逻辑完全不变(换存储、不换套路)。


现在,记忆模块进化到哪了?

回顾这个系列,我们已经拥有了一套"会管理自己记忆"的 Agent:

能力 手段 解决什么
会话内记忆 内存历史 无状态 → 记得住当前对话
跨重启记忆 文件历史 进程结束 → 记忆不丢
上下文不爆 截断 聊太久 → 塞不下
信息不丢 自动总结 老消息 → 压成摘要留下

🎬 下一篇预告

到目前为止,Agent 的"回忆"还是按顺序一股脑读 的------不管用户问什么,都得从头把历史读一遍。但人的记忆不是这样:你问"我上次说的项目进展",人脑是按语义直接调出那一小段,而不是重读全部人生。

所以记忆模块的终局形态,是把对话向量化 存进 Milvus 这类向量数据库,提问时先做语义检索 召回相关片段------真正的"长期记忆"(仓库里 insert-conversations.mjs / retrieval-memory.mjs 已经写好,下一篇拆给你看,正好配上你在装的那个 Attu 界面 👀)。

觉得这个记忆专题有用?点个赞收藏,催更第三篇~ 你现在的 Agent 处理超长历史,用的是截断、总结还是全量读?评论区聊聊 👏


完整项目代码

快速启动 (项目在仓库子目录,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.5langchain ^1.5.10js-tiktoken ^1.0.21dotenv ^17.4.2(Node 20.11+,ESM)。

相关推荐
runningshark2 小时前
Lecture: The ‘Why & How‘ Principle: Moving Beyond Simple Statements
开发语言·前端·javascript
雪芽蓝域zzs2 小时前
第三十一节:角色管理页面 + 权限分配树形弹窗
前端·javascript·vue.js
暖焰核心2 小时前
C++模板进阶——特化全解
javascript·c++·jquery
晓得迷路了2 小时前
栗子前端技术周刊第 145 期 - Remix 3 RC、htmx 4.0、Rslib 1.0...
前端·javascript·react.js
雪芽蓝域zzs2 小时前
第三十五节:Axios 统一错误拦截、401 Token 过期处理
前端·javascript·vue.js
范什么特西3 小时前
一些常用名词
开发语言·前端·javascript
牧瀬クリスだ3 小时前
前端三件套基础之JS!!!!
前端·javascript
乌夷3 小时前
JavaScript 的事件循环
开发语言·javascript·ecmascript
单线程_013 小时前
从案例分析 Vue3 Tokenizer+Parser 源码四
前端·javascript·vue.js