🤖 第六章 应用实战:采购订单智能识别与ERP自动化填报助手
📄 一句话概括

上一节把单据送出去了,这一节把结果收回来。收回来不难,难的是"收齐"和"分档"——每条结果必须正好落进三档之一(通过 / 待人 / 重试),三档加起来必须等于你送出去的单数。少一条,就是账上凭空丢了一张单。

📝 本节和前面几节的关系

6.4.1 结束时落盘的是一张任务号列表(异步调用的代价)。本节负责围绕它做轮询、取回、解析、判档、对账——这是 RPA 侧三步里的第二步。 与前面几章的同行对照:4.4.2 / 5.4.1 也做过"取结果",但那两章取回来的是【文字】(一篇文章、一张图文),对不上就是"这篇没写出来"。 本章取回来的是【数据】(几个字段、几行明细)——对不上不是"少了一篇",是"账上少了一张单"。所以本节比前两章多了一整套"数量对账"的动作。

一、本节的任务:把"还不知道"收敛成"三选一"

6.4.1 说过:异步调用让结果多出了第三种状态——"还不知道"。

本节就是把这个"还不知道"一条条消掉。 一条结果取回来,可能的终局只有三种:

档位 含义 谁处理
通过 解析成功、业务校验全过、置信度达标 机器直接往下走(6.4.3)
待人 系统修不好,必须人看一眼原单 异常表(6.3.2 那张)
重试 与单据内容无关的临时故障,重跑一遍就好 机器自己重跑

注意这个设计的形状:"通过"和"待人"都是终点,只有"重试"是回路。 这跟 6.2 那张状态机是同一件事——本节的判档,就是在给状态机发指令。

二、取回不是"一次动作",是"轮询 + 上限 + 落盘"

异步取回有三个动作,缺一个都会出事。

2.1 轮询:间隔不能太密,也不能太疏

提交之后不能干等。 而"等"的写法有两种,都不好:

合理做法:隔几秒到十几秒查一次,并且给一个总上限(比如 5 分钟)。

2.2 上限:给"超时"一个明确的出口

"还没好"和"永远不会好"必须分得清。

做法:到点还没出的,不再等,直接按"重试"档记下来——理由在 6.4.1 已经说过:超时不是"错了",是"还没好",它跟单据内容无关。

⚠️ 异步任务本身也有"保质期"

扣子的异步任务有结果保留时间(官方文档说的是 24 小时以内)。超过这个窗口,任务号就查不到结果了。 所以轮询的上限不能设得太长——"我等它一天"不是耐心,是把结果等丢了。 正确做法:上限设在分钟级;超了就落成"重试",下一轮重新送。 因为重送一张单的成本,远低于"翻遍日志找一条永远找不回来的结果"。

2.3 落盘:结果必须在磁盘上留下痕迹

内存里的结果等于没有结果——流程一崩就没了。

每取回一条,立刻往一张结果表里追加一行,至少四个字段:

字段 说明
task_id 任务号——对账的钥匙
status 通过 / 待人 / 重试
raw 模型的原始返回,一字不改
fetched_at 取回时间

为什么要留原始返回? 因为判错了要能复盘——"当时模型到底说了什么"是唯一的事后证据。 你只留一个"失败"两个字,三天后没人说得清是模型不行还是你的解析写错了。

三、解析:先想办法把 JSON 抠出来,别急着判罪名

工作流返回的往往不是干净的 JSON。 常见的三种"脏法":

  1. 干干净净一段 JSON —— 最好;
  2. 前面带一句人话(比如"好的,识别结果如下:"),JSON 在后面;
  3. JSON 里夹了模型的小毛病(比如数组最后一个元素后面多一个逗号,或者中文标点)。

对应三层解析,从最严到最松:

第一层:直接按 JSON 解析          → 成功就完事
第二层:正则抠出第一对【{}】再解析   → 救回"前面带人话"那一类
第三层:都失败                    → 落【重试】
📝 判据是"能不能拿到 JSON",不是"返回得干不干净"

这一点很容易搞反。 一条结果前面夹了句"好的,识别结果如下:",它不算失败——你抠出来照样用。 只有抠都抠不出来的,才算"模型的说话习惯"这类技术层问题→ 落重试。 为什么"多一个逗号"要重试而不是自己修? 因为有些脏数据可以安全地修,有些不能——一旦你自己动手补标点,就等于在替模型编数据。而 6.3.1 的提示词里那条"禁止虚构、推断、补全",同样适用于下游的解析代码。 Demo 里的 T4 就是这个案例。

