压缩专家
43.99M · 2026-04-01
很多人一开始做聊天机器人、RAG 助手或者 Agent,都会把 Memory 理解成一件很朴素的事:把用户和模型的对话一条条存起来,下次继续带上就行。
这个理解不算错,但只对了一半。
真正到了工程落地阶段,你很快会碰到两个现实问题:
所以,Memory 的本质并不是“存消息”,而是“管理哪些信息在什么时机、以什么形式进入当前上下文”。
这也是我对这类问题的核心判断:
如果你把这件事想明白,很多常见误区就会自然消失。比如:
下面就沿着这条主线,把截断、总结、检索三种策略讲清楚。
大模型本身是无状态的。
你这次调用模型,和下一次调用模型,从模型视角看是两次独立请求。它之所以能“记住”你前面说过的话,不是因为模型内部真的保留了会话,而是因为应用层把之前的消息重新组织进了这次 prompt。
也就是说,所谓“模型记忆”,本质上是应用在做上下文重建。
一个最典型的调用链路是这样的:
messages 列表。HumanMessage 追加进去。AIMessage、ToolMessage 一起保留下来。问题就出在这里。
这套机制短期内很好用,但只要对话足够长,就一定会出现以下问题:
所以 Memory 管理的目标,从来都不是“尽量多保留”,而是“尽量高质量地保留”。
很多文章容易把这两件事混在一起:
这是两个不同维度。
这是持久化问题,常见选项包括:
存储层解决的是“我能不能把历史找回来”。
这是上下文编排问题,常见做法就是本文要讲的三种:
策略层解决的是“就算我都存下来了,这一轮到底该拿哪些给模型看”。
工程上,很多系统失败不是因为“没存”,而是因为“乱取”。消息存储做得再完整,如果每轮 prompt 还是简单拼接全部历史,系统依然会越来越差。
截断是最基础、也最容易被低估的一种策略。
它做的事非常直接:当历史消息太长时,只保留最近的一部分,把更早的消息排除在本轮上下文之外。
很多人觉得截断太粗暴,但在工程上,它往往是第一道必须存在的防线。原因很简单:
截断常见有两种方式。
这是最简单的方案,比如只保留最近 8 条消息或最近 4 轮对话。
优点是实现快、行为可预测。
缺点也明显:
assistant tool_calls + tool result,按条数很容易截断得不完整。所以条数截断适合 demo,不适合作为生产系统唯一策略。
这是更实用的方案。因为模型真正关心的不是“多少条”,而是“多少 token”。
如果你在 LangChain JS 里做这件事,trimMessages 是一个很自然的入口。核心思想不是依赖某个“黑盒 Memory 类”,而是在模型调用前,显式地对消息做裁剪。
import { trimMessages } from "@langchain/core/messages";
async function buildRecentMessages(messages, tokenCounter) {
return trimMessages(messages, {
maxTokens: 3000,
strategy: "last",
startOn: "human",
endOn: ["human", "tool"],
tokenCounter,
});
}
这段代码做了三件事:
maxTokens:给本轮历史上下文设置硬预算。strategy: "last":优先保留最近消息。startOn / endOn:避免把消息裁成非法结构,尤其是在 tool call 场景下。这里最关键的不是 API 本身,而是预算意识。
生产环境里我通常不会把上下文窗口全部给历史消息,而会预留一部分给:
比如模型上下文是 32k,你可能只给历史消息分 8k 到 12k,而不是全部占满。
只靠截断有一个明显问题:系统会越来越“健忘”。
假设用户前面聊了 30 轮,最近几轮在讨论接口报错,但更早的时候已经明确说过:
如果这些信息被简单截掉,模型就会开始重复提问,或者给出明显不贴合上下文的建议。
这时就需要总结。
总结策略的核心不是“把历史变短”,而是“把低频但高价值的信息提炼出来”。它适合保留这几类内容:
不适合原样压成摘要的内容则包括:
总结本质上是有损压缩,因此它一定不是“越多越好”,而应该在阈值触发时执行。
总结能保住连续性,但它无法解决另一个问题:用户突然问一个很久以前聊过、但这几轮完全没提的话题。
例如:
这类问题不一定在最近消息里,也不适合全靠摘要兜底。因为摘要会压缩信息,很多细节未必会保留下来。
所以你需要检索型记忆。
它的工作方式和 RAG 很像:
但这里要特别强调一个常见误区:
更合理的做法通常是存三类对象:
原因很简单。原始聊天很碎、噪声很多,直接按消息粒度做向量检索,召回质量往往并不好。相比之下,经过整理的“事实”和“阶段摘要”更适合作为长期记忆单元。
如果只给一个工程结论,我的建议是:
分工如下:
这是绝大多数“长期聊天 + 任务协作 + 事实记忆”场景下更稳妥的默认方案。
比如临时客服问答、一次性表单助手、低复杂度工具型机器人。
很多人看到总结后会觉得:“那我把旧对话全总结成一段,不就够了吗?”
不够。
因为摘要一定会丢信息,尤其对以下场景不友好:
所以总结适合作为压缩层,不适合作为唯一记忆层。
检索很强,但它解决的是“召回”问题,不是“连续对话流”问题。
如果你把最近几轮也全交给检索处理,会遇到两个问题:
因此,检索应该补长期记忆,而不是替代最近上下文。
为了更贴近真实开发,我们不再用“做菜助手”这种教学型场景,而是换成一个企业内部支持助手。
这个助手要解决的问题是:
这种场景非常适合分层记忆。
我建议把 Memory 链路拆成四层:
History Store
负责持久化原始消息,比如 Redis、数据库或文件。Recent Window
对最近消息做 token 截断,形成短期工作记忆。Summary Memory
对被挤出窗口的旧消息做阶段性总结。Retrieval Memory
对摘要、事实卡片、关键事件做向量化,按需召回。最后在 prompt 组装阶段,把这几块信息按优先级拼进去:
System Prompt
+ 会话摘要 Summary Memory
+ 检索到的长期记忆 Retrieval Memory
+ 最近原始消息 Recent Window
+ 当前用户输入
注意顺序很重要。
最近原始消息应该比摘要更靠后,因为它离当前问题更近;摘要和检索结果则更适合作为背景信息,而不是主对话流本身。
下面这段代码的职责很明确:从原始消息里裁出一段可直接给模型的“工作区上下文”。
import { trimMessages } from "@langchain/core/messages";
import { encodingForModel } from "js-tiktoken";
const encoder = encodingForModel("gpt-4o-mini");
function countTokens(messages) {
return messages.reduce((total, message) => {
const content =
typeof message.content === "string"
? message.content
: JSON.stringify(message.content);
return total + encoder.encode(content).length;
}, 0);
}
export async function buildRecentWindow(messages) {
return trimMessages(messages, {
maxTokens: 4000,
strategy: "last",
startOn: "human",
endOn: ["human", "tool"],
tokenCounter: countTokens,
});
}
这里的设计意图不是“保留尽可能多”,而是“保留最近、完整、合法的一段消息结构”。
为什么这样写:
maxTokens: 4000 不是模型上限,而是历史消息预算。startOn: "human" 是为了减少截断后上下文从半截 assistant 开始的情况。endOn: ["human", "tool"] 是为了尽量避免把 tool call 配对关系裁坏。如果你换一种写法,直接 messages.slice(-8),当然也能跑,但在有工具调用和长文本响应时会明显不稳。
当最近窗口已经装不下,而旧对话又仍然有价值时,就要把“历史对话”转成“摘要记忆”。
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
export async function summarizeConversation(model, messages) {
const transcript = messages
.map((msg) => `${msg.getType()}: ${msg.content}`)
.join("n");
const prompt = [
new SystemMessage(
[
"你是对话记忆压缩器。",
"请只保留后续对话仍然需要的重要信息:",
"1. 用户身份和偏好",
"2. 已确认事实",
"3. 已完成动作与结果",
"4. 未完成任务和约束",
"不要保留寒暄,不要展开推理。"
].join("n")
),
new HumanMessage(`请总结以下历史对话:nn${transcript}`),
];
const response = await model.invoke(prompt);
return response.content;
}
这段代码的重点,不是“调用一次 LLM 做摘要”这么简单,而是摘要提示词的约束。
如果你只写一句“请总结以下对话”,模型很容易产出一段可读但不可用的自然语言总结。它看起来通顺,但未必适合继续喂给模型。
一个可用的摘要,应该尽量接近“记忆对象”,而不是“文章摘要”。它最好显式包含:
如果业务再复杂一点,我甚至更推荐把摘要输出成结构化 JSON,而不是自然语言段落。
检索型记忆的关键,不只是接入向量数据库,而是“存什么”。
如果你把每一句聊天原文都单独 embedding,后面会出现三个问题:
更稳妥的方式,是把长期记忆设计成文档对象:
const memoryRecord = {
memoryId: "tenant_42_task_20260329_001",
userId: "u_1001",
sessionId: "s_7788",
type: "task_summary",
topic: "支付服务排障",
content:
"用户在 staging 环境排查支付回调超时;已确认网关超时阈值为 3 秒;" +
"日志显示下游库存服务偶发 5 秒响应;下一步需要检查库存服务线程池配置。",
tags: ["staging", "payment", "timeout"],
createdAt: "2026-03-29T10:30:00Z",
};
然后你再对 content 或 content + tags + topic 做 embedding,存入向量数据库。
这样做的好处是:
userId、sessionId、type、topic 做过滤。这也是为什么我不建议把“对话消息”和“长期记忆对象”混为一谈。前者是原始日志,后者是经过治理后的知识单元。
真正的工程关键,不在于某一个 API,而在于调用前的编排顺序。
下面给一个简化版流程:
async function buildPrompt({
historyStore,
vectorStore,
model,
sessionId,
userId,
userInput,
}) {
const allMessages = await historyStore.getMessages(sessionId);
const recentMessages = await buildRecentWindow(allMessages);
const summary = await historyStore.getSummary(sessionId);
const retrievedMemories = await vectorStore.search(userInput, {
k: 4,
filter: { userId },
});
return [
{
role: "system",
content:
"你是企业内部支持助手。优先依据当前问题、最近上下文和已确认历史事实回答。",
},
summary
? {
role: "system",
content: `会话摘要:n${summary}`,
}
: null,
retrievedMemories.length
? {
role: "system",
content:
"以下是与当前问题相关的历史记忆:n" +
retrievedMemories.map((item) => `- ${item.content}`).join("n"),
}
: null,
...recentMessages,
{ role: "user", content: userInput },
].filter(Boolean);
}
这一段代码在整条链路中的位置,是“模型调用之前的上下文构建器”。
它体现的是四个工程判断:
maxTokens它决定短期工作记忆的大小。
这个值太小,系统容易失去连续性;太大,成本和延迟会上升。通常我会先根据模型总窗口,反推出历史预算,再逐步调。
responseReserveTokens虽然很多 demo 不会显式写这个参数,但生产系统一定要有“输出预留”意识。
如果你把上下文塞到接近上限,模型要么回答被截断,要么直接报错。经验上,给输出预留 15% 到 30% 的空间通常更稳。
summaryTriggerThreshold这是触发摘要的阈值,可以按消息条数,也可以按 token 数。
工程上我更推荐按 token 数,因为消息长度波动太大。一个现实做法是:当历史消息预算用到 70% 到 80% 时,就开始触发总结,而不是等到完全顶满。
keepRecentTokens触发摘要后,并不是把所有原消息都清掉,而是仍然保留最近的一段原始消息。
这是为了维持对话流的细节和语气连续性。一般来说,最近窗口应该始终保留,摘要只负责吸收更早的部分。
k这是检索返回条数。
k 不是越大越好。拿太多只会把噪声重新塞回 prompt。多数业务里,3 到 5 通常比 10 更实用。
scoreThreshold如果你的向量数据库支持分数阈值,最好加上。
因为很多时候“最相似”不代表“足够相关”。没有阈值时,系统会把一些勉强相关的历史也召回出来,污染当前上下文。
不对。
Memory 不是归档系统。归档的目标是尽量完整,Memory 的目标是尽量有用。把所有消息都塞进 prompt,很多时候是在增加噪声,而不是增加理解。
不建议。
摘要适合进入 prompt,但原始消息更适合留在存储层做审计、回放和二次处理。进入上下文和是否持久化,应该分开考虑。
不一定。
检索依赖 embedding 质量、召回粒度和过滤条件。如果你的长期记忆对象设计得很差,检索出来的内容一样会偏。
这通常只是“做了 embedding”,不等于“做好了 memory”。
长期记忆更像知识治理问题。你要定义记忆单元、元数据、过期策略、去重策略,而不是只接一个向量库。
这是很多 Agent 场景下最容易出 bug 的地方。
带 tool_calls 的 assistant 消息,往往必须和后续 tool 消息成对出现。你如果只保留了一半,模型下次调用时很容易出现结构错误或语义错乱。
如果你正在做一个真实 AI 应用,我的建议是:
别一上来就搞复杂记忆系统。先把最近消息的 token 预算、工具消息完整性、输出预留空间这些基础问题解决掉。
当你发现系统开始出现“聊久了就忘”“重复问用户背景”“跨 20 轮后明显失忆”,再把摘要层加进去。
只有当业务真的需要跨会话、跨天、跨任务恢复历史事实时,检索型记忆才会真正体现价值。否则过早上向量库,只是在增加系统复杂度。
不要停留在“消息检索”层面。尽量把长期记忆沉淀成:
这会比“检索聊天原文”稳定得多。
如果必须把这篇文章压缩成一句话,我会这样总结:
截断解决的是预算问题,总结解决的是连续性问题,检索解决的是远距离召回问题。三者不是竞争关系,而是不同时间尺度上的协作关系。
所以,真正可落地的默认方案通常不是“三选一”,而是:
当你用这种方式去理解 Memory,你会发现它不再只是聊天应用里的一个附属功能,而是整个 AI 系统质量、成本和可扩展性的关键控制点。