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

这条工作流做一件前五章都没做过的事:把一张图片变成一组结构化字段。它的形状是"看 → 认 → 写"三段,而三段里各有一个必踩的坑:输入变量的类型选错会直接报错、识别节点的选型决定成败、输出不开强制 JSON 就会夹私货。

📝 本节和前面几节的关系

6.2 把流程画成了七个节点,本节落的是第 ② 格(识别)——全章唯一需要 AI 的地方。 与前面几章的 Agent 侧对照:2.3.1 让 Agent 写文案、3.3.1 让 GPT 写公众号、4.3.2 让文心智能体批量出稿、5.3.2 让知乎智能体写图文——它们的输入都是【文字】,输出也都是【文字】。本节第一次让输入变成【图片】,而这一换,所有细节都要重来一遍。

一、这条工作流的形状:三段

别把它想复杂,它只做三件事:

① 看(输入)      把单据文件送进来
      ↓
② 认(识别)      看清图上有哪些字、哪些数
      ↓
③ 写(结构化)    按约定的字段形状,把结果写成 JSON

三段的顺序不能换,而且三段各自有一个"这一段最容易错的地方":

段 做什么 最容易错的地方
① 看 接收文件 输入变量的类型选错(选了 URL / 文本,整个工作流直接中断)
② 认 抽取信息 选错识别节点(用"看图说话"的模型去干"精确抄数"的活)
③ 写 结构化输出 没开强制 JSON(模型在 JSON 外面加一句"好的,以下是结果")

下面一段一段说。

二、第一段:看——输入变量的类型,是这条链的地基

2026 年在扣子上做这件事,第一条要记住的是:

开始节点里代表图像的变量,类型必须选【文件-图片】(File-Image)。

选错了会怎样? 不会"识别得不好",是工作流在识别阶段直接报错中断——因为你传进去的不是它期待的东西。

⚠️ 为什么这个坑特别值得单独讲

因为它不给你任何"技术信号"——你看到的只是"跑失败了",而原因是你根本没把图当成图传进去。 而且它发生在第一个节点,所以后面所有节点看起来都"没参与",排查时会本能地往后找,越找越远。 本课反复出现的那条纪律在这里又用了一次:先怀疑输入,再怀疑处理。

第二件要设计的事:多张单据怎么传。

一张一张传、还是打包一批?取决于工作量:

做法 适合 代价
一次传一张 单量小、要求实时 慢,调用次数多
打包成一次任务 单量大、可异步 要自己做分批、轮询与对账(6.4.1 会做)

本章选后者——因为 6.1.1 已经算过:一批单会有几十张,一张一张传会让调用次数变成瓶颈。

三、第二段:认——"看图说话"和"精确抄数"是两件事

这是本节技术含量最高的一段。

3.1 先看一个具体的错法

2026 年的一篇文章里举了这个例子:

原生大模型对像素级特征的感知并不稳定——比如把 iPhone 15 Pro 认成 iPh0ne 1S Pro。

这个例子值得盯一会儿:它认错的地方是一个字符——0 认成了 O,l 认成了 1。

为什么这在这里是致命的? 因为采购订单录的是编码。编码错一个字符,就是"另一个不存在的东西"——6.2 的 Demo 里,M2999 这个编码就因为在主数据里查不到,整条被阻断了。

而在内容侧,错一个字符是什么代价? 一篇文章里把"iPhone"写成"iPh0ne"——难看,但没人会因此对不上账。

📝 同一件事,在两个场景里的严重性完全不同

这一段是全课"内容侧 → 数据侧"这个转向最具体的一次体现: 识别率 99% 在写文章的场景里叫"很好",在录单的场景里叫"每 100 个字段错 1 个"。 而 6.1.1 算过:每 100 个字段错 1 个,一批单就要多花 25 分钟返工。

3.2 那两条路怎么选

扣子上做图片识别,有两条路:

