【腾讯云 AI Skills】电商发票批量录入自动化:CodeBuddy + OCR 票据识别 + 文档抽取 Agent 落地(月省 120 工时)

💡 摘要: 这篇是我作为电商架构师,用 CodeBuddy CLI 把腾讯云两个 OCR Skill(tencentcloud-ocr-vatinvoice、tencentcloud-ocr-extractdocagent)串成发票流水线的复盘。每月上万张采购/销售发票,从 3 个实习生加班 5 天手工录入,改成一句话触发全自动处理。文中记了 Skill 选型、CodeBuddy 编排、批量识别、字段校验、幂等入库、并发限流的全部实现,也把我踩过的 5 个坑------PDF 分辨率、API 限流、大写金额、重复识别、COS 大文件超时------一并写出来。上线后月省 120 工时,错误率从 3.2% 降到 0.1%,处理速度 15 张/分钟。

文章难度: ⭐⭐⭐⭐(适合有 Python 基础的后端/运维/财务技术开发者)

前置知识: 了解 OCR 基本概念、会使用命令行、对 HTTP API 有初步认知

适用版本: CodeBuddy CLI 5.x / tencentcloud-ocr-vatinvoice latest / tencentcloud-ocr-extractdocagent latest / Python 3.10+(2026 年 7 月版本)

前言

事情起因是 6 月底的一张工单。财务总监周五上午直接跑到我工位,说 6 月采购发票 8732 张加销售发票 12450 张压在桌上,3 个实习生加班 5 天才录完,税务对账还发现 217 张金额对不上。他问我能不能用技术手段把这堆发票自动录进 ERP。

我先把当时的痛点数据拉了出来:

text 复制代码
6 月发票录入台账(手工)

- 总量:21,182 张(采购 8,732 + 销售 12,450)
- 投入:3 个实习生 × 6 个工作日
- 错误率:3.2%(217 张金额不符)
- 痛点:PDF/图片/扫描件混杂,字段全靠肉眼抄

那个周末我没怎么休息,把市面上的方案捋了一遍。商业 RPA 按张收费 ¥0.3-0.5,2 万张就是 6 千到 1 万的开销,还得把数据交给第三方;开源 Tesseract 我试了 100 张样本,识别率 75%,复杂版式发票字段丢得没法用。最后锁定在腾讯云的两个 OCR Skill 上,配合 CodeBuddy CLI 做编排------一张发票 ¥0.06,识别率 98%,还能让财务同学自己调整字段映射,不用每次改逻辑都来找我。

这篇就是这次落地的完整复盘。

📖 第一章:痛点与 Skill 选型

1.1 手工录入到底有多痛

先把 6 月的工时台账摆出来,后面所有 ROI 计算都基于这组数:

环节 耗时 人力 问题
发票分类(采购/销售/专票/普票) 0.5 天 1 人 手工分拣易错
逐张录入 ERP 4 天 3 人 字段多(代码、号码、金额、税额、日期、销方信息)
交叉校验(金额=税前+税额) 0.5 天 1 人 大写金额转换常出错
税务对账纠错 1 天 1 人 217 张错误需逐张回溯
合计 6 天 3 人 月月如此

3.2% 的错误率单看不高,但放到 2 万张的量级上就是 640 张错单,每张都可能引发税务风险。

1.2 为什么选腾讯云 OCR Skill

那个周末我对比了 4 个方案:

方案 识别率 单张成本 是否支持自定义字段 部署复杂度
开源 Tesseract ~75% ¥0 需自行训练
商业 RPA A ~92% ¥0.4 支持
商业 RPA B ~95% ¥0.3 支持
腾讯云 OCR Skill ~98% ¥0.06 支持(文档抽取 Agent)

最后定腾讯云,主要看两点:

一是增值税发票是腾讯云 OCR 的主场。tencentcloud-ocr-vatinvoice 对专票/普票/电子发票做全字段识别,官方识别率 98%+,我拿 200 张样本实测通过率 99%,比商业 RPA 还稳。

二是 tencentcloud-ocr-extractdocagent 这个文档抽取 Agent 能按自定义字段从图片/PDF 抽信息,遇到非标发票(收据、报销单、手写单)也能兜底。这点很关键,因为财务那边每月总有几百张乱七八糟的票据混进来。

1.3 两个 Skill 的能力边界

tencentcloud-ocr-vatinvoice(通用票据识别高级版)

  • 识别增值税专用发票、普通发票、电子发票全字段
  • 支持 PDF 和图片输入
  • 返回字段:发票代码、发票号码、开票日期、金额、税额、价税合计、销方/购方信息、明细行

tencentcloud-ocr-extractdocagent(实时文档抽取 Agent)

  • 按自定义字段从图片/PDF 结构化抽取信息
  • 适用于非标发票、收据、报销单等
  • 需定义抽取字段 schema(JSON)

💡 我的用法是:标准发票走 vatinvoice,字段全速度快;非标票据走 extractdocagent,灵活定义字段。两个串联------vatinvoice 识别失败的,fallback 到 extractdocagent 兜底。

🏗️ 第二章:整体架构设计

2.1 流水线架构

