上一章把证据和事件建起来了。现在手上有一堆带来源、带时间、带范围的记录。
这一章解决最后一步:它们怎么合成唯一的当前状态。
一、“另外还要一个”
先看一个真实的故障形态。
一个多轮产品客服系统里,用户先咨询产品 A,聊了六七轮,单位、联系人、使用需求都说清楚了。然后他说:
另外我们还要了解产品 B,用在另一个场景
系统更新了品类,继续往下走。等到生成需求摘要时,问题出现了:
摘要里的品类已经是产品 B,使用需求却还是上一项需求留下来的内容。
用户看到摘要一头雾水,后续系统拿到的业务记录也是错的。
排查下来,原因不在抽取,也不在证据:那句话被正确理解成了“新增需求”,品类字段也正确更新了。问题在于,除了品类之外的其他字段,没有人规定它们该怎么办。 使用需求属于上一项需求,没被失效;联系人仍然适用于新需求,应该保留——但系统并没有区分这两种情况,它只是什么都没做。
这就是本章要处理的问题:当新的事实到来时,旧的事实该留、该改、该清,需要有明确的规则。 没有规则时,系统的默认行为是“保持原样”——而那往往是最坏的选择。
二、归并是一次折叠,不是一串赋值
先定义这件事怎么做。
从这里开始给出的是参考架构中的归并器设计。真实项目当时已经在提示词内部形成了类似的状态合并规则,但没有一套独立的持久化事件账本和外部代码归并器;后文的纯函数、版本化状态与回放能力属于目标实现。
很多实现是这样写的:抽取到什么,就往状态里赋值什么。
if 抽到了品类: state.category = 新品类
if 抽到了地区: state.region = 新地区
...
这种写法在简单场景能跑,但它有几个不好处理的问题:赋值顺序会影响结果、中途出错就留下一个半改的状态、而且没法回答“为什么现在是这个值”。
本书用另一种做法:
候选状态 = 折叠(上一份已提交状态, 本轮事件表)
也就是说,归并不是在旧状态上东改一处西改一处,而是拿上一份已提交状态当起点,把本轮的事件按顺序过一遍,算出一份新的候选状态。
这样做有三个直接的好处。
结果确定。 同一份起点加同一张事件表,必然折叠出同一个状态。可以写单元测试,可以复现。
可以回放。 出问题时,把事件表重新折叠一遍就能还原当时的状态——这是第 20 章失败定位的基础。
工具回执不会造成歧义。 第 9 章那条回路里,工具回执会在一轮之内追加新的事件。如果用增量赋值,就要纠结“第二次回执该基于哪个中间状态”;用折叠则不存在这个问题——新事件追加到本轮事件表末尾,整体重新折叠一次即可。
两个必须遵守的约束:
折叠必须是纯函数。 不读系统时钟、不读随机源、不依赖外部可变状态。需要时间就由事件自带时间戳。否则同一份事件表在不同时刻会折叠出不同结果,前面三个好处全部失效。
这条约束有一个很容易被忽略的推论:归并器不重新理解自然语言。 这句话是不是纠正、表述够不够明确、说的是哪个需求——这些判断必须在事件生成阶段就完成,并结构化地写进事件属性:
{
"type": "correct",
"slot": "region",
"value": "上海",
"evidence_ids": ["E07", "E19"],
"explicitness": "explicit",
"confirmed_by_user": false
}
归并器只读事件属性和字段配置,执行确定性规则。一旦它在运行时又去调模型判断一次“这句话够不够明确”,前面所有关于可复现、可测试、可回放的说法就都不成立了。
事件要去重。 同一条回执重复回流、同一个请求被重试,都可能产生重复事件,折叠前要按事件标识去重。
这和第 9 章的幂等键属于同一类问题,但不是同一个标识:外部动作靠幂等键防止重复产生副作用,内部归并靠稳定的事件标识防止同一个事件被消费两次。两者各管一段,不能互相替代。
三、折叠的顺序
事件不是随便过一遍就行,顺序有讲究。
第一步 处理作用域事件——这一轮有没有新开一组字段
第二步 把字段事件分派到各自的作用域
第三步 同一字段的多个事件,按确定性的事件顺序依次应用
第一步不能省,也不能放到后面。上一章讲事件分层时提过原因,这里可以说得更具体:
如果先折叠字段事件、再处理作用域,那么“另外还要了解产品 B”这句话产生的 set(category, 产品B),就会落在旧的那组字段上——第一节那个故障的一种典型成因。先确定这一轮在哪组字段上操作,再动字段。
第三步需要说得更严格一点:归并器需要的是一个可重复的全序,而不只是时间戳。
时间戳在生产里并不够用——用户消息和工具回调可能落在同一毫秒,服务时钟可能有偏差,回调可能晚到,同一条消息也可能产生多个事件。所以事件至少要带上足够的排序信息,并规定一个稳定的规则:
排序键:作用域 → 轮次或来源序号 → 事件序号 → 事件标识(最终裁断)
顺序为什么必须确定,用同一个字段的例子最清楚:
set(地区, 北京) → correct(地区, 上海) 结果:上海
correct(地区, 上海) → set(地区, 北京) 结果:北京
同样两个事件,顺序一反结果就不同。如果这个顺序不是确定的,“同样的输入得到同样的输出”就是一句空话。
四、冲突了按什么裁决
现在到了最难的一格:两条证据都合法、都没过期、都指向同一个字段,但值不一样。
先说一件不该做的事:不要设一个全局固定的来源排序,比如“人工 > 工具 > 用户”。上一章说过原因——权威性取决于字段,不取决于来源本身:
当前收货地址 用户此刻的说法通常最权威
企业工商注册信息 外部权威数据源通常更可靠
用户的联系偏好 只有用户本人说了算
订单状态 业务系统记录优于用户口述
可用的裁决维度通常有四个:
表 12-1 冲突裁决的判断维度
| 维度 | 判断 |
|---|---|
| 时间 | 用户纠正、当前需求这类事件,较新的“被观察到的时间”通常更有意义;而带生效期或版本的数据,应当按字段规则使用有效期与版本判断。不要把数据库写入时间当成事实的新旧 |
| 明确程度 | 直接陈述优先于顺带提及,顺带提及优先于推断——这个属性在事件阶段判定并写进事件,归并器只读它 |
| 来源权威 | 按字段定义,不是全局排序 |
| 是否经确认 | 用户复述确认过的值,优先于只说过一次的 |
本书案例系统使用的默认序是这样的:
本轮明确表述 > 历史中未被撤回的表述 > 注入字段或推断 > 未知
适用边界 上面这个序是一套默认值,不是通用规则。它适用于“用户是主要信息来源、且对自己的需求最了解”这类场景。如果你的业务里存在权威数据源(工商信息、订单系统、账户绑定信息),那么在那些字段上,外部数据应当优先于用户口述。优先级应该按字段写在配置里,而不是散落在代码判断中。
最后一条,也是最容易做错的一条:规则裁决不了的时候,不要硬选。
两条证据同样新、同样明确、同样来自用户,指向不同的值——这时候正确的动作是把字段置为冲突(CONFLICT),而不是随便挑一个继续往下走。冲突是第 10 章那七个状态里的一个,它的存在就是为了表达“系统知道自己拿不准”。
至于拿不准之后该怎么办——是追问一句,还是先绕开这个字段——那是策略要决定的事,第 13 章会讲。归并只负责把状态算准,包括准确地表达“算不出来”。
五、纠正、新增、撤回
第 11 章把用户表达产生的事件分成了字段事件和作用域事件。业务上最容易混淆的三种变化是纠正、新增需求和撤回——注意其中的“新增”属于作用域事件,另外两个是字段事件。它们的后果完全不同。
表 12-2 纠正、新增与撤回的差异
| 触发形态 | 对当前字段 | 对同组其他字段 | 旧证据 | |
|---|---|---|---|---|
| 纠正 | “说错了,是上海” | 换成新值 | 依赖它的字段要重新评估 | 保留,但不再支撑当前值 |
| 新增 | “另外还要一台” | 在新的一组里赋值 | 旧组完全不受影响 | 保留,继续支撑旧组 |
| 撤回 | “那个先不用了” | 转为已撤回 | 一般不影响 | 保留,证明该值曾经存在并被撤回 |
有两点值得单独说。
纠正会波及别的字段。 用户把品类从产品 A 改成产品 C,那么围绕产品 A 描述的使用需求可能也不再适用。纠正的影响范围不止被纠正的那个字段,还包括依赖它的字段。
但要注意这些字段应该变成什么。它们不是“从来没有过值”,而是“曾经有效、现在因为父字段变了而不再适用”——对应第 10 章定义的已过期(STALE):
不要:使用需求 → 未知 抹掉了"它曾经有值"这个事实
应该:使用需求 → 已过期 保留历史,同时表明现在不能用
这个区别直接影响后续行为:置为未知,系统会像从没聊过一样重新发问;置为已过期,系统可以说“之前提到的使用需求是针对产品 A 的,产品 C 这边需要重新确认吗”。
依赖关系应当在字段模型里显式声明,而不是指望每次都记得手动处理。
撤回不等于回到未知。 撤回事件应把字段置为已撤回(CLEARED)。这是一个很细但很重要的区分:
未知 从来没有过值,可以正常询问
已撤回 用户主动收回过,再问要更谨慎
如果撤回之后直接置回未知,系统下一轮就会像什么都没发生一样重新询问,而用户刚刚才明确说过“那个先不用了”。已撤回带着历史,未知不带——对询问策略的影响完全不同。
六、作用域的生命周期
第一节那个故障,根子在这里:新开一组字段、或者纠正关键字段时,哪些状态继承、哪些保留、哪些失效、哪些重新初始化,必须显式规定。
以这个多轮客服为例,三种情况的处理是不一样的:
情况一:纠正品类(“刚才说错了,不是产品 A,是产品 C”)
更新 品类 → 有效(产品 C)
失效 与原品类强依赖的字段 → 已过期
使用需求、预算区间、产品参数
保留 与人相关、仍然适用的字段
单位、联系人、联系方式
标记 如果摘要已经发出,标记为需要重新生成
情况二:新增需求(“产品 A 还要,另外再加一个产品 B 的需求”)
新建 一组独立的字段,有自己的标识
初始化 没有继承规则的字段,从"未知"开始
——注意这不是"清空旧字段",旧的那一组完全不动
继承 联系信息(同一个人,不必再问一遍)
重判 意向、阶段等状态要重新判断,不能沿用
要求 新需求必须有自己的摘要,不能并进旧摘要
纠正和新增的区别,在实现上就是“改同一份状态”和“新建另一份状态”的区别。 纠正动的是 demand_1,新增产生的是 demand_2,而 demand_1 保持原样。
情况三:摘要发出后的小改(“联系人换成王工”)
更新 联系人
不动 其他字段
处理 视摘要的载体而定,见下
第三种情况里的“更新摘要”要分清载体:如果摘要对应的是一条可编辑的业务记录,就更新那条记录;如果它已经作为消息发出去了——发给用户、发给后续业务人员、发成邮件——那就不能假装从没发过。 这时正确的做法是保留原记录,再发一条更正或补充,同时同步下游的业务对象。这和第 8、11 章那条“只追加、不改写”是同一个原则。
生产观察 三种情况里,最容易漏的是两件事:旧依赖状态的失效处理,和新作用域的初始化。 人们通常记得更新用户刚提到的那个字段,却容易忘掉那些因为它变化而不再适用的旧字段。而系统对没写规则的字段,默认行为是保持原样——于是旧需求的信息就这样混进了新需求。 第一节那个摘要就是这么来的。
继承规则最好写成一张显式的表,而不是散落在代码里:
表 12-3 品类纠正与新增需求走查
| 字段 | 纠正品类 | 新增需求 |
|---|---|---|
| 品类 | 更新 | 新组重新填 |
| 使用需求 | 转为已过期 | 新组从未知开始 |
| 产品参数 | 转为已过期 | 新组从未知开始 |
| 单位 | 保留 | 继承 |
| 联系人 | 保留 | 继承 |
| 联系方式 | 保留 | 继承 |
| 意向阶段 | 保留 | 重新判断 |
这张表的价值在于它逼你为每一个字段做一次决定。 没有这张表时,实际执行的是一条隐含规则:“凡是没被提到的,都不动”——而那条规则从来没有人认真同意过。
还有一点容易被忽略:继承不是无痕复制。 如果只是把 demand_1 的手机号拷进 demand_2,那么以后用户修改 demand_1 的手机号时,没人说得清 demand_2 该不该跟着变。要么把这类字段建模成会话级的共享状态(它本来就不属于某个具体需求),要么在新组里记下它继承自哪一组。
七、版本、快照与并发
折叠出来的候选状态,要标明它是基于哪一份已提交状态算出来的。
{
"base_version": 41,
"scopes": { "demand_1": { ... }, "demand_2": { ... } }
}
这里是 base_version 而不是 state_version——候选状态还没有被提交,它不该带提交时间,也不该占用一个正式版本号。 提交成功之后才产生:
{
"state_version": 42,
"committed_at": "<commit_time>",
"scopes": { ... }
}
版本号有三个用途。
冲突检测。 提交时比较:当前的已提交版本,是不是仍然等于候选状态的 base_version?如果不等,说明中间有别的东西改过状态——可能是一个晚到的工具回调——这次提交不能直接生效。
要说准的是:版本号只负责发现冲突,不负责裁决冲突。 发现之后还得决定怎么办:重新折叠一次、重试,还是把两条路径串行化。完整的提交路径在第 18 章。
追溯与回放。 配合事件表,历史版本可以被重新折叠出来。这是第 20 章失败定位的基础。
快照。 事件越积越多之后,没必要每次都从第一条开始重放。系统可以定期保存状态快照,之后只在快照上继续折叠:
快照(版本 40) + 事件 41…47 → 折叠 → 状态(版本 47)
但快照有一条纪律:它是回放的加速点,不是第二份独立的真相。 它必须能明确追溯到自己对应的版本和事件边界,否则一旦快照和事件表对不上,你会得到两个都自称正确的状态。
最后一个容易被忽略的点:要做到严格的历史回放,还需要记下当时的状态结构版本和归并规则版本。 归并规则后来改过,那么用当前规则去重放更早的事件,未必能得到当时那份状态——这不是 bug,但如果没意识到,追溯时会得出错误的结论。
八、状态算准了,然后呢
第三篇的前半段完成了:证据有出处、事件有分类、折叠有顺序、冲突有裁决、作用域有生命周期。系统现在能给出一份确定的、可复现的、可追溯的候选状态。
但这还不够。
假设归并的结果是:单位有了、联系人有了、手机号有了、品类和需求也都清楚了。那么现在可以生成需求摘要、向后续系统提交服务请求了吗?
不一定。
- 用户可能压根没表达过进一步处理的意向,他只是在问产品参数;
- 用户可能上一轮刚说过“我先自己看看,别联系我”;
- 摘要可能已经发过一次了,这一轮只是改了个联系人;
- 这一轮用户问的是一个具体的业务问题,此刻插进去一段摘要会很突兀。
“字段齐了”和“此刻可以做这件事”,是两个完全不同的判断。
第 10 章讲状态时说过一句话:状态只描述当前处境,不直接等于下一步动作。现在到了兑现它的时候——在状态和动作之间,还需要一道判断。
状态只说明当前处境;要把它变成可执行的业务许可,还需要闸门。