ある月の半ば、私が運営する壁紙アプリの1つで「キャプション自動生成」という小さな機能の Gemini API コストが、その機能が生んでいる広告収益を上回っていることに気づきました。アプリ全体では黒字なので、月末の請求書を眺めているだけでは絶対に見えてこない赤字でした。アカウント単位のコストは Google Cloud のコンソールで分かりますが、「どの機能が」「いくら稼いで」「いくら使っているか」は、こちらが意図して設計しない限り、どこにも残らないのです。
2014年から個人でアプリ開発を続け、累計5,000万ダウンロードのアプリ群を AdMob とアプリ内課金で運営してきましたが、AI 機能を本番に組み込むようになってから、この「機能単位の採算が見えない」問題が一気に重くなりました。従来の機能はコストがほぼゼロ(一度書けば動き続ける)だったのに対し、Gemini を呼ぶ機能はユーザーが使うたびに従量課金が発生します。つまり、機能ごとに損益計算書(P&L)を持つ発想が、ここで初めて必要になったのです。
ここでは、リクエスト単位のコスト記録を出発点に、収益シグナルと突き合わせて機能ごとの貢献利益を出し、「残す・直す・畳む」を判断するまでの設計を共有します。コストを記録するところまではusageMetadata で本番アプリのコストを記録する実装パターンで扱いましたので、本稿はその先、「記録したコストを意思決定に変える層」に焦点を当てます。
なぜ機能単位の採算は放っておくと永遠に見えないのか
コストと収益が、別々の場所で・別々の粒度で記録されているからです。コストは Google Cloud の請求や usageMetadata に「モデル単位・アカウント単位」で溜まります。一方、収益は AdMob のダッシュボードに「広告ユニット単位」で、課金は App Store / Google Play に「プロダクト単位」で溜まります。この3つは、どれも「機能」という軸を持っていません。
私が最初にやってしまった失敗は、コストだけを必死に下げようとしたことでした。プロンプトを短くし、Flash-Lite に寄せ、キャッシュを効かせ——確かにコストは2割ほど下がりました。けれども「その機能はそもそも残すべきだったのか」という問いには、まったく答えていなかったのです。コスト最適化は採算判断の代わりにはなりません。採算は収益と並べて初めて意味を持ちます。
そこで設計の出発点を、「すべての Gemini リクエストに、それがどの機能のためのものかを示すタグを必ず付ける」というルールに置きました。地味ですが、これがないと後段の集計が一切できません。
ステップ1:機能タグ付きのコスト台帳をつくる
リクエストを送る関数を1か所に集約し、呼び出し元が feature を渡すことを強制します。下のコードはそのまま動く最小構成です(Python・google-genai SDK 想定)。
import time
import sqlite3
from google import genai
client = genai.Client() # GEMINI_API_KEY を環境変数から読む
# Flash の概算単価(1M トークンあたり USD)。実際の単価は料金表で確認してください。
PRICE_PER_1M = {
"gemini-2.5-flash": {"input": 0.30, "output": 2.50},
"gemini-2.5-flash-lite": {"input": 0.10, "output": 0.40},
}
def _cost_usd(model: str, usage) -> float:
p = PRICE_PER_1M.get(model)
if not p:
return 0.0
in_tok = usage.prompt_token_count or 0
out_tok = (usage.candidates_token_count or 0) + (usage.thoughts_token_count or 0)
return in_tok / 1_000_000 * p["input"] + out_tok / 1_000_000 * p["output"]
def generate_tracked(feature: str, model: str, contents, db: sqlite3.Connection):
"""feature タグ必須。コストを台帳に1行記録してからレスポンスを返す。"""
t0 = time.time()
resp = client.models.generate_content(model=model, contents=contents)
u = resp.usage_metadata
db.execute(
"INSERT INTO ai_cost_ledger (ts, feature, model, in_tok, out_tok, cost_usd, latency_ms) "
"VALUES (?,?,?,?,?,?,?)",
(
int(t0), feature, model,
u.prompt_token_count or 0,
(u.candidates_token_count or 0) + (u.thoughts_token_count or 0),
_cost_usd(model, u),
int((time.time() - t0) * 1000),
),
)
db.commit()
return resp台帳のスキーマはこれだけです。
CREATE TABLE ai_cost_ledger (
id INTEGER PRIMARY KEY,
ts INTEGER NOT NULL, -- UNIX 秒
feature TEXT NOT NULL, -- 例: "caption_gen", "wallpaper_tagging"
model TEXT NOT NULL,
in_tok INTEGER NOT NULL,
out_tok INTEGER NOT NULL,
cost_usd REAL NOT NULL,
latency_ms INTEGER NOT NULL
);
CREATE INDEX idx_ledger_feature_ts ON ai_cost_ledger (feature, ts);ポイントは、feature を引数で「必ず」受け取る形にして、デフォルト値を持たせないことです。デフォルトを許すと、急いでいるときに無タグのリクエストが混ざり、半年後に「unknown が全コストの3割」という、いちばん知りたくない集計結果を見ることになります。私はこれを一度やりました。タグの強制は、未来の自分への配慮だと考えています。