#mermaid-svg-9K3uaEyvtYZq0Y8R{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9K3uaEyvtYZq0Y8R .error-icon{fill:#552222;}#mermaid-svg-9K3uaEyvtYZq0Y8R .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9K3uaEyvtYZq0Y8R .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .marker.cross{stroke:#333333;}#mermaid-svg-9K3uaEyvtYZq0Y8R svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9K3uaEyvtYZq0Y8R p{margin:0;}#mermaid-svg-9K3uaEyvtYZq0Y8R .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .cluster-label text{fill:#333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .cluster-label span{color:#333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .cluster-label span p{background-color:transparent;}#mermaid-svg-9K3uaEyvtYZq0Y8R .label text,#mermaid-svg-9K3uaEyvtYZq0Y8R span{fill:#333;color:#333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .node rect,#mermaid-svg-9K3uaEyvtYZq0Y8R .node circle,#mermaid-svg-9K3uaEyvtYZq0Y8R .node ellipse,#mermaid-svg-9K3uaEyvtYZq0Y8R .node polygon,#mermaid-svg-9K3uaEyvtYZq0Y8R .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9K3uaEyvtYZq0Y8R .rough-node .label text,#mermaid-svg-9K3uaEyvtYZq0Y8R .node .label text,#mermaid-svg-9K3uaEyvtYZq0Y8R .image-shape .label,#mermaid-svg-9K3uaEyvtYZq0Y8R .icon-shape .label{text-anchor:middle;}#mermaid-svg-9K3uaEyvtYZq0Y8R .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9K3uaEyvtYZq0Y8R .rough-node .label,#mermaid-svg-9K3uaEyvtYZq0Y8R .node .label,#mermaid-svg-9K3uaEyvtYZq0Y8R .image-shape .label,#mermaid-svg-9K3uaEyvtYZq0Y8R .icon-shape .label{text-align:center;}#mermaid-svg-9K3uaEyvtYZq0Y8R .node.clickable{cursor:pointer;}#mermaid-svg-9K3uaEyvtYZq0Y8R .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .arrowheadPath{fill:#333333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9K3uaEyvtYZq0Y8R .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9K3uaEyvtYZq0Y8R .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9K3uaEyvtYZq0Y8R .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9K3uaEyvtYZq0Y8R .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9K3uaEyvtYZq0Y8R .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9K3uaEyvtYZq0Y8R .cluster text{fill:#333;}#mermaid-svg-9K3uaEyvtYZq0Y8R .cluster span{color:#333;}#mermaid-svg-9K3uaEyvtYZq0Y8R div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9K3uaEyvtYZq0Y8R .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9K3uaEyvtYZq0Y8R rect.text{fill:none;stroke-width:0;}#mermaid-svg-9K3uaEyvtYZq0Y8R .icon-shape,#mermaid-svg-9K3uaEyvtYZq0Y8R .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9K3uaEyvtYZq0Y8R .icon-shape p,#mermaid-svg-9K3uaEyvtYZq0Y8R .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9K3uaEyvtYZq0Y8R .icon-shape .label rect,#mermaid-svg-9K3uaEyvtYZq0Y8R .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9K3uaEyvtYZq0Y8R .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9K3uaEyvtYZq0Y8R .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9K3uaEyvtYZq0Y8R :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 监控告警
数据处理层
Skill 调用层
CodeBuddy 编排层
数据源
标准发票
识别失败/非标
校验通过
校验失败
发票文件目录

PDF/图片/扫描件
CodeBuddy CLI

cbc 命令
自定义子代理

invoice-pipeline
tencentcloud-ocr-vatinvoice

标准发票识别
tencentcloud-ocr-extractdocagent

非标票据兜底
字段校验

金额=税前+税额
幂等去重

发票号码唯一
ERP 入库

批量 INSERT
Prometheus 指标
告警通知
人工复核队列

2.2 处理时序

Prometheus ERP 数据库 extractdocagent Skill vatinvoice Skill invoice-pipeline 子代理 CodeBuddy CLI 财务/运维 Prometheus ERP 数据库 extractdocagent Skill vatinvoice Skill invoice-pipeline 子代理 CodeBuddy CLI 财务/运维 #mermaid-svg-RcWON8uwefqh9ZCB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-RcWON8uwefqh9ZCB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-RcWON8uwefqh9ZCB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-RcWON8uwefqh9ZCB .error-icon{fill:#552222;}#mermaid-svg-RcWON8uwefqh9ZCB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-RcWON8uwefqh9ZCB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-RcWON8uwefqh9ZCB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-RcWON8uwefqh9ZCB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-RcWON8uwefqh9ZCB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-RcWON8uwefqh9ZCB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-RcWON8uwefqh9ZCB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-RcWON8uwefqh9ZCB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-RcWON8uwefqh9ZCB .marker.cross{stroke:#333333;}#mermaid-svg-RcWON8uwefqh9ZCB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-RcWON8uwefqh9ZCB p{margin:0;}#mermaid-svg-RcWON8uwefqh9ZCB .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RcWON8uwefqh9ZCB text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-RcWON8uwefqh9ZCB .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RcWON8uwefqh9ZCB .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-RcWON8uwefqh9ZCB .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-RcWON8uwefqh9ZCB .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-RcWON8uwefqh9ZCB #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-RcWON8uwefqh9ZCB .sequenceNumber{fill:white;}#mermaid-svg-RcWON8uwefqh9ZCB #sequencenumber{fill:#333;}#mermaid-svg-RcWON8uwefqh9ZCB #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-RcWON8uwefqh9ZCB .messageText{fill:#333;stroke:none;}#mermaid-svg-RcWON8uwefqh9ZCB .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RcWON8uwefqh9ZCB .labelText,#mermaid-svg-RcWON8uwefqh9ZCB .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-RcWON8uwefqh9ZCB .loopText,#mermaid-svg-RcWON8uwefqh9ZCB .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-RcWON8uwefqh9ZCB .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-RcWON8uwefqh9ZCB .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-RcWON8uwefqh9ZCB .noteText,#mermaid-svg-RcWON8uwefqh9ZCB .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-RcWON8uwefqh9ZCB .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RcWON8uwefqh9ZCB .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RcWON8uwefqh9ZCB .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-RcWON8uwefqh9ZCB .actorPopupMenu{position:absolute;}#mermaid-svg-RcWON8uwefqh9ZCB .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-RcWON8uwefqh9ZCB .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-RcWON8uwefqh9ZCB .actor-man circle,#mermaid-svg-RcWON8uwefqh9ZCB line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-RcWON8uwefqh9ZCB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt识别成功识别失败/非标 loop批量处理(每批 50 张) cbc "处理 ./invoices/2026-06 目录"1触发发票流水线子代理2扫描目录,发现 8732 个文件3调用 vatinvoice 识别4返回字段 JSON5字段校验(金额=税前+税额)6幂等 INSERT7调用 extractdocagent 兜底8返回自定义字段9字段校验10幂等 INSERT11上报指标(成功/失败/耗时)12汇总报告13输出处理结果14

上述时序图展示了发票批量处理的完整流程------CodeBuddy 子代理编排两个 OCR Skill 协同工作

2.3 关键设计决策

决策点 选择 原因
编排方式 CodeBuddy CLI 子代理 财务同学可用自然语言触发,无需写代码
批处理粒度 每批 50 张 平衡 API 并发限制与吞吐量
兜底策略 vatinvoice 失败→extractdocagent 标准发票走快通道,非标走灵活通道
幂等机制 发票号码 + 代码联合唯一索引 防止重复录入
校验规则 金额 = 税前金额 + 税额 税务合规基本校验

🔧 第三章:环境搭建与 Skill 安装

3.1 安装 CodeBuddy CLI

为什么用 CLI 而不用 IDE 插件?发票处理是批处理任务,要挂到 crontab 月初自动跑,CLI 更适合脚本化集成和定时调度。IDE 插件更适合交互式开发场景。

bash 复制代码
# 安装 CodeBuddy Code(CLI 形态)
# 环境要求:Node.js 18.20+
npm install -g @tencent-ai/codebuddy-code

# 验证安装
cbc --version
# 输出示例:CodeBuddy Code v5.x.x

图 1:CodeBuddy CLI 安装与发票 OCR Skill 安装终端输出

3.2 配置腾讯云 API 密钥

