第 18 章 验证、提交与回滚

上一章把一次调用拆成了三段。最后那一步还没有交代:渲染出来的那句话,在发出去之前要不要再检查一遍?如果这一轮既要改状态、又要发消息、还要调外部接口,怎么保证它们不会只成一半?

这一章处理一次交互的收尾。它也是全书最容易“修错地方”的一章。


一、同一句错话,可能有四种完全不同的病因

系统对用户说了这么一句:

“已经为您登记,稍后会有工作人员与您联系。”

事后发现,后续业务系统里根本没有这条服务请求。

这句话确实错了。但“这句话错了”不是一个诊断,它只是一个症状。至少有四种病因能产生同一句话:

表 18-1 四类错误及其修复位置

病因 实际发生了什么 该修哪里
表达错了 状态正确(未提交),渲染多说了一句 渲染契约与表达验证
决定错了 闸门放行了本不该放行的提交 闸门条件、决策表覆盖
状态错了 归并把一条无效证据算成了有效字段 证据规则、归并器
执行错了 接口其实返回了失败或超时,系统当成了成功 工具层与观测结果处理

四种病因,同一个症状。而当时采取的动作是:在渲染提示词里加一句“未提交时不得说’已登记’”。

下一次,同一类问题换了个说法出现——“已经安排好了,请保持电话畅通”。

这是第 7 章那条教训的又一次重演:禁令只堵住了这一次的措辞,没有修掉产生这句话的原因。 而更根本的问题在于,这次连“原因在哪一层”都没有查清楚就动手了。

生产观察 收尾环节的故障之所以难修,是因为症状通常出现在最后一层。表达是用户唯一能看见的东西,所以上游任何一层出的错,最终往往都表现为“它说错了一句话”。只在症状出现的那一层动手,通常修不到根因。

所以这一章的第一件事不是讲怎么检查,而是讲怎么分型。

二、错误分型:先定位,再动手

分型需要的东西很朴素:把这一轮每一层的中间产物都留下来,然后逐层对齐。

用户原话        →  证据候选、事件候选     ← 抽取对不对?
事件表          →  候选状态、base_version ← 归并对不对?
候选状态        →  闸门判定、原因码       ← 该不该放行?
判定结果        →  计划动作               ← 选的动作对不对?
计划动作        →  渲染文本               ← 说的和批的一致吗?
提交与调用      →  观测结果               ← 真的发生了吗?

逐层对齐的判断很机械:从后往前找第一个“输入是对的、输出是错的”位置——那一层通常就是最接近根因的候选位置。找到之后还要再看一层:是这层的规则写错了,还是它依赖的配置、契约版本或上游语义本身就有问题。

回到开头那句话,四种病因在这条链上留下的痕迹完全不同:

  • 状态是“未提交”、动作是“继续询问”,文本却说“已登记”——病因在渲染或验证
  • 状态里的字段都还没齐,闸门却给了 PASS——病因在闸门
  • 字段齐了,但那个手机号是从一段无关文本里抽出来的——病因在抽取或归并
  • 一切都对,接口返回的是超时,系统写成了成功——病因在观测结果处理

这件事做不到的前提只有一个:中间产物没有被记下来。 那样的话,任何诊断都只能靠猜,而猜的结果通常就是去改最容易改的那一层——提示词。

第七节会说这些东西该在什么时候写进追踪记录。

三、表达验证:验证器只能否决,不能改写

现在来看渲染出来的那句话该怎么检查。

第 17 章给渲染层定了四条禁止:不许改动作、不许添事实、不许翻案、不许直译原因码。但把禁止写进提示词,只是提出了要求;要让它成立,需要一道独立于生成的检查。

这道检查的核心纪律只有一条:

验证器只能否决整条消息,不能自己动手改写它。

真实系统的提示词里,这条纪律当时仍以发送前自检规则的形式写着:

