做票据自动化的同学大概率都有过一套人工审核的苦日子一堆发票照片、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_typestring增值税发票、出租车票、快递面单等是invoice_nostring纯数字或字母数字组合8位到20位是invoice_datestringYYYY-MM-DD是buyer_namestring购买方名称是buyer_tax_nostring购买方税号15至20位数字或大写字母否seller_namestring销售方名称是seller_tax_nostring销售方税号否amountfloat金额不含税保留两位小数是taxfloat税额保留两位小数是totalfloat价税合计保留两位小数是itemsarray商品明细列表每个元素含name、quantity、price字段否check_codestring校验码通常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_keyos.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, formatJPEG, quality90) 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( typeprotos.Type.OBJECT, properties{ invoice_type: protos.Schema(typeprotos.Type.STRING), invoice_no: protos.Schema(typeprotos.Type.STRING), invoice_date: protos.Schema(typeprotos.Type.STRING), buyer_name: protos.Schema(typeprotos.Type.STRING), buyer_tax_no: protos.Schema(typeprotos.Type.STRING), seller_name: protos.Schema(typeprotos.Type.STRING), seller_tax_no: protos.Schema(typeprotos.Type.STRING), amount: protos.Schema(typeprotos.Type.NUMBER), tax: protos.Schema(typeprotos.Type.NUMBER), total: protos.Schema(typeprotos.Type.NUMBER), items: protos.Schema( typeprotos.Type.ARRAY, itemsprotos.Schema(typeprotos.Type.OBJECT) ), check_code: protos.Schema(typeprotos.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_instructionSYSTEM_PROMPT, generation_configgeneration_config, ) response model.generate_content( contents[ { mime_type: fimage/{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_workersmax_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.10.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_retries3, delay2): 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_retries3, delay2) 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, expandTrue) elif orientation 6: img img.rotate(270, expandTrue) elif orientation 8: img img.rotate(90, expandTrue) 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_keyos.getenv(GEMINI_API_KEY)) def main(image_dir: str): rules json.load(open(rules.json, encodingutf-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, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) 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, requiredTrue, help存放票据图片的目录) args parser.parse_args() main(args.image_dir)这段脚本把票据图片目录里的所有图片自动处理一遍输出结果里每条记录都带上了票据图片路径、抽取数据、校验状态、错误原因后续你接数据库就从这个JSON结果里读。我在本地把脚本跑通之后又把它包装成了一个简单的Web上传接口前端拖入图片后端返回校验结果整个使用体验就非常顺了。8. 踩坑几次之后的一些心得这套方案从搭框架到上线前后迭代了好几版中间踩过的坑让我印象最深的有这么几个单独拿出来做个提醒。第一点模型很聪明但你不能把所有信任都给它。刚上线那会我图省事只调用了视觉模型没跑规则校验结果一批真票里混进了一张假票模型“自信”地把票面所有的字段都识别得漂漂亮亮包括那张假票上的错误金额。后来把价税合计校验规则加进去那类错误当场就被拦住了。这件事让我彻底明白了模型负责理解规则负责把关二者不可偏废。第二点Prompt的措辞对结果的影响比想象中大得多。最开始我在提示词里写“请抽取票面上的金额”模型偶尔会把税额也当成金额。后来改成了“金额为不含税的金额税额为单独的字段”识别准确率立刻上了一个台阶。做视觉抽取任务一定要在Prompt里把字段定义写得清清楚楚甚至把容易混淆的字段单独拎出来做对比说明效果远好过笼统地说“提取所有信息”。第三点处理大批量票据时失败重试不是可选项而是必选项。网络抖动、API限流、偶发超时这些在单张测试时几乎不会遇到但一到批量就频繁冒出来。你如果没有重试和中断恢复机制跑到第980张的时候崩了前面的活全白干。我在生产脚本里把处理进度实时写入日志文件每处理完一张就记录一条即使中途崩溃重启也能从断点继续跑这个设计可以说是批量任务的保命符。第四点版本升级要谨慎。有一次我图新鲜把模型切到了新版本同样的Prompt和图片识别结果整体没问题但个别字段的格式和之前不一样了直接导致下游的入库脚本解析失败。从那以后我就养成了习惯任何模型版本变更都要先拿历史样本跑回归验证确认不影响现有数据结构再切换。整体来说用Python接入Gemini 3.8 Flash做票据视觉抽取与规则校验是一条非常值得走的路。它解决了传统OCR票据识别最头疼的“字段理解和结构化输出”问题又凭借规则校验兜底了模型的幻觉和偶发错误把识别准确率和数据可信度同时拉到了可用的水平。不只是发票快递面单、银行回单、医疗收据、各种格式不统一的票据只要字段清楚、规则明确都可以用这套思路做自动化。如果你也在做类似的票据处理项目直接按这个框架起步即可把字段定义和规则配置做好、模型调用写好跑起来就是你自己的票据抽取引擎了。
阅读完成 · 觉得有帮助?