第 12 章 归并器

上一章把证据和事件建起来了。现在手上有一堆带来源、带时间、带范围的记录。

这一章解决最后一步:它们怎么合成唯一的当前状态。


一、“另外还要一个”

先看一个真实的故障形态。

一个多轮产品客服系统里,用户先咨询产品 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 章讲状态时说过一句话:状态只描述当前处境,不直接等于下一步动作。现在到了兑现它的时候——在状态和动作之间,还需要一道判断。

状态只说明当前处境;要把它变成可执行的业务许可,还需要闸门。

读到不对的地方?

勘误、疑问,或者你在自己项目里的验证与反例,都欢迎交流。

微信联系
微信二维码

扫码添加,备注「从提示词到系统」