AI 工具

用 Gemini API 做一个“拍小票记账”工具

用 Gemini API 做一个“拍小票记账”工具:把购物小票照片丢给脚本,Gemini 按你定义的 JSON 结构返回商家、日期、明细、总额和分类,脚本自动核对明细合计与总额,最后汇总成 Excel 能直接打开的 CSV。

BINBHO · 图文教程

用 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 表头

项目资源

用途 地址
Google AI Studio API 密钥 https://aistudio.google.com/apikey
模型列表 https://ai.google.dev/gemini-api/docs/models
结构化输出 https://ai.google.dev/gemini-api/docs/structured-output
图片理解 https://ai.google.dev/gemini-api/docs/image-understanding
Files API https://ai.google.dev/gemini-api/docs/files
可用地区 https://ai.google.dev/gemini-api/docs/available-regions
价格 https://ai.google.dev/gemini-api/docs/pricing
API 条款 https://ai.google.dev/gemini-api/terms

建议先跑离线测试理解流程,再用三五张清晰的小票试运行,逐张和原件对照。

识别效果满意后,再调整分类和字段,变成适合你自己的记账流程。