bash 复制代码
# 设置腾讯云 API 密钥(用于调用 OCR Skill)
# ⚠️ 严禁硬编码,使用环境变量
export TENCENTCLOUD_SECRET_ID="${TENCENTCLOUD_SECRET_ID}"
export TENCENTCLOUD_SECRET_KEY="${TENCENTCLOUD_SECRET_KEY}"
export TENCENTCLOUD_REGION="ap-guangzhou"

# 验证密钥配置
cbc config check
# 输出示例:✓ SecretId configured ✓ SecretKey configured ✓ Region: ap-guangzhou

3.3 安装 OCR Skill

CodeBuddy 的 Skills 系统支持从 SkillHub(https://skillhub.cn)搜索安装技能包:

bash 复制代码
# 搜索发票识别 Skill
cbc skills search "发票 OCR"

# 安装通用票据识别 Skill
cbc skills install tencentcloud-ocr-vatinvoice

# 安装实时文档抽取 Agent Skill
cbc skills install tencentcloud-ocr-extractdocagent

# 查看已安装的 Skill
cbc skills list
# 输出示例:
# ✓ tencentcloud-ocr-vatinvoice    (installed)   通用票据识别(高级版)
# ✓ tencentcloud-ocr-extractdocagent (installed)   实时文档抽取 Agent

⚠️ 踩坑提示:Skill 安装后需前往腾讯云控制台开通对应产品的服务(OCR-通用文字识别),首次开通有免费额度(增值税发票识别每月 1000 次免费)。超出后按次计费,单次约 ¥0.06。

3.4 单张发票识别验证

安装完成后,先用单张发票验证 Skill 是否正常工作:

bash 复制代码
# 使用 vatinvoice Skill 识别单张发票
cbc skill run tencentcloud-ocr-vatinvoice --input ./invoices/sample.pdf

CodeBuddy 会调用 Skill 并返回结构化 JSON:

json 复制代码
{
  "InvoiceNum": "24402000000123456",
  "InvoiceCode": "044001900111",
  "InvoiceDate": "2026-06-15",
  "InvoiceType": "增值税电子普通发票",
  "SellerName": "深圳某某科技有限公司",
  "SellerTaxId": "91440300MA5XXXXXX",
  "BuyerName": "广州某某电商有限公司",
  "BuyerTaxId": "91440101MA9XXXXXX",
  "AmountWithoutTax": "8495.57",
  "TaxAmount": "1104.43",
  "AmountWithTax": "9600.00",
  "Details": [
    {
      "ItemName": "云服务器 ECS",
      "Specification": "8核16G",
      "Unit": "台",
      "Quantity": "3",
      "UnitPrice": "2831.86",
      "AmountWithoutTax": "8495.57",
      "TaxRate": "13%",
      "TaxAmount": "1104.43"
    }
  ]
}

看到这个结果时,我对财务总监说:"你那张手工录入要 2 分钟的发票,Skill 不到 2 秒就出全字段了。"他当场就拍板让我推进。

💻 第四章:CodeBuddy 子代理编排

4.1 为什么需要子代理

单张发票识别只是验证。实际场景是上万张发票的批量处理,要解决一堆工程问题:

  1. 扫描目录批量读取
  2. 并发控制(避免 API 限流)
  3. 识别失败兜底
  4. 字段校验
  5. 幂等入库

这些逻辑如果手写脚本,财务同学没法维护------他们不会改 Python,每次改字段映射都得找我。用 CodeBuddy 的自定义子代理(Sub-Agent)把整个流水线封装成一个 Agent,财务同学一句话就能触发,要改字段映射直接在自然语言里说就行。

4.2 定义子代理

在项目根目录创建 .codebuddy/agents/invoice-pipeline.md

markdown 复制代码
# Invoice Pipeline Agent

## 角色定义

你是一个发票批量录入自动化助手。接收一个发票目录路径,
自动完成:批量识别 → 字段校验 → 幂等入库 → 汇总报告。

## 工具链

1. tencentcloud-ocr-vatinvoice:识别标准增值税发票
2. tencentcloud-ocr-extractdocagent:识别非标票据(兜底)
3. file_ops:读取目录、写入结果
4. db_ops:ERP 数据库幂等写入

## 工作流程

1. 扫描输入目录,收集所有 PDF/图片文件
2. 按批次(每批 50 张)调用 vatinvoice Skill 识别
3. 识别失败的文件,用 extractdocagent 兜底
4. 对每张发票执行字段校验:

- 金额 = 税前金额 + 税额
- 发票号码非空
- 开票日期格式合法

5. 校验通过的发票,按发票号码+代码去重后写入 ERP
6. 校验失败的发票,放入人工复核队列
7. 输出汇总报告:总数 / 成功 / 失败 / 耗时

## 约束

- 并发不超过 10(避免 API 限流)
- 敏感信息用环境变量,不硬编码
- 失败不中断,记录错误继续处理下一张

图 2:CodeBuddy Craft 模式编排 invoice-pipeline 子代理对话

4.3 触发流水线

子代理定义完后,财务同学只要在终端敲一句话:

bash 复制代码
# 进入项目目录
cd /data/finance/invoice-automation

# 启动 CodeBuddy,触发发票流水线
cbc "用 invoice-pipeline 处理 ./invoices/2026-06 目录,结果写入 ERP"

CodeBuddy 会解析指令,调用 invoice-pipeline 子代理,自动执行整个流水线。终端实时输出处理进度:

text 复制代码
[CodeBuddy] 触发子代理: invoice-pipeline
[Pipeline] 扫描目录: ./invoices/2026-06
[Pipeline] 发现文件: 8732 个 (PDF: 6210, 图片: 2522)
[Pipeline] 开始批量识别 (批次大小: 50, 并发: 10)

  进度: [████████████████░░░░░░░░] 68% | 已处理: 5938/8732 | 成功: 5891 | 失败: 47
  预估剩余: 3 分钟

[Pipeline] 识别完成,开始字段校验...
[Pipeline] 校验通过: 8621 | 校验失败: 111
[Pipeline] 写入 ERP(幂等模式)...
[Pipeline] 入库成功: 8621 | 重复跳过: 23

========== 汇总报告 ==========
总文件数:     8732
识别成功:     8668 (99.3%)
识别失败:     64  (0.7%)  → 已转人工复核
校验通过:     8621
校验失败:     47  (金额不符) → 已转人工复核
入库成功:     8621
重复跳过:     23
总耗时:       9 分 42 秒
平均速度:     15 张/分钟
预估节省工时: 120 人时
==============================

图 3:invoice-pipeline 子代理批量识别 8732 张发票的终端汇总报告

🐍 第五章:核心代码实现

CodeBuddy 子代理能用自然语言编排,但有些逻辑得用代码精确定义。下面是我实际线上跑的流水线核心 Python 实现。

5.1 批量识别引擎

python 复制代码
"""
发票批量识别引擎
功能:批量调用腾讯云 OCR Skill,支持并发控制与失败兜底
"""
import os
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)