OCR 节点(图片转文字) 图像理解插件(视觉理解)
它做什么 只把字认出来,还你一堆文本 看懂图上的语义,还你结构化描述
输出 文字(还要自己排结构) 已经带结构的文本
强项 版式规整的表格(有明确行列) 版式不固定的图(微信截图、手写单)
弱项 版式乱时对不上行列 精确抄写时字符级不稳(那个 iPh0ne 的例子)

怎么选?一句话判据:

有明确行列的(Excel、规整 PDF)→ 用 OCR,行列就是天然的结构; 没固定版式的(截图、手写、传真)→ 用图像理解,让模型自己判断"哪个是数量"。

实际做法往往是"两个都用":先用 OCR 把字取出来,再用大模型做语义归位——这样既拿到了字符级的准确,又拿到了结构理解。

3.3 无论走哪条路,最后都要过一遍"大模型 + 强制 JSON"

第二段和第三段的交界在这里:识别出来的东西(不管是 OCR 的文本还是图像理解的描述)都还不是最终结果——它要经过一次"按约定字段写成 JSON"的整理。

这一步有两个开关必须打开:

  1. 强制 JSON 输出——否则模型会在 JSON 外面加一句"好的,以下是识别结果:";
  2. 提示词里明确写上禁止编造——这一句是本节最该抄走的提示词原文:
所有字段值必须来自图像的原始输出,禁止虚构、推断、补全。
看不清的字段,值填 null,并在 conf 里如实报告置信度。
⚠️ 那两句"禁止"是整个识别的道德底线

"禁止虚构、推断、补全"——模型最擅长的事恰恰是"看起来合理地补全"。它会替你猜一个像模像样的物料编码,而那个编码图上根本没有。 "看不清就填 null"——这句话的价值在于它给了模型一个"承认不知道"的出口。没有这个出口,模型只能二选一:要么猜一个,要么报错。而它多半会猜。

四、第三段:写——字段契约

输出的字段形状,就是这条工作流与下游(6.4.2 的校验、6.4.3 的填报)之间的接口。

层 字段 说明
顶层 supplier / po_no / date 供应商、单号、日期
lines 明细行数组(一张单可以有很多行)
conf 整体置信度
raw_text 原始识别文本(调试留痕用)
行内 item_code / qty / unit / price / tax 物料、数量、单位、单价、税率
conf 每一行自己的置信度

三个设计说明:

  1. raw_text 必须存。 它是排查时唯一的依据——当有人问"这条为什么识别成这个数",你只能翻回原始文本。没有它,你只能重新跑一遍,而结果可能不一样。
  2. conf 要分两层(整体 + 每行)。全局的 0.95 不说明任何问题——6.2 的 Demo 反复演示了:真正要人看的是那一行 0.62。
  3. lines 是数组,不是单行。这是本章与前几章在数据结构上最大的不同:内容侧一篇就是一篇;这里的"一篇"里有 N 行——而批量场景里,真正的工作量单位是【行】。

五、识别这一步的三条纪律

这三条都是"看起来是技术问题、其实是业务问题"的:

纪律一:原图必须存,而且要与结果关联

理由在 6.1.2 的合规账:原始凭证要存 30 年,而且影像必须挂到订单上。 如果这一步只顾着"把字段抽出来",把原图丢了——后面 6.4.3 的"回挂"就无米下锅。

纪律二:识别结果与原文一起存

recog(结构化结果)+ raw_text(原始文本)一起落盘。 理由:结构化是有损的——JSON 里没有的东西,原文里可能有。当出现争议时("单价到底是 2.5 还是 25"),能拿出原文,才解释得清。

纪律三:置信度照实报,不许美化

这是三条里最容易被"优化"掉的一条——因为低置信度意味着更多人工,看起来像是"系统不够好"。 但美化置信度是自欺欺人:它不会让识别变准,只会让人工复核不知道去看哪里。6.2 的整张异常表都建立在这个字段上——它一旦失真,整套分流就废了。

六、动手 Demo:识别结果校验器

注意:这个检查器只做【格式与结构】层面的事——业务校验(主数据、算术)在 6.4.2。

