Skip to content

候选、确认与生效:抽取出的金额和训练出的模型 ​

一个票据处理系统接入了自动抽取:识别出的金额直接写进应付账款。识别模型升级后,一批票据被重新处理,几张已经人工核对过的金额被悄悄覆盖了。同一个团队的模型平台也有类似的问题:模型状态是一个字段,training、trained、validated、deployed、rolled_back 越加越多,有一次回滚把状态改回了 validated,结果谁也说不清线上到底在用哪个版本。

这两件事看起来属于不同的系统,其实是同一个建模问题:机器产出的东西是候选,不是事实。识别出一个金额,和批准这个金额入账,是两个决策;训练出一个模型,和让某个服务使用它,也是两个决策。把它们放进同一个对象、同一个状态字段,就失去了区分「谁在什么依据上做了什么决定」的能力。

本文用一个 SQLite 文件把两条路径各实现一遍,验证它们共用的四个约束:候选绑定来源、确认按版本条件更新、命令带幂等回执、异步回调匹配尝试号。抽取结果是写死的合成数据,没有调用任何识别或训练服务。实验见文末。

抽取结果文档内容哈希、修订号候选 c1、c2、c3来源哈希、金额确认确认命令回执 · 状态 · 来源 · 版本正式记录金额 200、版本 2来源变了:c2 被拒绝基于旧版本:被拒绝模型发布训练任务回调匹配尝试号模型版本 m1、m2产物哈希、验证状态切换切换命令已验证 · 部署版本条件部署指针 apim1、版本 2m2 未验证:被拒绝回退 = 新版本机器产出的只是候选:抽取器、训练任务都拿不到写正式记录或部署指针的接口指针改了不等于模型上线:加载、流量切换与观测状态在实验之外
图 1 · 实测(SQLite):抽取出的候选不改正式记录;确认在一个事务里检查回执、候选状态、来源哈希与业务版本。文档更新后旧候选被拒绝,对新版本重新抽取的候选可以确认。模型版本未验证时不能被部署指针引用;切换与回退都按部署版本条件更新,回退产生版本 2 而不是退回版本 0。

一、对象:候选、正式记录与回执分开 ​

对象保存什么谁改它
文档内容哈希、修订号上传与更新
抽取候选来源哈希、金额、状态抽取器写入;确认时标记
正式记录金额、业务版本只有确认命令
确认回执请求 ID、参数确认命令,用于去重

模型发布是同样的结构:模型版本(产物哈希、验证状态)对应候选,部署目标(当前指向哪个模型、部署版本)对应正式记录。

text
抽取出候选 c1(金额 100)之后,正式记录:金额 0、版本 0
训练出 m2(状态 candidate),切换到 m2:拒绝:m2 未通过验证;指向 m1、版本 0

候选出现不改变正式记录,这是整个模型的起点。抽取器、识别模型、规则解析都只能产出候选,它们没有写正式记录的权限;开头那批被覆盖的金额,就是因为抽取器直接写了应付账款。

二、确认命令:一次检查所有前提 ​

python
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("业务版本已变化")
        ...                                          # 标记候选、写回执

一条确认命令在一个事务里检查四件事,每一件都有对应的失败:

text
请求 r1 按版本 0 确认 c1:已确认;正式记录:金额 100、版本 1
r1 原样重发:重复请求,返回原结果;正式记录:金额 100、版本 1
r1 换了参数(版本 1)再发:拒绝:同一个请求 ID 换了参数
另一位审核人基于版本 0 确认候选 c2(金额 999):拒绝:业务版本已变化
  • 回执让同一个请求重发时返回原结果,而换了参数的重发被拒绝,这是命令与身份里幂等键的标准用法。
  • 业务版本让两位审核人不会互相覆盖:第二个人是基于版本 0 做的判断,而正式记录已经是版本 1,他的判断依据过时了。条件更新用影响行数判断,不需要先读后写。

三、来源变了,旧候选就失效 ​

text
文档更新为第 2 版后,按最新版本 1 确认 c2:拒绝:候选引用的来源已经变了;正式记录:金额 100、版本 1
对第 2 版重新抽取出 c3(金额 200),按版本 1 确认:已确认;正式记录:金额 200、版本 2

c2 携带的业务版本是对的,被拒绝是因为它抽取自文档的第 1 版。候选记录来源的内容哈希,确认时与文档当前的哈希比较:来源变了,基于旧来源的候选就不能再确认,只能对新版本重新抽取。这正是开头那个问题的反面:重新处理产生的是新候选,要经过确认才会改变正式记录,已确认的金额不会被悄悄覆盖。

内容哈希只说明「是哪一份输入」,不证明文档真实、金额正确。完整的系统还要记录抽取器与提示的版本、页码区域、确认人与确认依据,这些在这个金额从哪来里讨论的凭证链同样适用。

四、迟到的回调:匹配尝试号 ​

抽取通常是异步任务,可能超时重试。第 1 次尝试的结果晚于第 2 次到达时,不能覆盖第 2 次:

python
UPDATE extraction_job SET state = 'succeeded'
 WHERE id = ? AND attempt = ? AND state = 'running'
text
抽取任务已是第 2 次尝试:第 1 次尝试的迟到回调更新 0 行,第 2 次的回调更新 1 行,同一回调重复到达更新 0 行

条件里的尝试号挡住迟到的旧回调,状态条件挡住重复到达的回调。回调更新了任务状态之后,要不要通知下游,属于事务和消息的原子性问题,用 outbox 与消费去重处理。

五、模型发布:验证状态与部署指针分开 ​

开头那个越来越长的状态字段,问题在于它把三件事塞进了一个值:这个产物是什么、它是否通过了验证、哪个服务在用它。拆开之后:

  • 模型版本:产物哈希不可变,验证状态只会从 candidate 变成 validated(或者 rejected)。
  • 部署目标:每个目标一行,记录当前指向的模型和自己的部署版本。
text
m2 通过验证后按版本 0 切换:已切换;指向 m2、版本 1
另一个发布请求仍基于版本 0 要切回 m1:拒绝:部署版本已变化;指向 m2、版本 1
按版本 1 回退到 m1:已切换;指向 m1、版本 2(回退也是一次新版本,不是把版本减回去)
关闭连接后重新打开:指向 m1、版本 2;金额 200、版本 2

回退不是「撤销上一次切换」,而是一次新的切换,部署版本继续递增。这样「线上在用哪个版本」永远只有一个答案:读部署目标那一行;模型版本自己的状态不因为回退而改变,m2 仍然是一个通过验证的模型,只是没有被这个目标使用。同一个模型可以同时被几个目标引用,也只有指针和模型分开才表达得出来。

需要说清楚的是,指针改了不等于模型上线了。真实的发布还要区分期望状态与观测状态:控制面记下「api 应该用 m2」,执行器加载产物、服务实例报告实际加载的版本、流量按策略切换,部分实例失败时状态要能表达「部分生效」。本文的实验只验证了登记表这一层,没有推理服务、流量切换和资源调度。训练检查点与交付产物的区别,见模型部署。

六、规则放在哪里 ​

规则放在
抽取器、训练任务只能产出候选端口:它们拿不到写正式记录的接口
来源未变、金额合法、模型已验证确认或切换命令的前置检查
不覆盖别人的决定正式记录与部署目标上的版本条件
重复请求返回原结果回执表,与写入在同一个事务里
迟到的异步结果不生效回调更新的尝试号条件

更正一个已确认的金额,也应该是一条显式的命令:记录旧值、新值和原因,而不是让一个新候选绕过确认直接覆盖。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权