很多人一开始做聊天机器人、RAG 助手或者 Agent,都会把 Memory 理解成一件很朴素的事:把用户和模型的对话一条条存起来,下次继续带上就行。
这个理解不算错,但只对了一半。
真正到了工程落地阶段,你很快会碰到两个现实问题:
- 上下文窗口不是无限的,消息越堆越多,成本、延迟和失败率都会上升。
- "历史消息"并不都同样重要,有些要保留原文,有些只需要摘要,有些根本不该直接塞进 prompt。
所以,Memory 的本质并不是"存消息",而是"管理哪些信息在什么时机、以什么形式进入当前上下文"。
这也是我对这类问题的核心判断:
在大多数 AI 应用里,默认最优解不是只用截断、也不是只用向量检索,而是分层记忆:短期上下文靠截断控制窗口,中期连续性靠总结压缩信息,长期召回靠检索恢复关键事实。
如果你把这件事想明白,很多常见误区就会自然消失。比如:
- 为什么"把所有历史消息都带上"不是一个可持续方案。
- 为什么摘要明明会损失信息,却依然是必须要有的能力。
- 为什么检索很强,却不能替代最近几轮原始对话。
- 为什么 Memory 不该只被看成一个框架 API,而应该被设计成系统链路的一部分。
下面就沿着这条主线,把截断、总结、检索三种策略讲清楚。
为什么 AI 应用一定会遇到 Memory 问题
大模型本身是无状态的。
你这次调用模型,和下一次调用模型,从模型视角看是两次独立请求。它之所以能"记住"你前面说过的话,不是因为模型内部真的保留了会话,而是因为应用层把之前的消息重新组织进了这次 prompt。
也就是说,所谓"模型记忆",本质上是应用在做上下文重建。
一个最典型的调用链路是这样的:
- 应用维护一个
messages列表。 - 每次用户发来新问题,把这条
HumanMessage追加进去。 - 如果模型上一轮调用过工具,还要把
AIMessage、ToolMessage一起保留下来。 - 再把这份消息列表整体发给模型,让模型基于"看起来连续"的上下文继续回答。
问题就出在这里。
这套机制短期内很好用,但只要对话足够长,就一定会出现以下问题:
- token 持续增长,请求成本越来越高。
- 历史太长,响应速度变慢。
- 工具调用链一长,消息结构更复杂,容易碰到模型或 SDK 的上下文限制。
- 太旧、太碎、太嘈杂的信息混进 prompt,反而降低当前回答质量。
所以 Memory 管理的目标,从来都不是"尽量多保留",而是"尽量高质量地保留"。
先把概念摆正:存储层和策略层不是一回事
很多文章容易把这两件事混在一起:
- 一类问题是:消息存在哪里?
- 另一类问题是:消息怎么用?
这是两个不同维度。
1. 存储层:历史消息放在哪里
这是持久化问题,常见选项包括:
- 进程内存:适合本地 demo 和单进程实验。
- 文件:适合简单持久化、调试和个人工具。
- Redis:适合在线会话、低延迟访问。
- 数据库:适合审计、归档、复杂查询和多租户管理。
- 对象存储或日志系统:适合超长历史归档。
存储层解决的是"我能不能把历史找回来"。
2. 策略层:哪些历史应该进入当前 prompt
这是上下文编排问题,常见做法就是本文要讲的三种:
- 截断:只保留最近的一段消息。
- 总结:把旧消息压缩成摘要。
- 检索:按语义或结构化条件召回历史片段。
策略层解决的是"就算我都存下来了,这一轮到底该拿哪些给模型看"。
工程上,很多系统失败不是因为"没存",而是因为"乱取"。消息存储做得再完整,如果每轮 prompt 还是简单拼接全部历史,系统依然会越来越差。
三种策略分别解决什么问题
截断:解决窗口失控和成本失控
截断是最基础、也最容易被低估的一种策略。
它做的事非常直接:当历史消息太长时,只保留最近的一部分,把更早的消息排除在本轮上下文之外。
很多人觉得截断太粗暴,但在工程上,它往往是第一道必须存在的防线。原因很简单:
- 最近几轮对话通常和当前问题最相关。
- 成本、延迟、稳定性问题,首先需要一个硬边界。
- 就算你后面还会做总结和检索,截断依然是兜底机制。
截断常见有两种方式。
按消息条数截断
这是最简单的方案,比如只保留最近 8 条消息或最近 4 轮对话。
优点是实现快、行为可预测。
缺点也明显:
- 一条消息可能只有一句话,也可能是一大段 JSON。
- 工具调用场景里,一轮可能带上
assistant tool_calls + tool result,按条数很容易截断得不完整。
所以条数截断适合 demo,不适合作为生产系统唯一策略。
按 token 截断
这是更实用的方案。因为模型真正关心的不是"多少条",而是"多少 token"。
如果你在 LangChain JS 里做这件事,trimMessages 是一个很自然的入口。核心思想不是依赖某个"黑盒 Memory 类",而是在模型调用前,显式地对消息做裁剪。
js
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 本身,而是预算意识。
生产环境里我通常不会把上下文窗口全部给历史消息,而会预留一部分给:
- 当前用户问题
- system prompt
- tools 描述
- 模型输出空间
比如模型上下文是 32k,你可能只给历史消息分 8k 到 12k,而不是全部占满。
总结:解决长对话连续性问题
只靠截断有一个明显问题:系统会越来越"健忘"。
假设用户前面聊了 30 轮,最近几轮在讨论接口报错,但更早的时候已经明确说过:
- 他是哪个企业租户的管理员
- 当前环境是 staging
- 之前已经尝试过哪些排查步骤
如果这些信息被简单截掉,模型就会开始重复提问,或者给出明显不贴合上下文的建议。
这时就需要总结。
总结策略的核心不是"把历史变短",而是"把低频但高价值的信息提炼出来"。它适合保留这几类内容:
- 用户画像:角色、偏好、语言风格、权限身份
- 长期任务:目标、约束、验收条件
- 关键事实:已经确认的结论、排查结果、已执行动作
- 决策过程:为什么选择 A,而不是 B
不适合原样压成摘要的内容则包括:
- 最近几轮正在进行的细粒度推理过程
- 需要逐字准确保留的结构化结果
- 工具调用的原始返回体
总结本质上是有损压缩,因此它一定不是"越多越好",而应该在阈值触发时执行。
检索:解决跨时间跨度的精准召回问题
总结能保住连续性,但它无法解决另一个问题:用户突然问一个很久以前聊过、但这几轮完全没提的话题。
例如:
- "我上周提过的 API 密钥轮换方案是什么来着?"
- "我们上次确认过哪个服务是主调用方?"
- "我之前说过自己更偏向 Java 方案还是 Python 方案?"
这类问题不一定在最近消息里,也不适合全靠摘要兜底。因为摘要会压缩信息,很多细节未必会保留下来。
所以你需要检索型记忆。
它的工作方式和 RAG 很像:
- 把历史中的关键片段做 embedding。
- 存进向量数据库或可检索存储。
- 用户发起新问题时,对当前 query 做向量化。
- 检索最相关的历史片段,再拼回 prompt。
但这里要特别强调一个常见误区:
检索记忆不是把所有聊天原文都无脑扔进向量库。
更合理的做法通常是存三类对象:
- 每轮对话的成对摘要
- 已确认的用户事实卡片
- 任务阶段性总结
原因很简单。原始聊天很碎、噪声很多,直接按消息粒度做向量检索,召回质量往往并不好。相比之下,经过整理的"事实"和"阶段摘要"更适合作为长期记忆单元。
哪个方案应该作为默认方案
如果只给一个工程结论,我的建议是:
默认推荐:截断 + 总结 + 检索的分层组合
分工如下:
- 截断负责控成本,保证每次调用都不会失控。
- 总结负责保连续性,让系统在长对话中不至于"失忆"。
- 检索负责远距离召回,让旧信息在需要时重新出现。
这是绝大多数"长期聊天 + 任务协作 + 事实记忆"场景下更稳妥的默认方案。
只用截断,适合哪些场景
- 会话很短
- 用户问题独立性强
- 对长期上下文依赖很低
- 主要目标是先把系统跑稳、跑便宜
比如临时客服问答、一次性表单助手、低复杂度工具型机器人。
只用总结,不适合做默认方案
很多人看到总结后会觉得:"那我把旧对话全总结成一段,不就够了吗?"
不够。
因为摘要一定会丢信息,尤其对以下场景不友好:
- 需要恢复精确措辞
- 需要回看详细操作步骤
- 需要追踪多分支任务状态
所以总结适合作为压缩层,不适合作为唯一记忆层。
只用检索,也不适合做默认方案
检索很强,但它解决的是"召回"问题,不是"连续对话流"问题。
如果你把最近几轮也全交给检索处理,会遇到两个问题:
- 近场上下文本来就应该直接保留,检索是在浪费一次召回成本。
- 检索结果是"找回来",不是"天然连续",模型对话流会变得跳跃。
因此,检索应该补长期记忆,而不是替代最近上下文。
一个更接近真实业务的 Demo:企业内部支持助手
为了更贴近真实开发,我们不再用"做菜助手"这种教学型场景,而是换成一个企业内部支持助手。
这个助手要解决的问题是:
- 用户会连续多轮排查一个线上问题
- 期间会调用工具读取日志、查配置、看工单
- 会话可能跨天继续
- 用户经常会引用"上次我们说到哪了"
这种场景非常适合分层记忆。
整体链路
我建议把 Memory 链路拆成四层:
History Store负责持久化原始消息,比如 Redis、数据库或文件。Recent Window对最近消息做 token 截断,形成短期工作记忆。Summary Memory对被挤出窗口的旧消息做阶段性总结。Retrieval Memory对摘要、事实卡片、关键事件做向量化,按需召回。
最后在 prompt 组装阶段,把这几块信息按优先级拼进去:
text
System Prompt
+ 会话摘要 Summary Memory
+ 检索到的长期记忆 Retrieval Memory
+ 最近原始消息 Recent Window
+ 当前用户输入
注意顺序很重要。
最近原始消息应该比摘要更靠后,因为它离当前问题更近;摘要和检索结果则更适合作为背景信息,而不是主对话流本身。
关键实现一:把"最近消息"作为短期工作记忆
下面这段代码的职责很明确:从原始消息里裁出一段可直接给模型的"工作区上下文"。
js
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),当然也能跑,但在有工具调用和长文本响应时会明显不稳。
关键实现二:旧消息不是删除,而是压缩成摘要
当最近窗口已经装不下,而旧对话又仍然有价值时,就要把"历史对话"转成"摘要记忆"。
js
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(`请总结以下历史对话:\n\n${transcript}`),
];
const response = await model.invoke(prompt);
return response.content;
}
这段代码的重点,不是"调用一次 LLM 做摘要"这么简单,而是摘要提示词的约束。
如果你只写一句"请总结以下对话",模型很容易产出一段可读但不可用的自然语言总结。它看起来通顺,但未必适合继续喂给模型。
一个可用的摘要,应该尽量接近"记忆对象",而不是"文章摘要"。它最好显式包含:
- 用户是谁
- 当前在做什么
- 哪些事已经确认
- 哪些事还没完成
如果业务再复杂一点,我甚至更推荐把摘要输出成结构化 JSON,而不是自然语言段落。
关键实现三:长期记忆不要直接存聊天原文
检索型记忆的关键,不只是接入向量数据库,而是"存什么"。
如果你把每一句聊天原文都单独 embedding,后面会出现三个问题:
- 语义太碎,召回结果不稳定。
- 相似句子很多,排序噪声大。
- 缺乏结构字段,难以做过滤。
更稳妥的方式,是把长期记忆设计成文档对象:
js
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,而在于调用前的编排顺序。
下面给一个简化版流程:
js
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);
}
这一段代码在整条链路中的位置,是"模型调用之前的上下文构建器"。
它体现的是四个工程判断:
- 原始消息不直接全量透传。
- 摘要不是替代最近消息,而是补背景。
- 检索结果不是越多越好,而是只给当前 query 相关的几条。
- prompt 组装是应用层职责,不应该交给一个黑盒对象自动决定。
这些参数别只会调,得知道为什么存在
maxTokens
它决定短期工作记忆的大小。
这个值太小,系统容易失去连续性;太大,成本和延迟会上升。通常我会先根据模型总窗口,反推出历史预算,再逐步调。
responseReserveTokens
虽然很多 demo 不会显式写这个参数,但生产系统一定要有"输出预留"意识。
如果你把上下文塞到接近上限,模型要么回答被截断,要么直接报错。经验上,给输出预留 15% 到 30% 的空间通常更稳。
summaryTriggerThreshold
这是触发摘要的阈值,可以按消息条数,也可以按 token 数。
工程上我更推荐按 token 数,因为消息长度波动太大。一个现实做法是:当历史消息预算用到 70% 到 80% 时,就开始触发总结,而不是等到完全顶满。
keepRecentTokens
触发摘要后,并不是把所有原消息都清掉,而是仍然保留最近的一段原始消息。
这是为了维持对话流的细节和语气连续性。一般来说,最近窗口应该始终保留,摘要只负责吸收更早的部分。
k
这是检索返回条数。
k 不是越大越好。拿太多只会把噪声重新塞回 prompt。多数业务里,3 到 5 通常比 10 更实用。
scoreThreshold
如果你的向量数据库支持分数阈值,最好加上。
因为很多时候"最相似"不代表"足够相关"。没有阈值时,系统会把一些勉强相关的历史也召回出来,污染当前上下文。
常见误区和坑
误区一:Memory 越完整越好
不对。
Memory 不是归档系统。归档的目标是尽量完整,Memory 的目标是尽量有用。把所有消息都塞进 prompt,很多时候是在增加噪声,而不是增加理解。
误区二:总结之后就可以把原始消息彻底删掉
不建议。
摘要适合进入 prompt,但原始消息更适合留在存储层做审计、回放和二次处理。进入上下文和是否持久化,应该分开考虑。
误区三:检索到的内容一定比摘要更可靠
不一定。
检索依赖 embedding 质量、召回粒度和过滤条件。如果你的长期记忆对象设计得很差,检索出来的内容一样会偏。
误区四:把每条消息都向量化就是长期记忆
这通常只是"做了 embedding",不等于"做好了 memory"。
长期记忆更像知识治理问题。你要定义记忆单元、元数据、过期策略、去重策略,而不是只接一个向量库。
误区五:工具调用消息可以随便裁
这是很多 Agent 场景下最容易出 bug 的地方。
带 tool_calls 的 assistant 消息,往往必须和后续 tool 消息成对出现。你如果只保留了一半,模型下次调用时很容易出现结构错误或语义错乱。
实战建议:怎么选型更稳
如果你正在做一个真实 AI 应用,我的建议是:
第一阶段:先把截断做好
别一上来就搞复杂记忆系统。先把最近消息的 token 预算、工具消息完整性、输出预留空间这些基础问题解决掉。
第二阶段:补摘要层
当你发现系统开始出现"聊久了就忘""重复问用户背景""跨 20 轮后明显失忆",再把摘要层加进去。
第三阶段:再做长期检索
只有当业务真的需要跨会话、跨天、跨任务恢复历史事实时,检索型记忆才会真正体现价值。否则过早上向量库,只是在增加系统复杂度。
第四阶段:把长期记忆对象化
不要停留在"消息检索"层面。尽量把长期记忆沉淀成:
- 用户事实
- 任务摘要
- 决策记录
- 工单结论
- 偏好配置
这会比"检索聊天原文"稳定得多。
一个更值得采用的工程结论
如果必须把这篇文章压缩成一句话,我会这样总结:
AI 应用里的 Memory,不是把消息存起来这么简单,而是要把"存储、压缩、召回、组装"做成一套分层上下文管理系统。
截断解决的是预算问题,总结解决的是连续性问题,检索解决的是远距离召回问题。三者不是竞争关系,而是不同时间尺度上的协作关系。
所以,真正可落地的默认方案通常不是"三选一",而是:
- 用持久化存储保留原始历史
- 用 token 截断维护短期工作记忆
- 用摘要压缩中期上下文
- 用检索恢复长期关键事实
当你用这种方式去理解 Memory,你会发现它不再只是聊天应用里的一个附属功能,而是整个 AI 系统质量、成本和可扩展性的关键控制点。
延伸阅读
- LangChain JS Short-term memory: docs.langchain.com/oss/javascr...
- LangChain JS Long-term memory: docs.langchain.com/oss/javascr...
- LangChain JS Middleware(含 summarization middleware): docs.langchain.com/oss/javascr...