上一章结束在一个分工上:闸门管“此刻允许做什么”,状态机管“阶段之间怎么合法移动”。
这一章讲后者——系统不只要知道自己知道什么,还要知道自己走到了哪一步。
一、字段全对,话还是说错了
摘要已经发给用户了。过了两轮,用户说:
对了,联系人换成王工,他更清楚参数要求
系统更新了联系人字段,然后重新走了一遍闸门:需求清楚、单位有了、联系人有了、联系方式有了——条件全部满足。
于是它又发了一份摘要。
用户收到两份几乎一模一样的东西,只有联系人一行不同。后续系统里也出现了两条服务请求,看起来像重复提交。
排查的时候会发现一件让人不太甘心的事:每个字段的值都是对的,归并没出错,闸门的判断也没错。 按照它拿到的信息,条件的确满足。
问题在于系统不知道一件事:这件事已经做过一次了。
生产观察 这个缺陷在真实系统里留下了清晰的痕迹。第 1 章那次重构后的第一版骨架,闸门是这样写的:
自问——单位、联系人、有效联系方式是否全部具备,且业务描述已有任意一条真实信息?
判据全部是“字段在不在”。它没有任何一个变量记录“这一轮之前发生过什么”,所以上面那个重复摘要的故障是必然会出现的。
紧接着的下一个版本,名字直接叫“状态化闸门”。它做的第一件事是在闸门里加了三个内部状态:
信息收集是否已触发 当前需求是否已出摘要 手机号是否已问过然后把闸门的第一个条件改成了“当前需求尚未出摘要,且三项具备……”,并在输出摘要之后置
已出摘要 = 是。两个版本相隔一个小版本号,字段处理逻辑一行没改——改的全是“系统现在处在哪一步”。
有人会说,加个标志位不就行了——summary_sent = true,闸门里判一下就完了。上一章确实也把 ALREADY_SENT 列进了原因码。
但一个标志位挡得住这一次,挡不住这一类。紧接着的问题会是:
- 改了联系人,应该更新已发的摘要;改了品类呢?那多半要重新生成一份;
- 改了地区,算哪一种?
- 用户说“先别催我”之后,系统停了三轮,现在他主动说了个手机号——该恢复到什么状态?
- 已经转人工了,AI 还能不能接着说话?
每加一种情况,就多一个标志位;标志位之间还会互相影响——“暂停中”且“已发摘要”且“预算耗尽”时该怎么办?很快就会回到第 1 章那个局面:一堆布尔值,谁也说不清它们的合法组合有哪些。
真正缺的东西是:一套显式的模型,说清楚系统处在哪个阶段、由谁在控制,以及这些状态之间怎么合法地走。
二、什么该做成阶段,什么不该
第一节末尾说“缺的是阶段”。但在动手建阶段之前,要先问清楚一件事:哪些东西该做成阶段,哪些不该。 这个判断做错了,会造出一个谁也维护不了的巨型枚举。
先给三个基础问题。一个东西要值得被显式建模,它得答得上:
它有明确的进入条件吗?
它有明确的退出条件吗?
处在它里面时,系统的行为和别的时候明显不同吗?
三个都答得上来,说明它值得被显式建模——但注意,这只说明它该被显式表达,不代表它应该成为同一个状态机里的一个阶段。
这里要加上关键的第四问:
它能不能和别的阶段同时成立?
这一问决定了它该放在哪里:
不能同时成立 → 可以作为同一个状态机里的一个阶段(互斥)
可以同时成立 → 应该拆成一个正交的状态维度
拿几个候选来套一遍,差别立刻出来了:
表 14-1 状态变量候选的判定
| 候选 | 前三问 | 第四问:能和别的同时成立吗 | 结论 |
|---|---|---|---|
| 收集中、已摘要、已终止 | 都能答上 | 不能——一个会话不可能既在收集又已终止 | 同一枚举里的阶段 |
| 暂停 | 都能答上 | 能——“已摘要的会话正在暂停”完全正常 | 拆成正交维度 |
| 已转人工 | 都能答上 | 能——“已摘要且已转人工”也正常 | 拆成正交维度 |
summary_sent |
进入退出条件模糊、行为无本质不同 | —— | 只是个标志位 |
第一节那句“一个已发摘要的会话,可能正在暂停,也可能已经转人工”,其实已经证明了这件事:暂停和转人工能与“已摘要”同时成立,它们就不属于“阶段”那个互斥维度。
硬把它们塞进同一个枚举,就会丢信息——一旦进入“暂停”,“已经发过摘要”这个事实就没地方存了,于是恢复的时候只能猜:该回到收集中还是已摘要?这个猜测正是第一节那类故障的温床。
三、两个正交维度:流程阶段与控制模式
所以这个多轮客服例子,正确的建模是两个维度,而不是一个枚举。
流程阶段(phase):这个会话的业务生命周期走到哪了。它是互斥的。
未触发 UNTRIGGERED 用户还没表达后续处理意向
收集中 COLLECTING 正在收集需求与联系信息
已摘要 SUMMARIZED 摘要已经生成并送出
已终止 TERMINATED 需求取消或会话结束(吸收态)
控制模式(control_mode):现在由谁在驱动这个会话。它和流程阶段正交。
图 14-1 把两个维度展开成矩阵。一个格子表示一种组合,不代表需要为每个格子创造一个新的枚举值。
图 14-1 流程阶段与控制模式的正交矩阵
AI 主动 AI_ACTIVE AI 正常推进
暂停 PAUSED 暂时不主动推进,但流程阶段不变
已转人工 HUMAN_HANDOFF 控制权在人手里
两个维度组合,才能干净地表达那些原来很别扭的状态:
phase = 已摘要 & control_mode = 暂停 摘要发过了,此刻暂停追问
phase = 收集中 & control_mode = 已转人工 收集到一半转给了人
phase = 已摘要 & control_mode = AI 主动 正常的已摘要状态
TERMINATED 是一个例外:它是吸收态。终止后可以为了审计保留最后一个 control_mode,但这个值不再驱动后续动作,因此不需要把“已终止 × 三种控制模式”当成三种仍可运行的组合。
这样第一节那几个问题就都有了答案,而且答案是“读出来”的,不是“猜出来”的:
- 暂停之后恢复到哪个阶段? 阶段从来没变过——恢复的是 control_mode,从暂停回到 AI 主动,phase 还是它本来的值。
- 已摘要之后能不能暂停? 能,它们是两个维度,
phase=已摘要 & control_mode=暂停。 - 人工交还之后回到哪里? control_mode 从已转人工回到 AI 主动,phase 保持不动。
生产观察 状态爆炸最常见的成因,就是把正交的维度乘进了一个枚举。这里有三个可继续运行的流程阶段和三个控制模式;如果硬塞进一个枚举,光运行态就会扩成 3×3,还要另行处理终止态。分开以后只维护各自的取值与转换规则。当你发现某两个“阶段”能同时成立、或者恢复时要靠猜,通常说明两个维度被硬乘在了一起。
适用边界 正交拆分不是越多越好。维度多了,组合空间同样会爆炸。只有当两件事确实能独立变化时才拆成两个维度;如果它们总是绑定出现,塞进一个枚举反而更简单。这里 phase 和 control_mode 值得拆,是因为它们真的能各自独立变化。
四、转换:Trigger 说发生了什么,Guard 说允不允许
状态机的核心不是状态,而是转换。一条转换由四个部分组成:
当前状态 + 触发事件(Trigger) + 守卫条件(Guard) → 下一状态 + 附带动作(Effect)
这四个部分里,最容易搞混的是 Trigger 和 Guard——它们都在“判断”,但判断的东西不同:
Trigger 发生了一件什么事 (一个事件)
Guard 在当前情况下,这条转换允不允许发生 (一个条件判断)
这个区分能纠正一个很常见的错误:把闸门的判断结果当成触发事件。
比如“从收集中进入已摘要”,很容易写成“闸门允许生成摘要 → 已摘要”。但“闸门允许”是一个判断结果,不是一件发生了的事。闸门允许生成摘要,只意味着“生成摘要这个动作现在合法”,它本身没有让任何事情发生。真正让阶段改变的,是摘要真的被生成并提交了这件事。
所以正确的写法是把它们分开:
Gate: can_summarize = true
→ 让策略有资格选择"发送摘要"这个动作
→ 动作执行、消息提交成功
→ 产生 SUMMARY_COMMITTED 这个事件
→ 它才是触发 phase → 已摘要 的 Trigger
一张干净的转换表长这样:
表 14-2 业务阶段 phase 的转换
| 当前 phase | Trigger(发生了什么) | Guard(守卫条件) | 下一 phase | Effect |
|---|---|---|---|---|
| 未触发 | 检测到后续处理意向 | —— | 收集中 | 开始收集 |
| 收集中 | 摘要已提交 | —— | 已摘要 | —— |
| 已摘要 | 关键字段被纠正 | 影响摘要主体 | 收集中 | 标记需要重新生成 |
| 已摘要 | 次要字段被修改 | 不影响摘要主体 | 已摘要 | 发送更正 |
| 任一非终止 phase | 用户取消需求 | —— | 已终止 | 停止一切索取 |
控制模式的转换是另一张表,各走各的:
表 14-3 控制模式 control_mode 的转换
| 当前 control_mode | Trigger | Guard | 下一 control_mode |
|---|---|---|---|
| AI 主动 | 询问预算耗尽 | —— | 暂停 |
| 暂停 | 用户主动补充信息 | —— | AI 主动(受限,见第六节) |
| 非已转人工 | 人工移交完成(到达预定义的控制权转移点) | —— | 已转人工 |
| 已转人工 | 人工交还 | —— | AI 主动 |
注意 Trigger 这一列全是“已经发生的事”——摘要已提交、字段被纠正、预算耗尽、用户补充——没有一个是“闸门允许”这样的判断结果。这是本节最重要的一条纪律。
还有一件事要在这里说清:当一个事件在当前状态下没有对应的合法转换时,系统该怎么办。 比如已终止之后来了一个迟到的工具回调,或者暂停中收到一个只有收集态才处理的事件。
原则是:这类事件必须被显式识别和记录,绝不能和“漏处理了”混为一谈。
识别为 转换被拒绝(transition_rejected)
记录 事件内容 + 拒绝原因,写进追踪
状态 不改变
但“识别并记录”不等于“要对用户说一句这个事件非法”。终止后那个迟到的回调,正确的处理是记录下来、不改状态、不回话;只有在确实需要让用户知道时(比如用户主动做了一个当前不被允许的操作),才由策略决定给出可见的回应。内部必须明确判定,是否对用户可见由策略决定。
五、阶段不能领先于结果
上一节那条“摘要已提交才进入已摘要”,背后是一条更普遍的原则,值得单独拎出来,因为它和第 9、18 章直接相连:
系统不能因为“允许做”或“准备做”,就进入声称“已经做完”的阶段。阶段不能领先于它所声称已经发生的结果。
对于系统内部、可以原子提交的东西(改状态、发消息给用户),阶段变化和结果发生可以放在同一个提交边界里一起完成——第 18 章会讲这个提交怎么做到原子。
但对于外部副作用,就必须等结果被证明:
计划动作(提交服务请求)
↓
执行
↓
观测结果 = 成功 ← 只有拿到这个
↓
产生"服务请求已提交"事件
↓
phase 才能转到对应的完成态
这里直接应用第 9 章的回路和观测结果三态:回执是 UNKNOWN 时,阶段绝不能提前跳到“已完成”——否则系统会声称一件它并不知道有没有发生的事。原来那种“闸门放行 → 转到已摘要 → 然后发摘要”的写法,因果是反的:它让阶段领先于动作,一旦发送失败,系统就停在了一个谎称“已摘要”的状态里。
六、暂停、转人工与终止:三种不一样的“停”
控制模式里的暂停和转人工,加上流程阶段里的终止,是三种很容易混为一谈、其实完全不同的“停下来”。
暂停(control_mode = 暂停)。 系统问了两次单位都没得到,询问预算耗尽,于是不再主动追问。
生产观察 “问了没得到”和“用户明确拒绝”是两件不同的事。 前者进入暂停:不再主动追问,但用户若自己提起,仍可继续;后者是拒绝:这个字段从此不再索取,走替代路径。把前者当成后者,会永久失去本来能拿到的信息;把后者当成前者,就成了纠缠。
暂停之后,恢复要小心两点。第一,什么事件触发恢复——不能是“用户说话就恢复”,用户可能只是在问技术参数。合理的触发是“用户主动补充了某个待收集的信息”。第二,恢复后不能当作什么都没发生:第一节那个例子就栽在这,用户主动说了个手机号,系统立刻把剩下三个字段一口气问完,用户的感受是“我刚说过别催”。稳妥的做法是给恢复后加一份明确的询问预算——恢复后每个字段只保留很少的追问机会,用完仍未拿到就重新暂停。具体额度属于下一章的询问预算策略,写进配置,不在状态机里硬编码。
转人工(control_mode = 已转人工)。 控制权交给了人。这里有一条纪律容易做反:
失去行动权,不等于停止记录事实。
人工接管期间,AI 不据此主动推进流程、也不自行重新取得控制权,但用户说的话仍然要正常变成证据、更新状态、同步给人工坐席。只是这些状态更新不会触发 AI 的主动作。转人工怎么交接、什么条件下交还,是第 15 章的事。
终止(phase = 已终止)。 这是唯一一个真正的吸收态:普通业务事件不能把它带出来。 用户下一句随便说点什么,系统不能就恢复成收集中。
注意终止是 phase 维度的吸收态,转人工是 control_mode 维度的一个模式——上一版把它们混在一节里,是因为当时还没拆开两个维度。现在清楚了:转人工可以交还(回到 AI 主动),终止不能。
还要区分一个常被当成“终止”的例子。用户说“别再联系我了”,这不是业务终止,而是一个联系许可问题:
"这个需求取消了" → phase = 已终止 (业务真的结束了)
"别给我打电话" → channel_permission.PHONE_CALL = DENIED(拒绝授权;许可维度,见第 15 章)
完全可能同时成立 phase=已摘要 & phone=有效 & channel_permission.PHONE_CALL=DENIED——需求还在、号码也有,但用户不许电话联系。把“别联系我”错当成终止,会把一个还在进行的需求整个丢掉。
七、回到摘要之后
现在可以回答第一节那个问题了。摘要发出之后用户改了信息,系统该怎么走,取决于改的是什么——而且要同时看数据侧和流程侧两个投影:
表 14-4 用户表达在数据侧与控制侧的处理
| 用户表达 | 数据侧(第 12 章) | 流程/控制侧(本章) |
|---|---|---|
| 改联系人、联系方式等不影响需求主体的字段 | 该字段更新 | phase 停在已摘要,发送一条更正 |
| 改品类、核心需求等决定摘要内容的字段 | 依赖它的字段转为已过期 | phase 退回收集中,标记需要重新生成 |
| 新增了一个需求 | 新作用域,字段从未知起步 | 新作用域各走各的 phase |
| “这个需求不要了” | 相关字段转为已撤回 | phase → 已终止 |
| “这个需求先放放” | 原状态保留 | control_mode → 暂停 |
最后两行特意拆开:撤回和暂停是两个不同的 Trigger,导向两个不同维度——“不要了”是业务终止(phase),“先放放”是控制暂停(control_mode)。上一版把它们合成“已终止或暂停”,那正是没拆开两个维度时的含糊。
这张表分两列,是因为同一个业务事件,在归并(数据侧)和状态机(流程侧)上产生两个不同但必须一致的投影:数据侧把依赖字段转成已过期,流程侧把 phase 退回收集中。如果只做一侧——比如 phase 退回去了,旧的使用需求还挂着有效值——重新生成的摘要就会带着产品 A 的旧信息,第一节那个故障会以另一种形式重现。
状态机的转换和归并的字段处置,是同一个业务事件的两个投影,不能只实现一个。
关于本章,有三句话最值得记住:
能够同时成立的状态,不应该被硬塞进同一个互斥的阶段枚举。
Trigger 说明发生了什么,Guard 判断当前是否允许这条转换——判断结果本身不是事件。
系统只有在结果真正发生之后,才有资格进入声称“已经完成”的阶段。
还有一点:这张表本身也应当被当作决策表来检查。 第 13 章那条覆盖检查在这里同样适用——每一种“改了什么”都要有明确的走向,而不是只写常见的两三种,剩下的靠默认。
八、如果这三样里有一样算错了呢
第三篇的主干现在齐了:
- 字段告诉系统它知道什么;
- 闸门与策略告诉系统此刻允许做什么、这一轮先做哪件;
- 状态机告诉系统它走到了哪个阶段、由谁在控制、下一步能去哪里。
这三样东西各自都有明确的规则,也都可以被测试。
但它们仍然可能出错。
抽取可能把 A 字段的内容填进了 B 字段;归并可能因为一条脏证据算出了错误的当前值;闸门的某个条件可能引用了一个还没定义闭合的概念;状态机可能因为一个没预见到的事件走进了不该走的分支。
当这些错误发生时,系统会做什么?
答案往往是:它照常把流程执行下去。 因为每一步都符合规则——错的是状态本身,不是流程对状态的处理。系统没有任何环节会去怀疑“这个状态是不是本来就错了”,于是它会基于错误状态生成一份摘要,或者追问一个明明已经拒绝过的字段,全过程没有一处报错。
所以还差最后一层防护:当状态本身可能算错时,怎么让系统宁可停下,也不要把错误执行成一个更糟的结果。