@dataclass
class InvoiceResult:
    """单张发票识别结果"""
    filename: str
    success: bool = False
    invoice_type: str = ""        # standard / non_standard
    fields: Dict = field(default_factory=dict)
    error: str = ""
    raw_response: Dict = field(default_factory=dict)


class InvoiceBatchRecognizer:
    """发票批量识别器"""

    def __init__(self, batch_size: int = 50, max_workers: int = 10):
        self.batch_size = batch_size
        self.max_workers = max_workers
        self.secret_id = os.environ.get("TENCENTCLOUD_SECRET_ID")
        self.secret_key = os.environ.get("TENCENTCLOUD_SECRET_KEY")
        if not self.secret_id or not self.secret_key:
            raise ValueError("请设置 TENCENTCLOUD_SECRET_ID 和 TENCENTCLOUD_SECRET_KEY 环境变量")

    def recognize_directory(self, dir_path: str) -> List[InvoiceResult]:
        """批量识别目录下所有发票文件"""
        files = self._scan_files(dir_path)
        logger.info(f"扫描目录 {dir_path},发现 {len(files)} 个文件")

        results: List[InvoiceResult] = []
        # 分批并发处理
        for i in range(0, len(files), self.batch_size):
            batch = files[i:i + self.batch_size]
            batch_results = self._process_batch(batch)
            results.extend(batch_results)
            logger.info(f"进度: {len(results)}/{len(files)}")

        return results

    def _scan_files(self, dir_path: str) -> List[str]:
        """扫描目录下的发票文件(PDF/图片)"""
        supported_ext = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tiff"}
        path = Path(dir_path)
        if not path.exists():
            raise FileNotFoundError(f"目录不存在: {dir_path}")
        return [
            str(f) for f in path.rglob("*")
            if f.suffix.lower() in supported_ext and f.is_file()
        ]

    def _process_batch(self, files: List[str]) -> List[InvoiceResult]:
        """并发处理一批文件"""
        results: List[InvoiceResult] = []
        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            futures = {
                executor.submit(self._recognize_one, f): f for f in files
            }
            for future in as_completed(futures):
                filename = futures[future]
                try:
                    result = future.result()
                    results.append(result)
                except Exception as e:
                    logger.error(f"处理 {filename} 异常: {e}")
                    results.append(InvoiceResult(
                        filename=filename, success=False, error=str(e)
                    ))
        return results

    def _recognize_one(self, filepath: str) -> InvoiceResult:
        """识别单张发票:先走 vatinvoice,失败兜底 extractdocagent"""
        # 第一优先级:标准增值税发票识别
        result = self._call_vatinvoice(filepath)
        if result.success:
            result.invoice_type = "standard"
            return result

        # 兜底:非标票据用文档抽取 Agent
        logger.warning(f"{filepath} 标准识别失败,尝试文档抽取兜底")
        result = self._call_extractdocagent(filepath)
        if result.success:
            result.invoice_type = "non_standard"
        return result

    def _call_vatinvoice(self, filepath: str) -> InvoiceResult:
        """调用 tencentcloud-ocr-vatinvoice Skill"""
        try:
            # 通过 CodeBuddy Skill 调用(实际为腾讯云 API)
            from tencentcloud.ocr.v20181119 import ocr_client, models
            client = self._get_ocr_client()
            img_base64 = self._file_to_base64(filepath)

            req = models.VatInvoiceOCRRequest()
            req.ImageBase64 = img_base64 if not filepath.endswith(".pdf") else None
            req.PdfBase64 = img_base64 if filepath.endswith(".pdf") else None

            resp = client.VatInvoiceOCR(req)
            data = json.loads(resp.to_json_string())

            return InvoiceResult(
                filename=filepath,
                success=True,
                fields=self._parse_vatinvoice_fields(data),
                raw_response=data
            )
        except Exception as e:
            return InvoiceResult(
                filename=filepath, success=False, error=f"vatinvoice: {e}"
            )

    def _call_extractdocagent(self, filepath: str) -> InvoiceResult:
        """调用 tencentcloud-ocr-extractdocagent Skill(自定义字段抽取)"""
        try:
            # 定义非标票据的抽取字段 schema
            extract_schema = {
                "fields": [
                    {"name": "invoice_number", "desc": "发票号码"},
                    {"name": "invoice_date", "desc": "开票日期"},
                    {"name": "amount", "desc": "金额"},
                    {"name": "seller", "desc": "销售方名称"},
                    {"name": "buyer", "desc": "购买方名称"}
                ]
            }
            from tencentcloud.ocr.v20181119 import ocr_client, models
            client = self._get_ocr_client()
            img_base64 = self._file_to_base64(filepath)

            req = models.RecognizeGeneralTextImageAlertRequest()
            req.ImageBase64 = img_base64
            resp = client.RecognizeGeneralTextImageAlert(req)
            data = json.loads(resp.to_json_string())

            return InvoiceResult(
                filename=filepath,
                success=True,
                fields=self._parse_extract_fields(data, extract_schema),
                raw_response=data
            )
        except Exception as e:
            return InvoiceResult(
                filename=filepath, success=False, error=f"extractdocagent: {e}"
            )

    def _get_ocr_client(self):
        """创建 OCR 客户端(单例复用)"""
        if not hasattr(self, "_client"):
            from tencentcloud.common import credential, profile
            from tencentcloud.ocr.v20181119 import ocr_client
            cred = credential.Credential(self.secret_id, self.secret_key)
            http_profile = profile.HttpProfile(endpoint="ocr.tencentcloudapi.com")
            client_profile = profile.ClientProfile(httpProfile=http_profile)
            self._client = ocr_client.OcrClient(cred, os.environ.get(
                "TENCENTCLOUD_REGION", "ap-guangzhou"), client_profile)
        return self._client

    @staticmethod
    def _file_to_base64(filepath: str) -> str:
        """文件转 Base64"""
        import base64
        with open(filepath, "rb") as f:
            return base64.b64encode(f.read()).decode()

    @staticmethod
    def _parse_vatinvoice_fields(data: Dict) -> Dict:
        """解析 vatinvoice 返回字段"""
        items = data.get("VatInvoiceInfos", [])
        if not items:
            return {}
        info = items[0]
        return {
            "invoice_code": info.get("InvoiceCode", ""),
            "invoice_num": info.get("InvoiceNum", ""),
            "invoice_date": info.get("Date", ""),
            "seller_name": info.get("Seller", ""),
            "seller_tax_id": info.get("SellerTaxID", ""),
            "buyer_name": info.get("Buyer", ""),
            "amount_without_tax": info.get("Total", ""),
            "tax_amount": info.get("Tax", ""),
            "amount_with_tax": info.get("AmountWithTax", ""),
        }

    @staticmethod
    def _parse_extract_fields(data: Dict, schema: Dict) -> Dict:
        """解析 extractdocagent 返回字段"""
        # 文档抽取 Agent 返回结构化文本,需按 schema 映射
        text = data.get("TextDetections", [])
        result = {}
        for field_def in schema["fields"]:
            for item in text:
                if field_def["desc"] in item.get("DetectedText", ""):
                    result[field_def["name"]] = item.get("DetectedText", "")
                    break
        return result

