先月の AdMob レポートと Gemini API の請求書を並べて眺めていたとき、伸びている数字が片方だけでした。
広告収益はほぼ横ばい。API のコストだけが、前月の 2.4 倍。
障害でも不具合でもありません。ログを掘っていくと、ある一人の利用者が、AI 機能を一日に 300 回以上呼んでいました。悪意があったわけでもなさそうで、気に入った機能を延々と試していただけのようでした。レート制限は正しく効いていました。毎分の上限も、毎日の呼び出し回数も、設計どおりに守られていました。
守られていなかったのは、月末に届く請求書のほうです。
レート制限が守るものと、守らないもの
レート制限は「単位時間あたりの流量」を制御します。一方で私たちが本当に守りたいのは「課金期間あたりの金額」です。この二つは似ているようで、まったく違う量を見ています。
毎分 10 回、毎日 300 回という制限は、それ自体は破綻していません。ただ 300 回 × 30 日 = 9,000 回の呼び出しは、一人の無料ユーザーとしては明らかに過剰です。広告から得られる収益の何十倍にもなります。
個人開発で App Store と Google Play にアプリを出していると、この非対称性がそのまま損益に効いてきます。ユーザー数が増えて嬉しいはずの月に、口座の残高だけが減っていく。
必要なのは、時間軸ではなく金額軸のガードです。そして金額軸のガードは、収益から逆算しないと数字が決まりません。
一人あたりの予算は、収益から逆算する
まず「一人の無料ユーザーが、月にいくら稼いでくれるか」を出します。私のアプリの場合は AdMob のインタースティシャル広告が主な収益源ですので、eCPM と一人あたりのインプレッション数から求めます。
| 項目 | 値 | 出どころ |
| eCPM | 380円 | AdMob 直近 30 日 |
| 1 DAU あたり日次インプレッション | 3.0 | AdMob ÷ Firebase DAU |
| 1 DAU あたり日次収益 | 1.14円 | 380 ÷ 1000 × 3.0 |
| 月間アクティブ日数(中央値) | 9 日 | アプリ内計測 |
| 1 ユーザーあたり月次 ARPU | 10.3円 | 1.14 × 9 |
ここから、AI 機能に割り当ててよい原価比率を決めます。私は 30% を上限に置いています。サーバー費用と決済手数料を差し引いても、機能を維持できる水準がこのあたりでした。
つまり一人あたりの月次トークン予算は 3.0円 前後。ここに、実効単価を当てます。
実効単価は公表価格ではなく、自分の請求から逆算した数字を使ってください。キャッシュヒット率やプロンプトの長さで、同じモデルでも呼び出しあたりの金額はかなり変わります。私のケースでは、平均 1 回 0.42円 でした。
| 指標 | 私の実測 | 導かれる上限 |
| 月次 ARPU | 10.3円 | — |
| AI 原価比率の上限 | 30% | 3.09円 / 人・月 |
| 実効単価(1 呼び出し) | 0.42円 | — |
| 無料ユーザーの月次呼び出し上限 | — | 7 回 |
7 回。この数字を初めて出したとき、少し手が止まりました。無料で無制限に AI 機能を開放するという発想が、そもそも算数として成り立っていなかったのだと気づかされます。
呼び出し回数ではなくトークン数で管理するほうが精度は上がりますので、実装ではトークン建ての予算に換算します。私は「月次 7,500 トークン相当(入出力の加重合計)」という形で持たせています。
実費は usage_metadata の 4 フィールドで確定する
予算を持つには、まず 1 回の呼び出しの実費を正確に確定させる必要があります。Gemini API のレスポンスに付いてくる usage_metadata から、次の 4 つを取り出します。
| フィールド | 意味 | 課金上の扱い |
prompt_token_count | 入力トークン総数 | キャッシュ分を含むため差し引きが必要 |
cached_content_token_count | コンテキストキャッシュのヒット分 | 入力より安い単価で計上 |
candidates_token_count | 本文として返った出力 | 出力単価 |
thoughts_token_count | 思考トークン | 出力単価。candidates には含まれません |
この 4 番目が、私が最初に取りこぼしていた場所です。詳しくは後述します。
呼び出しラッパーは 1 か所に閉じ込めてください。アプリの各所から直接 SDK を叩いていると、予算の計上漏れが必ず起きます。
# gemini_budget.py
from dataclasses import dataclass
from google import genai
client = genai.Client()
# 自分の請求から逆算した実効単価(円 / 100万トークン)。四半期ごとに見直します。
PRICE_JPY_PER_MTOK = {
"input": 45.0,
"cached": 11.0,
"output": 360.0,
"thoughts": 360.0,
}
@dataclass
class Usage:
fresh_input: int
cached_input: int
output: int
thoughts: int
@property
def cost_jpy(self) -> float:
p = PRICE_JPY_PER_MTOK
return (
self.fresh_input * p["input"]
+ self.cached_input * p["cached"]
+ self.output * p["output"]
+ self.thoughts * p["thoughts"]
) / 1_000_000
@property
def weighted_tokens(self) -> int:
# 予算は「入力換算トークン」で持つと、モデルを替えても台帳が壊れません
return int(self.cost_jpy / (PRICE_JPY_PER_MTOK["input"] / 1_000_000))
def extract_usage(response) -> Usage:
u = response.usage_metadata
cached = getattr(u, "cached_content_token_count", 0) or 0
thoughts = getattr(u, "thoughts_token_count", 0) or 0
prompt = u.prompt_token_count or 0
output = u.candidates_token_count or 0
return Usage(
fresh_input=max(prompt - cached, 0),
cached_input=cached,
output=output,
thoughts=thoughts,
)
weighted_tokens を挟んでいるのには理由があります。予算を円建てで持つと、単価改定やモデル切替のたびに過去の台帳と現在の残高が地続きでなくなります。入力換算トークンという中立な単位に落としておけば、単価テーブルだけを差し替えれば済みます。
ソフトキャップとハードキャップを、段階で持つ
予算の 100% に達した瞬間に機能を止めると、利用者から見れば突然の故障と区別がつきません。私は 3 段の劣化ラダーを置いています。
| 消化率 | 挙動 | 利用者に見えるもの |
| 0〜70% | 通常のモデルで応答 | 変化なし |
| 70〜100% | 軽量モデルへ降格・思考予算を抑制 | わずかに簡潔な応答 |
| 100% 超 | キャッシュ済みの結果のみ返す | 今月の上限に達した旨と、翌月の再開日 |
70% で降格させるのは、消化速度そのものを落とすためです。軽量モデルに切り替えると実効単価はおよそ 1/4 になりますので、残り 30% の予算で当初の 4 倍近い回数を提供できます。上限に達する利用者の割合は、私の場合 3.1% から 0.4% まで下がりました。
# budget_guard.py
SOFT_CAP = 0.70
MONTHLY_BUDGET_TOKENS = 7_500
MODEL_LADDER = {
"normal": ("gemini-3-pro", {"thinking_level": "high"}),
"reduced": ("gemini-flash-latest", {"thinking_level": "low"}),
}
class BudgetExceeded(Exception):
pass
def select_tier(spent_tokens: int) -> str:
ratio = spent_tokens / MONTHLY_BUDGET_TOKENS
if ratio >= 1.0:
raise BudgetExceeded(f"budget exhausted: {ratio:.0%}")
return "reduced" if ratio >= SOFT_CAP else "normal"
def generate_for_user(ledger, user_id: str, prompt: str):
spent = ledger.spent_this_period(user_id)
tier = select_tier(spent) # ここで 100% 超は例外
model, config = MODEL_LADDER[tier]
response = client.models.generate_content(
model=model, contents=prompt, config=config
)
usage = extract_usage(response)
ledger.record(user_id, usage.weighted_tokens, usage.cost_jpy, tier)
return response.text, tier
select_tier を呼び出しの前に置き、ledger.record を後に置く。この順序を守ってください。逆にすると、上限を超えた 1 回分が必ず課金されてから止まります。一人分なら誤差ですが、月末に数百人が同時に上限へ到達する場面では、その 1 回が効いてきます。
なお、Gemini API 側の Project Spend Caps は最後の防波堤として別に設定しておくことを推奨します。台帳は台帳の外で起きた呼び出しを知りません。
濫用は「上位1%の消費比率」で見つかる
平均値を眺めていても、濫用は見えません。私が使っているのは、消費量の上位 1% が全体の何割を使っているか、という一つの比率です。
健全なアプリでは、この比率はおおむね 8〜15% に収まります。20% を超えたら、自動化されたクライアントか、機能を延々と往復している利用者がいます。
# concentration.py
import statistics
def top_share(spend_by_user: dict[str, float], top_pct: float = 0.01) -> float:
values = sorted(spend_by_user.values(), reverse=True)
if not values:
return 0.0
k = max(1, int(len(values) * top_pct))
return sum(values[:k]) / sum(values)
def anomaly_report(spend_by_user: dict[str, float]) -> dict:
values = list(spend_by_user.values())
median = statistics.median(values)
outliers = {
uid: v for uid, v in spend_by_user.items()
if median > 0 and v > median * 20
}
return {
"top1_share": round(top_share(spend_by_user), 3),
"median_tokens": median,
"outlier_users": sorted(outliers, key=outliers.get, reverse=True)[:20],
}
中央値の 20 倍という閾値には根拠らしい根拠はありません。私のデータで、正当なヘビーユーザーと自動化クライアントの分布が分かれる点がおよそそこだった、というだけです。ご自分のログで一度ヒストグラムを描いてから決めてください。
この集計は日次で回し、top1_share が 20% を超えた日だけ通知が飛ぶようにしています。毎日届くレポートは、三日で読まれなくなりますので。
計上を間違えていた 4 か所
台帳を作ってから 2 か月ほど、請求書と台帳の合計が 1 割ほどずれ続けていました。原因はすべて計上側にありました。
1. 思考トークンを数え落としていた
candidates_token_count に thoughts_token_count は含まれません。思考を有効にしたモデルでは、出力側の実費のかなりの部分が思考トークンです。私の場合、この一点だけで台帳が 7% ほど過小でした。
2. リトライを二重計上していた
429 を受けて再送した呼び出しは、失敗した側にも usage_metadata が返る場合があります。記録するのは成功したレスポンス 1 件のみ、という規約をラッパーに明文化しました。反対に、タイムアウトで切断した呼び出しはサーバ側で課金されている可能性があり、こちらは台帳に載りません。この非対称は残ったままです。
3. キャッシュヒットを入力単価で計上していた
prompt_token_count から cached_content_token_count を差し引かずに入力単価を掛けていました。キャッシュを効かせている機能ほど、台帳が実費より高く出ます。コスト削減の効果が見えず、しばらく首を傾げていました。
4. 月初リセットの時差
台帳のリセットを UTC の月初で回し、請求書を日本時間で読んでいました。月に 9 時間分だけ、二つの数字が別の期間を指しています。JST で揃えてから、差分は 1% 以内に収まりました。
今日できる一歩
すべてを一度に作る必要はありません。順序としては、次の 3 つを推奨します。
- 呼び出しを一つのラッパーに集約し、
usage_metadata の 4 フィールドをログに落とすだけの状態を作る(予算判定はまだ入れない)
- 1 週間分のログから、上位 1% の消費比率と中央値を出し、自分のアプリの分布を知る
- 分布を見てから予算値と閾値を決め、ソフトキャップだけを先に入れる
順番を逆にして、先に予算値を決めてしまうと、たいてい厳しすぎるか緩すぎるかのどちらかになります。私は最初、根拠のない「一日 10 回」を置いて、正当な利用者から先に止めてしまいました。
数字を見てから決める。当たり前のことですが、コストの話になると、なぜか先に上限のほうを決めたくなります。
私自身、七回という上限を突きつけられて初めて、この機能を誰にどこまで届けたいのかを考え直しました。台帳は、その問いを毎月静かに差し出してくれます。