
干财务自动化的同学应该都懂一个痛票据处理这事儿看起来就是“看图识字”真要落地却能把人磨疯。发票、收据、运单、报销单版式五花八门有的抬头在左下角有的金额横着写还有的红章子直接压在关键字段上。传统 OCR 能识别文字但识别完你还得写一堆坐标规则去拼字段稍微换个版式就全崩。这个项目的核心思路就一条用 Gemini 3.8 Flash 的视觉理解能力做端到端票据抽取把图片直接转成结构化 JSON再叠一层可配置的规则校验引擎按财务口径把 AI 吐出来的结果逐项复核。大模型当眼睛规则引擎当质检员各干各的活互不抢戏。适合正在做报销系统、财务自动化、票据归档、合规审核的朋友参考尤其是那种票据来源多、版式杂、又对准确率有硬要求的场景。1. 项目背景与方案选型1.1 票据抽取的真实痛点先说清楚“票据抽取”到底难在哪。很多人第一反应是“不就是 OCR 吗”真上手做过就发现没那么简单。常见的坑有三类。第一类是版面不固定。同一家公司的增值税发票还分专票、普票、电票更别提不同商户开的手写收据、物流面单、银行回单。传统 OCR 处理固定版式还行一旦版式变了坐标模板就废了维护成本极高。第二类是信息语义化。OCR 出来的是“一坨文字”你得自己判断哪行是金额、哪行是日期、哪段是税号。字段挨得近的、被水印盖住的、横竖混排的规则写起来非常痛苦。第三类是脏数据。印章压字、打印倾斜、褶皱反光、字迹模糊这些在真实票据里太常见了。传统 OCR 对这类情况的容错率很低一个字符错了后面校验全链跟着崩。这些痛点凑在一起纯靠规则和模板根本兜不住所以才要把视觉大模型拉进来。1.2 为什么选 Gemini 3.8 Flash 而不是传统 OCR选 Gemini 3.8 Flash 的逻辑其实很简单我对比过几条线。传统 OCR 方案比如开源 OCR 引擎或者本地部署的识别模型的问题在于识别和语义理解是分离的OCR 负责吐文字语义得自己写规则或训练模型。而 Gemini 3.8 Flash 这类多模态大模型是直接把“图片”映射到“结构化字段”跳过中间那一堆管道工程。选 Flash 系列而不是更大的旗舰模型主要是成本、延迟和上下文这三者的平衡。票据抽取属于单图、短输入、结构化输出的任务flash 级别的模型响应足够快单价低得多而且多模态能力和结构化输出对齐做得已经很成熟。实测下来在干净票据上准确率接近 95% 以上脏票据配合预处理也能到 85% 左右对业务场景来说够用了。还有一个关键点Flash 系列对JSON 结构化输出有原生支持直接把响应格式锁死成 JSON不需要自己解析 markdown 里的代码块这一项在工程化时省了太多事。1.3 整体架构设计这个项目的整体链路不长一共四个环节图片预处理、模型抽取、规则校验、结果落库。票据图片 -- 压缩/纠偏 -- Gemini 3.8 Flash -- 结构化 JSON -- 规则校验引擎 -- 入库/告警图片先做尺寸压缩和质量归一化再传给模型做抽取。模型返回 JSON 后规则引擎按财务口径逐字段校验金额一致性、税号格式、日期合法性、合计与明细求和是否吻合。通过的进正常流程不通过的挂起并标注异常原因。这里有一个重要的设计原则AI 只负责“提取”不负责“判断业务对错”。比如模型抽出一个金额 1280 元它无法知道这笔是不是超出报销上限那是规则引擎的事。AI 的能力边界是感知规则引擎的能力边界是业务约束两者解耦后面改业务规则就只改配置不用重新调模型。2. 工程环境与前置准备2.1 模型与接口版本确认先说环境。这个项目基于 Gemini 3.8 Flash 的多模态能力调用走的是官方 SDK。我在项目中用的是 Python因为后续规则校验、数据落库、告警推送跟公司现有技术栈衔接最顺。Gemini 3.8 Flash 是 Flash 系列里当前最新的轻量级多模态模型支持图片输入、文本输入、混合输入并且支持结构化的 JSON 输出。接口层面用的是统一的 generate_content 接口图片可以直接传原始字节不需要转 base64 字符串再包一层SDK 会自己处理。此类项目最关键的第一步是确认两件事一是模型名准确比如 gemini-3.8-flash 这种精度到小版本的模型标识二是确认账号开通了对应模型的访问权限。我踩过的最冤枉的坑就是模型名写错一位报错还不明显排查半天。2.2 环境依赖安装依赖项很少核心就两个包pip install google-genai python-dotenvgoogle-genai 是官方的新版 SDK接口风格比旧的 generativeai 包更简洁也更加贴近 OpenAI 时代的调用习惯。python-dotenv 用来加载环境变量不出现在代码里的密钥一律走环境变量。另外图片处理需要用到 Pillow如果有后续存储就用不到但我建议加上pip install pillow这些依赖装完项目的基础环境就算齐了。不需要 GPU不需要本地模型部署整个项目跑在一台 2C4G 的普通云服务器上没有任何压力因为计算都发生在模型服务端。2.3 密钥管理与安全API Key 是这类项目最容易翻车的地方。我的做法是在项目根目录建一个 .env 文件GEMINI_API_KEY你的密钥然后在代码里统一加载import os from dotenv import load_dotenv load_dotenv() GEMINI_API_KEY os.getenv(GEMINI_API_KEY)再在 .gitignore 里把 .env 加进去。这里额外提一句如果有 CI/CD 流水线密钥走部署平台的环境变量注入不要写死到配置文件里更不要提交到代码仓库。票据数据本身敏感密钥泄露加数据泄露两个一起炸就麻烦了。3. 视觉抽取的核心实现3.1 提示词工程让模型输出稳定的 JSON模型抽取的稳定性七分靠提示词三分靠参数。提示词这一环是整个项目真正的“调优战场”。我的一份核心抽取提示词大概长这样你是一个票据信息抽取引擎。你的任务是把用户提供的票据图片中的关键信息提取为 JSON。 严格按以下 JSON Schema 输出不要输出任何解释文字、不要输出 markdown 代码块 { vendor_name: 开票方名称字符串, vendor_tax_id: 开票方税号字符串, buyer_name: 购买方名称字符串, issue_date: 开票日期格式 YYYY-MM-DD, total_amount: 价税合计数字单位元保留两位小数, tax_amount: 税额数字单位元, items: [ {name: 项目名称, quantity: 数量, unit_price: 单价, amount: 金额} ] } 规则 1. 字段在图片中无法确认时填 null禁止编造。 2. 金额统一转成数字不要保留千分位逗号或货币符号。 3. 如果图片不是票据输出{error: NOT_A_RECEIPT} 4. 如果印章覆盖导致字段模糊在对应字段填 null并在 warnings 字段中说明。这里有几个关键设计点值得讲。“不要输出任何解释文字、不要输出 markdown 代码块”这句看似多余实际非常管用。大模型默认倾向在 JSON 外面包一层 json 代码块加上这句能大幅减少解析时的脏数据。“无法确认时填 null禁止编造”这条是防幻觉的关键。视觉模型在看不清楚的时候会倾向于“猜”而财务场景里臆造的字段比识别错误更危险。明确要求 null等于给模型一个安全的出口。最后一条“印章覆盖导致模糊”是我在实际处理中加上的。因为真实票据里印章压字太常见让模型把不确定的字段单独标记出来后面规则引擎可以直接命中人工复核。3.2 结构化输出配置提示词约束的是“模型意愿”结构化输出配置约束的是“响应格式”。两者缺一不可。Gemini 3.8 Flash 的接口层面支持 response_mime_type 和 response_schema 这两个参数代码里这样配from google import genai from google.genai import types client genai.Client(api_keyGEMINI_API_KEY) response_schema { type: OBJECT, properties: { vendor_name: {type: STRING}, vendor_tax_id: {type: STRING}, buyer_name: {type: STRING}, issue_date: {type: STRING}, total_amount: {type: NUMBER}, tax_amount: {type: NUMBER}, items: { type: ARRAY, items: { type: OBJECT, properties: { name: {type: STRING}, quantity: {type: NUMBER}, unit_price: {type: NUMBER}, amount: {type: NUMBER} } } }, warnings: {type: ARRAY, items: {type: STRING}} }, required: [vendor_name, total_amount, issue_date] } config types.GenerateContentConfig( response_mime_typeapplication/json, response_schemaresponse_schema, temperature0.2, )设置 response_mime_type 为 application/json 之后接口返回的文本就直接是 JSON不会再包代码块。配合 response_schema相当于给模型定义好了“输出契约”模型会在 JSON 范围内穷举最符合的填充内容。temperature 我固定在了 0.2。抽取任务不是创作任务温度越低输出越稳定0.2 到 0.3 之间是个比较舒服的区间。再低可能在某些边缘情况下陷入重复再高会引入无谓的随机性。3.3 图片预处理技巧模型输入有体积限制真实拍摄的票据照片动不动就 3MB、5MB直接传会有两个问题一是请求被拒或超时二是大图片会显著拉长响应时间。我的预处理函数很简单from PIL import Image import io def preprocess_image(image_path: str, max_side: int 1200, quality: int 88) - bytes: img Image.open(image_path) img img.convert(RGB) img.thumbnail((max_side, max_side), Image.Resampling.LANCZOS) buf io.BytesIO() img.save(buf, formatJPEG, qualityquality) return buf.getvalue()几个细节说明一下。转成 RGB 是因为 PNG 如果有透明通道某些环节处理起来可能出幺蛾子统一转掉省心。thumbnail 是等比缩放不会发生变形默认最长边限制到 1200 像素对票据识别完全够用因为模型本身会再做特征提取分辨率不是越多越好。最后 JPEG 压缩质量给到 88这是个折中值太高质量体积下不来太低质量文字会糊。实际测试里一张 3MB 的发票照片经这个函数处理后大约变成 150KB 到 300KB响应时间从七八秒降到两三秒识别准确率基本无感。注意图片方向的问题手机拍照经常是横的模型对旋转图片的容忍度还可以但我在预处理里还是会做 EXIF 方向矫正这一步用 Pillow 的 ImageOps.exif_transpose 搞定。3.4 完整抽取代码预处理和配置都到位后抽取调用其实就十几行from google.genai import types image_bytes preprocess_image(sample_invoice.jpg) prompt 请根据票据图片按既定 JSON Schema 提取信息。 response client.models.generate_content( modelgemini-3.8-flash, contents[ types.Content( roleuser, parts[ types.Part(textprompt), types.Part(inline_datatypes.Blob( mime_typeimage/jpeg, dataimage_bytes )), ], ) ], configconfig, ) raw_text response.text注意 response.text 在这种配置下返回的已经是纯 JSON 字符串。接下来解析并做基础健壮性处理import json def parse_model_output(raw_text: str) - dict: text raw_text.strip() if text.startswith(): # 兜底万一模型没遵守约定剥离 markdown 代码块 text text.strip() if text.startswith(json): text text[4:] try: return json.loads(text) except json.JSONDecodeError: # 最后兜底提取第一个 { 到最后一个 } 之间的内容 start text.find({) end text.rfind(}) if start ! -1 and end ! -1 and end start: return json.loads(text[start:end 1]) raise这段兜底代码是实际项目里被逼出来的。哪怕配置了 response_mime_type偶尔还是会有异常情况做两层兜底解析第一层剥代码块第二层截取括号区间。这不是防御过度是线上环境的保命符。解析出来的 dict在实际代码里我会再校验一遍必需字段是否存在、类型是否正确这一层是连接规则引擎的保险丝防止脏数据直接漏进业务逻辑。4. 规则校验引擎设计4.1 规则体系怎么搭模型把图片变成 JSON 之后真正的业务价值在规则校验这一环。我把它做成了一个独立的校验引擎而不是散落的 if-else原因很简单财务规则三天两头变散落的 if-else 改一处漏三处。校验引擎的核心抽象是这样的from abc import ABC, abstractmethod class BaseRule(ABC): name: str base abstractmethod def check(self, data: dict) - str | None: 校验通过返回 None不通过返回错误描述 def run(self, data: dict) - dict: result self.check(data) return { rule: self.name, passed: result is None, message: result or OK, }每条规则都是一个独立类实现 check 方法。校验引擎统一调度class ValidationEngine: def __init__(self, rules: list[BaseRule]): self.rules rules def validate(self, data: dict) - dict: results [rule.run(data) for rule in self.rules] failed [r for r in results if not r[passed]] return { passed: len(failed) 0, failed_count: len(failed), results: results, }这样每一条业务规则都是独立单元可以单独测试、单独开关、单独改参数。新增一条“单张发票超过 5000 元需要审批”的规则就是新建一个类然后注册进去完全不影响其他逻辑。4.2 关键校验规则实现我落地过程中真正有价值的几条规则分享出来。第一是金额一致性校验。票据上有单价、数量、小计、税额、价税合计这些数字之间存在固定的数学关系。模型单独识别每个字段都可能出错但利用数学约束可以把错揪出来import math class AmountConsistencyRule(BaseRule): name amount_consistency def check(self, data: dict) - str | None: total data.get(total_amount) tax data.get(tax_amount) items data.get(items) or [] if total is None: return 缺少价税合计字段 subtotal 0.0 for item in items: qty item.get(quantity) or 0 price item.get(unit_price) or 0 amount item.get(amount) or 0 expected round(qty * price, 2) if abs(expected - amount) 0.01: return f明细金额不一致: {item.get(name)} 期望 {expected} 实际 {amount} subtotal amount subtotal round(subtotal, 2) expected_total round(subtotal (tax or 0), 2) if abs(expected_total - total) 0.01: return f合计不一致: 明细合计 {subtotal} 税 {tax} ! 价税合计 {total} return None误差阈值给到 0.01 元也就是一分钱。浮点运算直接用 round 而不是精确比较避免二进制浮点的经典误差。第二是日期合规校验。报销单据的日期天然有边界条件不能是未来日期、不能早于某个业务启用日期、账期不能跨度太大。这种规则光靠模型不行模型只能“读”出日期字符串判断逻辑必须由规则引擎来做。第三是税号与格式校验。中国的统一社会信用代码有固定结构18 位包含数字和大写字母末位可能是校验位。税号提取错了整张票据几乎可以判定为需要人工复核。这类强格式约束用正则先过滤再给模型结果兜底import re class TaxIdFormatRule(BaseRule): name tax_id_format pattern re.compile(r^[0-9A-Z]{18}$) def check(self, data: dict) - str | None: tax_id data.get(vendor_tax_id) if not tax_id: return 缺少税号字段 if not self.pattern.match(tax_id): return f税号格式异常: {tax_id} return None这类“正则校验模型输出”的模式特别有用。模型识别是概率性的但格式约束是确定性的两者组合识别的容错性就有了兜底。4.3 校验结果的可视化与告警校验引擎出来的结果只有两个用途通过的继续走流程不通过的去人工复核。我在这套系统里做了三档处理通过正常入库进入自动化流程。警告有字段缺失或格式可疑但不影响主流程标记后入库供财务抽查。异常金额不一致、税号非法、日期越界直接挂起推到人工复核队列。告警通道我接了消息推送规则引擎一旦判出异常立刻推给处理人。这一步看似简单实际价值巨大。原来财务人员每天要开系统一张一张看现在只有异常单才会主动找上门处理量直接砍掉一大半。5. 常见问题排查与避坑实录5.1 高频问题速查表跑了大半年我把线上遇到的典型问题整理成一张表按频率排序。问题现象直接原因解决办法请求报 400 错误图片体积超限或格式不对走预处理函数压缩确认 mime_type 与字节内容一致返回内容不是合法 JSON模型未遵守输出格式设置 response_mime_type代码里加两层兜底解析金额偶尔多一位或少一位图片模糊或字体艺术化提示词要求填 null 而不是猜规则引擎做范围校验印章压字导致字段错乱红章覆盖文字区域预处理增强对比度提示词增加 warning 机制同一张票据两次结果不同temperature 偏高降到 0.2 以下关键字段以低温度结果为准响应时间突然变慢图片太大或并发过高压缩图片、控制并发、必要时限流5.2 实战踩坑记录有几个坑我觉得必须单拎出来说透。第一个坑是温度参数不是越小越好。我把 temperature 调到 0 试过结果在部分复杂票据上出现了“重复同一个字段值”的奇怪现象比如把 vendor_name 的值填进 buyer_name。后来把温度恢复到 0.2这个问题就消失了。生成模型的最低温度有时候反而会陷入确定性陷阱0.1 到 0.2 才是抽取类任务的舒适区。第二个坑是图片压缩尺度要克制。我为了追求响应速度一度把图片压缩到最长边 800 像素、质量 70结果小字号票据的金额识别率明显下降。后来做了组对照实验1200 像素加质量 88 是性价比最好的点识别率损失可以忽略速度也够快。第三个坑是模型幻觉比识别错误更难防。识别错误是“看清了但读错了”幻觉是“压根没看清但编了一个”。两者的处理方式完全不同。识别错误靠提示词和规则校验幻觉靠强制 null 策略。我给提示词加“无法确认时填 null”之后幻觉类错误减少得非常明显。这条经验值得所有做视觉抽取项目的人记住。第四个坑是批量处理必须做重试与退避。票据扫描经常一次进来几百张并发一高就会触发限流。我封装了一个带指数退避的重试函数import time from google.genai import types def generate_with_retry(client, prompt, image_bytes, config, max_retries4): for attempt in range(max_retries): try: response client.models.generate_content( modelgemini-3.8-flash, contents[prompt, image_bytes], configconfig, ) return response except Exception as e: if attempt max_retries - 1: raise wait 2 ** attempt random.uniform(0, 1) time.sleep(wait) # unreachable重试间隔从 1 秒、2 秒、4 秒递增再加一点随机抖动避免所有请求同时重试造成踩踏。实测下来批量场景的失败率从最初的 10% 以上降到了 0.5% 以内。还有一个很隐蔽的坑是票据方向问题。手机拍的红头文件、横版票据常常以横向拍摄的图片命中竖版模板。我一开始没有做方向矫正结果识别率在某个批次突然掉下来排查后发现是这批票据全是横拍。后来在预处理环节统一用 EXIF 信息矫正方向这个波动就消失了。做这个项目最深的体会是大模型降低了“看懂图片”的门槛但没有降低“搞对业务”的门槛。视觉模型负责把不确定的像素变成字段规则引擎负责把字段变成确定的业务事实。两者组合才是一个能真正上线的票据处理系统。最后再分享一个小技巧我会在每条抽取结果里额外让模型输出一个 confidence 字段虽然在规则引擎里不强制使用但在人工复核队列排序时特别好用置信度低的单子自动排前面复核效率又提升了不少。