候选、确认与生效:抽取出的金额和训练出的模型
一个票据处理系统接入了自动抽取:识别出的金额直接写进应付账款。识别模型升级后,一批票据被重新处理,几张已经人工核对过的金额被悄悄覆盖了。同一个团队的模型平台也有类似的问题:模型状态是一个字段,
training、trained、validated、deployed、rolled_back越加越多,有一次回滚把状态改回了validated,结果谁也说不清线上到底在用哪个版本。
这两件事看起来属于不同的系统,其实是同一个建模问题:机器产出的东西是候选,不是事实。识别出一个金额,和批准这个金额入账,是两个决策;训练出一个模型,和让某个服务使用它,也是两个决策。把它们放进同一个对象、同一个状态字段,就失去了区分「谁在什么依据上做了什么决定」的能力。
本文用一个 SQLite 文件把两条路径各实现一遍,验证它们共用的四个约束:候选绑定来源、确认按版本条件更新、命令带幂等回执、异步回调匹配尝试号。抽取结果是写死的合成数据,没有调用任何识别或训练服务。实验见文末。
一、对象:候选、正式记录与回执分开
| 对象 | 保存什么 | 谁改它 |
|---|---|---|
| 文档 | 内容哈希、修订号 | 上传与更新 |
| 抽取候选 | 来源哈希、金额、状态 | 抽取器写入;确认时标记 |
| 正式记录 | 金额、业务版本 | 只有确认命令 |
| 确认回执 | 请求 ID、参数 | 确认命令,用于去重 |
模型发布是同样的结构:模型版本(产物哈希、验证状态)对应候选,部署目标(当前指向哪个模型、部署版本)对应正式记录。
抽取出候选 c1(金额 100)之后,正式记录:金额 0、版本 0
训练出 m2(状态 candidate),切换到 m2:拒绝:m2 未通过验证;指向 m1、版本 0候选出现不改变正式记录,这是整个模型的起点。抽取器、识别模型、规则解析都只能产出候选,它们没有写正式记录的权限;开头那批被覆盖的金额,就是因为抽取器直接写了应付账款。
二、确认命令:一次检查所有前提
def confirm(db, request_id, candidate_id, expected_version):
payload = json.dumps([candidate_id, expected_version])
with db:
prior = db.execute("SELECT payload FROM receipt WHERE request_id = ?", (request_id,)).fetchone()
if prior:
if prior[0] != payload:
raise Rejected("同一个请求 ID 换了参数")
return "重复请求,返回原结果"
doc, source_hash, amount, state = ... # 读取候选
if state != "pending":
raise Rejected(f"候选状态是 {state}")
if current_hash != source_hash:
raise Rejected("候选引用的来源已经变了")
changed = db.execute("UPDATE ledger SET amount = ?, version = version + 1 "
"WHERE document = ? AND version = ?", (amount, doc, expected_version)).rowcount
if changed != 1:
raise Rejected("业务版本已变化")
... # 标记候选、写回执一条确认命令在一个事务里检查四件事,每一件都有对应的失败:
请求 r1 按版本 0 确认 c1:已确认;正式记录:金额 100、版本 1
r1 原样重发:重复请求,返回原结果;正式记录:金额 100、版本 1
r1 换了参数(版本 1)再发:拒绝:同一个请求 ID 换了参数
另一位审核人基于版本 0 确认候选 c2(金额 999):拒绝:业务版本已变化- 回执让同一个请求重发时返回原结果,而换了参数的重发被拒绝,这是命令与身份里幂等键的标准用法。
- 业务版本让两位审核人不会互相覆盖:第二个人是基于版本 0 做的判断,而正式记录已经是版本 1,他的判断依据过时了。条件更新用影响行数判断,不需要先读后写。
三、来源变了,旧候选就失效
文档更新为第 2 版后,按最新版本 1 确认 c2:拒绝:候选引用的来源已经变了;正式记录:金额 100、版本 1
对第 2 版重新抽取出 c3(金额 200),按版本 1 确认:已确认;正式记录:金额 200、版本 2c2 携带的业务版本是对的,被拒绝是因为它抽取自文档的第 1 版。候选记录来源的内容哈希,确认时与文档当前的哈希比较:来源变了,基于旧来源的候选就不能再确认,只能对新版本重新抽取。这正是开头那个问题的反面:重新处理产生的是新候选,要经过确认才会改变正式记录,已确认的金额不会被悄悄覆盖。
内容哈希只说明「是哪一份输入」,不证明文档真实、金额正确。完整的系统还要记录抽取器与提示的版本、页码区域、确认人与确认依据,这些在这个金额从哪来里讨论的凭证链同样适用。
四、迟到的回调:匹配尝试号
抽取通常是异步任务,可能超时重试。第 1 次尝试的结果晚于第 2 次到达时,不能覆盖第 2 次:
UPDATE extraction_job SET state = 'succeeded'
WHERE id = ? AND attempt = ? AND state = 'running'抽取任务已是第 2 次尝试:第 1 次尝试的迟到回调更新 0 行,第 2 次的回调更新 1 行,同一回调重复到达更新 0 行条件里的尝试号挡住迟到的旧回调,状态条件挡住重复到达的回调。回调更新了任务状态之后,要不要通知下游,属于事务和消息的原子性问题,用 outbox 与消费去重处理。
五、模型发布:验证状态与部署指针分开
开头那个越来越长的状态字段,问题在于它把三件事塞进了一个值:这个产物是什么、它是否通过了验证、哪个服务在用它。拆开之后:
- 模型版本:产物哈希不可变,验证状态只会从
candidate变成validated(或者rejected)。 - 部署目标:每个目标一行,记录当前指向的模型和自己的部署版本。
m2 通过验证后按版本 0 切换:已切换;指向 m2、版本 1
另一个发布请求仍基于版本 0 要切回 m1:拒绝:部署版本已变化;指向 m2、版本 1
按版本 1 回退到 m1:已切换;指向 m1、版本 2(回退也是一次新版本,不是把版本减回去)
关闭连接后重新打开:指向 m1、版本 2;金额 200、版本 2回退不是「撤销上一次切换」,而是一次新的切换,部署版本继续递增。这样「线上在用哪个版本」永远只有一个答案:读部署目标那一行;模型版本自己的状态不因为回退而改变,m2 仍然是一个通过验证的模型,只是没有被这个目标使用。同一个模型可以同时被几个目标引用,也只有指针和模型分开才表达得出来。
需要说清楚的是,指针改了不等于模型上线了。真实的发布还要区分期望状态与观测状态:控制面记下「api 应该用 m2」,执行器加载产物、服务实例报告实际加载的版本、流量按策略切换,部分实例失败时状态要能表达「部分生效」。本文的实验只验证了登记表这一层,没有推理服务、流量切换和资源调度。训练检查点与交付产物的区别,见模型部署。
六、规则放在哪里
| 规则 | 放在 |
|---|---|
| 抽取器、训练任务只能产出候选 | 端口:它们拿不到写正式记录的接口 |
| 来源未变、金额合法、模型已验证 | 确认或切换命令的前置检查 |
| 不覆盖别人的决定 | 正式记录与部署目标上的版本条件 |
| 重复请求返回原结果 | 回执表,与写入在同一个事务里 |
| 迟到的异步结果不生效 | 回调更新的尝试号条件 |
更正一个已确认的金额,也应该是一条显式的命令:记录旧值、新值和原因,而不是让一个新候选绕过确认直接覆盖。
配套实验
- codesphere-labs/ddd/candidate-confirmation-activation:Python 3.14.8、SQLite:候选与正式记录、回执去重、业务版本条件、来源变化后的失效与重新抽取、回调尝试号、模型验证状态与部署指针、回退(验证记录)
参考资料