四、分档:只有一条判据——重跑一遍能不能修好

这是本节的核心,也是整章最该记住的一句话。

问自己一个问题:"把这条单原封不动重跑一遍,结果会变好吗?"

这个判据的好处是:它不依赖你去"猜原因",只依赖一个可验证的判断。

4.1 落进"重试"的典型

共同点:与单据内容无关。

4.2 落进"待人"的典型

共同点:都在单据上,且都不是机器能决定的。

4.3 三档混起来的后果,比想象中严重

如果有人图省事,一律"重试":

这就是"分档"不是"分类美观",是"批量流程能不能跑完"的问题。

📝 主数据没命中和识别错误,是两件事

这是本章最容易被搞混的一处。 "物料编码 M2999 识别出来了,但系统里没有" —— 模型干得对,它把图上写的字如实读了出来; 问题在系统侧(这个物料还没建档)。 处置方式完全不同:前者要去补建档,后者要去修识别。 6.3.2 讲上下文五块时专门说过这一条:如果把这两件事混在一起报给助手,它会一本正经地建议你"重新识别"——而那根本修不好。

五、对账:三个数加起来必须等于提交数

这是本节唯一一个"不做就一定会出事"的动作。

通过 + 待人 + 重试  ==  本轮提交的任务数

为什么这条纪律这么重要?

因为"结果丢失"是批量场景里最隐蔽的错。 它不报错、不弹窗——只是某一条结果静悄悄地没了。而这条结果对应的单据,可能永远不会有下文:它既不在 ERP 里,也不在异常表上,就这么消失了。

等到月底对账,供应商来催款,你才发现少了一张单——而那时候已经过了三个月。

所以每轮取回之后,第一件事就是把这个加法算一遍。对不上就先去查丢的那几条,绝不能直接进下一步。

⚠️ 对账的是"数量",不是"内容"

对账不能替代业务校验。 把 8 条结果一条不少地取回来,只说明"没有丢失";这 8 条各自对不对,是第四节的判档在管。 两件事要都做:先对数量(有没有丢),再判档位(各自能不能走)。

六、动手 Demo:取回结果判档器

这段代码把上面的全部规则跑一遍:8 条任务,逐条判档,最后做一次对账。

# -*- coding: utf-8 -*-
"""6.4.2 Demo:取回结果判档器(纯标准库)
分批取回之后,每条结果该走哪条路:通过 / 待人 / 重试。
判据只有一条:重跑一遍能不能修好。
"""
import json
import re

# 返回值:单号 / 原始返回 / 主数据命中 / 合计是否平 / 最低字段置信度 / 技术异常
RET = {
    "T1": {"raw": '{"supplier":"宜昌三峡五金","po_no":"PO-0912","lines":[{"item_code":"M1001",'
                  '"qty":100,"price":2.5,"conf":0.97}],"conf":0.98}',
           "master": "10/10", "total_ok": True,  "min_conf": 0.96, "tech": ""},
    "T2": {"raw": '{"supplier":"湖北兴发物资","po_no":"PO-0913","lines":[{"item_code":"M2999",'
                  '"qty":20,"price":45.0,"conf":0.95}],"conf":0.96}',
           "master": "9/10",  "total_ok": True,  "min_conf": 0.94, "tech": ""},
    "T3": {"raw": '好的,识别结果如下:\n{"supplier":"宜昌恒达机电","po_no":"PO-0916",'
                  '"lines":[{"item_code":"M5001","qty":15,"price":9.5,"conf":0.96}],"conf":0.97}',
           "master": "10/10", "total_ok": True,  "min_conf": 0.95, "tech": ""},
    "T4": {"raw": '{"supplier":"宜昌长江轴承","po_no":"PO-0914",'
                  '"lines":[{"item_code":"M3001","qty":10,"price":120.0,}],"conf":0.93}',
           "master": "10/10", "total_ok": True,  "min_conf": 0.92, "tech": ""},
    "T5": {"raw": "", "master": "-", "total_ok": None, "min_conf": None, "tech": "请求超时(180s)"},
    "T6": {"raw": '{"supplier":"湖北兴发物资","po_no":"PO-0913","lines":[{"item_code":"M2001",'
                  '"qty":20,"price":45.0,"conf":0.97}],"conf":0.98}',
           "master": "10/10", "total_ok": False, "min_conf": 0.96, "tech": ""},
    "T7": {"raw": '{"supplier":"宜昌某五金","po_no":"PO-0915","lines":[{"item_code":"M4001",'
                  '"qty":30,"price":6.0,"conf":0.58}],"conf":0.88}',
           "master": "10/10", "total_ok": True,  "min_conf": 0.58, "tech": ""},
    "T8": {"raw": '{"supplier":"宜昌恒达机电","po_no":"PO-0917","lines":[{"item_code":"M6001",'
                  '"qty":5,"price":30.0,"conf":0.99}],"conf":0.99}',
           "master": "10/10", "total_ok": True,  "min_conf": 0.98, "tech": ""},
}

