ある朝、コーヒーを淹れる前に Gemini API のダッシュボードを開いて、手が一瞬止まりました。前夜から無人で回している自動投稿パイプラインが、想定の数倍のリクエストを叩いていたのです。原因は単純で、ある外部 API が断続的に 5xx を返し、私が書いたリトライが素直に何度も叩き直していただけでした。料金としては大事には至りませんでしたが、「無人で回す」とは「気づかないうちに費用が積み上がる経路を常に開けておく」ことでもあると、静かに胸に刻みました。
2026年6月26日に一般提供へ進んだ Project Spend Caps は、まさにこの不安に効く更新です。プロジェクト単位で Gemini API の月間ドル上限を設定でき、変更か無効化をするまで効き続けます。ただ、ハード上限だけでは足りないというのが、個人開発で複数のアプリやブログを並行して無人運用してきた私自身の実感でした。そこで取り入れたのが、Project Spend Caps を土台に置きつつ、その手前で静かに止まるアプリ側のソフト上限を重ねる、二段構えの設計です。実装コードとあわせて残しておきます。
無人運用で費用が跳ねる瞬間はどこか
費用が跳ねるのは、たいてい人が見ていない時間帯です。そして原因は数えるほどしかありません。
第一に、リトライの暴走です。一時的な 429 や 5xx に対して指数バックオフを入れていないと、失敗のたびに即座に叩き直し、短時間で呼び出し回数が膨らみます。私のヒヤリも、まさにこれでした。
第二に、モデルの取り違えです。下調べやタグ付けのような軽い前処理にまで重いモデルを当ててしまうと、1リクエストあたりの単価がそのまま何倍にもなります。出力トークンの単価は入力よりも高いため、長い応答を無造作に返させる設計も地味に効いてきます。
第三に、ループの取りこぼしです。エージェント的に「足りなければもう一度」を繰り返す処理で停止条件が甘いと、無限に近い往復が生まれます。Managed Agents のような自律実行を無人で走らせる場合、ここが一番こわい落とし穴になります。
これらはどれも、平常時のテストでは表に出ません。本番運用に乗せて、人が寝ている数時間のあいだに初めて牙をむきます。だからこそ、コードの正しさとは別のレイヤーで、費用そのものに天井を用意しておく必要があります。
Project Spend Caps はどこを守り、どこを守らないか
Project Spend Caps の役割は明快です。プロジェクトに紐づく Gemini API の月間支出が設定したドル額に達したら、それ以上の課金対象リクエストを止めます。クレジットカードの利用枠のように、最後の砦として効きます。
一方で、ハード上限には性質上の限界もあります。上限に達した瞬間、進行中の処理は一律に弾かれます。途中まで組み立てた記事の生成も、半分まで進んだバッチも、区別なく止まります。アプリから見れば、ある時刻を境に呼び出しが急にエラーを返し始める挙動になります。
つまりハード上限は「破滅を防ぐ」ためのもので、「優雅に減速する」ためのものではありません。月末に近づいて上限が迫っているとき、重要なジョブだけ通して些末なジョブを後回しにする、といった判断はハード上限にはできません。そこを埋めるのが、次に作るソフト上限です。
ソフト上限を自前で持つ理由
私はハード上限の少し手前、おおむね80%の地点にもう一本の線を引くようにしています。この内側の線をソフト上限と呼んでいます。ソフト上限の役割は三つあります。
ひとつ目は、減速です。ソフト上限に触れたら、新しい重い呼び出しを止め、軽いモデルへ切り替えるか、ジョブそのものを翌日へ送ります。ふたつ目は、可視化です。「いま月予算の何%まで来ているか」を自分のログで持っておくと、ダッシュボードを開かなくても異常に気づけます。三つ目は、チェックポイントです。止める前に進捗を保存しておけば、翌日に続きから再開できます。
ソフト上限はアプリ側のコードで持つので、ハード上限のように一律ではなく、ジョブの優先度に応じた振る舞いを書けます。ここがいちばんの利点です。
コストガバナーを実装する
実装は驚くほど小さく収まります。やることは三つだけです。1つ目に料金表を持つこと、2つ目に消費を台帳へ記録すること、3つ目に呼び出しの前後で上限を確認することです。
まず、モデルごとの単価を百万トークンあたりのドルで持ちます。具体的な数値は公式の料金ページで必ず確認し、ここでは構造だけ示します。
# pricing.py — 単価は公式の料金ページの最新値で必ず上書きしてください
# 単位: USD / 100万トークン
PRICING = {
"gemini-flash-latest" : { "in" : 0.10 , "out" : 0.40 }, # 3.5 Flash(GA)
"gemini-pro-latest" : { "in" : 1.25 , "out" : 5.00 }, # Pro 系
}
def estimate_cost (model: str , in_tokens: int , out_tokens: int ) -> float :
p = PRICING [model]
return (in_tokens * p[ "in" ] + out_tokens * p[ "out" ]) / 1_000_000
次に、日次の消費を sqlite に積んでいく台帳です。月の上限を日数で割って一日分の目安に落とすと、月末の駆け込みを避けやすくなります。
# ledger.py
import sqlite3, datetime, os
class CostLedger :
def __init__ (self, db = "cost.db" , monthly_cap_usd = 40.0 , soft_ratio = 0.8 ):
self .db = db
self .monthly_cap = monthly_cap_usd
self .soft_cap = monthly_cap_usd * soft_ratio
with sqlite3.connect( self .db) as c:
c.execute( "CREATE TABLE IF NOT EXISTS spend("
"ts TEXT, model TEXT, usd REAL, request_id TEXT UNIQUE)" )
def _month_key (self):
# 集計は JST 基準。素の UTC だと日付の境界がずれて当日分を取りこぼします
now = datetime.datetime.now(datetime.timezone(datetime.timedelta( hours = 9 )))
return now.strftime( "%Y-%m" )
def month_to_date (self) -> float :
with sqlite3.connect( self .db) as c:
cur = c.execute( "SELECT COALESCE(SUM(usd),0) FROM spend "
"WHERE substr(ts,1,7)=?" , ( self ._month_key(),))
return float (cur.fetchone()[ 0 ])
def record (self, model: str , usd: float , request_id: str ):
# request_id を UNIQUE にして、リトライ時の二重計上を防ぎます
ts = datetime.datetime.now().isoformat()
with sqlite3.connect( self .db) as c:
try :
c.execute( "INSERT INTO spend VALUES(?,?,?,?)" ,
(ts, model, usd, request_id))
except sqlite3.IntegrityError:
pass # 同じ request_id は記録済み。黙って無視します
def status (self) -> str :
mtd = self .month_to_date()
if mtd >= self .monthly_cap:
return "HARD"
if mtd >= self .soft_cap:
return "SOFT"
return "OK"
最後に、台帳を見てから呼び出しを通すサーキットブレーカーです。ソフト上限を越えていたら、重い処理を弾くか軽いモデルへ落とします。
# governor.py
from pricing import estimate_cost
from ledger import CostLedger
ledger = CostLedger( monthly_cap_usd = 40.0 , soft_ratio = 0.8 )
class BudgetExceeded ( Exception ):
pass
def guarded_generate (client, model, contents, request_id, priority = "normal" ):
state = ledger.status()
if state == "HARD" :
raise BudgetExceeded( "月のハード上限に到達。処理を停止します" )
if state == "SOFT" and priority != "high" :
# ソフト上限の内側では、優先度の低いジョブを軽いモデルへ落とします
model = "gemini-flash-latest"
resp = client.models.generate_content( model = model, contents = contents)
usage = resp.usage_metadata
usd = estimate_cost(model, usage.prompt_token_count,
usage.candidates_token_count)
ledger.record(model, usd, request_id)
return resp
ここで効いてくる細部が二つあります。request_id を UNIQUE 制約で守ること、そして集計を JST 基準で行うことです。前者はリトライによる二重計上を防ぎ、後者は日付の境界で当日分を取りこぼす事故を防ぎます。どちらも私が実際に踏んでから直した点です。
モデルルーティングで土台のコストを下げる
ソフト上限はあくまで保険であって、平常時の単価そのものを下げておくと、上限に触れる頻度自体が減ります。ここで2026年6月にGAへ進んだ gemini-flash-latest、すなわち 3.5 Flash が効いてきます。
私の運用では、下調べ・要約・タグ付けといった前処理はすべて 3.5 Flash に寄せ、最終的な文章の組み立てや判断が要る箇所だけ上位モデルへ上げる、という段構えにしています。前処理は回数が多いぶん、ここを軽いモデルに寄せるだけで月のコスト構成がはっきり変わります。重いモデルへ上げるのは、全リクエストのうちごく一部で十分なことが多いです。
加えて、推論にどれだけ予算を割くかは thinking budget で締めます。深い推論が要らない定型処理に厚い思考予算を与えても、出力トークンが伸びて単価を押し上げるだけです。私はバッチ的な前処理では thinking を最小に寄せ、設計判断のような場面だけ予算を開けるようにしています。
複数プロジェクトへの上限の配り方
Project Spend Caps はプロジェクト単位で効くので、私はアプリやブログごとにプロジェクトを分け、それぞれに別々のドル上限を割り当てています。こうしておくと、ひとつのプロジェクトが暴走しても被害がそこで止まり、ほかの運用に波及しません。
配分の目安は、平常月の実績に2割ほどの余白を足した値です。実績ぴったりに張ると、少しのトラフィック増で正常なジョブまで弾かれてしまいます。逆に余白を取りすぎると上限が保険として機能しません。
プロジェクト 平常月の実績(例) ハード上限 ソフト上限(80%)
自動投稿パイプライン $28 $40 $32
アプリ内アシスト機能 $15 $24 $19
検証・実験用 $5 $10 $8
検証用のプロジェクトには、あえて低めの上限を張っています。新しいプロンプトを試す場所こそ、想定外のループが生まれやすいからです。AdMob で収益を立てているアプリの本番系とは完全に分け、実験の失敗が本番の予算を削らないようにしています。
運用して見えた調整ポイント
数か月この二段構えで回してみて、いくつか調整した点があります。
1つ目は、ソフト比率です。最初は90%にしていましたが、ハード上限まで余裕がなく、減速が間に合わない場面がありました。いまは80%が私の落ち着きどころです。
2つ目は、アラートのしきい値です。ソフト上限に触れた瞬間に通知を出すと、月末は毎日鳴って慣れてしまいます。私はソフト上限に「初めて」触れた日だけ一度通知する形に変えました。
3つ目は、台帳のバックアップです。sqlite ファイルが消えると当月の集計がゼロに戻り、上限が一時的に効かなくなります。日に一度どこかへ写しておくだけで、この落とし穴は避けられます。
費用の天井は、一度引いてしまえば普段は意識に上りません。けれど人が見ていない時間に静かに効き続けて、ヒヤリを未然に止めてくれます。まずは無人で回している処理をひとつ選び、Project Spend Caps を一本引くところから始めてみてください。台帳と一緒に運用してきた身として、この小さな備えが夜の安心につながると感じています。お読みいただき、ありがとうございました。