第 14 章 状态机

上一章结束在一个分工上:闸门管“此刻允许做什么”,状态机管“阶段之间怎么合法移动”。

这一章讲后者——系统不只要知道自己知道什么,还要知道自己走到了哪一步。


一、字段全对,话还是说错了

摘要已经发给用户了。过了两轮,用户说:

对了,联系人换成王工,他更清楚参数要求

系统更新了联系人字段,然后重新走了一遍闸门:需求清楚、单位有了、联系人有了、联系方式有了——条件全部满足。

于是它又发了一份摘要。

用户收到两份几乎一模一样的东西,只有联系人一行不同。后续系统里也出现了两条服务请求,看起来像重复提交。

排查的时候会发现一件让人不太甘心的事:每个字段的值都是对的,归并没出错,闸门的判断也没错。 按照它拿到的信息,条件的确满足。

问题在于系统不知道一件事:这件事已经做过一次了。

生产观察 这个缺陷在真实系统里留下了清晰的痕迹。第 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 字段;归并可能因为一条脏证据算出了错误的当前值;闸门的某个条件可能引用了一个还没定义闭合的概念;状态机可能因为一个没预见到的事件走进了不该走的分支。

当这些错误发生时,系统会做什么?

答案往往是:它照常把流程执行下去。 因为每一步都符合规则——错的是状态本身,不是流程对状态的处理。系统没有任何环节会去怀疑“这个状态是不是本来就错了”,于是它会基于错误状态生成一份摘要,或者追问一个明明已经拒绝过的字段,全过程没有一处报错。

所以还差最后一层防护:当状态本身可能算错时,怎么让系统宁可停下,也不要把错误执行成一个更糟的结果。