浮世絵壁紙アプリの素材を追加する日は、プレビュー用のフォルダに並んだ画像を一枚ずつ開くところから始まります。30 ある分類のどれに入れるかを決めていく、地味な作業です。
この仕分けを Gemini に任せるようになってから、しばらくの間、私は当たり前のようにアプリの中で呼ぶつもりでいました。利用者が分類タブを開いたときに、その場で判定させる形です。図に描くと自然に見えました。
見積もりを取り直したのは、実装に入る直前でした。同じ処理なのに、置き場所を変えるだけで月あたりの呼び出し回数が3桁変わることが分かりました。
結論から書きます。入力が利用者に依存せず、対象が有限に列挙できる処理は、配布前に呼び終えておくほうが個人開発では合理的でした。 実行時に残したのは、事前に列挙できない入力を扱う機能ひとつだけです。
呼び出し回数を決めているのは、利用者数か素材数か
「アプリに AI を載せる」という言葉を、私は無意識に「実行時に API を呼ぶ」と同じ意味で受け取っていました。ここに落とし穴がありました。
同じ分類処理でも、置き場所によって回数の増え方が変わります。
- 実行時に置く場合、回数は 利用者数 × 起動回数 に比例します。アプリが伸びるほど増えます
- 配布前に置く場合、回数は 素材数 に比例します。利用者が何人になっても変わりません
壁紙アプリの分類結果は、誰が開いても同じです。同じ答えを人数分だけ計算し直していたことになります。この構造に気づいてから、判断の軸が「どちらが速いか」ではなく「回数が何に比例するか」に変わりました。
二つの置き場所を、同じ式に並べて比べる
見積もりは 20 行程度で足ります。実装の前にこれを書くだけで、後から請求額に驚くことがなくなります。
# 同じ機能を「実行時」と「配布前」に置いたときの月間呼び出し回数を比べる
# 自分のアプリの数字に置き換えて実行してください
def runtime_calls(mau, sessions_per_user, calls_per_session):
"""実行時に呼ぶ場合。利用者数に比例して増える"""
return mau * sessions_per_user * calls_per_session
def preship_calls(new_assets, passes_per_asset, prompt_revisions):
"""配布前に呼ぶ場合。素材数に比例する。
prompt_revisions は、分類プロンプトを直して全件やり直す回数"""
return new_assets * passes_per_asset * (1 + prompt_revisions)
rows = []
for mau in (500, 5_000, 50_000):
rt = runtime_calls(mau, sessions_per_user=6, calls_per_session=1)
ps = preship_calls(new_assets=120, passes_per_asset=1, prompt_revisions=2)
rows.append((mau, rt, ps, rt / ps))
print(f"{'MAU':>8} {'実行時/月':>12} {'配布前/月':>12} {'比':>8}")
for mau, rt, ps, ratio in rows:
print(f"{mau:>8,} {rt:>12,} {ps:>12,} {ratio:>7.1f}x")
# 損益分岐となる利用者数(配布前の回数と釣り合う MAU)
print(f"\nbreak-even MAU = {360 / (6 * 1):.0f}")
手元で実行すると、こうなります。
MAU 実行時/月 配布前/月 比
500 3,000 360 8.3x
5,000 30,000 360 83.3x
50,000 300,000 360 833.3x
break-even MAU = 60
分岐点は 60 人でした。つまり、身内に配る規模を超えた時点で、実行時に置く理由は回数の面からは消えます。素材を月 120 点足し、プロンプトを2回直して全件やり直しても、回数は 360 回で固定されます。
比で見ると、MAU 500 で 8.3 倍、5,000 で 83.3 倍、50,000 で 833.3 倍です。桁が変わっていくのは片側だけです。
この差は本番運用に入って請求を見るまで表に出てきません。気づいてから設計を戻すのは、審査を挟む分だけ高くつきます。回避策は単純で、実装に入る前にこの 20 行を走らせておくことでした。
ここで大事なのは絶対値ではなく、片方だけが利用者数に比例していることです。単価が下がっても、比例している側は伸びに合わせて増え続けます。呼び出し単価の記録方法は Gemini API の usageMetadata でコストを記録する実装パターン にまとめています。
配布前に寄せられるもの、実行時にしか置けないもの
線引きは、四つの問いで決まりました。
| 処理 | 入力は事前に列挙できるか | 結果は全員同じか | 失敗したとき誰が待つか | 置き場所 |
| 素材のカテゴリ分類 | できる | 同じ | 誰も待たない | 配布前 |
| 素材の説明文・代替テキスト | できる | 同じ | 誰も待たない | 配布前 |
| リリースノートの多言語化 | できる | 同じ | 誰も待たない | 配布前 |
| 検索クエリの意味解釈 | できない | 人により違う | 目の前の利用者 | 実行時 |
| 利用者が投稿した文の判定 | できない | 人により違う | 目の前の利用者 | 実行時 |
「入力は事前に列挙できるか」がいちばん効きます。ここが「できる」なら、残りの三つはたいてい後からついてきます。
配布前パスは、途中で止まる前提で書く
配布前に寄せると、処理は一度に数百件を回す形になります。ネットワークが切れたり、こちらの都合で止めたりします。そのたびに最初からやり直すのは現実的ではありません。
素材の内容ハッシュを鍵にして、済んだものを飛ばす作りにしました。同じ素材は何度実行しても一度しか呼ばれません。
"""素材のカテゴリ分類を配布前に一括で終わらせる。
中断しても再実行で続きから進む(内容ハッシュで判定)。"""
import hashlib
import json
import pathlib
from google import genai
from google.genai import types
CLIENT = genai.Client(api_key="YOUR_API_KEY")
MODEL = "gemini-2.5-flash"
LEDGER = pathlib.Path("classified.jsonl") # 追記のみ。これが成果物になります
CATEGORIES = ["landscape", "portrait", "flower", "wave", "night", "uncategorized"]
SCHEMA = types.Schema(
type=types.Type.OBJECT,
required=["category", "confidence"],
properties={
"category": types.Schema(type=types.Type.STRING, enum=CATEGORIES),
"confidence": types.Schema(type=types.Type.NUMBER),
},
)
def content_key(path: pathlib.Path) -> str:
"""ファイル名ではなく中身で同一性を判定します。
リネームや移動で二重に課金されるのを避けるためです。"""
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
def load_done() -> dict:
if not LEDGER.exists():
return {}
done = {}
for line in LEDGER.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
rec = json.loads(line)
done[rec["key"]] = rec # 同じ鍵が複数あれば後勝ちになります
return done
def classify(path: pathlib.Path) -> dict:
res = CLIENT.models.generate_content(
model=MODEL,
contents=[
types.Part.from_bytes(data=path.read_bytes(), mime_type="image/jpeg"),
"この画像を与えられた分類のいずれか一つに割り当ててください。"
"判断がつかない場合は uncategorized を選んでください。",
],
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=SCHEMA,
temperature=0, # 再実行で結果がぶれると台帳の意味が薄れます
),
)
return json.loads(res.text)
def main(src="_preview"):
done = load_done()
files = sorted(pathlib.Path(src).glob("*.jpg"))
skipped = called = failed = 0
with LEDGER.open("a", encoding="utf-8") as fp:
for path in files:
key = content_key(path)
if key in done:
skipped += 1
continue
try:
result = classify(path)
except Exception as exc: # 1件の失敗で全体を止めません
failed += 1
print(f" fail {path.name}: {exc}")
continue
record = {"key": key, "file": path.name, **result}
fp.write(json.dumps(record, ensure_ascii=False) + "\n")
fp.flush() # 途中終了しても直前までは残ります
called += 1
print(f"total={len(files)} called={called} skipped={skipped} failed={failed}")
if __name__ == "__main__":
main()
flush() を毎行入れている理由は、途中で止めたときに直前までの結果を確実に残すためです。まとめて書き出す作りにしていた頃、強制終了で数十件分をやり直したことがありました。1行ずつ書き出すほうが、失う量が読めます。
失敗行は台帳に入りません。次の実行で自動的に再試行の対象になります。行単位で失敗を拾う考え方は Gemini Batch API の行単位リトライ台帳 でも書きました。
端末側は、静的な JSON を読むだけになる
配布前に寄せると、アプリのコードから API 呼び出しが消えます。残るのは、生成済みの分類表を読む処理だけです。
// 生成済みの分類表をアプリに同梱し、実行時は読むだけにします
type Classified = { file: string; category: string; confidence: number };
const KNOWN = new Set([
"landscape", "portrait", "flower", "wave", "night", "uncategorized",
]);
export function groupByCategory(rows: Classified[]): Map<string, string[]> {
const grouped = new Map<string, string[]>();
for (const row of rows) {
// 配布済みアプリが知らない分類が後から増えても落ちないようにします。
// 分類を足すのは配布前パス側で、端末側の更新は後追いになるためです。
const key = KNOWN.has(row.category) ? row.category : "uncategorized";
const bucket = grouped.get(key) ?? [];
bucket.push(row.file);
grouped.set(key, bucket);
}
return grouped;
}
未知の分類を uncategorized に落とす数行が要ります。配布前パス側は自由に分類を増やせますが、端末側のコードは審査を通った版のまま利用者の手元に残るためです。ここを省くと、分類を1つ足した日に旧バージョンで画面が空になります。
寄せてから分かった、予想と逆だったこと
コストの話として始めた変更でしたが、実際に効いたのは別のところでした。
品質を直すループが速くなりました。 実行時に呼んでいる限り、過去に返した分類はもう手元にありません。配布前に寄せてからは、プロンプトを直して全件やり直すだけで、分類の質を丸ごと入れ替えられます。3桁の呼び出しなら、やり直しは選択肢に入ります。
モデル選びの制約が消えました。 目の前で誰かが待っているわけではないので、応答が数秒遅くても構いません。速さを理由に安いモデルを選ぶ必要がなくなりました。
結果が全員で揃うことが、体験として良い方向に働きました。 同じ画像が人によって別の分類に入る状態を、私は最初「多少のばらつき」と考えていました。実際には、問い合わせが来るのはこの種のずれです。
一方で、確かに弱くなった面もあります。新しい素材を足した瞬間には反映されません。分類は配布サイクルに縛られます。素材の追加が日次で起きるサービスなら、この設計は選べません。私の場合は素材の追加が数週間おきなので、噛み合いました。
判断としては、素材が有限で更新が週単位より遅いなら、配布前に寄せることを推奨します。日次で素材が増えるなら実行時に置いたまま、次の節の上限設計だけを先に入れるほうが現実的です。
それでも実行時に残した、ひとつの機能
検索クエリの意味解釈だけは実行時に残しました。入力が利用者の言葉なので、事前に列挙できません。
代わりに、三つの上限を置いています。
- 辞書で先に当てる。 既存の分類名やタグに一致すれば、API は呼びません。多くのクエリはこの時点で止まります
- 端末ごとの日次上限を持つ。 上限に達したら、その日は部分一致検索に落とします
- 失敗時は必ず従来の検索へ落とす。 AI を通せなかったことが、検索できないことにならないようにします
この3点があると、実行時の呼び出し回数は「利用者数 × 起動回数」ではなく「辞書を外れたクエリの数」に近づきます。実行時に置く判断をしたときは、比例する対象を小さいものに付け替えられないかを併せて考えると、後が楽になります。
次に確かめること
手元のアプリやサービスから AI 機能を一つ選んで、その呼び出し回数が何に比例しているかを一行で書いてみてください。「利用者数 × 起動回数」と書けてしまったら、その処理の入力が本当に利用者ごとに違うのかを見直す価値があります。
画像を扱う場合は、送る前の縮小も回数と同じくらい効きます。こちらは Gemini API に画像を送る前に Pillow で縮小しておくべき理由 にまとめてあります。
私自身、この切り替えをするまでは「AI 機能を載せる」と「実行時に呼ぶ」を同じものとして扱っていました。同じ思い込みを持っている方の役に立てば嬉しいです。お読みいただきありがとうございました。