任何一次最终文本与已提交状态冲突时,丢弃文本而不是修改状态;只允许重新渲染一次。仍冲突则不提问,只输出不含已知字段索取的安全结果。

一句话里其实压了三条本书分散在不同章节讲的原则:否决而不是改写(本章)、重渲染有次数上限(本章第三节)、兜底时选当前合法的最低风险动作(第 15 章)。而且它明确了冲突时该牺牲哪一边——丢文本,不动状态。

这条历史规则是后来独立验证器设计的前身,不等于当时已经存在应用层验证器。本章后面的验证器,是把这条纪律继续工程化后的目标形态。

这一条经常被违反,因为改写看起来更“聪明”——检测到文本里出现了不该出现的手机号,那就把它替换掉,消息照发。但这样做的后果是:验证器变成了第二个没有契约的决策者,而它改出来的文本,没有任何人再验证一遍。

而且改写往往会制造出更奇怪的东西:把“已经为您登记”里的“已经”删掉,剩下的半句话可能既不通顺,也不再表达任何明确意思。验证器是一道开关,不是一个文本编辑器。

检查什么,对应第 17 章那四条:

动作一致  文本里的问句、承诺、下一步,必须与已批准的计划动作对应
事实白名单 文本里出现的每一条事实,必须在"已获准表达的事实快照"里
完成语门槛 未提交、未确认时,不得出现"已登记""已提交""已通知"
不得翻案  不得推翻或软化闸门已经给出的结论

第二条和第三条最值得展开。

事实白名单依赖第 17 章那份快照——渲染只能引用其中的事实。对可以机械识别的具体值(型号、金额、日期、编号、联系方式),检查是干净利落的:把文本里出现的这类值抽出来,逐个比对快照,不在其中就否决。

但要说清它的限度:像“这款比较适合复杂使用场景”这样的句子里没有明显的具体值,它仍然是一个事实性主张。验证一段自然语言里的每一条主张是否都被快照支持,不是一个纯机械问题。 验证器能覆盖的是可确定检查的部分,不能假装完成了完整的事实核验。

完成语门槛接的是第 9 章那条规则:对外的事实主张必须与能证明它的结果严格对应。这里要说准一层——什么结果,支撑什么完成语

"信息已保存"      ← 内部状态类事实,由成功的应用内提交支撑
"服务请求已提交"  ← 外部动作类事实,必须由对应的 SUCCESS 观测结果支撑
"短信已发送"      ← 同上,由消息通道的 SUCCESS 观测结果支撑

本地提交成功只证明本系统写成功了,它证明不了后续业务系统里已经有这条服务请求。拿不到与这句话相匹配的结果,这句话就一个字都不能说。

否决之后有三条出路,按优先级:

  1. 重新渲染一次——必须有次数上限,否则会在同一个问题上空转;而且重渲染要沿用同一份计划动作、同一份事实快照和同一个 base_version。如果这期间状态版本已经变了,就不能接着重渲染,而应当丢掉草稿、重新规划这一轮——否则第二次渲染依据的是一份过期事实,验证也就失去了意义;
  2. 降级为模板表达——用一条不依赖生成的固定话术,把已批准的动作说出来;
  3. 不发出,转人工或安全降级——按第 15 章那条:在当前合法的动作里选代价最小的。

适用边界 逐字否决适用于事实和动作可以枚举的场景——字段、金额、型号、状态、下一步动作。对开放式长文本(一段解释、一份说明),验证只能覆盖其中可判定的部分:有没有出现禁止的完成语、有没有引用快照之外的具体值。验证覆盖不到的部分,不要假装它被覆盖了;那部分的可靠性只能靠评测来保证(第 19 章)。

四、准备、验证、提交:对话动作和工具动作走不同的路

检查放在哪一步,取决于这一轮要做的是什么动作。

图 18-1 先给出两条路径的差别:对话动作可以直接进入渲染与提交;工具动作必须先执行、观测并回流,再生成真正要说给用户听的第二个计划动作。

对话动作与工具动作的两条运行闭环

