多轮对话为什么会失忆?记忆设计
作者:程序员马丁
Ragent AI —— 从 0 到 1 纯手工打造企业级 Agentic RAG,拒绝 Demo 玩具!AI 时代,助你拿个offer。
到这里,RAG 系列的核心链路已经全部打通了:数据分块 → 元数据管理 → 向量化 → 向量数据库 → 检索策略 → 生成策略 → Function Call → MCP 协议。从数据准备到检索生成,再到工具调用,一条完整的链路。
但你把这套系统上线之后,用户很快就会给你提一个 bug:你们这个 AI 是不是没有记忆?
场景是这样的:用户问“iPhone 16 Pro 的退货政策是什么”,系统检索知识库,找到退货政策的 chunk,回答得很好。用户满意了,紧接着追问“那它的保修期呢”。
然后系统的回答画风突变:
请问您想了解哪款产品的保修期呢?请提供具体的产品名称,我帮您查询。
用户心想:我刚才不是说了 iPhone 16 Pro 吗?你怎么就忘了?
这不是 bug,这是你的 RAG 系统“失忆”了。每次用户提问,系统都是从零开始——不知道之前聊了什么,不知道“它”指的是什么。对用户来说,这种体验就像跟一个每隔 30 秒就失忆的人聊天,每句话都要把前因后果重新说一遍。

