27 对话历史:让多轮 RAG 正确理解追问
对话历史:让多轮 RAG 正确理解追问
对话历史的核心不是把所有聊天记录不断塞给模型,而是保存完整会话、选择必要上下文,并把依赖上文的追问改写成可以独立检索的问题。
[TOC]
1. 本节目标
单轮 RAG 只能处理相对独立的问题:
上传文件最大是多少?
真实用户会继续追问:
那超过以后返回什么错误码?
这个限制管理员也一样吗?
刚才提到的配置在哪里修改?
这些问题包含:
那
这个限制
刚才提到的配置
如果直接向量化,检索系统不知道具体指什么。
完成本节后,应能够:
设计conversation和message表
区分原始问题和独立检索问题
使用最近历史改写追问
控制历史消息的token预算
设计短期窗口和长期摘要
保存回答来源和模型用量
处理并发消息和消息顺序
在权限变化后重新校验旧来源
设计会话删除、保留和审计
2. 多轮 RAG 流程
graph TD;
A["用户提交追问"] --> B["读取当前会话最近历史"];
B --> C{"问题是否依赖历史?"};
C -- "否" --> D["直接作为检索问题"];
C -- "是" --> E["改写为独立问题"];
D --> F["生成问题向量"];
E --> F; F --> G["按当前权限检索 chunks"]; G --> H["组装历史摘要和检索上下文"];
H --> I["大模型生成回答"];
I --> J["保存用户消息、回答和来源"];
关键点:
用户展示的问题 = originalQuestion用于检索的问题 = standaloneQuestion```
两者必须同时保留,方便调试问题改写是否正确。
---
## 3. 为什么不能直接塞全部历史
无限追加消息会导致:
```text
输入token持续增长
模型调用费用增加
响应延迟增加
旧话题干扰新问题
错误回答在后续被反复引用
历史中的旧权限内容继续进入Prompt
达到模型上下文上限
例如一段 100 轮对话中,当前问题只依赖最近两轮。
合理策略:
数据库保存完整历史
模型只读取必要窗口
长会话生成摘要
检索前按需改写问题
事实答案仍以最新检索资料为准
4. 会话表设计
CREATE TABLE rag_conversation (
id uuid PRIMARY KEY, tenant_id varchar(64) NOT NULL, user_id varchar(64) NOT NULL, knowledge_base_id uuid NOT NULL, title varchar(255), summary text, summary_message_sequence bigint NOT NULL DEFAULT 0, status varchar(32) NOT NULL DEFAULT 'ACTIVE', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now());
字段说明:
| 字段 | 作用 |
|---|---|
tenant_id |
租户隔离 |
user_id |
会话所有者 |
knowledge_base_id |
默认检索知识库 |
title |
会话列表展示 |
summary |
长会话压缩摘要 |
summary_message_sequence |
摘要覆盖到哪条消息 |
status |
ACTIVE、ARCHIVED、DELETED |
会话必须绑定租户和用户。
客户端知道 conversationId 不代表有权读取该会话。
5. 消息表设计
CREATE TABLE rag_message (
id uuid PRIMARY KEY, conversation_id uuid NOT NULL, sequence_no bigint NOT NULL, role varchar(32) NOT NULL, content text NOT NULL, standalone_question text, sources jsonb, model varchar(128), prompt_tokens integer, completion_tokens integer, trace_id varchar(128), created_at timestamptz NOT NULL DEFAULT now(), CONSTRAINT rag_message_conversation_fk FOREIGN KEY (conversation_id) REFERENCES rag_conversation(id) ON DELETE CASCADE, CONSTRAINT rag_message_sequence_uk UNIQUE (conversation_id, sequence_no), CONSTRAINT rag_message_role_ck CHECK (role IN ('USER', 'ASSISTANT', 'SYSTEM')));
建议保存:
用户原始问题:content
改写后的检索问题:standalone_question
模型回答:ASSISTANT消息的content
当时使用的sources快照
模型名和token用量
traceId
5.1 为什么需要 sequence_no
仅按 created_at 排序可能遇到:
同一毫秒写入多条消息
不同服务器时钟差异
并发请求导致顺序不明确
会话内递增的 sequence_no 更适合表达严格消息顺序。
6. 问题改写
6.1 示例
历史:
用户:上传文件最大是多少?
助手:单个上传文件最大为20MB。
用户:那超过以后返回什么错误码?
改写:
上传文件超过20MB限制后返回什么错误码?
改写后的问题用于:
Embedding
向量检索
关键词检索
Rerank
原始问题用于:
用户界面
消息历史
审计
最终回答语境
6.2 改写 Prompt
请根据最近对话,把用户最新问题改写成一个不依赖上下文、可以独立检索的问题。
要求:
1. 保留用户原意。
2. 不要回答问题。
3. 不要添加对话中不存在的事实。
4. 不要改变知识库或权限范围。
5. 如果问题已经独立,原样返回。
输入:
最近对话:
{recentMessages}
最新问题:
{question}
输出最好使用结构化格式:
{
"requiresHistory": true, "standaloneQuestion": "上传文件超过20MB限制后返回什么错误码?"
}
7. 是否每轮都要改写
不一定。
独立问题:
什么是RAG?
PostgreSQL如何创建HNSW索引?
可以直接检索。
依赖问题:
那它有什么缺点?
这个配置在哪里改?
第二种方案呢?
需要历史改写。
第一版也可以每轮都调用改写模型,逻辑简单但会增加一次模型调用。
后续可以使用规则快速判断:
是否出现指代词
问题是否过短
是否包含“刚才、上面、第二个、它、这个”等表达
规则只能做优化,不能保证完全准确。
8. 历史窗口
第一版可以读取最近:
3到5轮问答
一轮通常包括:
一条USER消息
一条ASSISTANT消息
不要简单按最近 10 条数据库记录截取,而应考虑:
消息角色是否完整
是否包含失败回答
是否包含系统事件
总token是否超限
窗口选择伪代码:
List<Message> selected = new ArrayList<>();
int usedTokens = 0;
for (Message message : messagesFromNewestToOldest) {
int tokens = estimateTokens(message.content()); if (usedTokens + tokens > historyTokenBudget) { break; } selected.add(message); usedTokens += tokens;}
Collections.reverse(selected);
9. Token 预算
一次多轮 RAG 调用包含:
System Prompt
会话摘要
最近消息
检索上下文
当前问题
输出预留空间
推荐按优先级分配:
系统规则:必须保留
当前问题:必须保留
检索证据:高优先级
最近相关历史:中优先级
长期摘要:按需
旧消息:优先删除
不要因为历史太长而截掉真正的检索证据。
10. 会话摘要
当历史超过阈值时,可以把较早消息压缩成摘要:
当前话题
用户已经明确的约束
已经确认的事实
尚未解决的问题
示例:
用户正在询问文件上传限制。知识库资料表明单个文件上限为20MB,
超过限制会返回ERR_FILE_TOO_LARGE。用户尚未询问批量上传总容量。
摘要注意事项:
摘要是模型生成内容,可能出错
事实问题仍应重新检索知识库
摘要不能绕过当前文档权限
摘要要记录覆盖到哪个sequence_no
摘要更新流程:
graph TD;
A["消息超过历史预算"] --> B["读取旧摘要"];
B --> C["选择待压缩的旧消息"];
C --> D["生成新摘要"];
D --> E["保存summary"];
E --> F["更新summary_message_sequence"];
F --> G["Prompt只使用摘要和最近窗口"];
11. 事实来源优先级
多轮对话中可能同时存在:
知识库原文
历史助手回答
历史摘要
用户陈述
事实优先级应明确:
当前已授权知识库证据 > 用户明确提供的信息 > 历史摘要 > 旧助手回答
旧助手回答不能自动成为新的可靠知识。
如果上一轮答错,把它原样加入下一轮 Prompt,错误会被放大。
12. 保存时机
推荐顺序:
1. 校验会话权限
2. 保存USER消息
3. 改写检索问题
4. 执行RAG问答
5. 保存ASSISTANT消息和sources
6. 更新conversation.updated_at
模型调用失败时:
USER消息可以保留
可以增加FAILED状态或错误事件
不要保存一条伪造的正常ASSISTANT回答
允许用户重试
也可以先生成成功后一次事务保存本轮问答,但用户消息在失败时会丢失。两种方案需要按产品体验选择。
13. 并发与重复提交
同一会话可能发生:
用户连续点击发送
浏览器重试
两个标签页同时提问
网络超时后客户端重复提交
建议请求携带:
clientMessageId
建立唯一约束:
conversationId + clientMessageId
处理重复请求时返回原结果或当前处理状态,避免重复调用模型。
同一会话是否允许并发生成也要明确:
简单方案:同一会话串行处理
复杂方案:建立分支消息和父消息关系
第一版建议串行,保证历史顺序清晰。
14. 权限和隔离
读取会话时必须满足:
conversation.tenant_id = 当前tenantId
conversation.user_id = 当前userId
conversation.status != DELETED
会话中的来源在每次使用时重新鉴权。
不能因为:
用户以前看过该文档
旧答案中包含该片段
摘要中保存了该事实
就绕过当前权限。
权限撤销后,问题改写也不应把旧无权内容扩写进新的检索问题。
15. 会话 API
可以设计:
POST /conversations
GET /conversations
GET /conversations/{id}
POST /conversations/{id}/messages
DELETE /conversations/{id}
PATCH /conversations/{id}/title
发送消息请求:
{
"clientMessageId": "client_msg_001", "question": "那超过以后返回什么错误码?",
"stream": true}
响应:
{
"conversationId": "conv_001", "userMessageId": "msg_010", "assistantMessageId": "msg_011", "originalQuestion": "那超过以后返回什么错误码?",
"standaloneQuestion": "上传文件超过20MB限制后返回什么错误码?",
"answer": "超过限制时返回 ERR_FILE_TOO_LARGE [C1]。",
"sources": [], "traceId": "trace_xxx"}
16. Chat Memory 框架与业务历史
Spring AI、LangGraph 等框架都提供 Chat Memory 能力。
需要区分:
模型记忆:为了给本次模型调用提供上下文
业务会话历史:为了展示、审计、删除、权限和统计
框架内存不能自动替代业务消息表。
业务系统通常仍需要自己保存:
消息ID
用户和租户
来源
token用量
反馈
删除状态
traceId
框架可以帮助选择和注入消息,但数据生命周期仍由业务系统负责。
17. 删除与保留
需要定义:
用户能否删除会话
删除是软删除还是物理删除
日志和审计保留多久
来源快照是否一起删除
敏感信息如何脱敏
备份中的数据如何到期清理
软删除示例:
UPDATE rag_conversation
SET status = 'DELETED',
updated_at = now()WHERE id = :conversation_id
AND tenant_id = :tenant_id AND user_id = :user_id;
软删除后普通查询必须排除 DELETED。
18. 可观测性
每轮建议记录:
conversationId
userMessageId
assistantMessageId
originalQuestion摘要
standaloneQuestion摘要
使用的历史消息sequence范围
使用的summary版本
检索chunkId
模型和token用量
总耗时
排查多轮问题时重点检查:
问题是否需要改写
改写结果是否改变原意
历史窗口是否缺少关键消息
旧错误答案是否污染上下文
权限变化是否及时生效
19. 测试设计
19.1 独立问题
问题本身完整
期望:standaloneQuestion与原问题一致
19.2 指代追问
上一轮讨论上传限制
追问“那错误码是什么”
期望:改写包含上传限制语境
19.3 话题切换
前面讨论上传
新问题问PostgreSQL索引
期望:不被旧话题污染
19.4 长历史
消息超过预算
期望:旧消息被摘要
期望:当前问题和检索上下文不被截掉
19.5 权限撤销
旧消息含某文档来源
撤销权限后继续追问
期望:旧来源不进入新Prompt
19.6 重复提交
相同clientMessageId提交两次
期望:只生成一次回答
20. 常见问题
20.1 最近几轮取多少
第一版可以取 3 到 5 轮,再根据 token 和评测调整。
20.2 每轮都需要会话摘要吗
不需要。
只有历史超过预算或达到消息阈值时再更新摘要。
20.3 用户说的内容能当知识库事实吗
不能默认当作可信事实。
可以用于理解当前对话,但企业知识问答仍应以授权资料为依据。
20.4 对话标题怎么生成
第一版可以截取第一条问题。
后续可以异步生成短标题,不要阻塞第一次问答。
20.5 多轮对话一定要使用 Agent 吗
不需要。
固定的“历史读取、问题改写、检索、生成”链路就能实现可靠的多轮 RAG。
21. 练习清单
创建conversation和message表
保存originalQuestion和standaloneQuestion
实现最近3到5轮历史读取
编写问题改写Prompt
控制历史和检索上下文token预算
实现长会话摘要
保存sources、model、token和traceId
使用clientMessageId防止重复提交
完成会话租户和用户隔离
测试指代追问、话题切换、长历史和权限撤销
22. 小结
多轮 RAG 链路:
original question
-> recent history -> standalone question -> retrieval -> authorized context -> answer -> persisted messages```
本节最重要的结论:
> 数据库可以保存完整历史,但模型只应该看到与当前问题相关、在预算内并且仍然有权限使用的上下文。
## 23. 参考资料
- [Spring AI Chat Memory](https://docs.spring.io/spring-ai/reference/api/chat-memory.html)
- [LangGraph Memory](https://docs.langchain.com/oss/python/langgraph/add-memory)
- [Spring AI ChatClient](https://docs.spring.io/spring-ai/reference/api/chatclient.html)