图 18-1 对话动作与工具动作的两条运行闭环

先说没有外部副作用的那种——追问一个字段、回答一个问题、做一次确认:

准备   算候选状态、判闸门、选动作、渲染草稿
       ——全部在内存里,不产生任何外部可见的效果
验证   提交前的最终校验(本章)
提交   写入新的已提交状态版本、写入消息记录(或送进待发送队列)

这个划分的关键,在于准备阶段绝不能产生外部可见的副作用

最常见的违规是:在准备阶段“先调一下接口试试”。为了让渲染更准确,提前查一次库存,或者提前建了一张草稿单。前者通常还好(只读),后者就危险了——如果后面的验证否决了这条消息,那张草稿单已经留在外部系统里了。

一条实用的判据:准备阶段允许“读”,不允许“写”。

但“允许读”不等于允许无痕查询。查一次库存没有写副作用,可如果这个结果会影响状态、动作,或者会被说给用户听(有没有货、什么价、能不能承诺),那它就应该作为一次正式的工具动作走完整链路:留下观测结果与来源,进入证据和状态链,而不是渲染时顺手查一下就直接说出去。这里说的“允许读”,只是说它不会产生需要补偿的业务写副作用。

适用边界 “验证”不等于把所有不变量攒到最后跑一遍。第 15 章已经把它们分到了不同的检查点:状态不变量在候选状态刚算出时检查,动作不变量在策略给出计划动作后检查,跨层不变量在提交前检查。每一条都应该在最早能够判断的位置执行——越早发现,错误传播得越浅。这里说的“验证”,指的是提交前的最后一道:尚未完成的跨层不变量,加上本章的表达验证。

有外部副作用的那一轮,路径不一样

如果这一轮的计划动作是“在后续业务系统里创建一条服务请求”,上面那条路径就走不通了。

原因很直接:渲染凭什么说“已经登记”? 这句话的合法前提,是对应的外部调用返回了 SUCCESS 观测结果(第 9 章)。本地提交成功只能证明记录已经写入本系统,不能证明客户系统已经完成登记。

而如果外部调用被排在提交阶段、排在渲染之后,那么渲染在写这句话的时候,接口根本还没调。

所以工具动作走的是参考架构里那条更长的路:

计划动作(工具类)
   ↓
执行
   ↓
观测结果        SUCCESS / FAILURE / UNKNOWN
   ↓
落成证据、解释为事件
   ↓
回到归并,重新折叠出候选状态
   ↓
重新进入确定性控制流水线
   ↓
渲染 → 验证 → 提交(状态与消息记录)

这里的确定性控制流水线,仍然按第 13 至 17 章已经定义的职责运行——闸门、策略、状态机、不变量各在各自的检查点上,本章不重新规定它们的顺序。

只有当观测结果是 SUCCESS 并被折叠进状态之后,“已经登记”才成为渲染层可以引用的一条事实。观测结果是 FAILUREUNKNOWN,重新折叠出来的候选状态就不同,控制层给出的动作也不同,那一轮该说的话自然也不一样。

这条路径可以浓缩成一句话:

有外部副作用的这一轮,不是“先把话渲染好,再去执行动作”;执行结果本身会改变这一轮还能说什么。

它也解释了第 9 章那条执行预算为什么必要:这是一个会回到归并的环,没有预算它就可能一直转下去。

五、三种常被叫作“提交”的东西,只有一种是提交

现在到了本章最重要的概念澄清。“提交”这个词在提示词系统里至少被用在三个地方,而它们完全不是一回事:

模型输出的"提交"     一个计划动作,或者一句草稿
                    ——它本身什么也没发生

应用内的提交         版本检查通过、验证通过之后,
                    写入新的已提交状态版本与消息记录
                    ——这一种,才是提交

外部世界的动作       建单、发消息、扣款、通知后续处理人员
                    ——这是执行,它的结果由观测结果回答

