第 10 章:多轮对话状态管理:延续与脱离
10.1 工程问题的本质:对话不是单轮问答
真实业务对话是多轮的:「我要报销」之后跟着「500元」「差旅」「出差参加客户现场验收」。系统必须正确回答两个问题:这一轮还是上一轮的事吗? 若是,则延续状态并收集信息;若否,则脱离旧状态开始新任务。
设计原理: 对话状态管理的本质是记忆边界的管理——系统需要记得"进行到哪一步、已收集什么、还缺什么",同时要能识别"用户已转向新话题"。这两个能力缺一不可:只记得不转向,会吞掉新意图;只转向不记得,则流程无法推进。状态管理的质量,最终体现为"多少用户输入被错误地延续"与"多少补充信息被错误地打断"两个错误率的权衡。
10.2 状态记忆:跨轮的延续对象
框架以跨轮状态对象保存「业务意图 + 已收集槽位 + 流程实例引用」:
第 1 轮:用户「我要报销」 → 启动流程,挂起状态
第 2 轮:用户「500元」 → 补充信息 → 沿用状态 → 字段收集
第 3 轮:用户「差旅」 → 同上
第 4 轮:用户「出差参加客户现场验收」→ 同上 → 字段集齐 → 提交审批
原理: 状态的挂起与清除时机决定对话质量。挂起过宽(任务完成仍遗留状态)会吞掉下一轮新查询;清除过早(用户还在补充信息)则流程无法推进。框架的两个关键决策:只读查询执行完毕不遗留状态;Agent 向用户索要参数时才挂起。
10.3 延续与脱离的判定:零延迟收敛与发散
核心判定函数回答"这一轮是不是上一轮的事":输入是纯槽位值(产品码/数量/订单号/金额)→ 补充信息,延续(收敛);输入含明确业务名词(库存/订单/排产)→ 新业务话题,脱离(发散)。
原理: 收敛必须零延迟——补充信息是高频、确定、无歧义的,不值得走模型识别(曾因补充信息匹配失败触发模型语义识别,导致数秒延迟进而引发浏览器超时);发散必须走强模型——新话题的识别需要语义能力。这一"规则收敛 + 模型发散"的分工,是第 9 章"规则优先、模型兜底"在多轮场景的延伸。
字段收集中的特殊形态进一步体现了"收敛"的边界:流程字段收集进行中,输入只要能提取到当前流程必填字段(即使被误识别为显式业务意图,如"事由:出差参加客户现场验收"含"客户"),仍沿用收集器消化。字段收集时,输入优先被当作字段值解读——这是对"用户正在配合流程"这一情境的信任。
把"延续/脱离"的判定画成状态机,可看清挂起与清除的确切时机,如图 10-1 所示:
图 10-1 对话状态的延续与脱离(挂起/清除时机)
stateDiagram-v2
[*] --> 空闲
空闲 --> 待补状态: 写意图缺参 → 挂起
空闲 --> 识别中: 新输入
待补状态 --> 待补状态: 纯槽位值 → 延续(规则收敛,零延迟)
待补状态 --> 识别中: 含业务名词 → 脱离(走强模型)
待补状态 --> 空闲: 字段集齐 → 提交审批并清除
识别中 --> 待补状态: 写意图缺参 → 挂起
识别中 --> 空闲: 只读查询完成(不遗留状态)
原理: 状态机的两个出边方向对应两条判定路径——自环(待补状态 → 待补状态)是"收敛":纯槽位值直接消化,走规则匹配、零延迟;跳转到识别中是"发散":出现明确业务名词即视为新话题,交给强模型重新识别。
两个"回空闲"的迁移则定义了清除时机:只读查询执行完毕即清除(不留状态,避免吞掉下一轮新查询),写流程则要等字段集齐、提交审批后才清除。挂起与清除都是显式迁移而非隐式副作用,这正是 10.2 节"状态记忆是延续的对象"能被测试、能被推理的前提。
10.4 复合句拆解:一句话多个任务
一句话包含多个流程时("先查排班再下单 100 套""看库存够不够,够的话下单"),拆解为子任务执行:仅对显式标记拆解(顺序式/条件式/分析→动作链),普通连词不拆(保守原则防误拆);子任务逐一带授权过滤;有序链串行(后段注入前段结果摘要),其余并行;未授权子任务显式报告,不静默忽略。
原理: 拆解是"把用户的复合意图还原为系统可独立裁决的原子动作"。保守拆解原则对应一个判断:误拆的代价高于漏拆。漏拆(当作单任务处理)至多是能力不足,误拆(把一句话切成语义错误的片段)会产生错误执行。授权过滤则保证每个原子动作都在权限边界内——多跳不等于越权。
10.5 对话状态的形式化与会话持久化
10.5.1 状态的形式化:belief state
对话状态管理可以形式化为信念状态(belief state):系统对"对话进行到哪一步、已收集哪些槽位值"的信念集合。每一轮输入是信念状态上的一次更新:
belief_state = {intent: 报销, slots: {amount: 500, expense_type: 差旅, reason: 待补}, flow: 字段收集}
输入「出差参加客户现场验收」
→ 槽位提取 {reason: ...}
→ 更新 belief_state.slots.reason
→ 校验必填字段齐备
→ 状态迁移:字段收集 → 待审批
形式化的价值有三:状态可枚举(任何时刻系统处于哪个阶段可判定)、迁移可定义(什么输入触发什么更新)、缺失可检测(哪些必填槽位为空可计算)。缺乏形式化时,状态管理退化为"把东西塞进字典"——看似自由,实则无法验证。
10.5.2 状态持久化与恢复
跨轮状态若只存于进程内存,进程重启即丢失——用户刷新页面或服务重启后,对话"失忆"。状态持久化需要两个能力,见表 10-1:
表 10-1 对话状态的持久化能力
| 能力 | 机制 | 语义 |
|---|---|---|
| 持久化 | 状态序列化写入会话存储(Redis 注入/内存降级) | 重启后按会话 ID 恢复 |
| 恢复 | 按会话 ID 加载状态 + 历史 | 多端/多轮延续 |
原理: 状态持久化与记忆管理的分层(第 15 章)一致:短期记忆需要可恢复性,长期记忆需要可检索性。持久化不改变状态的语义,只改变状态的存续范围——会话内有效,但跨重启仍可恢复。
10.5.3 状态与并发的互斥
同一会话的多轮请求若并发到达(用户连发两条),状态更新必须互斥——否则两个请求各自读取旧状态、各自写回,后写覆盖先写,丢失一次更新。状态更新的读-改-写必须原子化(加锁或串行化),这是第 17 章并发正确性在状态管理层的具体形态[38]。
10.6 失败模式与权衡
表 10-2 多轮对话状态的失败模式与权衡
| 失败形态 | 根因 | 设计应对 |
|---|---|---|
| 查询完成遗留状态吞掉新查询 | 查询意图挂起 pending | 只读查询不挂起 |
| 补充信息触发模型识别延迟 | 每轮补充都走 LLM | 纯槽位值规则收敛、零 LLM |
| 字段值被误判为新话题 | 字段含业务名词(如"客户") | 字段收集期优先按字段值解读 |
| 复合句被错误拆解 | 连词过度拆解 | 仅显式标记拆解 + LLM 校正(防幻觉校验) |
权衡: 延续与脱离的判定是一个"持续校准"的过程——收敛过度的版本会吞掉用户明确的新意图,发散过度的版本会打断字段收集。框架通过可训练的业务名词表与槽位形态规则,把校准权交给业务侧,而非固化在代码中。
10.7 操作语义的子意图维度:跨轮的写/读判别
多轮延续时,补充轮常只带槽位值("AP-1001"、"原值 50 万"、"设备 CPA-9005"),单看当轮文本无法判断它是操作还是查询。
框架为这类"动作建立在延续语义上"的场景引入子意图维度:在意图名下细分业务动作(如固定资产的 asset_query 只读台账 / asset_write 登记·折旧),并把"这一轮是写还是读"的判别锚定在已延续的子意图上,而非用当轮关键词重新推导。
设计原理: 判别源必须与处理分支同源。若用当轮原文重算操作类型,补充轮的槽位文本会丢失操作语义——轻则走错分支(付费执行→应付查询),重则跳过权限门造成授权绕过。因此操作类型的承载是"子意图(跨轮状态)+ 判别谓词(同一来源)",且默认 fail-closed:非查询即写。
域隔离同样重要:子意图维度只在所属意图下解析(按意图名分支),避免其他意图文本提及"固定资产"时被该维度抢占;维度定义置于 agent 子意图表末尾,规避与既有子意图关键词冲突,并可从 business_rules(SUB-INTENT-DEFS) 可训练覆盖/新增。
失败防范: 操作类型判别退化为"当轮关键词重算",是在多轮护栏上开洞——这是权限门绕过的典型入口,必须由"子意图维度 + 跨轮携带"二者共同锚定,任一缺失都应视为回归。
10.8 实现剖析:三层记忆、递归意图状态与延续判定
本节从 runtime/conversation_memory.py(403 行)与 runtime/pending_state.py(262 行)摘取三段,印证 10.2 节的跨轮记忆、10.5 节的状态形式化与 10.3 节的延续/脱离判定。
10.8.1 三层记忆:轮次窗口 + 语义摘要 + 向量长期
def add_turn(self, user_input, reply, intent="", agent=""):
"""添加一轮对话到记忆"""
self.total_turns += 1
turn = {
"input": user_input[:MAX_INPUT_CHARS], # 截断,防单轮撑爆窗口
"reply": reply[:MAX_REPLY_CHARS],
"intent": intent, "agent": agent, "turn": self.total_turns,
}
self.turns.append(turn)
if intent:
self.last_intent = intent
self._update_intent_state(intent, agent) # 递归意图状态
if self.embedding_provider: # 向量长期记忆(异步落盘)
_MEMORY_BG_QUEUE.put((self, "embed", (user_input, reply)))
while len(self.turns) > MAX_TURNS: # 超窗压缩最旧轮次
old = self.turns.pop(0)
if self.llm_client is not None:
_MEMORY_BG_QUEUE.put((self, "compress", old)) # LLM 语义摘要
else:
self._compress_to_summary(old) # 降级:规则式摘要
def _compress_to_summary(self, turn):
"""将旧轮次压缩到摘要:优先 LLM 语义摘要,不可用时规则式"""
llm_summary = self._llm_summarize(turn) if self.llm_client else None
if llm_summary:
entry = llm_summary.strip()[:120]
else:
entry = f"T{turn['turn']} [{turn.get('intent', '?')}] " \
f"{turn['input'][:60]}->{turn['reply'][:60]}"
with self._bg_lock: # 与主链路读互斥
self.summary = (self.summary + "\n" + entry)[-MAX_SUMMARY_CHARS:]
这段代码印证 10.2 节与 15.2 节:记忆是"分层信度"而非"全部保留"。最近 MAX_TURNS 轮保留原文(高信度、高成本);更早轮次压缩为摘要(中信度、低成本),LLM 不可用时降级为规则式摘要(保意图与首句,弃细节);被压缩掉的对话仍以向量形式进入长期记忆(低信度、可检索)。
每层都有"容量上限 + 截断",保证上下文成本有界。异步落盘(_MEMORY_BG_QUEUE)把 embedding 与 LLM 摘要移出主链路,避免网络调用拖慢响应(第 17 章热路径优化)。
10.8.2 递归意图状态:轻量的跨轮轨迹
def _update_intent_state(self, intent, agent):
"""每轮增量更新意图状态(轻量级,无 LLM 调用)
格式:T1:query_qc->质检Agent | T2:create_order->销售Agent
"""
entry = f"T{self.total_turns}:{intent or '?'}->{agent or '?'}"
if self.intent_state:
self.intent_state = self.intent_state + " | " + entry
else:
self.intent_state = entry
if len(self.intent_state) > MAX_INTENT_STATE_CHARS:
self.intent_state = self.intent_state[-MAX_INTENT_STATE_CHARS:]
这段代码印证 10.5 节:对话状态的形式化不必复杂,关键是"可增量、可截断、零成本"。intent_state 用固定格式记录"第几轮 → 什么意图 → 交给哪个智能体",只做字符串追加与尾部截断,不调用 LLM。它是后续轮次判断"话题是否漂移"的轻量依据——比重新读全量历史便宜得多,也比单看 last_intent 更能反映轨迹。
10.8.3 延续与脱离:TTL、字段补充与槽位交集的合议
def should_continue_pending(pending, intent, user_input, intent_slots):
"""判定本轮输入是延续 pending 还是开启新话题"""
has_pending = bool(pending and isinstance(pending, dict)
and pending.get("target_agent"))
if not has_pending:
return PendingDecision(False, False, False, {})
# ★ TTL:挂起过久未被推进/补槽 → 视为过期,强制脱离(防"永久吞并")
_pending_ts = pending.get("created_at")
if _pending_ts is not None and time.time() - float(_pending_ts) > PENDING_TTL_SECONDS:
return PendingDecision(False, False, False, {})
wf_collecting = bool(pending.get("workflow_instance")
and pending.get("phase") != "awaiting_approval")
req_collecting = pending.get("action") in ("request_info", "ask_more_info", "not_found")
_explicit_business = intent.name not in (
"unknown", "knowledge_query", "greeting", "help",
"chitchat", "confirm", "thanks")
_new_topic = looks_like_new_business_query(user_input)
# ★ 补槽豁免:当前输入提取的槽位与 pending 所需槽位有交集 → 视为补槽
_has_slot_overlap = bool(set(intent_slots or {}) & _pending_needed_slots)
if (_wf_supply
or (wf_collecting and not _new_topic
and (not _explicit_business or _has_slot_overlap))
or (req_collecting and not _new_topic
and (not _explicit_business or _has_slot_overlap))
or (not _explicit_business and not _new_topic)):
merged_slots = dict(pending.get("slots", {}))
merged_slots.update(intent_slots or {}) # 合并新抽取的槽位
return PendingDecision(True, wf_collecting, req_collecting, merged_slots)
return PendingDecision(False, wf_collecting, req_collecting, {})
这段代码印证 10.3 节"零延迟收敛与发散"的工程实现:延续判定是多个信号合议的结果,而非单一规则。_new_topic(是否新业务话题)与 _explicit_business(是否显式业务意图)构成主判据;_has_slot_overlap(补槽豁免)与 _wf_supply(命中流程必填字段)则修正"补槽值被误判为新话题"的假脱离。
TTL 是关键的兜底——没有它,一次挂起的 pending 会永久吞并后续输入,系统再也无法开启新话题。判定输出 PendingDecision(continued + 合并后的 merged_slots)把结论与数据一起交给调用方,使"延续"成为显式、可测的状态迁移。
10.9 本章小结
- 状态管理的本质是记忆边界的权衡:延续 vs 脱离两个错误率的平衡;
- 收敛(补充信息)必须零延迟,发散(新话题)走模型识别;
- 字段收集期输入优先按字段值解读,体现对用户配合流程的信任;
- 复合句保守拆解,误拆的代价高于漏拆。