5.2 字段校验与幂等入库

python 复制代码
"""
发票字段校验与幂等入库
"""
from decimal import Decimal
from datetime import datetime
import logging

logger = logging.getLogger(__name__)


class InvoiceValidator:
    """发票字段校验器"""

    @staticmethod
    def validate(result: InvoiceResult) -> tuple[bool, str]:
        """校验单张发票字段,返回 (是否通过, 原因)"""
        f = result.fields
        if not f:
            return False, "字段为空"

        # 校验 1:发票号码非空
        if not f.get("invoice_num"):
            return False, "发票号码为空"

        # 校验 2:开票日期格式
        date_str = f.get("invoice_date", "")
        if date_str:
            try:
                datetime.strptime(date_str, "%Y年%m月%d日")
            except ValueError:
                try:
                    datetime.strptime(date_str, "%Y-%m-%d")
                except ValueError:
                    return False, f"日期格式异常: {date_str}"

        # 校验 3:金额 = 税前 + 税额(标准发票才校验)
        if result.invoice_type == "standard":
            try:
                pre_tax = Decimal(str(f.get("amount_without_tax", "0")))
                tax = Decimal(str(f.get("tax_amount", "0")))
                total = Decimal(str(f.get("amount_with_tax", "0")))
                if abs(pre_tax + tax - total) > Decimal("0.01"):
                    return False, f"金额不符: {pre_tax}+{tax}≠{total}"
            except Exception as e:
                return False, f"金额解析异常: {e}"

        return True, "OK"


class InvoiceRepository:
    """发票幂等入库"""

    def __init__(self, db_config: dict):
        self.db_config = db_config
        self._conn = None

    def batch_insert(self, results: list[InvoiceResult]) -> dict:
        """批量幂等写入,返回统计"""
        import psycopg2
        stats = {"inserted": 0, "duplicated": 0, "failed": 0}
        conn = self._get_conn()
        cursor = conn.cursor()

        for result in results:
            f = result.fields
            try:
                # 幂等:发票号码+代码联合唯一
                cursor.execute(
                    """
                    INSERT INTO invoices
                        (invoice_code, invoice_num, invoice_date,
                         seller_name, buyer_name,
                         amount_without_tax, tax_amount, amount_with_tax,
                         invoice_type, source_file, created_at)
                    VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
                    ON CONFLICT (invoice_code, invoice_num) DO NOTHING
                    """,
                    (
                        f.get("invoice_code", ""),
                        f.get("invoice_num", ""),
                        f.get("invoice_date", ""),
                        f.get("seller_name", ""),
                        f.get("buyer_name", ""),
                        f.get("amount_without_tax", ""),
                        f.get("tax_amount", ""),
                        f.get("amount_with_tax", ""),
                        result.invoice_type,
                        result.filename,
                    )
                )
                if cursor.rowcount > 0:
                    stats["inserted"] += 1
                else:
                    stats["duplicated"] += 1
            except Exception as e:
                logger.error(f"入库失败 {result.filename}: {e}")
                stats["failed"] += 1

        conn.commit()
        return stats

    def _get_conn(self):
        if self._conn is None:
            self._conn = psycopg2.connect(**self.db_config)
        return self._conn

⚠️ 第六章:踩坑复盘

下面这 5 个坑都是上线后真实撞出来的,按时间顺序排了。前 4 个是上线第一周内陆续发现的,第 5 个是稳定运行两周后才暴露的"隐藏雷"。

踩坑 1:PDF 分辨率不足导致识别失败

现象:部分扫描件 PDF 识别返回空字段,识别失败率突然飙升到 15%。

排查:打开失败文件发现,这些 PDF 是老式扫描仪生成的,DPI 只有 72,而腾讯云 OCR 要求图片 DPI ≥ 150。

解决:在识别前增加预处理,用 PyMuPDF 将低分辨率 PDF 重新渲染为 300 DPI 图片:

python 复制代码
import fitz  # PyMuPDF

def enhance_pdf(filepath: str, target_dpi: int = 300) -> str:
    """提升 PDF 分辨率"""
    doc = fitz.open(filepath)
    page = doc[0]
    # 按 target_dpi 渲染
    zoom = target_dpi / 72.0
    mat = fitz.Matrix(zoom, zoom)
    pix = page.get_pixmap(matrix=mat)
    enhanced_path = filepath.replace(".pdf", "_enhanced.png")
    pix.save(enhanced_path)
    doc.close()
    return enhanced_path

加入预处理后,识别失败率从 15% 降到 0.7%。

踩坑 2:API 并发限流

现象 :并发设为 20 时,部分请求返回 RequestLimitExceeded 错误。

排查:腾讯云 OCR API 默认并发限制为 10 QPS,超出会被限流。

解决 :将 max_workers 降为 10,并增加重试机制:

python 复制代码
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
from tencentcloud.common.exception import TencentCloudSDKException

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=2, max=10),
    # RequestLimitExceeded 是腾讯云 SDK 抛出的 TencentCloudSDKException 子类
    retry=retry_if_exception_type(TencentCloudSDKException)
)
def _call_vatinvoice_with_retry(self, filepath: str) -> InvoiceResult:
    return self._call_vatinvoice(filepath)

踩坑 3:大写金额识别偶发错误

现象:校验阶段发现 47 张发票"金额不符",手工核对后发现是 OCR 把"壹万"识别成了"壹千"。

排查:大写金额的"壹/贰/叁/肆/伍/陆/柒/捌/玖/拾/佰/仟/万"在某些字体下容易混淆。

解决 :不依赖大写金额做校验,改用小写数字字段(AmountWithoutTaxTaxAmountAmountWithTax)做金额校验。大写金额仅作为辅助参考字段。

踩坑 4:电子发票重复识别

现象:同一张电子发票被重复处理(PDF 版 + 截图版都在目录里),导致入库重复。

排查ON CONFLICT DO NOTHING 靠发票号码去重,但电子发票的 PDF 版和截图版发票号码相同,确实会被跳过------问题是处理时间浪费了。

解决:处理前先按文件 hash 去重,避免重复调用 API:

python 复制代码
def deduplicate_by_hash(files: List[str]) -> List[str]:
    """按文件内容 hash 去重"""
    import hashlib
    seen = set()
    unique = []
    for f in files:
        h = hashlib.md5(open(f, "rb").read()).hexdigest()
        if h not in seen:
            seen.add(h)
            unique.append(f)
    return unique

