做票据自动化的同学,大概率都有过一套人工审核的苦日子:一堆发票照片、PDF、截图堆在文件夹里,人眼盯着票面一个个字段往Excel里录,录完还要核对价税合计、校验码、商品明细有没有抄错。遇到月底对账,整个财务部门加班是常态。我前阵子用Python接入Gemini 3.8 Flash做了一套票据视觉抽取与规则校验的流程,把之前纯人工的票据录入流水线彻底换掉了,实测下来,单张发票从拍照到入库,耗时从原来手工录入的两三分钟压到了十秒以内,识别失败和金额对不上的票据还能自动标记出来,交由人工复核,整个环节闭环跑通之后,不只是省人力,更重要的是数据口径统一了、出错率也肉眼可见地降下来了。这篇就完整记录一下这套方案的设计思路、关键代码和踩过的坑,给同样在做票据自动化的朋友一个可复现的参考。
这套方案的核心就两句话:用Gemini 3.8 Flash的多模态视觉能力,把票据图片直接翻译成结构化字段;再用一套轻量的规则校验引擎,把模型输出里那些“看着像但实际错”的结果拦下来。视觉模型负责“看懂”,规则引擎负责“把关”,两者结合起来,才能在生产环境里真正省心。如果你手头是发票、收据、银行回单、报销小票这类标准化程度不高的票据,这套思路都能直接迁移。
1. 需求分析与整体方案设计
1.1 核心需求拆解
我在动手之前,先把需求掰开了理了一遍。手头的票据主要是增值税发票、出租车票和部分快递面单,需要抽取的字段包含:发票类型、发票号码、开票日期、购买方名称与税号、销售方名称与税号、金额、税额、价税合计、商品明细(名称+数量+金额)、校验码。这些字段各有各的麻烦:有的属于整数型短字段(发票号码),有的属于长文本(购买方名称),有的属于金额类型(价税合计),还有的需要按行拆分(商品明细)。人工录入容易出错的就是金额和数字串,所以规则校验的重点也放在了这两个方向。
传统OCR方案我也认真评估过:PaddleOCR、Tesseract、云厂商的OCR API,单独用它们做文字识别没问题,但要把识别的文字块按“字段”映射到结构化Schema,还需要额外写大量规整化代码。票面上的排版稍有偏移,字段映射就乱了。而Gemini 3.8 Flash这类多模态模型,天生就是“图片直接输出JSON”,省掉了中间的文字检测、版面分析、字段对齐这一大串流程,对票据这种结构相对固定但又存在变化的场景非常合适。
1.2 为什么选择Gemini 3.8 Flash做视觉抽取
选型的时候对比了几个方案,最终敲定Gemini 3.8 Flash,理由有三点。第一,它原生支持图片输入并结合文本指令输出结构化内容,一次请求就能完成“识别+抽取+格式化”,不需要像传统OCR那样再搭建一个理解层。第二,Gemini 3.8 Flash的响应速度非常快,单张票据请求的延迟实测在1到3秒范围内,对批量处理场景来说这个速度完全够用。第三,API定价格相对亲民,批量跑也不肉疼,后续细算成本账的时候我会给出具体数字。
还有一个很关键的理由:Gemini 3.8 Flash对中文票据的支持比早期模型强了很多。我拿了一批扫描件和手机拍的斜角照片做测试,增值税发票上的小字号备注栏、发票专用章叠印区域的文字,都能抽取出来,虽然偶尔会有个别字符看走眼,但配合规则校验兜底,整体可用性非常高。
1.3 整体架构:视觉模型做理解,规则引擎做兜底
整个系统跑下来是一个标准流水线,拆成五个环节:
- 输入层:接收上传的图片文件(jpg、png、pdf转图),做基础格式转换和压缩。
- 预处理层:校正图像方向,压缩分辨率,把图片转成Base64编码。
- 抽取层:调用Gemini 3.8 Flash,配合精心设计的System Prompt和JSON Schema,直接输出结构化抽取结果。
- 校验层:规则引擎逐字段检查模型输出,从正则格式、税务逻辑、业务阈值三个层面进行校验。
- 输出层:校验通过的数据写入数据库并生成汇总表;校验不通过的数据标记异常原因,进入人工复核队列。
这套架构里,规则校验绝不是可有可无的补充,而是保证系统可信度的关键。模型再强也会“一本正经地胡说八道”,它可能会把发票号码里的“0”识别成“O”,也可能会在金额上多算一块钱。规则引擎就是在最后一道关卡上,把这些错误拦在数据入库之前。
1.4 字段字典与抽取格式设计
要模型稳定输出,第一步就是把“票据字段”定义成明确的Schema。我设计了一张字段字典表,也建议你就按这个思路来做,先定义清楚每个字段的类型、格式、是否必填,模型才知道该按什么标准作答。
| 字段名 | 类型 | 格式/说明 | 必填 |
|---|---|---|---|
| invoice_type | string | 增值税发票、出租车票、快递面单等 | 是 |
| invoice_no | string | 纯数字或字母数字组合,8位到20位 | 是 |
| invoice_date | string | YYYY-MM-DD | 是 |
| buyer_name | string | 购买方名称 | 是 |
| buyer_tax_no | string | 购买方税号,15至20位数字或大写字母 | 否 |
| seller_name | string | 销售方名称 | 是 |
| seller_tax_no | string | 销售方税号 | 否 |
| amount | float | 金额,不含税,保留两位小数 | 是 |
| tax | float | 税额,保留两位小数 | 是 |
| total | float | 价税合计,保留两位小数 | 是 |
| items | array | 商品明细列表,每个元素含name、quantity、price字段 | 否 |
| check_code | string | 校验码,通常20位数字 | 否 |
字段表的意义不仅仅是给模型看的,后面所有的规则校验逻辑也都围绕这张表展开。所以第一步别急着调代码,先把票据种类摸清楚、字段定义好,后面事半功倍。
2. 环境准备与Gemini 3.8 Flash接入
2.1 Python环境快速配置
项目用Python 3.8以上的版本开发即可,建议直接用3.10或3.11,语法兼容性好,第三方库的支持也最全。安装Python的时候别忘记勾选“Add Python to PATH”,否则后面在命令行里执行pip会报“不是内部或外部命令”。装完以后,用以下命令确认环境就绪:
python --version pip --version如果是Linux服务器环境,通常系统自带了Python 3,但版本可能偏老。建议用apt install python3-pip把pip装上,再通过python3 -m venv env创建独立的虚拟环境,避免全局安装把系统搞乱。Windows用户直接在官网下载安装包就行,一路默认设置基本没问题。
接下来安装相关的依赖库。核心是Google官方的Gemini Python SDK,加上图像处理库Pillow和网络请求库requests。用一条命令搞定:
pip install google-generativeai pillow requests python-dotenv其中google-generativeai是官方SDK,负责调用Gemini接口;Pillow用于图片预处理;python-dotenv用来管理API Key,避免把密钥硬编码到代码里。这个组合实测下来最稳,没有多余的依赖,也不容易和现有项目冲突。
如果你在Windows上遇到Pillow安装失败,多半是网络源的问题,换成国内镜像源再装一次就好:
pip install pillow -i https://pypi.tuna.tsinghua.edu.cn/simple2.2 获取API Key并初始化客户端
在Google AI Studio后台创建API Key,然后把Key写进项目根目录的.env文件里:
GEMINI_API_KEY=你的API密钥写代码时从环境变量加载,这样后续代码提交到Git仓库也不会泄露密钥:
import os from dotenv import load_dotenv import google.generativeai as genai load_dotenv() genai.configure(api_key=os.getenv("GEMINI_API_KEY"))这里强调一句:API Key千万不要提交到公开仓库,之前我见过不少人在GitHub上把Key明文传到仓库里,结果被爬虫抓到、账号被刷爆。用.gitignore把.env文件忽略掉,或者用环境变量方式注入,是必须养成的习惯。
初始化模型时,我用的模型名是gemini-3.8-flash,这是当前Flash系列的最新型号。如果你的项目环境还没开放这个型号,也可以切换到同系列的其他Flash版本,代码逻辑基本通用,只需要改一下模型名字符串:
model = genai.GenerativeModel("gemini-3.8-flash")2.3 请求参数调优:Temperature与Token限制
调用Gemini模型时,有两个参数直接影响抽取质量,一个是temperature,一个是max_output_tokens。我做票据抽取时把temperature设为0或接近0,因为抽取任务要的是确定性输出,而不是“创造性发挥”。温度越高,模型越容易把同一个号码在不同请求里识别成不同结果,这对票据场景是灾难性的。建议直接写死:
generation_config = { "temperature": 0, "top_p": 0.1, "max_output_tokens": 4096, "response_mime_type": "application/json", }response_mime_type设成application/json非常重要,它告诉模型必须以合法的JSON格式返回,能极大减少输出里夹杂解释性文字的情况。早期我用纯文本提示词让模型“只输出JSON”,它偶尔还是会冒几句“好的,这是抽取结果”之类的话,设置这个参数之后就没再出过这种问题。
2.4 网络连通性自检
接入远程API的第一步永远是自检网络和Key的连通性。写一个小小的测试脚本,如果返回正常,说明环境链路已经打通,可以进入下一步:
import google.generativeai as genai model = genai.GenerativeModel("gemini-3.8-flash") response = model.generate_content("请回复OK") print(response.text)执行后如果没有异常,就能看到模型回复的内容。如果出现超时或ConnectionError,先检查环境变量是否加载成功、网络路径是否可通,再到AI Studio后台确认API Key额度是否还有剩余。这几个问题排干净,后面写业务代码时就不会卡在环境上。
3. 票据视觉抽取的核心实现
3.1 图片读取与Base64编码
Gemini的视觉接口支持直接传图片二进制内容,也可以传Base64字符串。实测下来传Base64配合mime_type信息最稳妥,尤其处理批量任务时,这种方式对请求格式的控制精度更高,也不容易踩到直接传字节流的兼容性问题。
import base64 from PIL import Image import io def image_to_base64(image_path: str, max_size: int = 2048) -> tuple[str, str]: with open(image_path, "rb") as f: img_bytes = f.read() img = Image.open(io.BytesIO(img_bytes)) if max(img.size) > max_size: ratio = max_size / max(img.size) new_size = (int(img.width * ratio), int(img.height * ratio)) img = img.resize(new_size, Image.LANCZOS) buf = io.BytesIO() img.save(buf, format="JPEG", quality=90) img_bytes = buf.getvalue() encoded = base64.b64encode(img_bytes).decode("utf-8") return encoded, img.format.lower() if img.format else "jpeg"这段代码做了两件事:把过大的图片等比缩放到最长边不超过2048像素,避免图片太大导致请求体膨胀、响应超时;把非JPEG图片统一转成JPEG,调用接口的兼容性更好。Pillow的Image.LANCZOS重采样算法在这个尺寸下能保持文字清晰度,实测缩到2048px之后发票上的数字照样能识别。
3.2 System Prompt设计:决定抽取精度的成败关键
很多人调用多模态模型时,System Prompt只写一句“请识别这张发票”,效果自然不稳定。我的经验是把票据识别的系统提示词当成“岗位说明书”来写,说清楚模型扮演什么角色、输入什么、输出什么、遇到疑难情况应该怎么处理。
实际项目里我用的Prompt大概长这样:
你是一名专业的票据信息抽取引擎。你的任务是分析用户提供的票据图片,抽取指定字段并以JSON格式返回。 严格遵循以下字段定义: - invoice_type: 发票类型,可选值为"增值税专用发票"、"增值税普通发票"、"出租车票"、"快递面单"、"其他" - invoice_no: 发票号码,只保留数字和字母,不要添加任何分隔符 - invoice_date: 开票日期,格式化为YYYY-MM-DD,如果票据上日期格式为2024年7月9日,输出为2024-07-09 - amount: 金额(不含税),使用数字类型,保留两位小数 - tax: 税额,使用数字类型,保留两位小数 - total: 价税合计,使用数字类型,保留两位小数 - items: 商品明细数组,每个元素包含name、quantity、price - check_code: 校验码(20位数字),如果票据上没有该字段,输出空字符串 注意事项: 1. 金额字段必须与票面印刷完全一致,不要把金额和税额搞混。 2. 销售方名称中如果有括号和特殊符号,保留原样输出。 3. 如果票据倾斜、模糊或有遮挡,仍然尽力识别,并在confidence字段中如实报告置信度。 4. 不要编造字段值。如果某个字段确实无法辨认,输出空字符串。Prompt里加入了“不要编造字段值”这个约束,实测能从根源上减少幻觉问题。模型在识不出来的时候更倾向于老老实实地给空值,而不是随手填一个“像”的数字,这让后面的规则校验可以精确拦截失败样本。
3.3 结构化输出:用JSON Schema约束模型返回
如果你的SDK版本支持response_schema参数,建议直接用JSON Schema定义输出结构,这比在Prompt里写字段说明更硬约束:
from google.generativeai import protos schema = protos.Schema( type=protos.Type.OBJECT, properties={ "invoice_type": protos.Schema(type=protos.Type.STRING), "invoice_no": protos.Schema(type=protos.Type.STRING), "invoice_date": protos.Schema(type=protos.Type.STRING), "buyer_name": protos.Schema(type=protos.Type.STRING), "buyer_tax_no": protos.Schema(type=protos.Type.STRING), "seller_name": protos.Schema(type=protos.Type.STRING), "seller_tax_no": protos.Schema(type=protos.Type.STRING), "amount": protos.Schema(type=protos.Type.NUMBER), "tax": protos.Schema(type=protos.Type.NUMBER), "total": protos.Schema(type=protos.Type.NUMBER), "items": protos.Schema( type=protos.Type.ARRAY, items=protos.Schema(type=protos.Type.OBJECT) ), "check_code": protos.Schema(type=protos.Type.STRING), }, ) generation_config = { "temperature": 0, "max_output_tokens": 4096, "response_mime_type": "application/json", "response_schema": schema, }设置Schema之后,模型返回的JSON格式错误率几乎降到了零。之前在没有Schema只有Prompt的情况下,偶尔还是会出现字段缺失、类型不对(比如金额返回字符串)的情况,现在这个坑算是彻底填上了。如果运行环境不支持这个参数,退回到普通JSON模式也能跑,只是需要自己在代码里多写一层容错解析。
3.4 调用模型完成票据抽取
下面这段是核心调用代码,把前面准备的图片、Prompt和配置全部串起来:
import json import google.generativeai as genai SYSTEM_PROMPT = """ 你是一名专业的票据信息抽取引擎。你的任务是分析用户提供的票据图片,抽取指定字段并以JSON格式返回。 严格遵循以下字段定义: ... """ def extract_invoice(image_path: str): image_b64, img_type = image_to_base64(image_path) model = genai.GenerativeModel( "gemini-3.8-flash", system_instruction=SYSTEM_PROMPT, generation_config=generation_config, ) response = model.generate_content( contents=[ { "mime_type": f"image/{img_type}", "data": image_b64, }, {"text": "请抽取这张票据中的字段,严格按Schema输出JSON。"}, ] ) try: data = json.loads(response.text) return data except json.JSONDecodeError: return {"error": "模型返回非法JSON", "raw": response.text}实际跑起来的效果,绝大多数票据都能一次性出完整字段。有个细节值得注意:请求内容里图片和文字指令都放在contents列表中,顺序为先图片后文字指令,模型既可以“看到图”又能接收到明确任务。如果你传PDF文件,Gemini 3.8 Flash也能直接处理,但需要把PDF转成图片再传,接口对图片格式的兼容性是最好的,PDF直传反而容易触发解析限制。
3.5 批量处理:多线程加速票据流水线
单张票据抽取速度已经很快,但面对几百上千张图片时,逐张串行请求还是会比较慢。这时候用Python的concurrent.futures.ThreadPoolExecutor开并发请求,速度能直接拉上去。Gemini接口本身支持并发,只要注意控制线程数,别把API配额一下子打满。
from concurrent.futures import ThreadPoolExecutor, as_completed def batch_extract(image_paths: list[str], max_workers: int = 5) -> list[dict]: results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {executor.submit(extract_invoice, path): path for path in image_paths} for future in as_completed(future_map): path = future_map[future] try: data = future.result() results.append({"path": path, "data": data}) except Exception as e: results.append({"path": path, "data": {"error": str(e)}}) return results我在实际项目中把max_workers调到5,每张图片请求耗时平均2秒,10张一组跑下来45秒左右,比串行节省了将近一半时间。如果你想更快,可以结合异步IO重新实现,不过5个线程的并发已经能满足大多数小团队的票据录入需求,没必要一开始就上复杂架构。
4. 规则校验体系:把模型的错误拦在入库前
4.1 为什么有了模型还要做规则校验
模型不是数据库,它的输出天然带概率性质。同一张发票,这次调用可能把发票号码识别对,下次调用可能把校验码里的“6”识别成“8”。如果系统不做任何校验就把数据写入财务系统,等到对账阶段才发现金额错了,返工成本就非常高了。规则校验的存在,是在模型输出和业务系统之间加一道“防错网”。
规则体系分为三层:格式规则、税务逻辑规则、业务规则。格式规则管长度和字符集,税务逻辑规则管金额联动关系,业务规则管报销场景的阈值和时间。三层规则逐层过滤,每层都不通过就直接打回人工复核。
4.2 格式校验:正则表达式先过滤明显异常
发票号码一般是8到20位的数字或字母组合,校验码是20位数字,日期必须是合法日期。这些基础格式问题用正则就能快速拦住。以发票号码为例:
import re import datetime def validate_format(data: dict) -> list[str]: errors = [] invoice_no = data.get("invoice_no", "") if not re.fullmatch(r"[A-Za-z0-9]{8,20}", invoice_no): errors.append(f"发票号码格式异常: {invoice_no}") check_code = data.get("check_code", "") if check_code and not re.fullmatch(r"\d{20}", check_code): errors.append(f"校验码格式异常: {check_code}") date_str = data.get("invoice_date", "") try: datetime.datetime.strptime(date_str, "%Y-%m-%d") # 如果日期晚于今天或早于合理范围(比如2000年以前),也属于异常 dt = datetime.datetime.strptime(date_str, "%Y-%m-%d") if dt.year < 2000 or dt > datetime.datetime.now(): errors.append(f"开票日期超出合理范围: {date_str}") except ValueError: errors.append(f"开票日期格式错误: {date_str}") if not isinstance(data.get("amount"), (int, float)): errors.append("金额字段缺失或类型错误") if not isinstance(data.get("tax"), (int, float)): errors.append("税额字段缺失或类型错误") if not isinstance(data.get("total"), (int, float)): errors.append("价税合计字段缺失或类型错误") return errors这套正则的写法比较宽松,因为不同省份、不同年份的发票号码规则还有细微差别,不是所有号码都严格遵守纯数字格式,所以字母数字混合的兼容反而更实用。等你的样本库积累到一定程度,再回来细化正则规则也不迟。
4.3 税务逻辑校验:价税合计必须等于金额加税额
增值税发票有一个铁律:价税合计等于金额加税额。这是做票据财务核验时最硬的一条规则,模型就算识别错了某个数字,也很难恰好满足这个等式。所以我一拿到模型输出,先做这个判断:
MONEY_PRECISION = 0.01 def validate_tax_logic(data: dict) -> list[str]: errors = [] amount = data.get("amount", 0) tax = data.get("tax", 0) total = data.get("total", 0) if not isinstance(amount, (int, float)) or not isinstance(tax, (int, float)) or not isinstance(total, (int, float)): errors.append("金额、税额、价税合计存在非数字字段") return errors calc_total = round(amount + tax, 2) if abs(calc_total - total) > MONEY_PRECISION: errors.append( f"价税合计不匹配: 金额({amount}) + 税额({tax}) = {calc_total}, 票面合计({total})" ) return errors这里用abs(...) > MONEY_PRECISION做浮点比较,而不是直接用等号,因为Python的浮点运算里0.1+0.2可能等于0.30000000000000004,四舍五入之后虽然能对上,但直接用等号判断容易误伤。round(amount + tax, 2)之后再做差值和精度比较,是财务数字校验的基本功。
除了价税合计,还可以顺便校验税额和金额的比例。增值税专用发票通常有13%、9%等固定税率,如果计算出的税额/金额比例明显不在合理范围内,也说明识别可能有误:
if amount and tax: rate = round(tax / amount * 100, 2) if rate not in [0, 3, 6, 9, 13]: errors.append(f"税率异常: {rate}%")注意这里不是硬性规定只能有这几档税率,而是用来提示“识别结果可能有问题”。免税发票税额为0,所以把0也列为合理值。遇到特殊情况(比如差额征税),人工复核时可以看到异常标记,再判断是识别错误还是业务确实特殊。
4.4 业务规则校验:按场景配置阈值
除了税务本身的逻辑,票据报销场景还有一些业务维度的规则。比如公司规定单张发票金额超过5000元需要走特殊审批,或者报销日期不能早于出差日期30天。这类规则如果散落在业务代码里,每次改一个阈值都要发一次版本,很麻烦。我把它们做成了配置文件:
{ "rules": { "max_total_amount": 5000, "allow_future_date": false, "date_range_days": 180, "check_seller_blacklist": true }, "blacklist_seller": [ "某某不合作供应商", "某某异常商户" ] }校验引擎读取配置后逐条执行,代码不写死任何业务数值:
def validate_business_rules(data: dict, rules: dict) -> list[str]: errors = [] total = data.get("total", 0) if total and rules.get("max_total_amount") and total > rules["max_total_amount"]: errors.append(f"单张票据金额超过阈值: {total}(上限{rules['max_total_amount']})") invoice_date = data.get("invoice_date", "") if rules.get("allow_future_date") is False: try: dt = datetime.datetime.strptime(invoice_date, "%Y-%m-%d") if dt > datetime.datetime.now(): errors.append(f"开票日期晚于当前日期: {invoice_date}") except ValueError: pass seller = data.get("seller_name", "") if rules.get("check_seller_blacklist") and seller in rules.get("blacklist_seller", []): errors.append(f"销售方在黑名单中: {seller}") return errors这样配置的好处是,业务人员或者运营人员可以直接修改JSON文件调整阈值,不需要动代码。我们把规则配置放在独立文件里之后,调整报销金额上限这类需求再也不用等开发排期,运营改个数字就能生效。
4.5 校验结果分类与处理流程
三个方向的校验结果汇总到一起,把票据分成三类状态:
- PASS(通过):所有校验全部通过,数据自动入库。
- WARN(警告):格式或逻辑校验有非致命问题(比如税率比例略偏高、日期较早),但字段整体可用,数据入库但附带警告标识。
- FAIL(失败):核心字段校验不通过,拒绝入库,自动进入人工复核队列。
完整代码如下:
def validate_invoice(data: dict, rules: dict) -> dict: all_errors = [] all_errors.extend(validate_format(data)) all_errors.extend(validate_tax_logic(data)) all_errors.extend(validate_business_rules(data, rules)) level = "PASS" if any("金额超过阈值" in e for e in all_errors): level = "WARN" if any("价税合计不匹配" in e or "发票号码格式异常" in e for e in all_errors): level = "FAIL" return { "status": level, "errors": all_errors, "data": data, }校验执行完之后,把结果写到CSV或者数据库里,前端展示的时候点开一条记录就能看到具体错误原因,人工复核也方便。处理流程上我是这么定的:PASS直接落库,WARN落库但打标,定期让财务抽检,FAIL进入待处理列表。人工复核的票据里大部分是模型识别有误(数字串错了、税额看混了),少数是票据本身模糊或者规则配置过严格(比如某张老发票税率格式特殊)。复核完修正的字段会回填到数据库,后续再出现类似票据,模型和规则都没拦住的情况下,人工复核记录也能作为训练样本沉淀下来。
5. 部署实践与踩坑记录
5.1 中断重试与容错设计
实际部署中,网络抖动、API限流、超时都是躲不开的问题。Gemini接口偶尔会返回429(配额超限)或者5xx(服务端临时错误),如果代码不做重试,整个批量任务就会中断在中间某张票据上。我加了一个简单的重试装饰器,遇到错误最多重试3次,间隔指数增长:
import time import functools def retry(max_retries=3, delay=2): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(max_retries): try: return func(*args, **kwargs) except Exception as e: if attempt == max_retries - 1: raise e time.sleep(delay * (attempt + 1)) return None return wrapper return decorator @retry(max_retries=3, delay=2) def extract_invoice_with_retry(image_path: str): return extract_invoice(image_path)重试机制虽然简单,但在跑大批量数据时非常管用。我一晚上跑了800张票据,中途遇到一两次限流,有了重试就不会整体翻车。注意重试次数别设太多,不然单张图占线时间太长,反而拖慢整体进度。
5.2 图片方向与质量问题处理
照片类票据最大的问题不是内容复杂,而是拍摄方向倾斜、光照不均、还有褶皱阴影。Gemini的视觉模型对轻度倾斜和阴影有不错的鲁棒性,但方向横竖颠倒就经常出错了。我的经验是预先对图片做一次方向判断和自动旋转。这个可以借助Pillow的Image.transpose配合简单的启发式规则:比如检查EXIF信息里的Orientation字段,以及通过票面边缘矩形检测来判断方向。
def auto_orient(image_path: str) -> str: img = Image.open(image_path) exif = img.getexif() orientation = exif.get(274) if orientation == 3: img = img.rotate(180, expand=True) elif orientation == 6: img = img.rotate(270, expand=True) elif orientation == 8: img = img.rotate(90, expand=True) output_path = image_path.replace(".", "_orient.") img.save(output_path) return output_path光线太暗或者曝光过度的图片,我试过做直方图均衡化和对比度增强,对提升小字号文字的识别率有微弱的正向效果,但收益不如直接调高拍摄质量。所以栈底层的建议依然是:输入图片质量决定了抽取效果的上限,模型再好也经不住图片糊成一片。批量处理时建议在采集端就做质量校验,分辨率低于800px或者画面大面积模糊的图片直接提示重新拍摄,比事后修图高效得多。
5.3 中文输出乱码与JSON解析异常处理
调用Gemini接口时偶尔会遇到返回内容里混了非UTF-8字符或者转义符,导致json.loads直接报错。我的处理方案是加一个清洗函数,把常见的异常字符剔除掉再解析:
import re def clean_json_response(raw_text: str) -> str: cleaned = raw_text.strip() cleaned = cleaned.replace("```json", "").replace("```", "") cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", cleaned) return cleaned def safe_json_parse(raw_text: str) -> dict: try: return json.loads(clean_json_response(raw_text)) except json.JSONDecodeError: # 尝试提取大括号之间的内容 match = re.search(r"\{.*\}", raw_text, re.S) if match: return json.loads(match.group()) raise ValueError("无法从模型输出中解析JSON")这个清洗逻辑在线上跑了很久,基本拦截了大部分模型输出的非JSON杂质。如果你拿到的是完整的响应对象而不是response.text,还可以直接从response.result.candidates[0].content.parts[0].text里取文本,这两种方式我都试过,用response.text更简洁,但遇到多模态输出时需要确认取的是文本部分。
5.4 成本估算与批量优化
Gemini 3.8 Flash的定价按输入和输出的token数计算,图片以固定token数计入输入。我粗略估算了一下,一张分辨率2048的JPEG图片,加上Prompt,大约消耗1500到2500个输入token,单张票据的成本很低。批量报销1000张票据,按当前API定价换算,总成本在可控范围内,相比传统OCR加人工复核的组合反而更划算。这套方案省下的不仅仅是API调用费,更关键的是节省了人力的时间成本。
如果你的场景要求极致省钱,可以加一个“先局部后整体”的策略:先用传统OCR快速处理,把置信度高的字段直接填好,只拿置信度低的区域截图给Gemini做二次识别。这种做法能减少图片传给模型的尺寸和数量,进一步降低成本,代价是实现复杂度上升。对小团队来说,直接全量调用Flash的价格已经够低,没必要提前做这套优化。
5.5 生产环境稳定性与观测
接入生产环境后,光有功能不行,还得能观测、能排障。我在系统里加了两类日志:一类是识别日志,记录每张图片的请求ID、处理耗时、返回状态;另一类是校验日志,记录每个字段的校验结果和异常原因。这些日志汇总到ELK或者简单写入数据库表格都可以,关键是出了问题时能快速定位是模型识别问题、规则配置问题还是网络问题。
再提醒一个隐蔽的坑:Gemini接口的版本迭代和模型参数更新,可能导致同一Prompt的输出格式出现细微变化。所以生产环境用到的模型版本最好锁定下来,不要跟着“最新版”乱跳,等验证好新版本再切换。我们项目里就把模型名写进了配置项,升级时先跑一批历史票据回归测试,通过后再切换流量,这个步骤能避免很多莫名其妙的线上回归问题。
6. 常见问题排查速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 调用接口超时 | 图片过大、网络延迟 | 压缩图片至2048px以内,适度提高请求超时时间 |
| 返回JSON解析失败 | 模型输出混入杂质文本 | 使用clean_json_response清洗后解析 |
| 发票号码识别错误 | 图片模糊、光照不均 | 改善输入图片质量,降低temperature到0 |
| 价税合计校验失败 | 模型把金额字段看串行 | 查看原始识别结果,人工复核修正 |
| API报429限流 | 并发过高或配额不足 | 降低线程数,加重试机制,检查API配额 |
| 日期格式不统一 | 票面日期格式多样 | Prompt中明确要求统一为YYYY-MM-DD |
| 输出字段缺失 | Schema设置不完整 | 在JSON Schema中指定全部必填字段 |
| 中文名称识别乱码 | 编码处理不正确 | 检查代码字符集,统一使用UTF-8 |
| 校验规则误报 | 业务阈值设置不合理 | 调整配置JSON中的规则阈值 |
这张表是我在实际运维过程中一点一点积累下来的,每一条都是真实踩过的坑。遇到问题先查表,能省下不少排查时间。
7. 把整套流程串起来:完整示例
到这一步,把之前的所有代码整理成一个可以直接运行的脚本,作为整套方案的落地参照。实际项目里你可以在这个基础上扩展数据库写入、报表生成、队列管理等模块:
import json import os import argparse from dotenv import load_dotenv import google.generativeai as genai from concurrent.futures import ThreadPoolExecutor, as_completed load_dotenv() genai.configure(api_key=os.getenv("GEMINI_API_KEY")) def main(image_dir: str): rules = json.load(open("rules.json", encoding="utf-8")) image_paths = [os.path.join(image_dir, f) for f in os.listdir(image_dir) if f.lower().endswith((".jpg", ".jpeg", ".png"))] results = [] for image_path in image_paths: try: raw_data = extract_invoice_with_retry(image_path) validated = validate_invoice(raw_data, rules) results.append({"path": image_path, **validated}) except Exception as e: results.append({"path": image_path, "status": "FAIL", "errors": [str(e)]}) with open("output_results.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2) pass_count = len([r for r in results if r["status"] == "PASS"]) fail_count = len([r for r in results if r["status"] == "FAIL"]) print(f"处理完成: 共{len(results)}张, 通过{pass_count}张, 失败{fail_count}张") if __name__ == "__main__": parser = argparse.ArgumentParser(description="票据视觉抽取与规则校验") parser.add_argument("--image_dir", required=True, help="存放票据图片的目录") args = parser.parse_args() main(args.image_dir)这段脚本把票据图片目录里的所有图片自动处理一遍,输出结果里每条记录都带上了票据图片路径、抽取数据、校验状态、错误原因,后续你接数据库就从这个JSON结果里读。我在本地把脚本跑通之后,又把它包装成了一个简单的Web上传接口,前端拖入图片,后端返回校验结果,整个使用体验就非常顺了。
8. 踩坑几次之后的一些心得
这套方案从搭框架到上线,前后迭代了好几版,中间踩过的坑让我印象最深的有这么几个,单独拿出来做个提醒。
第一点,模型很聪明,但你不能把所有信任都给它。刚上线那会,我图省事只调用了视觉模型,没跑规则校验,结果一批真票里混进了一张假票,模型“自信”地把票面所有的字段都识别得漂漂亮亮,包括那张假票上的错误金额。后来把价税合计校验规则加进去,那类错误当场就被拦住了。这件事让我彻底明白了,模型负责理解,规则负责把关,二者不可偏废。
第二点,Prompt的措辞对结果的影响比想象中大得多。最开始我在提示词里写“请抽取票面上的金额”,模型偶尔会把税额也当成金额。后来改成了“金额为不含税的金额,税额为单独的字段”,识别准确率立刻上了一个台阶。做视觉抽取任务,一定要在Prompt里把字段定义写得清清楚楚,甚至把容易混淆的字段单独拎出来做对比说明,效果远好过笼统地说“提取所有信息”。
第三点,处理大批量票据时,失败重试不是可选项而是必选项。网络抖动、API限流、偶发超时,这些在单张测试时几乎不会遇到,但一到批量就频繁冒出来。你如果没有重试和中断恢复机制,跑到第980张的时候崩了,前面的活全白干。我在生产脚本里把处理进度实时写入日志文件,每处理完一张就记录一条,即使中途崩溃重启,也能从断点继续跑,这个设计可以说是批量任务的保命符。
第四点,版本升级要谨慎。有一次我图新鲜把模型切到了新版本,同样的Prompt和图片,识别结果整体没问题,但个别字段的格式和之前不一样了,直接导致下游的入库脚本解析失败。从那以后我就养成了习惯:任何模型版本变更,都要先拿历史样本跑回归验证,确认不影响现有数据结构再切换。
整体来说,用Python接入Gemini 3.8 Flash做票据视觉抽取与规则校验,是一条非常值得走的路。它解决了传统OCR票据识别最头疼的“字段理解和结构化输出”问题,又凭借规则校验兜底了模型的幻觉和偶发错误,把识别准确率和数据可信度同时拉到了可用的水平。不只是发票,快递面单、银行回单、医疗收据、各种格式不统一的票据,只要字段清楚、规则明确,都可以用这套思路做自动化。如果你也在做类似的票据处理项目,直接按这个框架起步即可,把字段定义和规则配置做好、模型调用写好,跑起来就是你自己的票据抽取引擎了。