T_PASS = 0.95
T_FIELD = 0.80

def parse(txt):
    if not txt:
        return None, "无返回"
    try:
        return json.loads(txt), "直接解析"
    except Exception:
        m = re.search(r"\{.*\}", txt, re.S)
        if m:
            try:
                return json.loads(m.group(0)), "大括号兜底"
            except Exception:
                pass
    return None, "解析失败"

def hit(frac):
    if "/" not in frac:
        return False
    a, b = frac.split("/")
    return a == b

def judge(r):
    """返回 (档位, 理由)"""
    # 第一关:技术层 —— 重跑能修好的,都在这儿
    if r["tech"]:
        return "重试", "技术异常:%s —— 与内容无关,重跑一遍即可" % r["tech"]
    data, path = parse(r["raw"])
    if data is None:
        return "重试", "解析失败(三层都没解开)—— 属于【模型的说话习惯】,重发一次多半就好"
    # 第二关:业务层 —— 重跑修不好的,都在这儿
    if not hit(r["master"]):
        return "待人", "主数据命中 %s —— 系统里没有这个编码,重跑一百遍也还是它" % r["master"]
    if r["total_ok"] is False:
        return "待人", "行小计与合计栏不相等 —— 算术不平,只能由人对原单"
    if r["min_conf"] is not None and r["min_conf"] < T_FIELD:
        return "待人", "最低字段置信度 %.2f —— 需要人看一眼原图再定" % r["min_conf"]
    # 第三关:放行线
    if r["conf_ok"] and r["min_conf"] >= T_PASS:
        return "通过", "解析 %s,业务校验全过,字段置信度均达标" % path
    return "通过", "解析 %s,业务校验全过(置信度可抽检)" % path

for k in RET:
    RET[k]["conf_ok"] = True

SUBMIT = 8   # 本轮实际提交的任务数(来自 6.4.1 落盘的那张任务号列表)

print("=" * 78)
print("一、逐条判档")
print("=" * 78)
res = {}
for k in sorted(RET):
    lvl, why = judge(RET[k])
    res[k] = lvl
    print("  [%s] 【%s】 %s" % (k, lvl, why))

print()
print("=" * 78)
print("二、汇总与对账")
print("=" * 78)
total = 0
for lvl in ["通过", "待人", "重试"]:
    ids = [k for k in sorted(res) if res[k] == lvl]
    total += len(ids)
    print("  %-4s %d 条:%s" % (lvl, len(ids), "、".join(ids) if ids else "(无)"))
print()
print("  对账纪律:通过 %d + 待人 %d + 重试 %d = %d ;本轮提交 %d 条" % (
    len([k for k in res if res[k] == "通过"]),
    len([k for k in res if res[k] == "待人"]),
    len([k for k in res if res[k] == "重试"]),
    total, SUBMIT))
if total == SUBMIT:
    print("  ✅ 对得上 —— 没有结果丢失。")
else:
    print("  ⚠️ 对不上,差 %d 条 —— 先去查它们,绝不能直接进下一步。" % (SUBMIT - total))

print()
print("=" * 78)
print("三、判据为什么只有一条:重跑一遍能不能修好")
print("=" * 78)
print("  · T4(尾逗号)与 T5(超时)→ 重试。它们与【单据本身】无关。")
print("  · T2(编码 9/10)与 T6(合计不平)→ 待人。它们在单据上,重跑还是它。")
print("    把这两类混在一起(比如一律重试)的后果是:")
print("    T2 会被无限重试,而 T6 永远重试不好 —— 批量任务会在这一条上卡死。")
print()
print("  · T3 特别值得看一眼:它夹了【好的,识别结果如下:】,但走兜底救回来了。")
print("    这类不算失败 —— 判据是【能不能拿到 JSON】,不是【返回得干不干净】。")

实际运行输出:

==============================================================================
一、逐条判档
==============================================================================
  [T1] 【通过】 解析 直接解析,业务校验全过,字段置信度均达标
  [T2] 【待人】 主数据命中 9/10 —— 系统里没有这个编码,重跑一百遍也还是它
  [T3] 【通过】 解析 大括号兜底,业务校验全过,字段置信度均达标
  [T4] 【重试】 解析失败(三层都没解开)—— 属于【模型的说话习惯】,重发一次多半就好
  [T5] 【重试】 技术异常:请求超时(180s) —— 与内容无关,重跑一遍即可
  [T6] 【待人】 行小计与合计栏不相等 —— 算术不平,只能由人对原单
  [T7] 【待人】 最低字段置信度 0.58 —— 需要人看一眼原图再定
  [T8] 【通过】 解析 直接解析,业务校验全过,字段置信度均达标

==============================================================================
二、汇总与对账
==============================================================================
  通过   3 条:T1、T3、T8
  待人   3 条:T2、T6、T7
  重试   2 条:T4、T5

  对账纪律:通过 3 + 待人 3 + 重试 2 = 8 ;本轮提交 8 条
  ✅ 对得上 —— 没有结果丢失。

==============================================================================
三、判据为什么只有一条:重跑一遍能不能修好
==============================================================================
  · T4(尾逗号)与 T5(超时)→ 重试。它们与【单据本身】无关。
  · T2(编码 9/10)与 T6(合计不平)→ 待人。它们在单据上,重跑还是它。
    把这两类混在一起(比如一律重试)的后果是:
    T2 会被无限重试,而 T6 永远重试不好 —— 批量任务会在这一条上卡死。

  · T3 特别值得看一眼:它夹了【好的,识别结果如下:】,但走兜底救回来了。
    这类不算失败 —— 判据是【能不能拿到 JSON】,不是【返回得干不干净】。

读结果,五个值得注意的地方:

  1. 8 条里只有 3 条"通过"。 这个比例不是"工作流不行"——Demo 故意把异常样本加浓了。真实场景里免检率通常高得多(6.1.1 那条"准确率 99% 以上"说的是字段识别准确率,不是"整单免检率",两个指标别混)。这里把 8 条里的 5 条做成异常,是为了让你一次看清所有分档。

  2. T3 是全场最值得多看两眼的一条。 它的返回开头夹了一句人话("好的,识别结果如下:"),但它走第二层兜底救回来了,最后判【通过】。 如果代码只写了一层 json.loads,这条会被误判成"重试"——然后白重跑一次,甚至可能一直重跑不好(因为模型下次还会说那句人话)。 这是"解析要分三层"最直接的理由:模型的说话习惯是随机的,而你的解析必须是稳的。

  3. T4 与 T5 都进了"重试",但原因完全不同。 T5 是网络超时(压根没返回),T4 是返回了但 JSON 里多一个逗号。 两条都不算什么大问题——一个重发就好,一个下次模型大概率就写对了。把它们和 T2、T6 混在一起才是灾难。

  4. T2 那条的理由写得最要命:"重跑一百遍也还是它"。 因为问题不在识别,在系统——物料编码 M2999 本来就没建档。 对人来说这是一分钟的活(去补个档),对机器来说这是一堵墙。认不出这件事的区别,就会把它永远挂在"重试"里。

  5. 对账那句 ✅ 对得上 看着不起眼,它是本节唯一一个"守命"的检查。 Demo 里 SUBMIT = 8 是手工写死的(模拟 6.4.1 落盘的那张任务号列表)。 真实场景里这个数必须从磁盘读——而且必须读【提交时落盘的那个数】,不能读【这次取回时拿到的数】。否则就成了循环论证:我取回 8 条,所以我说提交了 8 条,所以对得上。 那就等于没查。

七、结果的归宿:通过的去填报,待人的进异常表

判完档不是终点,是分流。

📝 "重试"必须有次数上限

否则一条永远修不好的单会变成"永动机":重试 → 还是它 → 重试 → 还是它…… 这正是第四节说的"三档混起来会卡死"的另一种形态。 给个上限(比如 2 次),超了就转【待人】——让"机器自己搞不定"这件事,有人兜住。

30 秒实验

把 Demo 里 parse() 的第二层(那段 re.search(r"\{.*\}", txt, re.S) 的兜底)整段注释掉,再跑一次。

会发生什么:

结论:对账管的是"有没有丢",管不了"判得对不对"。 一个判错的档位(把一条好单扔进"重试"),对账是抓不出来的——它只能靠你对判据的理解。

