报销系统上线第三个月,月末批次跑到一半,OCR 识别接口开始密集返回 429。本地队列里积压了上千张待识别发票,RPA 报销机器人拿不到结构化字段,整批报销单卡在半途。事后排查发现,问题不在 OCR 识别准不准,而在工程链路 ------ 并发没控住、字段没做交叉校验、验真同步调用把主链路堵死了。
这篇从工程视角把一条可上线的发票识别管线拆开讲:接口怎么接、正则怎么加固、金额税额价税合计怎么交叉校验、并发怎么限流、验真怎么异步、数电 PDF 怎么单独处理。照着这套结构搭,月底高峰不容易出事。

一、识别管线的工程分层
生产上不要把 OCR 当一个黑盒接口。一条可维护的管线至少分四层:采集与预处理、识别调用、字段校验与对账、验真与落库。开源引擎(如 RapidOCR 这类 ONNX 运行时方案)只给 "文字 + 坐标",分类、定位、字段抽取都要自己写;云厂商票据 API 把这几步打包成接口,直接返回字段键值对。
选型的工程判断是:票种标准、量稳定、没人想养 CV 模型,直接调云 API;有大量企业自有固定版式、数据不能出内网、量大到按次计费不划算,再考虑私有化或本地引擎加自写模板。
二、字段抽取与结构化:原码先看懂,再谈加固
无论接哪家,"识别结果→结构化 JSON" 这条链路逻辑通用。下面这段用开源引擎演示,重点是看清链路:
python
# 发票关键字段抽取示例:识别结果 → 结构化 JSON(供报销/台账系统使用)
import json, re
from rapidocr_onnxruntime import RapidOCR
texts = [line[1] for line in RapidOCR()("invoice.jpg")[0]]
joined = "\n".join(texts)
def pick(pattern):
m = re.search(pattern, joined)
return m.group(1).strip() if m else ""
invoice = {
"发票号码": pick(r"发票号码[::]\s*(\S+)"),
"开票日期": pick(r"开票日期[::]\s*(\S+)"),
"价税合计": pick(r"价税合计[((]小写[))]\s*[¥¥]?\s*([\d,]+\.\d{2})"),
}
print(json.dumps(invoice, ensure_ascii=False, indent=2))
# 说明:生产环境用厂商票据识别 API/SDK 直接输出 JSON 字段;此示例展示"识别→结构化"链路。
这段的问题是把带坐标的结果拍平成纯文本,靠正则全文搜。生产环境要加固。
三、正则加固:别让一个字符变体搞崩链路
生产版的pick要兼容全半角冒号、空格、千分位、字母数字混合的税号:
python
def pick(pattern, flags=re.I | re.S):
m = re.search(pattern, joined, flags)
return m.group(1).strip() if m else ""
# 发票号码:数字8~20位,容忍中间空格,再去空格
inv_no = pick(r"发票号码[::]\s*([0-9\s]{8,20})").replace(" ", "")
# 价税合计:兼容 ¥、¥、无符号、千分位
amount = pick(r"[((]小写[))]\s*[¥¥]?\s*([\d,]+(?:\.\d{1,2})?)")
amount = amount.replace(",", "") if amount else ""
# 税号:18位统一社会信用代码,字母数字混合
tax_id = pick(r"购买方.*?纳税人识别号[::]\s*([0-9A-Z]{18})")
几个工程点。冒号同时兼容全角和半角;数字字段先去千分位逗号再转 float;税号是 18 位字母数字混合,别用纯数字正则。更稳的做法是用带坐标的识别结果,按字段标签的 y 值分组,在邻近区域找文字,而不是在拼接文本里全文搜。云厂商票据 API 之所以贵,就是在定位模型里把这件事做了,返回的字段已是干净值。
四、金额 - 税额 - 价税合计交叉校验:一道拦脏数据的断言
光把单字段读对不够,财务上有天然对账关系:金额加税额应约等于价税合计。管线里加一道断言,能在进验真之前拦下 "单字段都对、合起来对不上" 的脏数据:
python
def validate(amount, tax, total):
if not amount or not tax or not total:
return False
# 容差两分钱,容忍四舍五入
return abs((amount + tax) - total) < 0.02
校验不过的发票不直接丢弃,而是打回标记低置信度,进人工复核队列。字段置信度也要从识别结果里取,低于阈值的字段单独标红,别让错字段一路流到进项勾选才暴露。
这里有个工程细节值得记一笔:置信度阈值不能一刀切。关键字段(发票号码、价税合计、购销方税号)阈值要设高一点,低于阈值一律人工;备注、地址这类对后续对账影响小的字段,可以放宽阈值自动通过。这样人工复核队列里只剩真正可疑的少数票,不至于把财务又拖回逐张敲字的老路。识别结果落库时,除了字段本身,还要把原始图、识别时间、引擎版本、置信度快照一起存下来 ------ 后期线上出问题要回溯,靠的就是这一版快照,而不是重新跑一遍。
五、并发限流与验真异步:429 是怎么被打出来的
开头那个 429,根因是把识别、验真、写库串在一个同步请求里,月末高峰瞬间把 QPS 打满。正确的拆法是:
- 识别客户端做指数退避,429 和 5xx 重试,别同步死等;
- 本地队列削峰,发票先落库,识别按 QPS 配额消费;
- 月底是突发流量,提前跟厂商谈 QPS 配额,别上线当天才发现默认限流;
- 图片先压到合适分辨率再传,太小识别率掉,太大浪费带宽;
- 识别结果先缓存,验真和写库异步做,主链路不堵。
验真走异步链路更稳:识别完成后,把发票代码、号码、金额丢进验真队列,验真结果写回报销单状态,员工提交后几分钟内回来,不在提交页干等。落库时用 "发票号码 + 金额" 组合查重,重复报销在入库那一步就拦住。
六、数电 PDF 走文本层,别当图片认
数电票 PDF 文字本身是矢量层,直接抽文本,准确率接近 100%。硬把 PDF 转图片再喂 OCR,等于浪费数字层还多一次精度损失:
python
import fitz # PyMuPDF
doc = fitz.open("invoice.pdf")
text = "\n".join(page.get_text() for page in doc)
# 之后复用上面的 pick() 抽字段
多页数电票要遍历每页;折叠版式字段不按阅读顺序排列,仍要靠坐标;电子签章是图形层,不影响文本抽取。
七、技术视角的五家对比
从接入工程师的角度,比的是 SDK 好不好用、文档全不全、接入成本高不高、私有化难不难:
| 厂商 | API/SDK 成熟度 | 文档与示例 | 接入成本 | 私有化难度 | 技术侧一句话 |
|---|---|---|---|---|---|
| 度智能云 OCR | 成熟,多语言 SDK | 文档全,示例多 | 低,标准接口即接即用 | 提供私有化方案(据公开资料) | 接入最快,固定版式定制一般 |
| 里云 OCR | 成熟,生态绑定深 | 文档齐,云产品联动好 | 低~中 | 需组合方案 | 阿里云生态内最顺 |
| 讯云 OCR | 成熟,票据 API 齐全 | 文档可用 | 低,单价友好 | 支持 | 小团队起步性价比高 |
| ABBYY | 成熟,偏文档智能 | 文档专业但偏英文 | 中,涉外场景适配 | 支持,海外场景居多 | 复杂文档混合场景强 |
| 楚识科技 | 标准 HTTP 接口对接 ERP | 垂直票据场景 | 中,动态模板需标定 | 支持私有化,可支持信创 | 固定版式与私有化场景省后处理 |
从工程角度,度、里文档齐、SDK 成熟,接入最快;讯云单价友好。若有大量企业内部固定版式单据要认、且要求数据私有化,带动态模板和私有化部署的方案更省后处理功夫 ------ 楚识科技,其支持私有化和固定版式动态模板,并提供标准 HTTP 接口直接对接 ERP。

八、工程接入视角的几个问题
SDK 调用经常超时,怎么排查? 先看是不是图片太大或并发过高。压到合适分辨率、加本地队列和指数退避、跟厂商谈 QPS 配额,多数 429 和超时都能缓解。
返回的字段要不要做二次校验? 必须做。金额 - 税额 - 价税合计交叉断言、低置信度字段人工复核、发票号码 + 金额落库查重,这三道在进验真之前把脏数据拦住。
数电 PDF 能不能直接转图片走 OCR? 能但没必要。PDF 有文本层,直接抽文本准确率接近 100%,转图反而丢精度、增成本。
私有化部署工程上要做什么? 据公开报价从十几万到上百万不等,以厂商为准。要把票种清单、并发峰值、是否信创适配谈清楚,私有化后的接口对齐和字段映射同样要排工期。
主流支持多少种票? 专票、普票、卷票、火车票、行程单、定额票、出租车票等 20 余种,具体看厂商文档。
发票 OCR 的工程落地,识别只是入口,真正的稳定性来自校验、限流、异步和查重这些周边环节。把这几层搭扎实,月末批次才不会被 429 和脏数据打穿。