第二部 原理篇:规则驱动的智能体设计

第 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 本章小结