字段齐了,不等于此刻可以做这件事。
这一章处理的就是“此刻可以做什么”——在状态和动作之间的那道判断。
一、条件都写清楚了,它还是提前提交了
第 1 章那个系统有一个最关键的业务闭环:什么时候可以生成需求摘要,什么时候可以向后续系统提交服务请求。
规则写得很清楚——需求说清楚了、联系方式拿到了、用户也明确要求继续处理,三条都满足才能推进。但线上的表现是这样的:
- 有时候字段还没齐,摘要就发出去了;
- 有时候字段明明齐了,却迟迟不发;
- 同样的对话重跑一遍,结果可能不一样。
排查的时候,第一反应是判断条件写错了。逐条看下去,每一条都是对的。
真正的问题是:那个判断在提示词里出现了四次。
一次在处理用户提供联系方式的段落里,一次在“何时推进流程”的说明里,一次在摘要话术模板的前置条件里,还有一次在一条后来补的例外规则里。四处的条件不完全一样:有的要求“有后续处理意向”,有的写的是“用户明确要求继续处理”;有的把单位列为必需,有的没提。
每一处单独看都合理。合在一起,系统就有了四个不同的答案。
生产观察 这类判断很少是一开始就重复的。它的典型长法是:遇到一个新场景,在离那个场景最近的地方顺手加一句判断——因为那样改动最小、最好验证。几次之后,同一个业务闭环就散落在了四个位置上。同一个问题有四个答案,等于没有答案。
二、唯一闸门
修法不是把四处的条件改成一致——那只是把问题推迟到下一次修改。修法是把它们合并成一个。
同一个“现在是否允许执行某项业务动作”的问题,只保留一个权威判定入口。
需要说准的是:闸门内部可以组合多个条件;执行层仍然可以有权限校验、安全策略、幂等保护——那些不是同一份业务规则的重复实现,而是不同层次的独立防护(第 9 章)。要消除的是“同一个许可问题有好几个答案”,不是“一个动作只能被检查一次”。
这个判定入口叫闸门。它有三条纪律。
图 13-1 先把闸门、策略和计划动作的关系画清:闸门计算允许集合,策略只从其中选择,动作不变量检查选择结果是否越权。
图 13-1 闸门、策略与计划动作的职责边界
第一,所有信息先进状态,闸门只读状态和配置。 闸门不做抽取、不做解析、不重新理解用户说了什么。它拿到的是上一章折叠出来的候选状态,加上确定性的业务配置(哪些字段必填、每个字段最多问几次),只对着这些做判断。这样它才可能是确定的、可测试的。
第二,闸门只回答一个问题:对于某个候选动作,当前状态是否满足它的放行条件。 不回答“该说什么”、不回答“该问哪个字段”、也不回答“要不要换个方式推进”——那些是后面的事。
实现上通常是一组各自独立的判断:能不能生成摘要、能不能提交服务请求、能不能索取手机号……把它们的结果合起来,就得到这一轮被允许的动作集合。
第三,闸门不执行动作。 它给出结论,由别的环节去做。放行不等于立刻执行——这一点在下一节和第六节都会用到。
合并之后,前面那四处判断都改成引用同一个闸门的结果。判定口径就此收敛到一个位置:口径要改,只改这里;行为要测,只测这里。
生产观察 “唯一”这件事值得写进提示词本身。第 1 章那次重构之后的版本里,这一节的标题是“闸门(全文唯一的摘要闸门)”——括号里那五个字是写给后来维护者看的:你可以改这道门的条件,但不要在别处再开一道。 而在重构之前的版本里,同一个业务闭环在四个不同位置各判断了一次。
三、闸门要给原因,不能只给是与否
一个只返回真假的闸门是不够用的。
不够: can_summarize() -> false
够用: can_summarize() -> { allowed: false,
blockers: ["MISSING_UNIT", "MISSING_CONTACT"] }
注意这里是 blockers 复数,这不是细节。如果闸门只返回一个原因,它就必须从多个阻塞条件里挑一个——而“这一轮先处理哪个”是优先级判断,属于策略,不属于闸门。 闸门的职责是如实报告哪些条件没满足;先解决哪一个,第六节会交给策略决定。
原因码看起来只是个附加信息,实际上有三个不可替代的用途。
分流。 不同的拒绝原因对应完全不同的后续动作:
表 13-1 闸门原因码
| 原因码 | 含义 | 后续 |
|---|---|---|
NOT_TRIGGERED |
用户还没表达过意向 | 正常回答问题,不推进 |
MISSING_UNIT |
缺单位 | 询问单位 |
MISSING_CONTACT |
缺联系方式 | 询问联系方式 |
CONTACT_DECLINED |
用户拒绝提供 | 走替代路径,不再索取 |
ALREADY_SENT |
摘要已经发过 | 走更新,而不是重发 |
PAUSED |
流程处在暂停状态 | 不主动推进 |
没有原因码,调用方拿到一个 false 之后只能自己再判断一遍“到底缺什么”——于是判断逻辑又开始在闸门外面重新长出来,回到第一节那个问题。
诊断。 线上出现“该发的没发”时,原因码直接告诉你卡在哪一条。没有它,你只能重放整个会话去猜。第 20 章会把它写进执行追踪。
话术。 第 17 章会讲到一条纪律:渲染层不能自己编造理由。用户问“为什么还没帮我对接”,回复里给出的原因必须来自闸门给出的原因码,而不是模型现场想一个听起来合理的说法。
四、定义闭合:每个概念都要有出口
闸门的条件里会引用一些概念:“有效的联系方式”“明确的后续处理意向”“需求已经说清楚”。这些词看起来不言自明,实际上是最容易出问题的地方。
第 3 章讲过一次定义闭合,这里是它在控制层的形态。方法是同一个:
沿着闸门引用的每一个变量问一遍——它可能出现哪些取值?每一个取值有没有明确的出口?
以“有效的联系方式”为例。最初的定义往往只有一句:“用户提供了手机号。”把可能的取值展开:
给了手机号,符合字段契约 → 可作为有效候选值
给了手机号,格式不对 → ?
说"加微信",但没给账号 → ?
给了微信号 → ?
说"微信同号" → ?
明确拒绝提供任何联系方式 → ?
给了又说"先别联系我" → ?
七种情况,最初的定义只覆盖了一种。剩下六个问号,每一个都是一次线上故障的入口——因为没有出口的取值,模型只能自己发明一个。
这个展开动作有一个很实用的性质:它不需要等故障发生。 拿着闸门的条件逐个变量问一遍,通常半小时就能找出好几个从来没定义过的取值。这是本书里性价比最高的检查之一。
五、真值表:把组合穷尽
单个变量闭合之后,还有组合的问题。
闸门通常引用不止一个变量。用条文写规则时,人写的是“路径”——先看有没有意向,有意向再看单位,有单位再看联系人。这种写法容易漏掉那些没被走到的组合。
真值表把它换成穷举空间。
真实系统里这个过程是可以看见的。第 1 章那次重构之后,闸门最初只看字段在不在,出口只有两个:
三项齐备 + 有业务描述 ⇒ 输出摘要
否则 ⇒ 补一个缺失字段
两行,看起来已经闭合了。但下一个版本把出口扩到了六个:
① 三项齐备、有描述、尚未出过摘要 ⇒ 输出摘要
② 三项齐备但业务描述为空 ⇒ 只问一句"想咨询哪类产品"
③ 不齐备,但信息收集已触发 ⇒ 补一个缺失字段
④ 不齐备,且信息收集尚未触发 ⇒ 正常答疑,不问联系字段
⑤ 当前需求已经出过摘要 ⇒ 不再重发,转入摘要后处理
⑥ 安全场景 / 候选转正 ⇒ 两条明确的例外
多出来的四个出口,没有一个来自新业务需求,全都来自原来那两行覆盖不到的组合。 ② 是三项齐但没有描述——最初的条件写的是“且业务描述已有任意一条真实信息”,但没写不满足时该干什么;③④ 的区别是信息收集到底触发没有,最初根本没有这个变量;⑤ 则是本章第八节要讲的那件事。
这就是定义闭合与真值表在真实系统里的样子:不是一次想全,而是每发现一个没有出口的组合,就补一行。
表 13-2 闸门真值表
| 意向 | 单位 | 联系人 | 渠道 | 唯一出口 |
|---|---|---|---|---|
| 无 | 任意 | 任意 | 任意 | 不推进,正常回答 |
| 有 | 缺 | 任意 | 任意 | 问单位(已问满则暂停) |
| 有 | 有 | 缺 | 任意 | 问联系人(已问满则暂停) |
| 有 | 有 | 有 | 缺 | 问联系方式 |
| 有 | 有 | 有 | 有 | 允许生成摘要 |
需要说明一下这张表的性质:严格意义上的真值表会把每一个变量组合逐行列出,而上面这张用“任意”合并了那些不影响出口的组合,实际上是一张压缩后的条件表。
两者用途不同:完整展开用来检查组合空间有没有漏,压缩表用来实现和维护。 建议的做法是先在纸上完整展开一次,确认每个组合都有出口,再压缩成可维护的形式。
这张表还有第二个性质要说明:末列“唯一出口”直接给出了本轮动作,这其实是把下一节要拆开的两件事合并展示了——闸门判定哪些动作被允许,策略在允许的动作里选出唯一主动作。在拆开后的架构里,闸门一侧的输出是 allowed_actions / blockers(末行的“允许生成摘要”就是这种许可口径),“问单位”这类唯一出口则属于策略的决策表。
不管用哪种形式,价值都不在于表格更好看,而在于它逼你面对每一个组合。写条文时你可以说“其他情况按常理处理”,写表时你必须给每一行一个出口。
适用边界 变量一多,组合会迅速膨胀——四个三值变量就是八十一行,没法维护。这时候要做的不是放弃穷举,而是分层:先用一两个关键变量分出几个大类(比如“有没有意向”),每一类内部再单独展开。真值表适合关键闭环上那三五个决定性变量,不适合拿来描述整个系统。
六、闸门与策略:能不能做,和该做哪一件
到这里还差一步。闸门告诉你“允许做什么”,但允许做的事常常不止一件。
举个具体的例子。这一轮用户问了一个技术问题,同时顺手给了手机号。此刻:
- 允许回答技术问题;
- 允许确认收到了手机号;
- 允许推进到下一个字段;
- 摘要条件也刚好齐了,允许生成摘要。
四件事都被允许。全做出来会得到一段又长又乱的回复:先答问题、再确认号码、再问下一项、最后甩一份摘要——用户根本不知道该回应哪一句。
所以需要第二层:
闸门 这一刻,哪些动作是被允许的 → 一个集合
策略 在允许的动作里,这一轮做哪一件 → 唯一的主动作
策略通常写成决策表:候选状态、闸门结果和确定性配置进去,唯一主动作出来。
表 13-3 策略决策表
| 条件 | 主动作 | 原因码 |
|---|---|---|
| 未触发意向 | 只回答问题 | NOT_TRIGGERED |
| 缺单位,且未问满 | 问单位 | MISSING_UNIT |
| 缺联系人,且未问满 | 问联系人 | MISSING_CONTACT |
| 字段齐备,摘要未发 | 生成摘要 | READY_TO_SUMMARIZE |
| 字段齐备,摘要已发 | 更新摘要 | ALREADY_SENT |
| 问询次数已满 | 只回答问题,不再追问 | BUDGET_EXHAUSTED |
这是一张简化的示例表,只展示了主干路径。 正式版本还要覆盖联系方式已拒绝、流程暂停、关键字段冲突或过期等出口——下一节会说明为什么这些不能省。
一轮只做一件主动作,这条约束值得单独说。它不是为了简洁,而是为了可控:一轮一件事,你才能判断这一轮做得对不对;一轮四件事,出问题时你分不清是哪一件的判断错了。
需要说明的是,“唯一主动作”不排斥附带内容。用户问的技术问题当然要回答——它是这一轮回复的一部分,但不是这一轮要推进的那件事。区别在于:主动作决定这一轮的走向,附带内容只是把话说完整。
七、覆盖检查:决策表不能有洞
决策表和真值表有同一个要求:穷尽且互斥。
互斥意味着任何一个状态只匹配一行。两行同时命中,就等于又回到了第一节——同一个问题有两个答案。实现上通常靠有序匹配(自上而下取第一个命中),但这只是把冲突藏起来,最好在测试里显式检查是否存在多重命中。
穷尽意味着所有预期的状态空间都有明确规则;除此之外,还要有一条安全兜底,接住没预见到的状态:
以上都不匹配 → 只回答用户的问题,不推进流程 UNMATCHED
兜底行的动作必须是明确且安全的,不能是“按常理处理”。第 15 章会展开这个原则:兜底的标准是“最坏也只是不推进”,而不是“赌一把可能推进”。
但有一件事要说清楚:兜底行是安全网,不是完整性的证明。
有了这一行,系统确实不会崩,但它证明不了你已经想清楚了所有业务状态——否则漏掉二十种场景也可以宣称“我的决策表是穷尽的,因为最后有兜底”。这里要区分两件事:
覆盖 所有预期状态,都有专门为它写的规则
兜底 意外状态有一个安全出口
正式的检查方法是:看哪些状态实际落到了兜底行上,逐个判断它究竟是预期之内的异常,还是决策表漏了一行。 长期落在兜底行上的状态,几乎都是后者。
决策表还有一个附带的好处:它本身就是一份测试用例清单。 每一行都是一个可执行的用例——构造符合该行条件的状态,检查输出的动作和原因码是否与表一致。第 19 章讲评测时会用到这一点。
八、条件可以判断,阶段怎么变化
控制层里“当前能不能做、这一轮先做什么”这部分已经建起来了:唯一的判定入口、闭合的定义、覆盖完整的条件表、给出唯一主动作的决策表,以及可诊断的原因码。
需要先澄清一件事:流程阶段以及其他过程状态,当然可以作为闸门的输入。 第三节那张表里就有两个例子——PAUSED 表示流程正处在暂停状态(下一章会把它归入控制模式,而不是阶段),ALREADY_SENT 表示“摘要已经发生过”这个过程事实。两者性质不同,但闸门都读得到。
这个区分对下一章有用:不是所有控制信息都要塞进阶段枚举。 阶段、标志位、计数器都属于过程状态,其中只有真正需要定义转换规则的那部分,才应该做成阶段。
但有一类问题闸门确实回答不了:这些阶段本身是怎么变化的。
- 从“收集中”能不能直接跳到“已转人工”,中间不经过任何确认?
- “暂停”要靠什么事件才能回到“收集中”,用户随便说句话算不算?
- 摘要已经发出之后,还能不能退回收集状态?
- 已经终止的会话,有没有恢复的路径?
这些问的不是“当前允不允许做某件事”,而是“阶段与阶段之间,哪些路径合法”。闸门看的是当前这一瞬间,它不描述路径。
两者的分工是这样的:
闸门 在当前状态下,这个动作允不允许
状态机 当前阶段能转换到哪些阶段,什么事件可以触发转换
合起来,第三篇目前有了四层:
字段回答“我知道什么”,阶段回答“我现在在哪一步”,闸门回答“在这一步允许做什么”,策略回答“这一轮先做哪一件”。
到这里,还剩一个变量没有展开:阶段,以及阶段之间的转换规则。