# -*- coding: utf-8 -*-
"""6.3.1 Demo:识别结果校验器(纯标准库)
只做【格式与结构】层面的检查:能不能解析、字段齐不齐、有没有在编造。
业务校验(主数据/算术)留给 6.4.2。
"""
import json
import re

REQUIRED = ["supplier", "po_no", "lines"]
LINE_FIELDS = ["item_code", "qty", "unit", "price"]

# 这是"模型返回的原始文本",故意写得脏
RAW = {
    "R1": '{"supplier":"宜昌三峡五金有限公司","po_no":"PO-2026-0912","lines":['
          '{"item_code":"M1001","qty":100,"unit":"个","price":2.5,"conf":0.98},'
          '{"item_code":"M1002","qty":50,"unit":"米","price":8.0,"conf":0.96}],'
          '"conf":0.97}',
    "R2": '好的,以下是识别结果:\n{"supplier":"湖北兴发物资","po_no":"PO-2026-0913","lines":['
          '{"item_code":"M2001","qty":20,"unit":"箱","price":45.0,"conf":0.94}],"conf":0.95}',
    "R3": '{"supplier":"宜昌长江轴承","po_no":"PO-2026-0914","lines":['
          '{"item_code":"M3001","qty":10,"price":120.0,"conf":0.92}],"conf":0.93}',
    "R4": '{"supplier":"某五金公司","po_no":"PO-2026-0915","lines":['
          '{"item_code":"M4001","qty":30,"unit":"个","price":6.0,"conf":0.90}],"conf":0.91}',
    "R5": '{"supplier":"宜昌恒达","po_no":"PO-2026-0916","lines":['
          '{"item_code":"M5001","qty":15,"unit":"个","price":9.5,}],"conf":0.96}',
}

VAGUE = ["某五金", "某某", "某公司", "不详", "未知"]

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

print("=" * 78)
print("一、逐份检查(只查格式与结构)")
print("=" * 78)
verdict = {}
for rid in sorted(RAW):
    txt = RAW[rid]
    data, path = try_parse(txt)
    issues = []
    level = "通过"
    if data is None:
        level = "重试"
        issues.append("三层解析全部失败 —— 这是【模型的说话习惯】问题,重发一次多半就好")
    else:
        # 必填字段
        for k in REQUIRED:
            if k not in data:
                issues.append("缺必填顶层字段 %s" % k)
        lines = data.get("lines", [])
        if not lines:
            issues.append("lines 为空 —— 一张只有抬头没有明细的单,不能算识别成功")
        for i, ln in enumerate(lines, 1):
            for k in LINE_FIELDS:
                if k not in ln:
                    issues.append("第 %d 行缺字段 %s —— 缺的字段没有人能替它补" % (i, k))
            for k in ("qty", "price"):
                if k in ln and not isinstance(ln[k], (int, float)):
                    issues.append("第 %d 行的 %s 不是数字(%r)" % (i, k, ln[k]))
        # 编造征兆:模糊指代
        sup = str(data.get("supplier", ""))
        for w in VAGUE:
            if w in sup:
                issues.append("供应商名【%s】含模糊指代 —— 像模型自己补的词,不是图上写的" % sup)
                break
        # 有没有老实报"没看见"
        if "conf" not in data:
            issues.append("整份没返回 conf —— 模型没告诉你它有多大把握")
        if issues:
            level = "进异常" if not any("解析全部失败" in x for x in issues) else "重试"

    verdict[rid] = level
    print()
    print("  [%s] 解析路径 = %s → 【%s】" % (rid, path, level))
    for x in issues:
        print("        · %s" % x)

print()
print("=" * 78)
print("二、汇总")
print("=" * 78)
for lvl in ["通过", "进异常", "重试"]:
    ids = [k for k in sorted(verdict) if verdict[k] == lvl]
    print("  %-6s %d 份:%s" % (lvl, len(ids), "、".join(ids) if ids else "(无)"))