生产观察 那个系统的提示词内部把“状态提交”和“文本提交”写成两个规则区段,前面还有一句总纲——“正常回复与诊断必须读取同一份已提交状态,禁止’回复时一套状态、查询槽位时再算另一套状态’”。这里的“提交”仍是提示词内部的行为规范,不是应用层事务提交。

这句话防的是一种很隐蔽的故障:诊断接口和业务回复各算各的。那样一来诊断永远显示正常,而用户看到的是另一回事——排查工具本身成了失效点(第 20 章)。

第一种最容易被误认。模型说“服务请求已经提交”,这句话的全部效力就是“模型说了这句话”。它不是事件,更不是提交——仍然是第 9 章那条规则:模型建议行动,不创造结果。

第三种更值得强调:外部系统到底成功没有,不能靠本系统“提交成功”来推断。 你的库里写了一条记录,只证明你的库里有了这条记录。外部那一侧发生了什么,只有观测结果能回答。把这两件事混起来,就会产生一种很难查的故障:本地状态一片正常,外部什么也没有。

消息也一样。提交消息,指的是这条消息进入了本系统的权威会话记录或待发送队列,不等于用户已经收到。 如果投递依赖短信、邮件、企业微信这类外部通道,那次投递本身就是一个外部动作,它的结果同样要由观测结果来回答——不能凭本地提交成功就声称“已送达”。

消息已提交  ≠  消息已送达
动作已计划  ≠  动作已成功

这两行是同一条原则的两种写法。

所以第 17 章末尾那个问题——“怎么保证要么都成、要么都不成”——的诚实回答是:做不到。 跨越本地存储、外部接口和已发出消息的原子提交,在多数业务系统里并不存在。能做的是把失败的代价压到最小。

第一,外部调用之前只持久化“待执行”,不写“已完成”。

这一条最容易做错。为了“先把状态记上”,很多实现会在调用前写下:

request_submitted = true      ← 错

然后接口超时。于是本地状态在声称一件没有被证实的事,而下一轮的渲染会理直气壮地说“已经登记”。

调用之前该写的是执行意图,不是执行结果:

service_request.status = PENDING_CONFIRMATION
idempotency_key = ...
planned_action = SUBMIT_REQUEST

结果由观测结果决定:

SUCCESS   → 已完成
FAILURE   → 已失败
UNKNOWN   → 待确认(不是成功,也不是失败)

这里要防一个混淆:PENDING / SUCCESS / FAILURE / UNKNOWN 描述的是一次外部执行尝试的状态,和第 10 章槽位的 UNKNOWN / VALID / DECLINED 那套枚举、以及第 14 章的阶段与控制模式,都不是同一套东西。全书到这里已经出现了四套状态体系,它们各自回答不同的问题:槽位状态说这个业务字段现在处于什么状况,流程阶段说流程走到哪一步,控制模式说现在谁在开车,执行尝试状态说这一次外部调用有没有被确认。 第 24 章还会引入第五类——业务执行状态,描述整件业务的生命周期。

第二,所有可能重试的外部写动作,都要有稳定的幂等身份。 外部接口原生支持幂等键,就直接传;不支持的话,也要用本地执行记录、业务唯一键或者一层去重来实现等价保护(第 9 章)。没有幂等身份的重试,等于在赌对方不会重复受理。

第三,UNKNOWN 记成“待确认”。 这是第 9 章那条三态规则在这一层的落地:不知道有没有成功,和知道没成功,是两件不同的事——而它们都不足以支撑一句完成语。

第四,出了问题用补偿,不用撤销。 消息已经发出,就再发一条更正;单子已经建了,就走作废流程。补偿是一个新的动作,它同样要经过闸门、验证和记录,不是绕过流程的后门。

六、跨过提交边界之后,只能往前修

最后一个纪律,关于历史。

先把两件事分开:当前已提交状态是权威的读入口,它当然会变——第 12 章那条折叠语义讲的就是它怎么从一个版本走到下一个版本。不可改的是另一样东西:已经形成的历史版本,不应该被原地改写。