这一篇咱们就来聊聊怎么给 RAG 系统装上记忆——会话记忆(Conversation Memory)。
大模型的记忆真相:每次请求都是失忆的
1. 一个常见的误解
很多人用过 ChatGPT、Kimi 这些产品,觉得大模型天生就能记住对话内容。你跟它聊了十几轮,它还记得第一轮你说了什么,感觉像是在跟一个有记忆的智能体对话。
但实际上,大模型 API 的每次请求都是完全独立的。模型不会保存任何对话状态——它没有上一轮对话的概念,没有这个用户之前问过什么的记忆,甚至不知道你是谁。
打个比方:大模型就像一个极其聪明但患有严重失忆症的专家。每次你找他,他都不记得你之前来过。你得把之前聊过的内容全部重新说一遍,他才能接着聊。
那 ChatGPT 网页上的记忆是怎么实现的?答案很简单——把你之前所有的对话历史全部塞进了 messages 数组,每次请求都重新发给 API。模型看到了完整的对话历史,所以显得有记忆,实际上每次都是从头看一遍。
2. messages 数组就是记忆的全部
回顾一下之前模型调用 API 那篇讲过的 messages 数组结构。
单轮对话——只有系统指令和用户消息:
{
"messages": [
{"role": "system", "content": "你是一个电商客服助手"},
{"role": "user", "content": "iPhone 16 Pro 的退货政策是什么?"}
]
}
多轮对话——每次请求都要带上之前所有的 user 和 assistant 消息:
{
"messages": [
{"role": "system", "content": "你是一个电商客服助手"},
{"role": "user", "content": "iPhone 16 Pro 的退货政策是什么?"},
{"role": "assistant", "content": "iPhone 16 Pro 因屏幕定制工艺,拆封后不支持七天无理由退货..."},
{"role": "user", "content": "那它的保修期呢?"}
]
}
看到区别了吗?第二轮请求的 messages 里包含了第一轮的 user 消息和 assistant 回复。模型看到了“iPhone 16 Pro 的退货政策是什么”和对应的回答,所以它知道“它”指的是 iPhone 16 Pro。
如果你第二轮请求只发了这样的 messages:
{
"messages": [
{"role": "system", "content": "你是一个电商客服助手"},
{"role": "user", "content": "那它的保修期呢?"}
]
}
模型看到的就是一个莫名其妙的问题——“它”是什么?保修期是什么产品的?所以它只能反问你“请问您想了解哪款产品的保修期”。
一句话概括:大模型没有记忆。所谓的记忆,就是你把对话历史塞进 messages 数组重新发送。记忆的管理不在模型侧,完全在你的代码侧。
3. Token 膨胀:记忆越多,成本越高
既然记忆就是把历史消息塞进 messages,那最简单的做法就是——全部塞进去呗。用户聊了 10 轮,就把 10 轮的 user + assistant 消息全带上。
听起来很合理,但实际跑起来会遇到一个严重的问题:Token 膨胀。
用一个具体的例子算一下。假设电商客服场景,每轮对话大约的 Token 消耗:
| 轮次 | 用户消息 | 助手回复 | 单轮 Token | 累计历史 Token |
|---|---|---|---|---|
| 第 1 轮 | 30 Token | 200 Token | 230 | 230 |
| 第 2 轮 | 20 Token | 150 Token | 170 | 400 |
| 第 3 轮 | 40 Token | 250 Token | 290 | 690 |
| 第 5 轮 | ... | ... | ... | ~1,200 |
| 第 10 轮 | ... | ... | ... | ~2,500 |
| 第 20 轮 | ... | ... | ... | ~5,000 |
这还只是对话历史。别忘了,RAG 系统每次请求还要带上:
- System Prompt:500~1,000 Token(角色定义、行为规则、兜底指令等)
- 检索上下文:2,000~5,000 Token(Top-K chunk 的内容)
- 预留生成空间:500~2,000 Token(模型回答需要的空间)
把这些加起来:
第 10 轮总 Token ≈ 1,000(System)+ 2,500(历史)+ 3,000(检索)+ 1,000(生成)= 7,500 Token
第 20 轮总 Token ≈ 1,000(System)+ 5,000(历史)+ 3,000(检索)+ 1,000(生成)= 10,000 Token
如 果用户聊了 50 轮(比如一个复杂的售后问题来回沟通),光对话历史就可能超过 10,000 Token。加上其他部分,总 Token 轻松突破 15,000~20,000。
这会带来两个问题:
- 超出上下文窗口:有些模型的上下文窗口只有 4K 或 8K Token,塞不下这么多内容,请求直接报错
- 费用飙升:即使模型支持 32K 或 128K 的上下文窗口,Token 越多费用越高。每轮对话都带着完整历史,相当于每次都在为重复发送旧消息付费
所以,会话记忆的核心问题不是要不要记住历史,而是怎么在有限的 Token 预算内,尽可能多地保留有用的历史信息。
会话记忆的五种策略
1. 完整历史(Full History)
最简单粗暴的策略:把所有对话历史全部塞进 messages 数组,一条不丢。
// 完整历史策略:每次请求带上所有历史消息
List<Message> messages = new ArrayList<>();
messages.add(new Message("system", systemPrompt));
messages.addAll(conversationHistory); // 全部历史消息
messages.add(new Message("user", currentQuestion));
这个策略的优点很明显——信息零丢失,模型能看到整个对话过程中的每一句话。
但缺点前面已经算过了:Token 无限膨胀。对话轮数越多,成本越高,最终要么超出上下文窗口,要么费用不可接受。
适用场景:对话轮数确定不超过 5 轮的简单场景,比如一问一答的 FAQ 查询。如果你能确保对话不会太长,用完整历史策略最省心。
2. 滑动窗口(Sliding Window)
滑动窗口是最常用的策略之一:只保留最近 N 轮对话,更早的对话直接丢弃。
打个比方:你的对话历史就像一条传送带,传送带只有 N 格长。每来一轮新对话,就放到传送带末尾;如果传送带满了,最早的那一轮就从头上掉下去。
对话历史(N=3):
第 1 轮对话结束:[第1轮]
第 2 轮对话结束:[第1轮, 第2轮]
第 3 轮对话结束:[第1轮, 第2轮, 第3轮] ← 传送带满了
第 4 轮对话结束:[第2轮, 第3轮, 第4轮] ← 第 1 轮被丢弃
第 5 轮对话结束:[第3轮, 第4轮, 第5轮] ← 第 2 轮被丢弃
优点是实现简单、Token 可控——不管聊了多少轮,历史消息的 Token 上限是固定的。
缺点也很明显:早期对话信息会永久丢失。如果用户在第 1 轮提到了一个关键信息(比如我的订单号是 #12345),到第 6 轮追问“那个订单到货了吗”,系统已经忘了订单号是 什么。
那 N 取多大合适?没有标准答案,取决于几个因素:
| 场景 | 推荐 N 值 | 理由 |
|---|---|---|
| 简单 FAQ 问答 | 3~5 | 用户通常 2~3 轮就能得到答案,保留太多没意义 |
| 电商客服 | 5~8 | 退货、售后等场景可能需要来回确认细节 |
| 技术支持 | 8~10 | 排查问题需要较长的上下文,但太长的历史意义不大 |
| 复杂咨询(法律、金融) | 10~15 | 需要保留较多背景信息,但建议配合摘要压缩使用 |
经验法则:从 N=5 开始,根据实际效果调整。如果用户频繁遇到系统忘了之前说的,就增大 N;如果 Token 成本过高,就减小 N。
3. Token 截断(Token Truncation)
滑动窗口按轮数截断,但有一个问题:不同轮的消息长度差别很大。
- 用户说“好的”——2 个 Token
- 用户贴了一段商品描述——500 个 Token
- 模型详细解释退货流程——800 个 Token
如果 N=5,但其中有一轮模型回复特别长(比如 800 Token),5 轮的历史就占了 3,000~4,000 Token。而如果每轮都是简短对话,5 轮可能只占 500 Token。按轮数截断无法精确控制 Token 消耗。
Token 截断策略更精确:给对话历史设一个 Token 上限(比如 4,000 Token),从最新的消息往前算,超出上限的消息直接丢弃。
Token 上限:4,000
当前历史消息(从旧到新):
- 第 1 轮 user: 100 Token ← 超出,丢弃
- 第 1 轮 assistant:500 Token ← 超出,丢弃
- 第 2 轮 user: 50 Token ← 超出,丢弃
- 第 2 轮 assistant:300 Token ← 超出,丢弃
- 第 3 轮 user: 200 Token ✓ 保留(累计 3,800)
- 第 3 轮 assistant:800 Token ✓ 保留(累计 3,600)
- 第 4 轮 user: 100 Token ✓ 保留(累计 2,800)
- 第 4 轮 assistant:1200 Token ✓ 保留(累计 2,700)
- 第 5 轮 user: 500 Token ✓ 保留(累计 1,500)
- 第 5 轮 assistant:1000 Token ✓ 保留(累计 1,000)
注意:截断时要保证成对丢弃——一轮对话的 user 和 assistant 消息要么都保留,要么都丢弃。如果只丢了 user 留了 assistant,模型会看到一个没有问题的回答,容易混乱。
那怎么计算 Token 数呢?精确计算需要用 tokenizer(如 OpenAI 的 tiktoken),但大多数国产模型的 tokenizer 不一样,而且 Java 生态中没有通用的 tokenizer 库。实际项目中,用字符数估算就够了:
- 中文:1 个汉字 ≈ 1~2 Token
- 英文:1 个单词 ≈ 1~1.5 Token
- 简单估算公式:Token 数 ≈ 中文字符数 × 1.5 + 英文单词数 × 1.3
/**
* 简单的 Token 估算方法
* 精度不高,但足以用于对话历史的截断控制
*/
public static int estimateTokens(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
int chineseChars = 0;
int otherChars = 0;
for (char c : text.toCharArray()) {
if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) {
chineseChars++;
} else if (!Character.isWhitespace(c)) {
otherChars++;
}
}
// 中文:1 字 ≈ 1.5 Token,英文/数字:约 4 字符 ≈ 1 Token
return (int) (chineseChars * 1.5 + otherChars / 4.0);
}
4. 摘要压缩(Summary Compression)
前面两种策略有一个共同的缺陷:被丢弃的历史信息就永远找不回来了。如果用户在第 1 轮提到了一个关键信息,滑动窗口和 Token 截断都会在一定轮数后把它丢掉。
摘要压缩的思路不一样:不是丢掉早期对话,而是用大模型把早期对话压缩成一段简短的摘要。
打个比方:你跟同事接手一个客户工单,同事之前跟客户聊了 20 轮。你不需要看完 20 轮的完整记录,同事给你一段交接说明就行:“客户张先生,买了 iPhone 16 Pro,反映屏幕有亮点,已确认在保修期内,客户希望换新而不是维修,目前在等审批结果。”——这就是摘要。
原来 20 轮对话可能有 5,000 Token,压缩成一段摘要只需要 200~500 Token,但关键信息都保留了。
4.1 摘要 Prompt 的设计
压缩对话历史需要一个专门的 Prompt,告诉模型哪些信息要保留,哪些 可以省略。
请将以下对话历史压缩为一段简洁的摘要,要求:
1. 保留用户的核心意图和关注点
2. 保留所有关键实体(产品名、订单号、日期、金额等)
3. 保留已经确认的结论和决定
4. 保留尚未解决的问题
5. 省略寒暄、重复确认、无关细节
6. 摘要以第三人称描述,控制在 200 字以内
对话历史:
{conversation_history}
压缩后的摘要会作为一条 system 或 user 消息放在 messages 的前面,让模型了解之前的对话背景。