print()
print("=" * 78)
print("三、这份检查为什么只做【格式与结构】")
print("=" * 78)
print("  · 它回答的是:模型有没有【按约定把话说出来】。")
print("  · 它回答不了:说出来的东西【业务上对不对】—— 那要靠主数据和算术校验(6.4.2)。")
print("  · 分开的理由:两类问题的处置完全不同 ——")
print("    格式问题【重发一次】就能修好;业务问题重发一百次也没用,得【人来判断】。")

实际运行输出:

==============================================================================
一、逐份检查(只查格式与结构)
==============================================================================

  [R1] 解析路径 = 直接解析 → 【通过】

  [R2] 解析路径 = 大括号兜底 → 【通过】

  [R3] 解析路径 = 直接解析 → 【进异常】
        · 第 1 行缺字段 unit —— 缺的字段没有人能替它补

  [R4] 解析路径 = 直接解析 → 【进异常】
        · 供应商名【某五金公司】含模糊指代 —— 像模型自己补的词,不是图上写的

  [R5] 解析路径 = 解析失败 → 【重试】

==============================================================================
二、汇总
==============================================================================
  通过     2 份:R1、R2
  进异常    2 份:R3、R4
  重试     1 份:R5

==============================================================================
三、这份检查为什么只做【格式与结构】
==============================================================================
  · 它回答的是:模型有没有【按约定把话说出来】。
  · 它回答不了:说出来的东西【业务上对不对】—— 那要靠主数据和算术校验(6.4.2)。
  · 分开的理由:两类问题的处置完全不同 ——
    格式问题【重发一次】就能修好;业务问题重发一百次也没用,得【人来判断】。

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

  1. R2 是本节最该看的一条:它夹了一句"好的,以下是识别结果:",但被判为【通过】了。 为什么?因为判据是"能不能拿到 JSON",不是"返回得干不干净"。 三层解析里第二层(大括号兜底)本来就是为这类情况准备的。 这个判据很重要:如果把它判成失败去重试,那你等于在跟模型的口头禅打仗——它加一句开场白是常态,重试一百次还是会加。

  2. R5 是唯一真正失败的(尾逗号),而且它的失败与"模型聪不聪明"无关。 尾逗号是生成过程中断在了一个不合法的地方——属于"技术性口误"。 处置是重试:因为重跑一遍多半就好,而且重跑的成本远低于让人去看。 这与 6.2 那条"重跑一遍能不能修好"的判据是同一条。

  3. R3 暴露的是一种"看起来没问题的残缺":它 JSON 完全合法、字段也都对,只是少了一个 unit。 这种错最容易被漏掉——因为它不会让任何程序报错。而缺的字段没有人能替它补:到底是"个"还是"箱"?猜错了就是数量级错误。

  4. R4 检查的是"编造征兆"——注意这里只是一个【征兆】,不是证据。 脚本发现"某五金公司"里有"某五金"这个模糊指代,所以它怀疑这是模型自己补的。但它判不了真假——"宜昌鑫盛五金"也可能是真的存在,这个脚本查不了。 查不了的那部分,正好就是 6.4.2 要做的业务校验(拿主数据去比)。 这就是本节标题里"只做格式与结构"的含义。

  5. 最后一段那个分工,是整章的一条暗线: 格式问题靠重试,业务问题靠人。 如果这两类混在一起(一律重试),后果是 R3 和 R4 会被无限重试——因为它们重试一百次还是缺那个 unit、还是那个模糊的名字。批量任务就卡死在这两条上了。 (6.4.2 的 Demo 会把这个判据写成一个明确的函数。)

30 秒实验

把 Demo 里 R4 的供应商名从 "某五金公司" 改成 "宜昌鑫盛五金有限公司",再跑一次。

会发生什么:

结论:这个检查器判的是【形迹】,不是【真假】。

顺着这个思路想一层:那"宜昌鑫盛五金有限公司"到底是不是真的存在?这个脚本永远回答不了——因为答案不在图里,在公司的供应商主数据里。这正是 6.2 那节把"业务校验"独立成一格的原因:它要查的东西在系统里,不在图上。

