用户问"差旅住宿标准是多少",系统答"一线城市 600 元/晚"。用户追问"那二线呢",系统答"请提供具体城市"。因为第二轮只把"那二线呢"当作检索查询,而这句话里没有任何可检索的关键词——追问的语义依赖上一轮,而检索只看当前这句。
这也解释了为什么"把历史全塞进去"并不能解决追问问题:检索器不看历史,它只看你给它的那句查询。所以历史的价值在于"生成更好的查询",而不在于"让模型看到更多"。
多轮 RAG 的第一个问题不是"上下文怎么塞",而是**"检索时该用哪句话"**。用原始追问去检索必然召回失败,因为代词与省略让查询失去了信息。
1. 三种上下文策略
| 策略 | 做法 |
|---|---|
| 全文拼接 | 把历史消息全部拼进查询 |
| 查询改写 | 用模型把追问改写成完整问题 |
| 结构化状态 | 维护槽位(主题、约束、已答项) |
第一项最省事,但会把噪声一起带进检索:历史里可能同时有"一线标准"与"报销流程"两个主题,拼接后检索会被拉向多个方向。第三项的"结构化"还有一个隐藏收益:它让"这轮该问什么"变得可计算。有了待答项,系统可以在用户沉默时主动提示"还需要二线/三线标准吗",而不是等一句模糊的追问。
第二项是主流做法:"那二线呢" → "二线城市差旅住宿标准是多少",改写后检索质量立刻改善。代价是多一次模型调用与改写错误的风险。
第三项最稳但要设计:把对话理解成"逐步填充的槽位",每轮只更新槽位,检索由槽位生成查询。
// 查询改写:把追问变成可检索的问题
REWRITE = """把用户的追问改写成独立、完整的问题。
要求:保留上一轮的主语与约束,只补全省略部分;不要新增信息。
上一轮问题:{prev_q}
本轮追问:{cur_q}
只输出改写后的问题,一行。"""
// "那二线呢" → "二线城市差旅住宿标准是多少"
2. 改写失败的三种形态
| 形态 | 后果 |
|---|---|
| 改写过度(加了不存在的信息) | 检索到错误资料 |
| 改写不足(仍含代词) | 检索为空 |
| 改写丢失约束 | 答案超出范围 |
改写的成本也要算:每轮多一次模型调用(通常 200–600ms),而多轮对话本就要求低延迟。所以实践中常见的折中是"简单追问用规则改写(补主题词),复杂追问才调模型"。
第一种最危险:模型会"补全"用户没说的条件,比如把"那二线呢"改写成"二线城市差旅住宿标准 2024 年版",而用户并没有限定年份。
第三种在多轮里很常见:上一轮的"仅限国内"这个约束在改写时被丢掉,于是答案里混进了海外标准。
// 改写的三道检查(写进提示或后置校验)
1. 改写后的问题里,代词是否全部消除
2. 上一轮的关键约束是否保留(数量、范围、时间)
3. 是否出现了原始对话里没有的新实体
// 任一条不通过 → 回退为"用槽位生成查询"或直接追问澄清
改写还有一种"零模型"的实现:规则拼接。用"上一轮主题 + 本轮追问"拼成查询,例如"差旅住宿标准 二线"。它的召回质量不如模型改写,但延迟几乎为零,且不会"补全没说过的东西"。
// 规则改写:无模型调用,不引入不存在的信息
def rewrite_rule(prev_topic: str, cur: str) -> str:
if any(p in cur for p in ("这", "那", "它")) or len(cur) < 8:
return f"{prev_topic} {cur}" # 补主题词,保留原追问
return cur # 已是完整问题,不处理
// 例:prev="差旅住宿标准" + cur="那二线呢" → "差旅住宿标准 那二线呢"
3. 槽位式状态的设计
| 槽位 | 例子 |
|---|---|
| 主题 | 差旅住宿标准 |
| 约束 | 国内、2024 年、普通员工 |
| 已答项 | 一线 600 元 |
| 待答项 | 二线、三线 |
槽位的好处是每轮只动一个槽,其余保持。槽位设计的关键是"哪些约束必须进槽":数量、范围、时间、身份这四类最容易在多轮里丢失,也最影响答案正确性。其余信息可以留在最近两轮消息里,不必结构化。
所以追问"那二线呢"只更新"待答项 → 二线",主题与约束不动,检索条件因此稳定。
代价是覆盖面:槽位是预先设计的,遇到没设计过的追问类型就会漏。实践中常见做法是"槽位优先,槽位未命中时回退到改写"。
// 槽位状态(随会话保存,序列化后很小)
session: s-7781
topic: travel_lodging_standard
constraints: { region: domestic, year: 2024, level: staff }
answered: { "一线": "600 元/晚" }
pending: ["二线", "三线"]
// 下一轮"那二线呢" → 直接按 topic+constraints+二线 检索,无需改写
4. 上下文长度与召回质量
| 现象 | 原因 |
|---|---|
| 越聊越跑题 | 上下文里旧主题残留 |
| 早期约束失效 | 被后来的信息挤出 |
| 答案重复 | 检索命中同一批片段 |
| 响应变慢 | 上下文变长,输入 token 增加 |
第一项(越聊越跑题)在拼接策略下最明显:旧主题的片段仍会被检索到,因为查询里还带着旧主题的词。所以拼接必须配"只保留最近两轮"的裁剪。
第二项是长对话的通病:关键约束在头部,而模型更关注尾部。所以槽位状态应当"每轮重写进末尾",而不是依赖历史消息里的某一句。
第四项要算成本:每轮把全部历史塞进去,token 成本线性增长,而收益在第五轮之后接近于零——因为真正有用的是槽位,不是逐句历史。
5. 追问与澄清的边界
| 情况 | 做法 |
|---|---|
| 追问有明确指代 | 改写或槽位补全 |
| 追问跳转新主题 | 重置槽位 |
| 追问含歧义 | 反问澄清 |
| 追问超出知识范围 | 拒答 + 建议 |
第三项要克制:反问是兜底手段,不是默认手段。第二项(跳转新主题)需要明确判定规则:新问题里不含上一轮的任何实体/约束时,视为新主题。否则会出现"把新问题硬塞进旧槽位"的错答。
频繁反问会让体验变成"审问",而多数追问其实可以通过上一轮的槽位推断。
第四项与拒答策略要一致:多轮里的拒答要给"下一步",比如"二线标准未收录,可查阅《差旅制度 2024》第 3 条附表"。
落地的状态存储也要想清楚:多轮状态放服务端会话还是随请求带上。前者便于服务端主动提示且不易被篡改,后者无状态、易横向扩展——但每次请求都带完整槽位,客户端可能不更新。
// 随请求携带的槽位(简短、可校验)
context:
topic: travel_lodging_standard
constraints: { region: domestic, year: 2024 }
answered: ["一线"]
turn: 2
# 服务端校验 turn 递增,防止重放
6. 排错:五种"多轮就变差"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 第二轮起召回为空 | 用原始追问检索 | 加查询改写 |
| 约束丢失 | 改写时丢条件 | 后置校验约束 |
| 答案与上轮矛盾 | 状态未维护 | 引入槽位 |
| 越聊越贵 | 全量历史进上下文 | 只保留槽位与最近两轮 |
| 同一问题反复答错 | 检索命中同一片段 | 加去重与轮次惩罚 |
第五项(反复答错)还有一个来源:上一轮的答案被当成了新的"事实"塞进上下文,于是模型在错误的答案上继续推理。所以"已答项"应当只用于避免重复,不应当作为事实来源。
第五项有个实用手段:对已用过的片段做"轮次惩罚",让下一轮优先检索新片段。否则同一批文档会被反复命中,答案永远一样。
7. 边界:什么情况不必做多轮
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单轮问答为主(<10% 追问) | 成本不划算 | 直接拒答"请重新表述" |
| 每轮都是独立查询 | 无上下文依赖 | 不维护状态 |
| 追问极少且明确 | 无需改写 | 直接检索 |
| 有更强的人工兜底 | 转人工更快 | 拒答 + 转人工 |
判断口径一句话:追问里有多少比例包含代词或省略。最后一条与"评测"有关的边界:多轮效果必须用多轮样本评。用单轮问题集测出来的指标,对多轮体验几乎没有预测力——因为失败几乎都发生在"第二轮起"。
比例高,就必须做改写或槽位;比例低(多数是完整新问题),多轮只会增加复杂度。
8. 自测
- 多轮 RAG 的第一个问题是什么?为什么用原始追问去检索必然召回失败?
- 三种上下文策略各有什么代价?"全文拼接"为什么会把检索拉向多个方向?
- 改写失败的三种形态里,哪两种最危险?"改写过度"会造成什么后果?
- 槽位式状态为什么能让检索条件稳定?它的覆盖面代价是什么?
- 为什么"每轮重写约束到末尾"比依赖历史消息更可靠?上下文变长的成本怎么算?
- 判断"是否需要做多轮"的那个问题是什么?