多轮对话:用户说「那它呢」的时候,AI 得听懂
约 2375 字大约 8 分钟
2026-09-28
到目前为止,我们的助手是「金鱼模式」——每次提问都是全新开始,前一句聊了什么一概不知。问答系统这么凑合还行,对话系统不行。今天把记忆装上,顺便处理一个和它纠缠在一起、但很多教程没分开讲的问题。本篇对应 relax-admin 的「会话洞察 → 多轮改写」页——改写质量好不好,那一页见。
先看两个不同的毛病,别搞混了
多轮场景下有两类「听不懂」,根子完全不同,药也不同:
毛病一:检索听不懂。 用户先问「Redis 分布式锁怎么实现」,接着问「那它超时了怎么办」。如果拿「那它超时了怎么办」这句话原样去检索——上一章刚说过,Embedding 对这种指代句毫无办法,「它」在语义空间里是个空洞。检索歪了,后面全歪。这病病在检索入口。
毛病二:生成不知道前情。 就算检索修好了,把「锁超时的处理」相关段落捞回来了,如果模型完全不知道用户在聊 Redis 锁(上下文里没有第一轮对话),它可能按「Java 的锁」来组织回答。这病病在生成上下文。
所以正确的设计是两条独立通道,各治各的:
检索通道:原始问题 → 改写成独立完整的问题 → 去查库(不带历史)
生成通道:改写后的问题 + 裁剪后的对话历史 + 检索证据 → 生成回答一个反直觉的点
历史对话本身不要直接参与向量检索。「那它超时了怎么办」和第一轮问题拼一起算向量,是向量污染,不是改写。历史只在两处出场:给改写模型当参考、给生成模型当前情。两条通道,一个管「查什么」,一个管「知道什么聊过」。
检索通道:查询改写
做法直白:在检索之前,加一步「让小模型把用户当前这句话改写成独立完整的问句」。
Prompt 大概这个意思:
你是查询改写器。根据对话历史,把用户的最新问题改写成一个
不依赖上下文也能看懂的独立问题。只输出改写后的问题。
历史:
用户:Redis 分布式锁怎么实现?
助手:可以通过 SET NX EX 命令……
最新问题:那它超时了怎么办?
改写输出:Redis 分布式锁超时了怎么处理?「那它超时了怎么办」变成了「Redis 分布式锁超时了怎么处理」——这句话拿去检索,向量就锋利了。
几个工程细节,都是要提前想到的:
- 用小而快的模型做这事。 改写是简单任务,输出就一句话,别用旗舰模型,延迟和成本都浪费。输出限制
max_tokens(比如 100),防止它啰嗦 - 超时和降级是生命线。 改写是旁路增强,挂了不能拖垮主链路:超时 500 毫秒没回来,直接拿原句去检索。宁可偶尔检索歪一点,不能偶尔卡死两秒
- 只在需要时改写。 第一轮对话没有历史,跳过改写省一次调用。判断很简单:有历史才改写
老规矩,rag.rewrite.enabled 开关伺候。
进阶玩法:HyDE
让模型先凭空编一个假答案,拿假答案的向量去检索真文档(HyDE,假设文档嵌入)。原理是「问题和文档的向量分布天然有鸿沟(一个口语一个书面),文档和文档之间的距离更近」。对问法千奇百怪的冷门领域有奇效,但假答案跑偏会把检索带沟里。博客场景预计收益一般,知道有这招,遇到「用户问法太野」的场景再掏出来。
生成通道:历史裁剪
生成端要喂历史,但历史是个单调增长的东西,放着不管会出三种事:撑爆上下文窗口(报错)、撑爆预算(账单)、以及最隐蔽的第三种——撑爆信噪比:陈年上下文稀释了模型对当前问题的注意力,回答质量反而下降。「历史不是越多越好」这事挺反直觉,但真的。
所以要有裁剪策略,从简到繁四档:
第一档:窗口截断。 只保留最近 N 轮(比如 6 轮)。Java AI 的 MessageWindowChatMemory 现成可用,博客助手用这档就够了。它的短板是「重要的老信息被截掉」——用户第六轮说过「我用的是 Java 17」,第十轮问语法问题时模型已经忘了。
第二档:摘要压缩。 老历史滚动摘要成一段,近几轮保留原文。信息保住了,实现复杂度上来了,长会话场景才值得。
第三档:检索式记忆。 历史也进向量库,按当前问题检索相关历史片段——用 RAG 的思想做记忆本身。跨会话的「记住我」功能会用到它,目前我们用不上。
第四档:槽位记忆。 从对话里抽取结构化事实(用户的技术栈、版本、身份)单独存,每轮固定注入。客服类场景神器,博客场景可选。
我的选型:窗口截断起步,留两个心眼。心眼一:每轮消息落库时记下 token 数,将来好统计和裁剪(这数据也是第 15 篇会话洞察的原料);心眼二:架构上把「历史从哪来」抽象成 ChatMemory 接口,将来升级摘要式不用动业务代码。
还有个容易忽略的省钱细节:
别为过期信息付费
喂给模型的历史只要用户和助手的消息,不要喂上一轮的检索证据。证据是给上一轮那道题用的,占 token 大头且基本无用,留着它等于每轮都在为过期信息买单。常见浪费点,画重点。
串起来:完整的对话服务
改造后的主流程:
public Flux<...> chat(String sessionId, String question) {
var history = chatMemory.get(sessionId, 6); // 取历史
var rewritten = rewriteService // 需要才改写
.rewrite(history, question);
var evidence = retrievalService // 用改写后的问题检索
.retrieve(rewritten);
var answer = chatClient.prompt()
.system(系统纪律) // 12 篇细讲
.users(historyText) // 裁剪后的历史
.user(evidenceText + rewritten) // 证据 + 问题
.call().content();
chatMemory.append(sessionId, question, answer); // 记住本轮
return answer;
}看一眼就能发现和之前单轮版的差别:入口多了 sessionId,中间多了改写,生成多了历史,尾巴多了记忆。骨架依然朴素,但「对话感」就是从这四个增量来的。
验收:三连问测试
写完别急着收工,跑一个固定的三连问脚本,这是多轮功能的照妖镜:
1. Redis 分布式锁怎么实现? ← 基础题,考验检索
2. 那它超时了怎么办? ← 指代消解,考验改写
3. 除了这个还有别的办法吗? ← 更模糊的指代 + 意图第二个问题答对,说明改写通道工作了;第三个问题答对且不跑题,说明历史通道也工作了。同时把改写前后的问句都打进日志——日志里「那它超时了怎么办 → Redis 分布式锁超时了怎么处理」这一行,是整个功能最踏实的证据。这些日志后面排查 badcase 时也是第一现场。
而这份「最踏实的证据」,在 relax-admin 里的展示位就是「会话洞察 → 多轮改写」页:每条多轮对话一行,左边原始问题、右边改写后的问题,对照着看改写模型的翻译水平;再配上改写后检索命中的切片,改写好不好直接反映在命中质量上。改写是个黑盒动作,这一页的价值就是把它拉到亮处——改写跑偏(比如把「那它呢」改写成了不相关的方向)在这一页一眼就能抓出来,比从最终答案倒推方便十倍。
📷 图片位(待补充)
应放什么图:「会话洞察 → 多轮改写」页截图,展示三连问的改写对照——原始问题「那它超时了怎么办」→ 改写后「Redis 分布式锁超时了怎么处理」,旁边带上命中切片的标题路径,体现改写 → 命中的因果链。
今天的落地清单
到这里,「对话系统」的骨架齐了。但有个幽灵一直悬在头顶:模型万一一本正经地胡说八道怎么办?下一篇正面刚幻觉——从 Prompt 纪律到输出校验的五层防御,以及「每个答案都能查户口」的引用归因怎么做。那是这个系列我觉得最「值回票价」的一篇。
下篇见。