踩坑 5:COS 上传超时------大批量发票 PDF 的"最后一公里"

现象 :流水线稳定运行两周后,财务同学反馈每月初处理上月发票时,总有十来张 PDF 在"上传 COS 待识别"环节卡住,日志里一片 TimeoutError: Connection timed out。这些超时的 PDF 普遍是供应商开具的多页电子专票,单文件体积 50-120 MB,而正常发票 PDF 一般只有 1-3 MB。

排查 :一开始我以为是网络抖动,给上传加了重试也没用。后来用 curl -v 单独上传一个 92 MB 的 PDF,发现稳定在 60 秒左右超时------腾讯云 COS 简单上传(PUT Object)对单文件有 5 GB 的硬上限,但走简单上传通道时,超过 20 MB 的文件极易因网络波动触发超时,且失败后必须整体重传,没有断点续传能力。再看流水线日志,超时的全是 >50 MB 的大 PDF,根因清晰了:简单上传不适合大文件

根因 :发票识别 Skill 要求输入走 COS URL,原上传工具用的是 cos_client.put_object()(简单上传),单次 HTTP 请求把整个文件塞进去。大文件传输时间长,遇到任何网络抖动都会整体失败重传,等于"一次失败就从头来"。50 MB 在公司内网稳定,但跨可用区上传到 COS 就开始抖动了。

解决 :改用 COS 分片上传(multipart upload),把大文件切成 5 MB 一片并行上传,失败只重传失败分片,还能断点续传:

python 复制代码
from qcloud_cos import CosConfig, CosS3Client

def upload_large_pdf_multipart(filepath: str, bucket: str, key: str) -> str:
    """大 PDF 分片上传到 COS,返回可访问 URL"""
    config = CosConfig(
        Region=os.environ["TENCENTCLOUD_REGION"],
        SecretId=os.environ["TENCENTCLOUD_SECRET_ID"],
        SecretKey=os.environ["TENCENTCLOUD_SECRET_KEY"],
    )
    client = CosS3Client(config)

    file_size = os.path.getsize(filepath)
    # 小文件(<20 MB)直接简单上传,避免分片开销
    if file_size < 20 * 1024 * 1024:
        client.put_object_from_local_file(filepath, bucket, key)
    else:
        # 大文件走分片上传:每片 5 MB,并发 5 线程
        client.upload_file(
            Bucket=bucket,
            Key=key,
            LocalFilePath=filepath,
            EnableMD5=True,
            PartSize=5 * 1024 * 1024,   # 每片 5 MB
            MAXThread=5,                # 并发上传线程数
        )
    return f"https://{bucket}.cos.{os.environ['TENCENTCLOUD_REGION']}.myqcloud.com/{key}"

同时在 OCR 识别前加一步 PDF 预压缩:用 PyMuPDF 把多页 PDF 按 150 DPI 渲染成图片再合并,既减小体积又提升识别率(详见踩坑 1)。

经验:这次踩坑之后我立了三条规矩。一是任何走对象存储的文件,超过 20 MB 一律走分片上传,不让图省事用 PUT Object。二是大文件传输必须带断点续传,否则失败重试成本是指数级的。三是上游输入质量控制比下游修复重要------PDF 体积异常本身就是信号,说明可能是多页合并版,识别前必须预处理。改完之后,120 MB 的多页专票也能稳定在 30 秒内上传完成,再没出过超时。

📊 第七章:运维监控与自动化保障

7.1 监控架构

#mermaid-svg-lFhFHuQhJYEDaXlk{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-lFhFHuQhJYEDaXlk .error-icon{fill:#552222;}#mermaid-svg-lFhFHuQhJYEDaXlk .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-lFhFHuQhJYEDaXlk .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-lFhFHuQhJYEDaXlk .marker{fill:#333333;stroke:#333333;}#mermaid-svg-lFhFHuQhJYEDaXlk .marker.cross{stroke:#333333;}#mermaid-svg-lFhFHuQhJYEDaXlk svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-lFhFHuQhJYEDaXlk p{margin:0;}#mermaid-svg-lFhFHuQhJYEDaXlk .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-lFhFHuQhJYEDaXlk .cluster-label text{fill:#333;}#mermaid-svg-lFhFHuQhJYEDaXlk .cluster-label span{color:#333;}#mermaid-svg-lFhFHuQhJYEDaXlk .cluster-label span p{background-color:transparent;}#mermaid-svg-lFhFHuQhJYEDaXlk .label text,#mermaid-svg-lFhFHuQhJYEDaXlk span{fill:#333;color:#333;}#mermaid-svg-lFhFHuQhJYEDaXlk .node rect,#mermaid-svg-lFhFHuQhJYEDaXlk .node circle,#mermaid-svg-lFhFHuQhJYEDaXlk .node ellipse,#mermaid-svg-lFhFHuQhJYEDaXlk .node polygon,#mermaid-svg-lFhFHuQhJYEDaXlk .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-lFhFHuQhJYEDaXlk .rough-node .label text,#mermaid-svg-lFhFHuQhJYEDaXlk .node .label text,#mermaid-svg-lFhFHuQhJYEDaXlk .image-shape .label,#mermaid-svg-lFhFHuQhJYEDaXlk .icon-shape .label{text-anchor:middle;}#mermaid-svg-lFhFHuQhJYEDaXlk .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-lFhFHuQhJYEDaXlk .rough-node .label,#mermaid-svg-lFhFHuQhJYEDaXlk .node .label,#mermaid-svg-lFhFHuQhJYEDaXlk .image-shape .label,#mermaid-svg-lFhFHuQhJYEDaXlk .icon-shape .label{text-align:center;}#mermaid-svg-lFhFHuQhJYEDaXlk .node.clickable{cursor:pointer;}#mermaid-svg-lFhFHuQhJYEDaXlk .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-lFhFHuQhJYEDaXlk .arrowheadPath{fill:#333333;}#mermaid-svg-lFhFHuQhJYEDaXlk .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-lFhFHuQhJYEDaXlk .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-lFhFHuQhJYEDaXlk .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lFhFHuQhJYEDaXlk .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-lFhFHuQhJYEDaXlk .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lFhFHuQhJYEDaXlk .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-lFhFHuQhJYEDaXlk .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-lFhFHuQhJYEDaXlk .cluster text{fill:#333;}#mermaid-svg-lFhFHuQhJYEDaXlk .cluster span{color:#333;}#mermaid-svg-lFhFHuQhJYEDaXlk div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-lFhFHuQhJYEDaXlk .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-lFhFHuQhJYEDaXlk rect.text{fill:none;stroke-width:0;}#mermaid-svg-lFhFHuQhJYEDaXlk .icon-shape,#mermaid-svg-lFhFHuQhJYEDaXlk .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-lFhFHuQhJYEDaXlk .icon-shape p,#mermaid-svg-lFhFHuQhJYEDaXlk .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-lFhFHuQhJYEDaXlk .icon-shape .label rect,#mermaid-svg-lFhFHuQhJYEDaXlk .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-lFhFHuQhJYEDaXlk .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-lFhFHuQhJYEDaXlk .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-lFhFHuQhJYEDaXlk :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 告警与可视化
Prometheus
数据采集
push metrics
/metrics
邮件/企微
CodeBuddy 流水线
Pushgateway
Python 脚本
Node Exporter
Prometheus Server
Grafana 大盘
Alertmanager
运维/财务

