多轮对话记忆设计
实际上,大模型 API 的每次请求都是完全独立的。模型不会保存任何对话状态——它没有上一轮对话的概念,没有这个用户之前问过什么的记忆,甚至不知道你是谁。
那 ChatGPT 网页上的记忆是怎么实现的?答案很简单——把你之前所有的对话历史全部塞进了 messages 数组,每次请求都重新发给 API。模型看到了完整的对话历史,所以显得有记忆,实际上每次都是从头看一遍。
多轮对话 ——每次请求都要带上之前所有的 user 和 assistant 消息:第二轮请求的 messages 里包含了第一轮的 user 消息和 assistant 回复。模型看到了“iPhone 16 Pro 的退货政策是什么”和对应的回答,所以它知道“它”指的是 iPhone 16 Pro。
实际跑起来会遇到一个严重的问题:Token膨胀 。超出上下文窗口而且费用飙升
所以,会话记忆的核心问题不是要不要记住历史,而是怎么在有限的Token预算内,尽可能多地保留有用的历史信息 。模型的能力有限,比如最近的gemini3.1pro,上下文不知道;他的注意力在google层层降智削弱基本上只有32k了, 不过这也可能是gemini3.1的特性使然,因为最近的几个模型一直在降本,google也吃不住大规模的使用啊
会话记忆的五种策略
1. 完整历史(Full History)
最简单粗暴的策略:把所有对话历史全部塞进 messages 数组,一条不丢。
2. 滑动窗口(Sliding Window)
滑动窗口是最常用的策略之一:只保留最近 N 轮对话,更早的对话直接丢弃。
打个比方:你的对话历史就像一条传送带,传送带只有 N 格长。每来一轮新对话,就放到传送带末尾;如果传送带满了,最早的那一轮就从头上掉下去。
优点是实现简单、Token 可控——不管聊了多少轮,历史消息的 Token 上限是固定的。
缺点也很明显:早期对话信息会永久丢失。如果用户在第 1 轮提到了一个关键信息(比如我的订单号是 #12345),到第 6 轮追问“那个订单到货了吗”,系统已经忘了订单号是什么。
那 N 取多大合适?没有标准答案,取决于几个因素:
| 场景 | 推荐 N 值 | 理由 |
|---|---|---|
| 简单 FAQ 问答 | 3~5 | 用户通常 2~3 轮就能得到答案,保留太多没意义 |
| 电商客服 | 5~8 | 退货、售后等场景可能需要来回确认细节 |
| 技术支持 | 8~10 | 排查问题需要较长的上下文,但太长的历史意义不大 |
| 复杂咨询(法律、金融) | 10~15 | 需要保留较多背景信息,但建议配合摘要压缩使用 |
3. Token 截断(Token Truncation)
滑动窗口按轮数截断,但有一个问题:不同轮的消息长度差别很大。
- 用户说“好的”——2 个 Token
- 用户贴了一段商品描述——500 个 Token
- 模型详细解释退货流程——800 个 Token
如果 N=5,但其中有一轮模型回复特别长(比如 800 Token),5 轮的历史就占了 3,000~4,000 Token。而如果每轮都是简短对话,5 轮可能只占 500 Token。按轮数截断无法精确控制 Token 消耗。
截断时要保证成对丢弃——一轮对话的 user 和 assistant 消息要么都保留,要么都丢弃。如果只丢了 user 留了 assistant,模型会看到一个没有问题的回答,容易混乱。
那怎么计算 Token 数呢?精确计算需要用 tokenizer(如 OpenAI 的 tiktoken),但大多数国产模型的 tokenizer 不一样,而且 Java 生态中没有通用的 tokenizer 库。实际项目中,用字符数估算就够了:
- 简单估算公式 :Token 数 ≈ 中文字符数 × 1.5 + 英文单词数 × 1.3
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 的前面,让模型了解之前的对话背景。
4.2 什么时候触发摘要
摘要压缩不是每轮都做——每轮都调用大模型压缩一次,成本太高了。常见的触发策略有三种:
| 触发策略 | 做法 | 优缺点 |
|---|---|---|
| 按轮数触发 | 每隔 N 轮(如每 5 轮)压缩一次 | 简单直接,但不够灵活,短消息也触发浪费 |
| 按 Token 阈值触发 | 对话历史超过 M Token(如 3,000 Token)时压缩 | 更精确,推荐使用 |
| 按话题切换触发 | 检测到用户换了话题时压缩上一段对话 | 效果最好,但话题切换检测本身有难度 |
推荐使用按 Token 阈值触发。比如设定阈值为 3,000 Token,当对话历史超过 3,000 Token 时,把最早的若干轮对话压缩成摘要,保留最近 2~3 轮完整对话。
需要注意的是,摘要压缩有额外的 API 调用开销:每次压缩都要调一次大模型,会增加延迟和费用。不过压缩用的 Prompt 比较短,可以考虑用小模型(如 Qwen2.5-7B-Instruct)来做,成本很低。
5. 混合策略:摘要 + 最近 N 轮(推荐)
实际生产中,最常用的是混合策略:早期对话压缩成摘要+最近N轮保留完整对话 。
这个策略兼顾了两个需求:
- 长期记忆 :通过摘要保留早期对话的关键信息,不会完全丢失
- 短期精度 :最近几轮完整对话原封不动地保留,保证模型能准确理解当前话题的细节
混合策略下,发给模型的 messages 数组长这样:
{
"messages": [
{
"role": "system",
"content": "你是一个电商客服助手,基于提供的信息回答用户问题..."
},
{
"role": "system",
"content": "【对话背景摘要】客户咨询 iPhone 16 Pro 的售后问题。已确认:1)客户于 2025 年 1 月 15 日购买,订单号 #20250115001;2)产品屏幕出现亮点,客户已提供照片;3)确认在保修期内(1 年保修,至 2026 年 1 月 15 日);4)客户希望换新而非维修。"
},
{
"role": "user",
"content": "换新的话,是换同款还是可以换其他型号?"
},
{
"role": "assistant",
"content": "根据我们的换新政策,保修期内因质量问题换新,默认更换同款同配置的产品。如果原款已停产或缺货,可以协商更换同价位的其他型号。您的 iPhone 16 Pro 目前在售,所以会换一台全新的同款产品。"
},
{
"role": "user",
"content": "换新之后保修期怎么算?是重新开始还是接着之前的?"
}
]
}
这里有几个要点:
- 摘要放在system消息里 ,紧跟在角色定义之后,让模型优先看到对话背景
- 最近1~2轮的完整对话 原封不动保留,保证模型理解当前话题
- 总 Token 数 = System Prompt + 摘要(约 200 Token)+ 最近几轮对话(约 500 Token),远小于保留全部历史的方案
以 32K 上下文窗口为例,推荐的 Token 分配:
| 部分 | 推荐预算 | 说明 |
|---|---|---|
| System Prompt | 1,000 Token | 角色定义 + 行为规则 + 兜底指令 |
| 对话历史 / 摘要 | 4,000 Token | 摘要 + 最近 3~5 轮完整对话 |
| 检索上下文 | 5,000 Token | Top-3 到 Top-5 个 chunk |
| 当前用户问题 | 100 Token | 通常很短 |
| 预留生成空间 | 2,000 Token | 模型回答的最大长度 |
| 总计 | ~12,100 Token | 远低于 32K 上限,留有余量 |
当 Token 预算紧张时(比如用了小模型只有 8K 窗口),各部分的优先级是什么?
推荐优先级 (从高到低):
- 1.SystemPrompt :定义模型的行为规则,不能省。没有 System Prompt,模型可能会编造答案、不标注引用、回答超出知识库范围的问题
- 2.预留生成空间 :不够的话模型回答会被截断,用户看到半句话
- 3.最近2~3轮对话 :对理解当前意图至关重要。用户说“那它呢”,如果没有最近的对话,模型不知道“它”是什么
- 4.检索上下文 :RAG 的核心价值。没有检索上下文,模型就只能用自己的知识回答,等于 RAG 失效了
- 5.更早的对话历史 :优先级最低,可以用摘要压缩或者直接丢弃
3. 动态调整策略
一个实用的做法是:根据当前对话历史的 Token 数,动态调整检索 chunk 或历史对话的数量。
public int calculateChunkBudget(int historyTokens) {
int totalBudget = 12000; // 总 Token 预算
int systemPromptTokens = 1000; // System Prompt 固定开销
int reservedForGeneration = 2000; // 预留生成空间
int queryTokens = 100; // 用户问题
int availableForChunks = totalBudget - systemPromptTokens
- reservedForGeneration - queryTokens - historyTokens;
// 确保至少能放 1 个 chunk(约 500 Token)
return Math.max(500, availableForChunks);
}
3. 数据库存储(MySQL)
用数据库表存储每条消息,字段设计大概是这样:
CREATETABLE conversation_message (
id BIGINTPRIMARYKEYAUTO_INCREMENT,
session_id VARCHAR(64)NOTNULL,
role VARCHAR(16)NOTNULLCOMMENT'system/user/assistant',
content TEXTNOTNULL,
token_count INTDEFAULT0COMMENT'该消息的 Token 数',
created_at DATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,INDEX idx_session_id (session_id));
优点:持久化、可审计(查看任意历史对话)、可做数据分析(统计热门问题、平均对话轮数等)。缺点:读写性能比内存和 Redis 低,需要数据库连接管理。
适合需要审计和数据分析的企业场景。
生产环境推荐方案:Redis做主存储+MySQL做归档 。对话进行时,消息存 Redis(快速读写);对话结束后,异步写入 MySQL(持久化、审计)。
查询重写与语义增强机制
RAG 系统在回答之前,要先去向量数据库检索相关的 chunk。检索系统拿到的 query 是什么?是用户的原始问题——那它的保修期呢。
“它”这个字对检索系统来说没有任何语义信息。向量化之后,“那它的保修期呢”和“iPhone 16 Pro 保修期”的向量距离可能相差甚远。检索召回的结果大概率不是你想要的——可能是“笔记本电脑保修政策”“家电延保服务”这些不相关的内容。问题出在哪?模型有记忆,但检索系统没有
检索系统的"失忆"问题
1. 模型有记忆,但检索没有
用一张图来看看问题出在哪。
没有Query改写的流程 ——检索用的是原始 query,召回结果不相关:加入Query改写的流程 ——先改写再检索,召回精准:区别就在中间多了一步 Query 改写。这一步把用户含糊的追问转化成了一个清晰、完整、独立的检索查询。
指代消解(把“它”替换成具体实体)是最常被提到的改写场景,但 Query 改写要解决的问题远不止这个。
看几个电商客服场景下的典型问题:
省略上下文
用户在聊了几轮 iPhone 16 Pro 之后,突然问“还有别的颜色吗?”。别的颜色是什么产品的?检索系统不知道。如果直接拿“还有别的颜色吗”去检索,可能召回所有产品的颜色信息,而不是 iPhone 16 Pro 的。
口语化表达
用户说“东西坏了咋整?”。知识库里的文档标题大概率是产品故障维修流程或售后服务指南,不会写东西坏了咋整。口语化的 query 和正式文档之间存在语义鸿沟,检索效果打折扣。
多意图混合
用户问“退货流程是什么,运费谁承担?”。这一句话里其实包含两个独立的问题:退货流程和运费承担方。一次检索很难同时命中两个主题的 chunk。
模糊描述
用户说“那个很贵的手机”。哪个?多少钱算贵?检索系统没有上下文,无法理解这种模糊描述。
这些问题的共同点是:用户的原始query对检索系统不够友好 。Query 改写要做的事情,就是在检索之前把原始 query 转化为一个独立的、完整的、对检索系统友好的查询。
Query 改写的五种策略
1. 指代消解(Coreference Resolution)
指代消解,说白了就是把代词替换成它指代的具体实体。这是多轮对话中最高频的改写场景。
常见的代词和指代表达:
| 代词 / 指代表达 | 示例 | 改写结果 |
|---|---|---|
| 它、它的 | 那它的保修期呢? | iPhone 16 Pro 的保修期 |
| 这个、那个 | 这个支持分期吗? | iPhone 16 Pro 支持分期吗? |
| 上面说的 | 上面说的退货条件再详细说说 | iPhone 16 Pro 拆封后退货条件的详细说明 |
| 同样的问题 | 另一款也是这样吗? | iPhone 16 Plus 的退货政策和 iPhone 16 Pro 一样吗? |
指代消解的关键在于:你必须结合对话历史才能确定代词指的是什么 。脱离了对话历史,“它”可以是任何东西。
需要注意一个边界情况:有时候“它”的指代并不明确。比如用户前面同时聊了 iPhone 16 Pro 和 AirPods Pro,然后问“它的保修期呢”,“它”到底指哪个?这种情况下,改写模型需要根据最近的上下文做判断——通常取最近一次被提到的实体。
2. 上下文补全(Context Completion)
人在多轮对话中会自然地省略信息,因为他觉得对方应该知道上下文。但检索系统不知道。上下文补全和指代消解经常同时出现。“价格呢”既省略了产品名(上下文补全),又省略了主语(可以理解为“它的价格呢”,指代消解)。实际改写时,大模型会一并处理,不需要你单独区分。
3. 口语化转正式(Colloquial to Formal)
用户的提问方式和知识库里的文档写法通常差异很大。用户说人话,文档写书面语。
| 口语 query | 知识库中的正式表达 | 改写结果 |
|---|---|---|
| 东西坏了咋整 | 产品故障报修流程 | 产品故障后的维修和报修流程 |
| 快递咋还没到 | 订单物流查询 / 发货时效 | 订单发货后物流状态查询 |
| 能不能便宜点 | 优惠活动 / 促销政策 / 折扣信息 | 当前可用的优惠活动和折扣信息 |
| 买贵了能补差价不 | 价格保护政策 | 商品降价后是否支持差价补偿 |
这种改写有一个特点:不依赖对话历史 。即使是第一轮对话,口语化的 query 也需要转化成更正式的表达。所以口语化转正式其实在单轮对话的 RAG 中也有价值。
不过要注意,口语化转正式不是“翻译”,而是“意图提取”。“能不能便宜点”的意图不是字面上的“降低价格”,而是“查询有没有优惠”。改写模型需要理解用户的真实意图。
4. 多意图拆分(Intent Decomposition)
用户有时候一句话里包含多个问题:
- 退货流程是什么,运费谁承担?→ 两个独立意图
- iPhone 16 Pro 和 iPhone 16 Plus 有什么区别?→ 一个对比意图,不需要拆
- 我想退货,另外帮我查一下保修期→ 两个完全不相关的意图
拆分后,每个子查询分别去检索,各自召回最相关的 chunk,合并后再生成答案。
不是所有长 query 都需要拆。“iPhone 16 Pro 的价格和颜色”虽然问了两个方面,但通常在同一个产品介绍 chunk 里就能找到,不需要拆成两次检索。拆分的判断标准是:两个意图是否可能分布在不同的 chunk 里。
5. 关键词扩展(Keyword Expansion)
关键词扩展是补充同义词和相关术语,提高检索的召回率。
| 原始 query | 扩展后 |
|---|---|
| 七天无理由退货 | 七天无理由退货 退换货政策 无条件退款 退货期限 |
| 屏幕碎了 | 屏幕碎裂 屏幕破损 屏幕维修 碎屏险 |
| 充不进去电 | 无法充电 充电故障 充电接口问题 电池问题 |
这种改写主要对**关键词检索(BM25)**有帮助——BM25 是按词匹配的,同义词扩展能提高命中率。对向量检索来说,帮助有限,因为向量检索本身就能理解语义相似性(屏幕碎了和屏幕破损的向量已经很接近了)。
如果你的 RAG 系统用的是混合检索(向量 + BM25),关键词扩展在 BM25 那一路上会有明显提升。
用大模型做 Query 改写
前面讲了五种改写策略,但在实际实现中,你不需要为每种策略单独写一套规则。用大模型做改写,一个 Prompt 就能覆盖大部分场景——指代消解、上下文补全、口语化转正式,大模型一次性搞定。
1. 改写 Prompt 的设计
1.1 基础版改写 Prompt
这个 Prompt 适合大多数场景,简单直接:
你是一个查询改写助手。根据对话历史和用户的最新问题,将问题改写为一个独立的、完整的检索查询。
要求:
1. 如果最新问题中包含代词(它、这个、那个等)或省略了关键信息,请结合对话历史补全
2. 如果问题已经足够完整清晰,请原样输出,不要画蛇添足
3. 只输出改写后的查询,不要输出任何解释、前缀或多余内容
4. 改写后的查询应该是一个独立的句子,不依赖对话历史也能理解
对话历史:
{history}
用户最新问题:{query}
改写后的查询:
基础版 Prompt 能很好地处理指代消解和上下文补全,对口语化转正式也有一定效果。
1.2 进阶版改写 Prompt
如果你的业务场景需要支持多意图拆分,可以用进阶版 Prompt。输出格式改为 JSON,方便程序解析:
你是一个查询改写助手。根据对话历史和用户的最新问题,将问题改写为适合检索的查询。 要求: 1. 补全代词和省略的上下文信息 2. 将口语化表达转化为更正式、更适合检索的表达 3. 如果问题包含多个独立意图,拆分为多个子查询 4. 如果问题已经完整清晰且只有一个意图,只输出一个查询 5. 以 JSON 格式输出,格式为:{"queries": ["查询1", "查询2"]} 6. 不要输出 JSON 以外的任何内容 对话历史: {history} 用户最新问题:{query}
进阶版的输出示例:
| 原始 query | 输出 JSON |
|---|---|
| 那它的保修期呢? | {"queries": ["iPhone 16 Pro 的保修期是多久"]} |
| 退货流程和运费谁承担? | {"queries": ["退货流程是什么", "退货运费由谁承担"]} |
| 东西坏了咋整? | {"queries": ["产品故障后的维修和报修流程"]} |
基础版够用就用基础版。进阶版虽然功能更强,但 JSON 解析增加了复杂度,模型偶尔也会输出格式不规范的 JSON。除非你的业务确实有多意图拆分的需求,否则基础版是更稳的选择。
改写质量受几个因素影响:
对话历史的质量 :如果对话历史被摘要压缩得太狠,关键实体可能已经丢了。比如摘要里只写了“客户咨询手机售后”,没有提到具体型号,改写时就无法把“它”替换成具体产品名。所以会话记忆的摘要质量直接影响改写质量。
Prompt的设计 :Prompt 里的规则要明确——什么时候改写、什么时候原样输出。如果 Prompt 没有说“已经完整的 query 不需要改写”,模型可能会画蛇添足,把一个本来就很好的 query 改得面目全非。
模型的能力 :小模型在复杂指代消解(多个实体交替出现)和口语化理解上可能不够准确。改写任务对模型要求不高,Qwen2.5-7B-Instruct 级别的模型就能胜任大部分场景。
每次 Query 改写都要调一次大模型 API,增加大约 200~500ms 的延迟和一小笔 Token 费用。虽然单次成本不高,但并不是每次请求都需要改写。
什么时候可以跳过改写 :
- 第一轮对话 :没有对话历史,不存在指代消解和上下文补全的需求。如果 query 本身够完整(iPhone 16 Pro 的退货政策是什么),直接检索就行
- query本身已经完整 :用户的问题明确包含了主体、动作和对象,不包含代词和省略
什么时候必须改写 :
- query 包含代词:它、这个、那个、上面的
- query 很短且缺少主体:还有吗?多少钱?怎么办?
- 多轮对话中的追问(非第一轮)
可以用一个简单的规则做预判断,减少不必要的 API 调用:
可以用一个简单的规则做预判断,减少不必要的 API 调用:
/**
* 判断是否需要 Query 改写
*/publicbooleanneedsRewrite(String query,List<Message> history){// 第一轮对话且 query 足够长,大概率不需要改写if(history.isEmpty()&& query.length()>15){returnfalse;}// 包含代词,需要改写if(query.matches(".*[它它的这个那个这些那些上面].+")){returntrue;}// query 太短,大概率省略了上下文if(query.length()<10&&!history.isEmpty()){returntrue;}// 有对话历史的情况下,默认都改写(安全起见)return!history.isEmpty();}
实际项目中,更稳的做法是:有对话历史就一律改写。改写 API 用小模型(如 Qwen2.5-7B-Instruct),成本很低,但可以避免因为规则没覆盖到而漏改的情况。
3. 改写在 RAG 流程中的位置
把 Query 改写加入后,多轮对话 RAG 的完整流程是这样的:

注意两个细节:
- 1.检索用改写后的query,但Prompt里放的是用户原始问题 。改写的目的是让检索更精准,不是改变用户的问题。模型生成答案时,应该针对用户的原始问题回答,而不是改写后的 query。保险点改写问题前后放进去也可以。
- 2.Query改写在会话记忆读取之后、检索之前 。这个位置很关键——改写需要对话历史作为输入,改写的结果用于检索
生产环境的注意事项
1. 改写质量的监控
Query 改写是一个容易默默出错的环节——改写结果不好,检索不到相关内容,模型给出一个兜底回答或者答非所问,用户可能只是觉得这个 AI 不够聪明,不会意识到是改写环节出了问题。
建议记录每次改写的完整信息:
{"session_id":"session-001","original_query":"那它的保修期呢?","rewritten_query":"iPhone 16 Pro 的保修期是多久?","history_length":2,"rewrite_latency_ms":320,"timestamp":"2025-03-07T10:30:00Z"}
定期抽检改写日志,关注几个指标:
- 改写率 :所有请求中触发了改写的比例。如果太低,可能是判断规则太严格,该改写的没改写
- 过度改写率 :人工标注后发现模型画蛇添足的比例。如果太高,需要调整 Prompt
- 改写后检索提升率 :对比改写前后的检索命中率(需要配合人工标注或自动化评测)
2. 改写失败的兜底
改写 API 可能因为网络超时、模型服务不可用、返回格式异常等原因失败。这时候应该用原始 query 兜底,而不是报错。
publicStringsafeRewrite(List<Message> history,String query){try{String rewritten =rewrite(history, query);// 基本校验:改写结果不能为空,不能太长if(rewritten !=null&&!rewritten.isEmpty()&& rewritten.length()<500){return rewritten;}}catch(Exception e){
log.warn("Query 改写失败,使用原始 query: {}", e.getMessage());}return query;// 兜底:返回原始 query}
Query 改写是锦上添花,不是雪中送炭。即使改写失败,用原始 query 检索也能有一定的效果(只是精度可能差一些)。千万不要因为改写失败就让整个 RAG 流程挂掉。
3. 改写缓存
同一个 session 内,用户可能重复提问或者问类似的问题。可以用一个简单的缓存避免重复调用改写 API:
// 缓存 key = sessionId + 原始 query 的哈希Map<String, String> rewriteCache =newConcurrentHashMap<>();publicStringrewriteWithCache(String sessionId,List<Message> history,String query)throwsIOException{String cacheKey = sessionId +":"+ query.hashCode();return rewriteCache.computeIfAbsent(cacheKey,
k ->{try{returnrewrite(history, query);}catch(IOException e){return query;}});}
注意:缓存的粒度要包含 sessionId,因为同样的 query 在不同的对话上下文中,改写结果可能不同。“价格呢?”在聊 iPhone 时改写成 iPhone 的价格,在聊 AirPods 时改写成 AirPods 的价格。

Comments NOTHING