第二篇的结论是:系统需要一份跨轮的、权威的状态。这一篇讲怎么把它建起来。
起点是最小的那个单元——一个字段。 而第一个问题就出在最不起眼的地方:这个字段是空的,到底什么意思。
图 10-1 标出第三篇的位置:从这一章开始,系统正式建立状态与控制。
图 10-1 全书六篇路线:第三篇
一、这个字段是空的,到底什么意思
先看一个具体的故障。
用户在第 4 轮明确说过:“手机号先不留了,加微信吧。”到了第 8 轮,系统又问:“方便留个手机号吗?”用户重复了一次拒绝。第 12 轮,系统第三次问了同样的问题。
排查下来,原因简单得让人意外:系统里根本没有“已拒绝”这个状态可以存。
字段的定义是这样的:
{ "phone": null }
用户拒绝之后,这套状态表示仍把它归成了 null。而在下一轮的判断逻辑里,null 又被解释成“还没拿到,去问”。于是每一轮都在重新执行同一个动作。
问题在于,被记成 null 的不止这一种情况。至少有六种:
表 10-1 六种空值处境
| 实际发生了什么 | 状态里如何表示 |
|---|---|
| 还没问过 | null |
| 问了,用户没回答 | null |
| 用户给了,但格式不对 | null |
| 用户明确拒绝 | null |
| 用户给了又撤回 | null |
| 以前有效,现在过期了 | null |
六种完全不同的处境,被压成了同一个值。 而系统后续要做的判断——该不该问、该问几次、该不该推进、该说什么——都依赖这些区别。
这件事和第 8 章那个重复追问是同一族问题,但深了一层。第 8 章说的是“系统里没有一个地方存这个答案”;这里是有地方存了,但那个地方表达不了真实的处境。
生产观察 相当一部分“AI 又问了一遍”的投诉,最终都能归到这一条上:不是模型忘了,是状态设计里没有给“用户拒绝过”留位置。 这类问题改提示词收效有限——因为提示词读到的那个字段,本身就已经丢失了信息。
二、先看整体结构:这一篇要建的东西
在展开细节之前,把第三、四篇要建的整体结构摆出来。第 2 章给过它的轮廓,这里是正式版本。
┌───────────────────── 追踪贯穿全链 ─────────────────────┐
│ 成功与失败同等记录——被拦下、被拒绝、没提交,尤其需要留痕 │
└───────────────────────────────────────────────────────┘
输入(用户消息 / 工具回执 / 外部事件)
↓
证据 谁说的、原话是什么、来自哪里 〔第 11 章〕
↓
事件 这句话对系统意味着什么 〔第 11 章〕
↓
归并 候选状态 = 折叠(上一份已提交状态, 本轮事件) 〔第 12 章〕
↓
候选状态 本轮算出来的,尚未生效 〔第 10、12 章〕
↓
闸门 此刻允许做哪些事 〔第 13 章〕
↓
策略 在允许的事里,这一轮做哪一件 〔第 13 章〕
↓
计划动作
├── 对话动作 ────────────────────────────────┐
│ │
└── 工具 / 副作用动作 │ 〔第 9 章〕
↓ │
执行 │
↓ │
观测结果 │
↓ │
落成证据,再成事件 ──► 回到归并重新折叠 │
(受执行预算约束) │
↓
渲染 〔第 17 章〕
↓
验证 〔第 18 章〕
↓
提交 〔第 18 章〕
第 2 章讲过这条链的顺序就是三条红线:证据先于状态、状态先于决策、决策先于表达。 这一篇会把前半段建起来(证据、事件、归并、状态、闸门、状态机),第四篇建后半段(渲染、验证、提交)。
本章的位置是“状态”那一格里的最小单元:一个字段该长什么样。
三、槽位六要素
一个能用的槽位,不只是一个值。状态管理至少要能回答六个问题:
表 10-2 槽位必须回答的六个问题
| 要回答的问题 | 回答不了会怎样 |
|---|---|
| 当前值是什么 | —— |
| 它处于什么处境 | 第一节那个故障 |
| 哪些证据支持它 | 出错时无法追溯,冲突时无法裁决 |
| 这些证据来自哪里 | 无法按可信度排序,纠正时不知道能否覆盖 |
| 什么时候确定的 | 无法判断是否过期,冲突时无法按时序裁决 |
| 属于哪个作用域 | 多需求场景下互相污染(第 12 章) |
要注意的是:这是六个必须能被回答的问题,不等于这六项都要平铺在槽位对象里。 实际结构通常这样分工:
// 槽位:当前值、处境,以及支持它的证据编号
{
"phone": {
"value": null,
"status": "DECLINED",
"evidence_ids": ["E14"],
"meta": {
"ask_count": 2,
"last_asked_turn": 8,
"confirmed_by_user": false,
"updated_at": "<update_time>",
"scope": "demand_1"
}
}
}
// 证据:来源、时间和原话,各自保存
{
"id": "E14",
"source": "user",
"timestamp": "<event_time>",
"scope": "demand_1",
"quote": "手机号先不留了,加微信吧"
}
这样分开有个很实际的理由:一个值可能由多条证据共同支撑。 用户说过一次、后来又确认了一次、外部系统也返回了同样的结果——这时候在槽位上写一个 "source": "user" 就不够用了。槽位引用证据,证据各自保存来源和时间,一对多的关系才表达得出来。证据本身怎么设计,第 11 章再展开。
注意 value 仍然是 null——但这一次,null 只表示“没有值”,而“为什么没有值”由 status 回答。 这就是本章标题那句话的全部含义:
未知不是空值。“没有值”和“为什么没有值”,是两个必须分开表达的问题。
四、状态枚举
下面这七个状态是本书的冻结版本。它们互斥;在本书的参考业务场景里,它们覆盖了需要区分的主要处境。
表 10-3 槽位七态及其处理方向
| 状态 | 含义 | 典型来源 | 典型处理方向 |
|---|---|---|---|
未知 UNKNOWN |
系统当前仍不知道这个字段的有效值 | 从没问过,或问过但没得到答案 | 可以问 |
有效 VALID |
已有被系统接受的当前值 | 用户提供并通过校验 | 不要再问;可以使用 |
无效 INVALID |
收到过候选值,但不符合字段契约,不能作为当前有效值 | 格式错误、不在允许范围 | 可以澄清或请对方更正 |
已拒绝 DECLINED |
用户明确表示不提供 | “手机号先不留了” | 不要再问;走替代路径 |
冲突 CONFLICT |
存在互相矛盾且未解决的证据 | 前后说了两个不同的值 | 需要澄清或按规则裁决 |
已过期 STALE |
曾经有效,但已失效或场景已变 | 时效到期、需求换了 | 需要重新确认 |
已撤回 CLEARED |
用户明确收回了之前的值 | “刚才那个先不用了” | 视同没有,但保留痕迹 |
图 10-2 把七种状态按“为什么没有当前可用值”展开。它不是转换图:状态只描述处境,真正的下一步仍由元数据、闸门和策略共同决定。
图 10-2 槽位七态及其典型处理方向
需要说明的是,未知不等于“什么都没发生过”。问过两次却始终没拿到答案,字段依然是未知——只是元数据里 ask_count 已经是 2 了。这恰好演示了下一节要讲的正交关系:
未知 + ask_count = 0 从来没问过
未知 + ask_count = 2 问过两次,仍然不知道
还有一件对实现很关键的事:每种状态下 value 该放什么。 原则是一句话——value 表示系统当前允许使用的值,而用户实际说过什么由证据保存。
表 10-4 七态下 value 的语义
| 状态 | value 的典型语义 |
|---|---|
| 未知 | null |
| 有效 | 当前已接受的值 |
| 无效 | 一般不作为当前值,原始输入留在证据里 |
| 已拒绝 | null |
| 冲突 | 不选定单一权威值,候选值留在证据里 |
| 已过期 | 可保留旧值用于追溯,但禁止当作当前有效值使用 |
| 已撤回 | null,原值留在证据与历史里 |
最后一列叫“典型处理方向”而不是“该做什么”,是有意的:
状态只描述字段当前的处境,它不直接等于下一步动作。
phone 是未知、但已经问过两次,闸门可能已经不允许再问;phone 是有效、但这是个高风险字段且没有经过二次确认,策略仍可能要求确认一次。是否询问、是否确认、是否推进,要结合元数据、策略和闸门一起判断——第 13 章会正式处理这一层。
几个容易混的区分也值得强调:
未知 vs 已拒绝。 前者是“我还不知道”,后者是“我知道对方不给”。对系统的要求完全相反:一个可以问,一个必须停止问。第一节那个故障就是把后者存成了前者。
无效 vs 未知。 用户给了一个只有 10 位的号码,这不是“没给”,而是“给了但不对”。区别在于系统该说什么:对未知说“方便留个电话吗”,对无效说“这个号码好像少了一位,麻烦您再确认下”。把无效当未知,用户会觉得自己说的话没被听见。
已过期 vs 已撤回。 前者是系统判断它不再适用(时间、场景变了),后者是用户主动收回。责任方不同,恢复方式也不同。
适用边界 这七个状态不是标准,是一套在多轮业务对话场景下被验证过好用的划分。别的业务可能需要更少(一个只读的查询系统或许只需要未知和有效),也可能需要更多(涉及审批的场景可能需要“待确认”“已驳回”)。判断方法是第 3 章那条定义闭合:沿着每一个会读取这个字段的判断问一遍——它可能出现哪些取值,每个取值有没有明确的出口。 没有出口的取值,就是下一个故障。
五、状态与元数据是正交的
现在来看一个很容易做错的设计。
既然要区分处境,那“已经问过”是不是也该进这个枚举?
不能。因为“问过了而且被拒绝”这种再普通不过的情况,会立刻无法表达——如果状态是“已问过”,就丢了拒绝;如果是“已拒绝”,就丢了问过几次。
根本原因是这两类信息不在一个维度上:
状态(互斥,七选一) 这个字段现在处于什么处境
元数据(与状态正交) 这个字段被怎样询问、确认、更新和维护
元数据里通常有这些:
表 10-5 槽位元数据及其用途
| 元数据 | 用来做什么 |
|---|---|
ask_count |
问过几次,用于第 15 章的询问预算 |
last_asked_turn |
上次什么时候问的,用于隔轮再问 |
confirmed_by_user |
是否发生过显式的二次确认 |
updated_at / scope |
什么时候更新的、属于哪个作用域 |
(evidence_ids 不算元数据,它是槽位对证据的引用;来源和时间由证据自己保存。)
有一组容易混的时间字段需要提前分清:槽位里的 updated_at 是“这个状态最后一次被更新的时间”,而证据里的 timestamp 是“那条证据发生或被采集的时间”。 两者经常不同——一句三轮之前说过的话,可能到本轮才被归并进状态。
confirmed_by_user 是同一条规则的另一个应用。手机号这类关键字段有时需要用户复述确认一次,但“确认过”不是一种字段状况,而是发生过的一次交互——所以它属于元数据,不新增状态。
一般规则:
“这个字段现在是什么状况”属于状态;“系统对它做过什么”属于元数据。混进一个枚举,就会出现无法表达的组合。
六、什么时候该拆开:大枚举的信号
另一个常见的设计问题是把太多东西塞进一个枚举。
比如联系方式,很容易写成这样:
contact_status: 未知 | 手机号已获得 | 微信已获得 | 邮箱已获得
| 拒绝电话 | 拒绝一切 | 微信同号 | 已过期 ...
这个枚举很快会失控,因为它同时在表达三件不同的事:用哪个渠道、渠道上的值是什么、用户授权到什么程度。
拆开之后,每个渠道各自是一个槽位:
{
"phone": { "value": null, "status": "DECLINED" },
"wechat": { "value": "wx_wang01", "status": "VALID" }
}
如果业务上还需要知道“优先用哪个渠道联系”,那就再加一个槽位,而不是塞进别人的元数据里:
{ "preferred_channel": { "value": "wechat", "status": "VALID" } }
这里顺便演示了一个很容易犯的错误:把“用户拒绝了电话”写成 meta: { phone_consent: "declined" }。它看起来像元数据,实际描述的却是一种需要持久维护的处境,因此应该进入状态模型——而且要落对位置:拒绝提供手机号,进入 phone.status = DECLINED;拒绝被电话联系,是对渠道授权的拒绝,应当保存为独立的许可状态(例如 channel_permission.PHONE_CALL = DENIED),而不是塞进 phone 的字段状态或元数据里。这两种“拒绝电话”的区分,第 15 章会正式展开。
要注意的是,判断标准不是“它会不会影响后续决策”——ask_count 同样会影响决策(问满两次就不再问),却仍然是元数据。真正的区别是:
状态描述业务对象当前处于什么处境;元数据描述这个状态是怎么形成的、被问过几次、有没有确认过、什么时候更新的。
判断该不该拆,有两个很实用的信号:
信号一:一个枚举同时在编码好几件事。 “微信已获得”这个值里,混着渠道类型、是否拿到值、以及值本身。如果一个枚举开始同时承担“是什么类型”和“有没有对应数据”两件事,就该拆成独立的字段或结构。(只标类型不承担状态的枚举没有问题——channel: wechat 配一个独立的 value 字段,是完全合理的写法。)
信号二:枚举里出现了“其他”。 出现“其他”时要追问一句:被归到“其他”里的那些情况,后续是否需要区别处理? 如果需要,说明当前的粒度不够;如果业务上确实只需要一个开放的兜底类别,那“其他”可以保留。
七、有字段不等于有状态管理
第 8 章说过一句话:用一个 JSON 存字段,并不等于有了状态。这里可以把它讲完整。
一份数据结构要成为真正的状态,至少需要三件事:
唯一的读取入口。 所有判断都从同一个地方取当前值。如果闸门读的是状态、话术层却从对话历史里又推了一遍,那么“当前单位是什么”就会有两个答案——第 8 章讲的正是这种情况。
明确的写入路径。 值的改变必须经过归并逻辑,而不是哪一段代码觉得该改就直接赋值。第 12 章会讲这条路径怎么设计。
状态可判定。 任何时刻都能明确回答“这个字段现在处于七种状态中的哪一种”,而不是靠 if value is None 猜。
三种常见的做错方式:
表 10-6 槽位建模的常见错误
| 做错的方式 | 后果 |
|---|---|
| 多个模块各自维护一份 | 同一字段出现不同版本,且没人知道该信哪个 |
| 直接覆盖,不留痕迹 | 用户纠正之后,无法回答“之前那个值是怎么来的” |
用 null 表达多种含义 |
第一节那个故障 |
八、槽位回答不了的问题
到这里,一个字段已经能表达“现在是什么处境”了。但它回答不了另一类问题。
用户在第 3 轮说单位是“A 公司”,在第 7 轮又说“刚才说错了,是 B 公司”。对于单位这样的单值槽位,最终只能有一个当前权威值——存哪个?依据是什么?
再复杂一点:第 3 轮的说法来自用户,第 5 轮系统从工具查到的是第三个名字。这时候该信谁?如果两天后用户又改回第一个,算不算撤回?
这些问题不是靠槽位字段本身就能回答的。槽位可以保存当前结论,也可以用 evidence_ids 指向它的依据,但证据究竟是什么、来自谁、彼此冲突时怎么解释和裁决,并不是槽位的职责。
要回答“凭什么从未知变成了有效”“为什么现在相信这个值而不是另一个”,需要正式建立证据层——谁说的、原话是什么、什么时候说的、来自哪里、适用于什么范围。
这些问题属于证据层。