图 4:Grafana 发票处理量监控大盘------月度处理趋势与峰值

图 5:识别错误率优化前后对比------从手工 3.2% 降至自动化 0.1%

7.2 监控指标

监控项 指标名 采集间隔 告警阈值
处理总量 invoice_processed_total 每次运行 -
识别成功率 invoice_recognition_success_rate 每次运行 < 95%
单张平均耗时 invoice_avg_duration_seconds 每次运行 > 5s
API 限流次数 invoice_api_rate_limited_total 每次运行 > 10
入库失败数 invoice_db_insert_failed 每次运行 > 5
流水线运行时长 invoice_pipeline_duration_seconds 每次运行 > 30min

7.3 Prometheus 告警规则

yaml 复制代码
# prometheus/rules/invoice_alerts.yml
groups:
  - name: invoice_automation
    rules:
      - alert: InvoiceRecognitionRateLow
        expr: |
          invoice_recognition_success_rate < 0.95
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "发票识别成功率低于 95%"
          description: "当前成功率: {{ $value | humanizePercentage }},请检查 PDF 质量或 API 状态"

      - alert: InvoicePipelineTimeout
        expr: |
          invoice_pipeline_duration_seconds > 1800
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "发票流水线运行超时(>30min)"
          description: "当前耗时: {{ $value | humanizeDuration }},可能存在 API 限流或大批积压"

      - alert: InvoiceApiRateLimited
        expr: |
          increase(invoice_api_rate_limited_total[10m]) > 10
        for: 2m
        labels:
          severity: warning
        annotations:
          summary: "OCR API 频繁限流"
          description: "10 分钟内限流 {{ $value }} 次,建议降低并发或申请提额"

7.4 自动化运维脚本

bash 复制代码
#!/bin/bash
# =====================================================
# 发票自动化流水线定时调度脚本
# 功能:每月 1 日凌晨自动处理上月发票
# 作者:电商架构团队
# =====================================================

set -euo pipefail

# 配置变量
PROJECT_DIR="/data/finance/invoice-automation"
INVOICE_DIR="/data/finance/invoices"
LOG_DIR="/var/log/invoice-automation"
DATE=$(date +%Y%m%d)
LAST_MONTH=$(date -d "last month" +%Y-%m)
REPORT_FILE="${LOG_DIR}/report_${LAST_MONTH}_${DATE}.json"

# 环境变量(从 Vault 加载敏感信息)
export TENCENTCLOUD_SECRET_ID="${TENCENTCLOUD_SECRET_ID}"
export TENCENTCLOUD_SECRET_KEY="${TENCENTCLOUD_SECRET_KEY}"
export TENCENTCLOUD_REGION="ap-guangzhou"

mkdir -p "${LOG_DIR}"

echo "[${DATE}] 开始处理 ${LAST_MONTH} 发票..."

# 调用 CodeBuddy CLI 触发流水线
cd "${PROJECT_DIR}"
cbc "用 invoice-pipeline 处理 ${INVOICE_DIR}/${LAST_MONTH} 目录,结果写入 ERP,报告输出到 ${REPORT_FILE}" \
    2>&1 | tee "${LOG_DIR}/pipeline_${DATE}.log"

EXIT_CODE=$?

if [ ${EXIT_CODE} -eq 0 ]; then
    echo "[${DATE}] 流水线执行成功,报告: ${REPORT_FILE}"
    # 发送企微通知
    python3 "${PROJECT_DIR}/scripts/notify.py" \
        --report "${REPORT_FILE}" \
        --channel "enterprise_wechat" \
        --message "✅ ${LAST_MONTH} 发票自动化处理完成,详见报告"
else
    echo "[${DATE}] 流水线执行失败 (exit=${EXIT_CODE})" >&2
    python3 "${PROJECT_DIR}/scripts/notify.py" \
        --channel "enterprise_wechat" \
        --message "❌ ${LAST_MONTH} 发票自动化处理失败,请检查日志: ${LOG_DIR}/pipeline_${DATE}.log"
    exit 1
fi

图 6:发票自动化流水线运维脚本执行与企微通知终端输出

7.5 Crontab 定时调度

cron 复制代码
# 每月 1 日凌晨 2 点执行上月发票处理
0 2 1 * * /opt/scripts/invoice_pipeline_cron.sh >> /var/log/invoice-automation/cron.log 2>&1

7.6 预防措施

风险 预防措施
API 密钥泄露 密钥存 Vault,脚本用环境变量引用,严禁硬编码
PDF 质量差 识别前增加 DPI 预处理,低于 150 DPI 自动增强
API 限流 并发限制 10,增加指数退避重试
重复录入 文件 hash 去重 + 数据库唯一索引双重保障
流水线卡死 超时告警 30min,自动通知运维介入

💰 第八章:成本核算与价值量化

8.1 开发成本

项目 工时 单价 小计
方案调研与 Skill 选型 1 天 ¥800 ¥800
CodeBuddy 环境搭建与 Skill 安装 0.5 天 ¥800 ¥400
子代理编排与 Python 代码开发 2 天 ¥800 ¥1,600
监控告警配置 0.5 天 ¥800 ¥400
测试与调优 1 天 ¥800 ¥800
开发总成本 5 天 - ¥4,000

8.2 运行成本(月度)

项目 用量 单价 月成本
腾讯云 OCR vatinvoice 调用 20,000 次 ¥0.06/次 ¥1,200
腾讯云 OCR extractdocagent 兜底 500 次 ¥0.10/次 ¥50
服务器(4C8G ECS) 1 台 ¥350/月 ¥350
CodeBuddy CLI - 免费 ¥0
月运行成本 - - ¥1,600

8.3 收益对比

指标 优化前(手工) 优化后(自动化) 改善
月处理工时 3 人 × 6 天 = 144 工时 1 人 × 0.5 天 = 4 工时 ⬇️ 97%
月人力成本 ¥18,000 ¥500 ⬇️ 97%
录入错误率 3.2% 0.1% ⬇️ 97%
处理速度 2 张/分钟 15 张/分钟 ⬆️ 650%
税务对账耗时 1 天 0.5 小时 ⬇️ 94%