顺着这个思路想一层:那"多一层兜底"有没有代价?有。 兜底用的是正则 \{.*\}(贪婪匹配)——如果返回里有两段 JSON,它会一路吃到最后一个 },把两段拼成一段,直接解析失败。

所以兜底这一层的定位要摆正:它是【救急】,不是【常态】。 更稳的做法是:把提示词写死"只输出 JSON、不要任何前后缀"(6.3.1 那条强制 JSON),让第一层就能过——兜底只是万一的保险。

再想一层:T7 的置信度是 0.58,低于字段线 0.80 就进"待人"。如果把门槛从 0.80 调成 0.50 呢? 它会变"通过"——但那张单上的物料单价,模型自己只有 58% 的把握。 门槛的松紧,本质是在"省人的时间"和"漏错的概率"之间挪。 这类数字不能凭感觉定——最稳的做法是拿一批历史单跑一遍,看每条线各自拦下了什么。

常见误区

  1. 【结果取回来就行,不用逐条判档】——不行。 "取回来"只说明你有数据了,不说明每条都能往下走。 一条置信度 0.58 的结果直接填进 ERP,后果是账上多一笔错的采购——而 6.1.1 已经说过,这类错误的暴露是延迟的,等发现时可能已过月结。

  2. 【解析失败就当失败,直接交人】——浪费人的时间。 解析失败大多是"模型这次话说得不规矩",重发一次多半就好。把它扔给人,人只能手工录一遍——那些时间纯属白花。 判据永远是那条:重跑一遍能不能修好。

  3. 【主数据没命中 = 识别错了】——不是,这是最常见的误判。 模型把图上的字如实读了出来,它没错;错的是"系统里没有这个物料"这件事实本身。 处置方式完全不同:一个要补建档,一个要修识别。 混在一起报给助手(6.3.2),它会建议你"重新识别"——而那根本修不好。

  4. 【对账可有可无,反正失败会报错】——结果丢失根本不报错。 它不弹窗、不写日志,只是某一条结果静悄悄地没了。而那张单既不在 ERP 也不在异常表——等到供应商来催款,已经过去三个月。 所以每轮必算那行加法。

  5. 【重试就是无限重试】——必须有次数上限。 一条主数据没命中的单如果在"重试"档里放着,它会一直重试、永远不好;而批量任务会挂在它身上。正确做法:重试给 2 次上限,超了转"待人"。 机器搞不定的,要有人兜住。

自测三问(点开对答案) 1. **为什么判档只有"重跑一遍能不能修好"这一条判据?** 因为它**不依赖你去猜原因,只依赖一个可验证的判断**。 - **会变好 → 重试**:问题在链路的某一环(超时、限流、模型偶发不听话),**与单据内容无关**; - **不会变好 → 待人**:问题就在单据上(编码系统里没有、金额算不平、字太糊),**重跑一百遍还是它**。 好处是**它把"技术故障"和"业务事实"这两类彻底分开了**——**而这两类的处置方式完全相反**:前者机器自己重跑就好,后者人必须去看原单。**混起来的后果是批量任务卡死**(T2 被无限重试,T6 永远重试不好)。 2. **为什么"结果丢失"是最隐蔽也最危险的一类错?** 因为它**不报错**。它不弹窗、不写日志,**只是某一条结果静悄悄地没了**。 而**丢掉的这条对应的单据,会就此失去下文**:**它既不在 ERP 里,也不在异常表上**——**既没人填,也没人处理。** 等发现时通常是**供应商来催款**,**那时候可能已经过了三个月,而月结早做完了。** 所以对账纪律(通过 + 待人 + 重试 = 提交数)**必须每轮做**,而且**要对的是"提交时落盘的那个数",不是"这次取回拿到的数"**——否则就成了循环论证。 3. **"解析失败"和"主数据没命中"看起来都是失败,为什么要分到不同档?** 因为**"重跑能不能修好"的答案相反**。 - **解析失败**:模型这次话说得不规矩(多一个逗号、夹了句话)。**下次它大概率写对了**——所以是**重试**。 - **主数据没命中**:模型读得一点没错,**是系统里本来就没有这个编码**。**重跑一百遍,那个编码还是不在系统里**——所以是**待人**(去补建档)。 把它们混在一起的后果是:**一条会被无限重试(浪费),另一条永远重试不好(卡死)。** **根子在 6.3.2 那条**:模型没错,是事实如此——**认不出这个区别,就会给出"重新识别"这种无效处置。**

相关笔记