把票据识别成一段文字并不等于能自动报销。真正麻烦的是:总额和税额是否看反、币种是否缺失、合计能否对上、低清图片是否应该转人工。本教程用Python调用Gemini 3.8 Flash读取票据,再用结构化Schema和本地金额规则做双重检查。你会得到一个“模型负责看,本地代码负责判”的最小闭环,而不是把财务决定交给一段自然语言。
基础概念:视觉理解不只是OCR
OCR(Optical Character Recognition,光学字符识别)关注“图里写了什么字”;视觉语言模型还会结合版面、标签和相邻关系,判断哪个数字是小计、税额或总额。它对复杂布局更灵活,但也可能看错小数点、把折扣当费用,或在模糊区域补出看似合理的内容。
因此可靠流程需要三层约束:输入层检查格式和大小;模型层用Schema固定字段;业务层重新计算金额并设置转人工条件。Google官方说明,小图片可以以内联Base64数据提交,整个请求小于20MB时最方便;大文件或重复使用的图片应走Files API。
技术流程
flowchart TD
A[本地票据图片] --> B[格式与大小检查]
B --> C[Base64内联给Gemini]
C --> D[Pydantic Schema约束输出]
D --> E[Decimal金额复算]
E --> F{差额小于0.01且字段完整?}
F -- 是 --> G[进入待审批队列]
F -- 否 --> H[标记原因并转人工]
注意,结果进入的是“待审批队列”,不是“自动打款”。即使金额数学上吻合,也可能存在重复票据、伪造图片、超预算或不合规品类。
环境准备
使用Python 3.10或更高版本。官方google-genai仓库提醒当前大版本仍应固定在3.0以下,示例选择2.24系列;同时安装Pydantic。密钥从GEMINI_API_KEY读取。
python -m venv .venv
source .venv/bin/activate
pip install "google-genai>=2.24,<3" "pydantic>=2.8,<3"
export GEMINI_API_KEY="你的密钥"
export RECEIPT_PATH="receipt.jpg"
python receipt_check.py
完整代码
import base64
import mimetypes
import os
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Literal
from google import genai
from google.genai import types
from pydantic import BaseModel, Field, ValidationError
class Receipt(BaseModel):
merchant: str = Field(description="商户名称,无法识别时为空字符串")
currency: str = Field(description="ISO币种,如CNY;无法判断时写UNKNOWN")
subtotal: str = Field(description="税前或小计金额,十进制字符串")
tax: str = Field(description="税额,没有时写0")
total: str = Field(description="最终应付总额,十进制字符串")
image_quality: Literal["clear", "uncertain", "unreadable"]
notes: list[str]
def to_money(value: str) -> Decimal:
try:
return Decimal(value).quantize(Decimal("0.01"))
except InvalidOperation as exc:
raise ValueError(f"非法金额: {value}") from exc
def main() -> None:
api_key = os.getenv("GEMINI_API_KEY")
image_path = Path(os.getenv("RECEIPT_PATH", "receipt.jpg"))
if not api_key:
raise SystemExit("请先设置 GEMINI_API_KEY")
if not image_path.is_file():
raise SystemExit(f"图片不存在: {image_path}")
if image_path.stat().st_size > 15 * 1024 * 1024:
raise SystemExit("示例只接受15MB以内图片;更大文件请使用Files API")
mime, _ = mimetypes.guess_type(image_path.name)
if mime not in {"image/jpeg", "image/png", "image/webp"}:
raise SystemExit(f"不支持的图片格式: {mime}")
image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
client = genai.Client(
api_key=api_key,
http_options=types.HttpOptions(timeout=30_000),
)
try:
response = client.interactions.create(
model="gemini-3.8-flash",
input=[
{"type": "text", "text": (
"读取票据可见内容。不要猜测被遮挡字段;金额保留两位小数。"
"若画面不足以确认,降低image_quality并在notes说明。"
)},
{"type": "image", "data": image_b64, "mime_type": mime},
],
response_format={
"type": "text",
"mime_type": "application/json",
"schema": Receipt.model_json_schema(),
},
)
receipt = Receipt.model_validate_json(response.output_text)
subtotal, tax, total = map(
to_money, [receipt.subtotal, receipt.tax, receipt.total]
)
delta = abs(subtotal + tax - total)
needs_review = (
receipt.image_quality != "clear"
or receipt.currency == "UNKNOWN"
or delta > Decimal("0.01")
)
print(receipt.model_dump_json(indent=2))
print(f"calculation_delta={delta}; needs_human_review={needs_review}")
except (ValidationError, ValueError) as exc:
raise SystemExit(f"模型结果未通过本地校验: {exc}") from exc
except Exception as exc:
raise SystemExit(f"API调用失败,请检查网络、配额和权限: {exc}") from exc
if __name__ == "__main__":
main()
逐段解释
Receipt把模型输出限制为七个字段。金额故意用字符串而非浮点数,因为二进制浮点会产生0.1 + 0.2一类精度问题;进入业务代码后再转成Decimal。image_quality是三选一枚举,让“看不清”成为显式状态,而不是逼模型编造答案。
输入检查把大小控制在15MB,给官方20MB的整个内联请求上限留出提示词和编码余量。Base64会比原文件更大,所以不要把20MB原图直接塞进去。对于大图、PDF或同一图片多次询问,应上传到Files API后引用URI。
模型返回后,Pydantic先验证结构,本地代码再计算subtotal + tax - total。差额超过0.01、币种未知或图像不清晰时一律转人工。这里没有要求模型输出“置信度百分比”,因为未经校准的自报分数容易制造虚假确定性。
图片送入模型前,先做哪些处理
预处理的目标不是把票据“修得更像真的”,而是让可见证据更稳定。移动端可以在本地检测四角、纠正旋转并提示用户补光;不要过度锐化或涂抹,因为这可能改变小数点和数字边缘。原图与处理后图片应使用不同哈希并建立关联,审核人员需要能够回到原始证据。
分辨率也不是越高越好。超大图片增加上传时间和成本,却未必改善小字体;过度压缩又会让“6”和“8”混在一起。最实用的方法是用自己的票据集合做分档实验:按短边像素、压缩质量和拍摄条件分组,观察关键字段完全匹配率,而不是只看“模型给了答案”的比例。
如果一张图片含多张票据,不要让模型自行猜边界后合并总额。先做页面或票据分割,为每个裁剪区域生成独立ID,再分别抽取和复算。多页发票则应保留页码与文档ID,防止第一页的小计与最后一页的总额被错误组合。
预期输出
清晰样例可能得到:
{"merchant":"示例咖啡","currency":"CNY","subtotal":"46.00","tax":"0.00","total":"46.00","image_quality":"clear","notes":[]}
calculation_delta=0.00; needs_human_review=False
本次任务所在机器只有Python 3.9.6,而官方最新SDK要求Python 3.10+。我对示例做了py_compile语法检查,但没有安装依赖、没有提供密钥,也没有调用线上API或识别真实票据;这不等于模型效果验证。
常见错误
- 把20MB当原图上限:内联限制覆盖整个请求,Base64还会膨胀;应预留空间。
- 用
float处理金额:可能产生精度误差;使用Decimal并统一两位小数。 - 提示模型“必须给答案”:模糊图片会诱发猜测;要允许
uncertain和unreadable。 - 只校验JSON结构:结构正确不代表合计正确,还要做本地复算和业务规则。
- 忽略图片隐私:票据可能含姓名、地址和卡号,上传前应脱敏并确认数据政策。
适用与不适用场景
适合低风险报销预审、收据归档、商品标签抽取和人工审核辅助。不适合单凭图片自动打款、判断票据真伪或处理高额异常交易。视觉模型能读内容,却不能替代发票查验平台、重复报销检测和财务授权。
工程化改进
生产环境应保存图片哈希而非随意复制原图,建立不同拍摄角度、低光、折痕和多语言票据的评测集;分别统计字段准确率、金额完全匹配率和人工转交率。对高频商户可增加模板规则,但不要让模板覆盖模型原始证据。模型或SDK升级时先做影子流量对比,再调整自动通过阈值。
评测时应按字段赋予不同风险权重:商户名错一个字可能仍可搜索,总额错一位小数却不能接受。可以同时记录字符准确率、字段完全匹配率、金额差错率与“应该转人工却自动通过”的漏拦率。最后一个指标最关键,因为系统价值不在于少点几次鼠标,而在于把高风险错误挡在付款之前。
还要加入重复报销检查。模型抽取出的票号、日期、金额和商户可以生成候选键,但最终去重应结合原图哈希、感知哈希与历史记录;相似并不等于重复,阈值附近仍需人工确认。删除原图或执行付款都属于高风险写操作,不应由这个只读识别流程触发。
若业务覆盖多币种,还要保存汇率来源和生效时间,不能让视觉模型自行换算。含服务费、折扣、预授权或小费的票据也不能只套“小计加税等于总额”这一条公式,应先按票据类型选择规则。无法识别类型时宁可转人工,也不要为了提高自动通过率而放宽金额差额。最终审批页面应同时展示原图裁剪、模型字段、本地复算和触发的风险规则,让审核者看到证据,而不是只看到一个绿色结果。
5分钟实践题
给Schema增加invoice_date和receipt_number,再在本地加入一条规则:日期晚于今天或票号为空时必须转人工。用一张清晰票据和一张故意裁掉日期的图片比较结果。
你更愿意让视觉模型自动通过哪类低风险单据,又会把哪类永远留给人工?
关注「蜗牛聊AI」,一起看懂技术变化背后的真正机会。
本文首发于 java4u.cn,转载请注明出处。