第 10 章 槽位

第二篇的结论是:系统需要一份跨轮的、权威的状态。这一篇讲怎么把它建起来。

起点是最小的那个单元——一个字段。 而第一个问题就出在最不起眼的地方:这个字段是空的,到底什么意思。

图 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 指向它的依据,但证据究竟是什么、来自谁、彼此冲突时怎么解释和裁决,并不是槽位的职责。

要回答“凭什么从未知变成了有效”“为什么现在相信这个值而不是另一个”,需要正式建立证据层——谁说的、原话是什么、什么时候说的、来自哪里、适用于什么范围。

这些问题属于证据层。