发现某一轮提交错了,正确的做法不是回去把那条记录改对,而是提交一个新版本,并记录一个纠正事件(第 11 章的 correct):

state_version = 42     ← 错了,但保持原样
state_version = 43     ← 新版本,附带纠正事件

理由有三条:

  • 可回放——第 12 章那条折叠语义成立的前提,是历史不被篡改。改了历史,就再也无法重现当时的判断(第 20 章);
  • 并发安全——原地修改会和其他正在进行的写入冲突;
  • 可追溯——审计要看的是“当时认为是什么、后来怎么改的”,而不是一份被抹平的最终结果。

并发冲突沿用第 12 章那条:提交时检查这一轮的 base_version 是否仍等于当前的 state_version。不相等,说明中间有别的写入进来了。此时既不合并,也不覆盖,而是拿最新的已提交状态重新折叠一遍——版本只负责检测冲突,不负责裁决谁对。

于是“回滚”这个词的含义可以说准了:

真正能无痕回滚的,只有尚未跨过提交边界的准备工作和本地事务;已经提交的状态靠新版本纠正,已经发生的外部副作用靠补偿处理。

准备阶段的候选状态、计划动作、渲染草稿,被否决了就整体丢弃,什么也不留下——除了追踪记录。

七、追踪记录写在哪几个点

前面所有的分型、验证、冲突检测,都依赖一件事:中间产物被记下来了。

写入点大致是这些:

抽取之后    证据候选、事件候选、未解析项
归并之后    候选状态、base_version、应用了哪些事件
闸门判定    PASS / BLOCK、blockers[]、原因码
策略之后    计划动作(类型、作用域、事实引用)
渲染之后    文本草稿
验证之后    通过或否决;否决时记下是哪一条检查、否决了什么
提交之后    提交结果、新的 state_version
外部调用    待执行记录、幂等身份、请求、观测结果三态、回执内容

有一条最容易被忽略:被拦下的那些,尤其需要记录。

很多系统只记成功路径——提交了什么、发了什么。但成功路径本来就最容易复原。真正难查的是“它为什么没做”:闸门为什么拦下、验证为什么否决、工具为什么没调成。这些时刻如果不留痕,事后就只剩下用户的一句“它没反应”。

一份合格的追踪记录,应该能回答一个问题:

这句话,凭什么说得出口?

顺着它往回走,应该能看到:这句话对应哪个已批准的动作,那个动作基于哪个版本的候选状态,那个状态由哪些事件折叠而来,那些事件又引用了哪几条证据。这条链断在哪里,系统的可解释性就止于哪里。 第 20 章会讲怎么用它做失败定位。

八、一轮闭合了,人还没有出现

一次交互的完整链路现在已经闭合:理解、归并、判定、计划、渲染、验证、提交、记录。每一步都有契约,每一步都可追溯,出了错知道该往哪一层看。

把本章收束成三条提交纪律:

症状出现在最后一层,病因往往不在那里;先定位,再动手。

验证器可以否决生成结果,但不能偷偷替它做第二次决策。

提交边界之前可以回滚;跨过边界之后,状态用新版本纠正,外部副作用用补偿动作向前修复。

第二至四篇要讲的机制至此已经齐了:上下文与证据、状态与判定、分工与提交,以及第 15 章那条兜底——包括什么时候该停下来把控制权交给人。一个能上生产的对话系统,需要的东西大体都在这里。

但还有一个问题一直没有回答。

前面反复在说“这样更可靠”“那样更稳”。凭什么?换了一个版本之后,你怎么知道它真的比上一版好,而不只是在你随手试的那几条上看着更顺眼?

第 1 章那份提示词之所以能改到两千两百行,一个重要原因就是:每一次修改都无法被证伪。 没有回归集,没有判分口径,改完只能靠“感觉好像好了”。于是没有人敢删任何东西。