用 Gemini API 做一个“拍小票记账”工具:把购物小票照片丢给脚本,Gemini 按你定义的 JSON 结构返回商家、日期、明细、总额和分类,脚本自动核对明细合计与总额,最后汇总成 Excel 能直接打开的 CSV。
先看清成品
成品是
receipt.py。本文已实际运行 2 个测试并全部通过(用模拟客户端,不联网):请求里带上了 JSON 结构和正确的模型名,CSV 写出正确;明细合计与总额不符、缺日期时会给出提醒。另外用真实 SDK 和一个无效密钥发了一次请求,服务器返回的是“API key not valid”,说明请求格式被接受。本文环境没有有效的 Gemini API 密钥,没有用真实小票测试识别效果。
注意:Gemini API 和 Google AI Studio 只在官方“可用地区”列表中的国家和地区提供,请在可用地区合规使用,不要用代理绕过地区限制。Gemini API 条款写明,使用免费服务时提交的内容可能被用于改进 Google 产品,不要上传含身份证号、银行卡号、他人隐私的材料。价格和免费额度以官方价格页为准。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 结构定义 | 用 Pydantic 写一次,同时用于请求和校验 |
| 图片识别 | 通过 Files API 上传图片 |
| 结构化输出 | response_format 指定 JSON 结构 |
| 自动核对 | 明细合计 vs 总额,缺日期提醒 |
| 记账表 | receipts.csv,带 BOM,Excel 不乱码 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Gemini API 密钥 | 在 Google AI Studio 创建 | 保存为环境变量 GEMINI_API_KEY |
| Python | 3.10 或更高 | |
| 依赖 | pip install google-genai pydantic pytest |
本文使用 google-genai 2.32.0 |
| 模型 | gemini-3.8-flash |
官方模型页 2026-10-19 更新时列为稳定版 |
第一步:怎样拿到 API 密钥?
打开 Google AI Studio 的 API 密钥页面,点 Create API key,复制后:
export GEMINI_API_KEY="你的密钥"
先做一次最小测试(官方快速入门的写法):
python -c "from google import genai; print(genai.Client().interactions.create(model='gemini-3.8-flash', input='用一句话介绍你自己').output_text)"
能打印出一句话就说明密钥和网络都正常。
第二步:代码怎么写?
1. 项目结构
receipt-ai/
├── receipt.py
├── test_receipt.py
└── photos/ # 你的小票照片
2. receipt.py
"""用 Gemini 识别小票照片,输出结构化 JSON,并汇总成记账 CSV。
用法:python receipt.py 小票1.jpg 小票2.png ...
"""
import csv
import mimetypes
import sys
from pathlib import Path
from typing import List, Optional
from pydantic import BaseModel, Field
MODEL = "gemini-3.8-flash"
class Item(BaseModel):
name: str = Field(description="商品名称,按小票原文")
quantity: float = Field(description="数量,没写就填 1")
amount: float = Field(description="该行小计金额")
class Receipt(BaseModel):
merchant: str = Field(description="商家名称")
date: Optional[str] = Field(description="日期,格式 YYYY-MM-DD,看不清填 null")
currency: str = Field(description="币种代码,例如 CNY")
items: List[Item]
total: float = Field(description="小票上印的实付总额")
category: str = Field(description="从 餐饮/超市/交通/日用/其他 中选一个")
PROMPT = """你是记账助手。识别这张购物小票,按给定 JSON 结构输出。
规则:金额只写数字;看不清的字段填 null,不要猜;总额以小票上印的实付金额为准。"""
def extract(path: Path, client) -> Receipt:
f = client.files.upload(file=str(path))
mime = f.mime_type or mimetypes.guess_type(path.name)[0] or "image/jpeg"
interaction = client.interactions.create(
model=MODEL,
input=[
{"type": "text", "text": PROMPT},
{"type": "image", "uri": f.uri, "mime_type": mime},
],
response_format={
"type": "text",
"mime_type": "application/json",
"schema": Receipt.model_json_schema(),
},
)
return Receipt.model_validate_json(interaction.output_text)
def check(r: Receipt) -> List[str]:
"""模型可能读错数字:用明细合计和总额互相核对。"""
warns = []
s = round(sum(i.amount for i in r.items), 2)
if abs(s - r.total) > 0.01:
warns.append(f"明细合计 {s} 与总额 {r.total} 不一致(可能有折扣或识别错误),请人工核对")
if r.date is None:
warns.append("没识别出日期")
return warns
def main(paths, client=None, out="receipts.csv"):
if client is None:
from google import genai
client = genai.Client() # 读取环境变量 GEMINI_API_KEY
rows = []
for p in map(Path, paths):
r = extract(p, client)
for w in check(r):
print(f"⚠ {p.name}:{w}")
print(f"✓ {p.name}:{r.merchant} {r.date} {r.total} {r.currency}({r.category})")
rows.append([p.name, r.date or "", r.merchant, r.category, r.total, r.currency, len(r.items)])
with open(out, "w", newline="", encoding="utf-8-sig") as fh:
w = csv.writer(fh)
w.writerow(["文件", "日期", "商家", "分类", "总额", "币种", "明细数"])
w.writerows(rows)
print(f"已写入 {out}")
return rows
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit("用法:python receipt.py 小票.jpg ...")
main(sys.argv[1:])
| 要点 | 说明 |
|---|---|
Field(description=...) |
描述会进入 JSON 结构,相当于给模型的字段说明 |
response_format |
官方结构化输出文档的写法:type、mime_type、schema |
model_validate_json |
返回结果再用 Pydantic 校验一遍 |
| 看不清填 null | 减少模型“编”数字 |
check() |
识别结果不可全信,用规则兜底 |
第三步:怎样测试和运行?
1. 离线测试
import json
from types import SimpleNamespace
from receipt import Receipt, check, main
SAMPLE = {"merchant": "便利蜂", "date": "2026-10-20", "currency": "CNY",
"items": [{"name": "矿泉水", "quantity": 2, "amount": 4.0}, {"name": "饭团", "quantity": 1, "amount": 7.5}],
"total": 11.5, "category": "餐饮"}
class FakeClient:
"""模拟 genai.Client,记录请求参数,不联网"""
def __init__(self, payload):
self.payload, self.calls = payload, []
self.files = SimpleNamespace(upload=lambda file: SimpleNamespace(uri="files/abc", mime_type="image/jpeg"))
self.interactions = SimpleNamespace(create=self._create)
def _create(self, **kw):
self.calls.append(kw)
return SimpleNamespace(output_text=json.dumps(self.payload, ensure_ascii=False))
def test_schema_and_request(tmp_path):
c = FakeClient(SAMPLE)
rows = main(["a.jpg"], client=c, out=tmp_path / "r.csv")
kw = c.calls[0]
assert kw["model"] == "gemini-3.8-flash"
assert kw["response_format"]["mime_type"] == "application/json"
assert "items" in kw["response_format"]["schema"]["properties"]
assert rows[0][2] == "便利蜂"
assert (tmp_path / "r.csv").read_text(encoding="utf-8-sig").splitlines()[1].startswith("a.jpg,2026-10-20,便利蜂")
def test_check_mismatch():
bad = Receipt.model_validate({**SAMPLE, "total": 20, "date": None})
w = check(bad)
assert any("不一致" in x for x in w) and any("日期" in x for x in w)
assert check(Receipt.model_validate(SAMPLE)) == []
python -m pytest -q
本文实际输出 2 passed。
2. 用真实小票运行
python receipt.py photos/*.jpg
期望输出类似(具体内容取决于你的小票):
✓ 001.jpg:便利蜂 2026-10-20 11.5 CNY(餐饮)
⚠ 002.jpg:明细合计 52.0 与总额 47.0 不一致(可能有折扣或识别错误),请人工核对
✓ 002.jpg:某超市 2026-10-19 47.0 CNY(超市)
已写入 receipts.csv
3. 检查结果
| 检查项 | 期望 |
|---|---|
| 商家、总额 | 与小票一致 |
| 有折扣的小票 | 出现“不一致”提醒,属于正常 |
| 模糊照片 | 日期为空并提醒,而不是乱填 |
出问题时该怎么调?
1. 识别不准
| 原因 | 解决方法 |
|---|---|
| 照片糊、反光 | 平铺、正对、光线均匀 |
| 小票太长 | 分两张拍 |
| 分类不符合你的习惯 | 修改 category 的描述和可选值 |
2. 返回的 JSON 校验失败
打印 interaction.output_text 看原始内容。结构太复杂时模型更容易出错,尽量保持字段扁平。
3. 报 429
请求太快或超出免费额度。在循环里加 time.sleep(2),或到 AI Studio 查看用量。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
API key not valid |
密钥错误 | 重新复制 |
User location is not supported |
所在地区不在可用列表 | 只能在可用地区使用 |
| 金额带“¥” | 模型没完全遵守 | 结构里已定义为数字,校验会报错,调整提示词 |
| 上传的图片还在吗 | Files API 文件 48 小时后自动删除 | 无需手动清理 |
| 想直接导入记账软件 | 字段名不匹配 | 改 CSV 表头 |
项目资源
建议先跑离线测试理解流程,再用三五张清晰的小票试运行,逐张和原件对照。
识别效果满意后,再调整分类和字段,变成适合你自己的记账流程。