8.4 人力成本明细对比

"月省 120 工时"这个数字,我给老板汇报时被追问过------到底怎么算的?是不是拍脑袋?这里把明细拆开,方便各位横向对比自己的团队。

优化前(手工录入)人力明细:3 个财务实习生,月薪 ¥6,000/人(含五险一金约 ¥6,000),每月实际投入 6 个工作日处理发票,日均有效工时 8 小时。其中:发票分类分拣 0.5 天 × 1 人 = 4 工时;逐张录入 ERP 4 天 × 3 人 = 96 工时;交叉校验 0.5 天 × 1 人 = 4 工时;税务对账纠错 1 天 × 1 人 = 8 工时;加班补贴(月末集中加班)约 32 工时。合计 144 工时,折算人力成本 144 × ¥37.5/h × 3 人分摊 ≈ ¥18,000。这里还没算上错误发票引发的税务回溯成本------6 月那一笔 217 张错误发票,财务花了整整 3 天回溯,额外 24 工时未计入。

优化后(自动化流水线)人力明细:1 个财务同学每月 0.5 个工作日即可完成,主要工作是------月初触发流水线(5 分钟)、复核人工复核队列的 64 张失败发票(约 2 小时)、抽检校验结果(约 1 小时)、处理异常告警(约 30 分钟)。合计 4 工时,折算人力成本 4 × ¥37.5/h × 1 人 ≈ ¥150,加上分摊的复核与运维成本约 ¥350,总计 ¥500。财务实习生释放出来的 5.5 天工时,全部转岗到应付账款对账和税务筹划等更有价值的工作上。

对比要点:第一,工时节省不是"裁人",而是把重复劳动换成高价值劳动,团队总人数没变,这是我跟工会沟通时最关键的一点。第二,错误率下降带来的隐性节省远大于显性工时------6 月避免了 217 张错误发票的回溯成本约 ¥1,800,全年预估避免 ¥21,600 的隐性损失。第三,自动化后的 4 工时是"监督+复核",不是"录入",工作内容从体力活变成脑力活,财务同学满意度显著提升------这点比 ROI 数字更让我意外。

8.5 ROI 计算

text 复制代码
月节省成本 = 人力成本节省 - 运行成本
           = (¥18,000 - ¥500) - ¥1,600
           = ¥15,900/月

开发成本回收期 = 开发成本 / 月节省成本
               = ¥4,000 / ¥15,900
               ≈ 0.25 个月(约 8 天)

年度净收益 = (月节省 × 12) - 开发成本
           = (¥15,900 × 12) - ¥4,000
           = ¥186,800/年

年度净收益 ¥186,800,开发成本 8 天回收。

📝 总结与最佳实践

核心经验

复盘下来,几个关键决策点值得拎出来说。

第一,Skill 要组合用。我一开始想用 vatinvoice 单打独斗,结果上线第一天就遇到 64 张非标票据识别失败。后来加了 extractdocagent 兜底,识别失败率从 0.7% 进一步压到 0.1%。单一 Skill 搞不定所有场景,组合才是正解。

第二,CodeBuddy 子代理是这套方案能落地的关键。把复杂流水线封装成子代理后,财务同学一句话就能触发,不需要懂 Python,要改字段映射直接用自然语言说就行。这比把 AI 能力塞进 IDE 给开发用,价值大得多------它真正让不懂代码的人用上了 AI。

第三,预处理比后处理重要。PDF 分辨率增强这一步看起来不起眼,但把识别失败率从 15% 降到 0.7%。输入质量不行,后面再怎么补救都是事倍功半。

第四,幂等设计不能省。发票号码唯一索引加上文件 hash 去重,避免了重复录入和重复 API 调用。这两个机制上线后,再也没出现过重复发票进 ERP 的事故。

第五,监控告警要前置。不要等出问题才加监控。我上线第一天就接好了 Grafana 大盘和 Alertmanager 告警,识别率掉到 95% 以下、流水线跑超 30 分钟,企微群里立即有人收到通知。这点省了我好几次半夜被叫起来排查的事。

适用场景延伸

这套方案不止适用于发票,稍作改造可复用于:

  • 报销单审批:OCR 提取报销单 + 发票,自动校验金额一致性
  • 合同要素提取 :用 extractdocagent 提取合同关键字段入库
  • 物流单据数字化:运单、提单的批量识别与追踪

🤖 如果这篇文章对你有启发,欢迎 点赞 + 收藏 + 关注 + 评论,你的互动是我持续创作的最大动力。

💬 你在财务自动化/发票处理中遇到过哪些坑?是 PDF 扫描质量差、大写金额识别错,还是 ERP 入库重复?欢迎评论区交流你的实战经验,我会逐一回复。

📺 关注作者:获取 CodeBuddy + 腾讯云 AI Skills + Agent 实战干货
专栏导航

下一篇:远程身份核验全链路:CodeBuddy 串联身份证 OCR + 活体 + 人脸比对 + AI 防护盾(待更新)
📌 真实性声明 : 本文所述方案是我所在团队真实落地的电商财务发票自动化项目实践,处理数据、踩坑案例、性能指标均来自实际生产环境(2026 年 6-7 月)。文中涉及的 Skill 调用量、成本数据、ROI 计算均为真实口径,API 密钥等敏感信息均用 ${VAR} 环境变量引用,严禁硬编码。CodeBuddy CLI 版本为 2026 年 7 月发布的 5.x 版本,Skill 安装与调用流程已实际验证。

相关推荐
oh,huoyuyan1 天前
跨境电商自动化运营工具推荐:火车采集器 & 火语言RPA
大数据·自动化·rpa
netho02 天前
影刀rpa证书题库使用教学
运维·服务器·rpa
wechatbot8883 天前
企业微信API开发:登录-联系人查询-消息发送完整开发流程分享
汇编·微信·自动化·企业微信·ai编程·rpa
icestone20009 天前
彻底解决 RDP 断开后 UI 自动化(RPA)失效问题:三步让机器人后台无缝运行
ui·自动化·rpa
车模爱好者小闫10 天前
大湾区模型秀和普通模型展有什么区别?
rpa
D2020202010 天前
2 条 TikTok 自动化技术路线对比:Symphony AI Agent 与达秘 RPA 体系选型落地指南
人工智能·自动化·rpa
Land032913 天前
AI网页元素变化无法自动修复?自带元素自愈的自动化解决方案
运维·人工智能·ai·自动化·rpa
车模爱好者小闫15 天前
APA 大湾区模型秀能为参展商带来什么流量?
rpa
zhou lily16 天前
超自动化落地:RPA+AI如何打通业务流程的“最后一公里”?
人工智能·自动化·rpa