在企业 NL2SQL 的落地里,单轮问答的准确率往往不是最难的部分。真正决定它能不能被当成工具用的,是第二轮之后——用户不会每次都说完整,他会说"那这个月呢""按楼栋拆一下""算了,看看收缴率"。
这篇文章整理我们在多轮追问上的设计思路:怎么表示对话状态、怎么做指代消解、怎么裁剪上下文、什么时候必须反问而不是硬答。文中 YAML 与伪代码均为示意,用于说明结构,不是任何产品的源码。
一、多轮比单轮难在哪
把用户真实说出来的追问归个类,基本是三种:
| 追问类型 | 典型说法 | 单轮方案的失败方式 |
|---|---|---|
| 指代型 | "那这个月呢?" | 缺时间基准,模型只能猜一个日期 |
| 省略型 | "按楼栋拆一下" | 缺分组维度,模型复述上一轮结果 |
| 切换型 | "算了,看看收缴率" | 继承了上一轮的对象与过滤条件,两个问题混在一起算 |
三种失败的共同根源是同一件事:系统没有把"上一轮已经确认了什么"当成结构化数据保存下来,而是把历史对话原文一整段塞回给模型,让模型自己从自然语言里再理解一遍。
自然语言里再理解一遍,就意味着:同样的信息,第二次理解的结果可能和第一次不一样。
二、核心设计:显式槽位,而不是拼接历史
我们的做法是把对话状态从"文本"变成"结构"。每一轮结束后,把这一轮已经确认的查询要素提取成槽位(slots),下一轮只带槽位,不带原文。
# 对话状态(示意) session_id: s_20260928_001 turn: 3 slots: object: park_building_resource # 必须是语义层注册过的对象 metrics: [vacant_count] # 必须是语义层注册过的度量 dimensions: [building_no] filters: park_id: P001 status: VACANT time_range: 2026-08 confirmed: true # 口径是否已经与用户确认过 last_sql: SELECT ... # 上一轮真实执行的查询三个必须坚持的约束:
- 槽位里只能放语义层可识别的标识。对象的内部名、字段名、枚举值都来自语义层注册,模型不能自己创造一个槽位。做不到这一点,"槽位化"就退化成了另一种自由文本。
- 每轮重新构造提示词,输入 = 槽位快照 + 本轮问题 + 必要的规则,而不是 N 轮对话原文。这直接决定了 token 成本是否随轮次线性增长。
- 槽位快照可回放。有了结构化状态,任何一轮都可以复现——这在排查"为什么第四轮答错了"时非常关键。
三、指代消解:把代词映射到槽位,而不是让模型自由联想
指代消解在多轮里的正确姿势不是语义分析,而是最小补全:只有当本轮问题缺少最小必需槽位时,才用历史槽位回填;用户在本轮显式给出的要素,永远覆盖历史值。
# 示意:最小补全式回填 PRONOUN_MAP = {"它": "object", "这个": "object", "这些": "dimensions", "那": "time_range"} def fill_slots(new_query, slots): draft = parse(new_query) # 只解析本轮 for word, slot_key in PRONOUN_MAP.items(): if word in new_query and slot_key not in draft: draft[slot_key] = slots.get(slot_key) # 只在缺失时回填 for key, val in draft.items(): if val is not None: slots[key] = val # 显式值覆盖历史 return slots关键在最后两行:回填是兜底,覆盖是默认。如果用户说"那这个月呢",回填time_range,其他槽位保持;如果用户说"按楼栋拆一下,只看 1 号楼",filters被显式替换而不追加——否则过滤条件会一轮轮累积,最后查出来的结果和用户想的完全不是一回事。
四、上下文裁剪:全量历史是负债,不是保险
三种常见策略的对比:
| 策略 | token 成本 | 稳定性 | 可排查性 |
|---|---|---|---|
| 全量历史原文拼接 | 随轮次线性增长 | 差:旧口径残留、多意图混叠 | 差:无法定位是哪一轮带偏的 |
| 最近 N 轮原文 | 固定上限,但会截断关键约束 | 中:N 取小了丢约束,取大了仍有噪音 | 中 |
| 槽位化状态 + 最近一轮 | 基本恒定 | 好:约束显式、互斥可校验 | 好:状态可回放 |
我们选第三种。除了成本,更重要的是可控性:槽位是结构化的,所以可以在构造提示词之前做校验——比如"用户既要按楼栋分组、又要求明细不含楼栋"这种冲突,能在发模型之前就拦下来,而不是等答案出来再发现不对。
另外,裁剪掉的是原文,不是依据。上一轮真实执行的last_sql要保留,因为用户追问"这个数怎么来的"时,系统需要能指回具体那一次查询。
五、话题切换检测与槽位重置
多轮里最隐蔽的一类错误,是把"换了个问题"当成"继续上一个问题"。用户说"算了,看看收缴率"——对象从房源变成了账单,如果槽位没重置,park_id、building_no这些过滤条件会被错误继承。
我们的判定规则很朴素,按优先级依次检查:
- 本轮问题命中的语义层对象与当前槽位不一致 → 重置;
- 出现"另外 / 换个 / 重新 / 不看这个了"等切换意图词 → 重置;
- 本轮问题能独立成句(对象、度量、时间都齐全)且与当前槽位无交叠 → 倾向重置。
重置不是清空会话,而是清空继承关系:历史提问仍可回溯,但不参与本轮查询构造。
六、澄清策略:什么时候必须反问,而不是硬答
多轮能力成熟度的一个标志,是知道什么时候不该答。我们把澄清分成三档:
| 触发条件 | 动作 | 例子 |
|---|---|---|
| 有明确默认口径、且影响面小 | 静默取默认,在答案来源里标注所用口径 | 未指定时间 → 取当月 |
| 口径存在分支、且会影响结论 | 显式确认:给出可选口径,让用户选 | "在租"是否含空置待租 |
| 对象不唯一 / 时间缺失 / 指代无法解析 | 必须反问,一次只问一个关键项 | "你说的'这个'指园区还是楼栋?" |
第三档最容易被做坏。两个反方向都要避免:一是硬答(猜一个对象就开始查,返回一个看似合理的结果);二是反问过度(每一句都确认一次,用户很快就烦了)。
我们的经验是一次只问一个最关键项,并且把候选值列出来让用户点选,而不是让用户重新描述一遍。确认过的口径要写进槽位的confirmed标记,短时间内同类问题不再重复问。
七、一条完整的追问链路
用一个园区场景把上面几节串起来。用户连续四轮,槽位变化如下:
| 轮次 | 用户输入 | 槽位变化 | 系统动作 |
|---|---|---|---|
| 1 | 目前园区还有多少空置房源 | 新建:object=房源,metrics=空置数,time=当月 | 执行查询,返回三层答案 |
| 2 | 按楼栋拆一下 | 追加:dimensions=building_no | 复用其余槽位,仅改分组 |
| 3 | 只看 1 号楼 | 覆盖:filters.building_no=1(而非追加) | 复用,过滤条件替换 |
| 4 | 这些房源里合同到期的有多少 | 不重置:object 仍为房源,新增 metrics=合同到期数 | 跨对象取数,走关联 |
第 3 轮是"覆盖"而不是"追加",第 4 轮是"新增度量"而不是"重置"——这两个判断做错任意一个,用户拿到的数就是错的。
八、五个常见反模式
- 全量历史塞进提示词。看起来最省事,实际是成本与不稳定性的双输。多轮一长,规则和旧约束互相干扰,出错了也查不出是哪一轮带偏的。
- 槽位用自然语言存。存成"用户想看1号楼空置的房子",下一轮还得再理解一遍——等于没做槽位化。
- 把追问当重新提问。每轮都从零解析,用户就得像第一次一样把条件说全,多轮体验直接消失。
- 没有话题重置。切换问题后旧过滤条件仍在生效,这是最难被用户发现、也最容易导致错误决策的一类 Bug。
- 反问过度。把澄清做成"什么都问一遍",用户体验比答错还差。
九、小结:多轮设计的检查清单
| 检查项 | 达标标准 |
|---|---|
| 对话状态 | 结构化槽位,字段来自语义层注册 |
| 提示词构造 | 槽位快照 + 本轮问题,不拼接 N 轮原文 |
| 回填规则 | 缺失才回填,显式值覆盖历史 |
| 话题切换 | 有明确的重置判定规则 |
| 冲突检测 | 发模型之前做槽位冲突校验 |
| 澄清策略 | 三档分级,一次只问一个关键项 |
| 可回溯 | 任一槽位快照可回放,last_sql 可查 |
如果说单轮问答证明的是"模型能不能听懂一句话",那多轮追问证明的是"它能不能被当成工具用一整天"。前者是能力演示,后者才是工程。
在多轮这件事上,"答得快"永远排在"答得准、答得可核对"之后。
文中产品截图取自演示环境,案例数据仅作示意;YAML 与伪代码用于说明设计结构,非实际源码。欢迎在评论区交流你们在多轮追问与上下文管理上的做法与踩过的坑。