再想一层:这个实验也说明格式检查不能省。如果 R4 直接提交给业务校验,会怎样?——它会去查主数据,而它查的是"某五金公司"这个字符串,多半查不到,然后被判成"供应商未命中"。于是你收到一条错误的诊断:以为是"供应商没建档",其实是"模型编了个名字"。两类问题必须分开查,否则人会照着错的诊断去处理。

常见误区

  1. 【用多模态大模型直连图片就行了,不用专门的识别节点】——要分任务。 "这张图里有什么"这类描述性任务,直连可以;但精确抄数(型号、编码、数量)不行——字符级不稳(那个 iPh0ne 1S Pro 的例子就是)。判据:错一个字符会不会变成业务事故?会,就别直连。

  2. 【图片输入随便建个变量就行】——错,类型必须选【文件-图片】。 选成文本或 URL,工作流会在识别阶段直接报错中断——而且它发生在第一个节点,看起来像后面所有节点都没参与,排查时会本能地往后找,越找越远。先怀疑输入,再怀疑处理。

  3. 【模型输出 JSON 是理所当然的】——错,必须开强制 JSON 并写进提示词。 默认情况下它很爱在 JSON 外面加一句"好的,以下是识别结果:"。两层防线:提示词明确要求只输出 JSON + 节点上打开强制 JSON 输出。再加一层兜底:解析失败时用大括号截取(Demo 的第二层)。

  4. 【识别结果里不用存原始文本】——错,raw_text 是排查时唯一的依据。 结构化是有损的:JSON 里没有的东西,原文里可能有。当出现"单价到底是 2.5 还是 25"这种争议时,能拿出原文,才解释得清。

  5. 【模型说"没看见"是能力不行】——恰恰相反,那是它最该说的话。 所以要在提示词里给它一个承认不知道的出口("看不清的填 null")。没有这个出口,它只能二选一:猜一个,或者报错——而它多半会猜。 而猜出来的值会被下游当成事实,这是最危险的失败方式。

自测三问(点开对答案) 1. **为什么"识别率 99%"在写文章的场景里叫很好,在录单的场景里叫"每 100 个字段错 1 个"?** 因为**两个场景里"一个字符的价值"完全不同**。 在内容侧,把 iPhone 写成 iPh0ne 只是难看,**没人会因此对不上账**;在录单侧,**编码错一个字符就是"另一个不存在的东西"**——6.2 的 Demo 里 `M2999` 就因为这个被整条阻断。 而 6.1.1 算过:**每 100 个字段错 1 个,一批单就要多花 25 分钟返工,而且要等 5 天后对账时才发现**。 **这就是"平均准确率"这个指标的危险之处**:它把两类完全不同的场景拉到了同一个数字上。 2. **OCR 节点和图像理解插件,各适合什么情况?** **OCR**:**只把字认出来**,还你一堆文本——**适合版式规整的表格**(Excel、规整 PDF),因为**行列本身就是天然的结构**。 **图像理解**:**看懂图上的语义**,还你结构化描述——**适合版式不固定的图**(微信截图、手写单、传真),**让模型自己判断"哪个是数量"**。 **实际做法往往是"两个都用"**:先用 OCR 拿字符级准确,再用大模型做语义归位。 **但无论走哪条路,最后都要过一遍"大模型 + 强制 JSON"**——把结果按约定的字段形状写出来。 3. **为什么这个校验器只查"格式与结构",不查"业务对不对"?两类问题的处置有什么不同?** 因为**两类问题的处置完全不同**: - **格式问题 → 重试**:R5 的尾逗号、R2 的多余开场白都属于这一类,**重跑一遍多半就好,成本远低于人工**; - **业务问题 → 待人**:R3 缺的 `unit`、R4 那个可疑的供应商名,**重试一百次还是老样子**,因为**问题不在模型的表达能力上,在单据本身**。 **混在一起(比如一律重试)的后果是**:R3、R4 被无限重试,**批量任务卡死在这两条上**。 **分开的依据就是那条判据:重跑一遍能不能修好。**

相关笔记