設計判断その2: テナント認証とリクエストコンテキスト
すべての Gemini API 呼び出しは「どのテナント・どのユーザー・どのプランか」というコンテキストを持つべきです。これを実装の入口で TenantContext 型に押し込み、後続のすべての関数に伝搬させると、後の課金計測・監査ログ・予算チェックがすべて自然に書けます。
# tenant_context.py
from dataclasses import dataclass
from typing import Literal
PlanTier = Literal[ "free" , "starter" , "pro" , "enterprise" ]
@dataclass ( frozen = True )
class TenantContext :
"""1リクエストのライフサイクル中だけ生きるテナント情報。
認証ミドルウェアで生成し、Gemini 呼び出し関数まで引き回す。"""
tenant_id: str # 例: "tnt_01HX..."
user_id: str # 例: "usr_01HX..."
plan: PlanTier # 課金プラン
monthly_token_budget: int # この月の残り使用可能トークン数
request_id: str # トレース用の一意ID
@ property
def has_budget (self) -> bool :
return self .monthly_token_budget > 0
この TenantContext を、FastAPI なら Depends、Flask なら g、TypeScript の Hono なら c.set() で全エンドポイントから取得できるようにします。「無認証で Gemini API を叩く経路」を1つでも残さないこと が、後で出てくるすべての制御の前提になります。よくある事故は「内部スクリプトだから認証スキップ」したエンドポイントが、本番のテナント請求と紐づかずにコストだけ垂れ流す、というパターンです。
設計判断その3: テナント別レート制限(トークンバケット)
Gemini API そのもののクオータはプロジェクト全体に対して効くため、「テナントBが暴走しても、テナントAの応答時間と成功率が落ちない」を保証するレートリミッタをアプリ側に置きます。
私はこれを Redis のトークンバケットで実装するのが好きです。理由は、(1) サブミリ秒で判定できる、(2) スケールアウト時に複数の API サーバーから一貫した制御ができる、(3) Lua スクリプトで原子的に補充と消費ができる、の3点です。
# tenant_rate_limiter.py
import time
import redis.asyncio as redis
# Lua: 1呼び出しの中で「補充→残量取得→残ってれば1減らす」を原子的に行う
# return: 残りトークン(負なら拒否すべき)
LUA_TOKEN_BUCKET = """
local key = KEYS[1]
local rate_per_sec = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now
-- 経過時間ぶん補充
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * rate_per_sec)
local allowed = tokens >= cost
if allowed then
tokens = tokens - cost
end
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return allowed and tokens or -1
"""
class TenantRateLimiter :
# プランごとに rate(tokens/sec) と burst(capacity) を分ける
PLAN_LIMITS = {
"free" : ( 1.0 , 10 ), # 1 req/sec、burst 10
"starter" : ( 5.0 , 50 ),
"pro" : ( 20.0 , 200 ),
"enterprise" : ( 100.0 , 1000 ),
}
def __init__ (self, redis_client: redis.Redis):
self .redis = redis_client
self ._sha: str | None = None
async def _ensure_loaded (self) -> str :
if self ._sha is None :
self ._sha = await self .redis.script_load( LUA_TOKEN_BUCKET )
return self ._sha
async def try_acquire (self, ctx, cost: int = 1 ) -> bool :
"""このテナントが今すぐ1リクエスト送ってよいかを判定する。
cost を可変にすると「重いリクエストは2消費」のような重み付けも可能。"""
rate, capacity = self . PLAN_LIMITS [ctx.plan]
sha = await self ._ensure_loaded()
key = f "ratelimit:tenant: { ctx.tenant_id } "
result = await self .redis.evalsha(
sha, 1 , key, rate, capacity, time.time(), cost
)
return result != - 1
補足です。now を Python 側の time.time() で渡しているため、API サーバー間で時計がずれると補充量にもずれが出ます。気になる場合は、Lua の中で redis.call('TIME') を呼ぶ形に変えられます(お使いの Redis のバージョンで一度試してください)。
ポイントは2つあります。1つ目は 「プランごとに上限が違う」ことをコードに刻む ことです。SaaS の課金プランは差別化のためにレート制限が効くべきですが、これを Stripe や DB だけで管理して Gemini 呼び出し側で参照しないと、「Pro プランなのに Free と同じ速度しか出ない」というクレームが必ず来ます。
2つ目は 拒否時の挙動を決めておく ことです。try_acquire が False を返したとき、(a) 即 429 を返す、(b) 短いキューに入れて少し待たせる、(c) Retry-After ヘッダー付きで丁寧にリトライ依頼する、のどれを取るかは UX 次第です。私はチャット UI なら (c)、Webhook 経由なら (a) を推奨します。
設計判断その4: 1リクエスト単位のトークン課金計測
レート制限と並んで重要なのが、**「テナントが今月いくら使ったかを、リクエスト粒度で正確に記録する」**ことです。月末になって「請求は何ドル?」と聞かれて答えられない SaaS は、信用を失います。
Gemini API は応答の usage_metadata に prompt_token_count / candidates_token_count / total_token_count を返してきます。ここを必ず拾います。
# usage_recorder.py
import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from google import genai
from google.genai import types
# モデル別の単価(2026年4月時点・USD / 1M tokens、サンプル値)
MODEL_PRICING = {
"gemini-2.5-pro" : { "input" : 1.25 , "output" : 10.00 },
"gemini-2.5-flash" : { "input" : 0.10 , "output" : 0.40 },
"gemini-2.5-flash-lite" : { "input" : 0.05 , "output" : 0.20 },
}
@dataclass
class UsageRecord :
tenant_id: str
user_id: str
request_id: str
model: str
input_tokens: int
output_tokens: int
cost_usd: float
created_at: datetime
success: bool
error_code: str | None = None
def _calc_cost (model: str , input_tokens: int , output_tokens: int ) -> float :
p = MODEL_PRICING .get(model, { "input" : 1.0 , "output" : 5.0 })
return (input_tokens * p[ "input" ] + output_tokens * p[ "output" ]) / 1_000_000
class GeminiClientWithMetering :
"""Gemini 呼び出しと課金記録を同じトランザクションにまとめるラッパー。
呼び出し側は ctx と prompt を渡すだけでよい。"""
def __init__ (self, client: genai.Client, recorder, budget_guard):
self .client = client
self .recorder = recorder # 後述: DB 書き込み担当
self .budget_guard = budget_guard # 後述: 月予算チェック担当
async def generate (self, ctx, model: str , prompt: str ) -> str :
# 月予算チェック(残り0なら即拒否)
if not await self .budget_guard.allow(ctx):
raise PermissionError ( f "tenant { ctx.tenant_id } over monthly budget" )
record = UsageRecord(
tenant_id = ctx.tenant_id,
user_id = ctx.user_id,
request_id = ctx.request_id,
model = model,
input_tokens = 0 ,
output_tokens = 0 ,
cost_usd = 0.0 ,
created_at = datetime.now(timezone.utc),
success = False ,
)
try :
resp = await self .client.aio.models.generate_content(
model = model,
contents = prompt,
)
usage = resp.usage_metadata
record.input_tokens = usage.prompt_token_count or 0
# 思考トークンは出力と同じ単価で課金されるが、
# candidates_token_count には含まれない。別枠の値を足す
record.output_tokens = (
(usage.candidates_token_count or 0 )
+ (usage.thoughts_token_count or 0 )
)
record.cost_usd = _calc_cost(
model, record.input_tokens, record.output_tokens
)
record.success = True
return resp.text
except Exception as e:
record.error_code = type (e). __name__
raise
finally :
# 成功・失敗を問わず必ず記録する。失敗した呼び出しでも、
# Gemini 側で課金が発生する場合があるため。
await self .recorder.persist(record)
ここで気をつけるべきは、finally の中で必ず recorder.persist() を呼ぶ ことです。エラーで例外が飛んだ場合でも、入力トークンに対する課金は発生していることがあります(特に出力中にネットワークが切れたケースなど)。「成功時だけ記録する」実装は月末に必ずズレを生みます。
見直して気づいた穴: 思考トークンと、失敗した呼び出し
この節のコードは、最初の版では出力トークンを candidates_token_count だけで数えていました。見直していて手が止まったのは、公式ドキュメントの「思考をオンにしたときの料金は、出力トークンと思考トークンの合計」という一文を読み返したときです。candidates_token_count に入るのは見えている回答の分だけで、思考に使われたトークンは thoughts_token_count に別枠で入ります。
入力1,000トークン、見える回答200トークン、思考3,000トークンの呼び出しを、単価表の Flash で計算してみます。
出力側だけを見れば16倍、入力を含めた1回の費用でも約7.7倍の差です。記録がこの差を黙って落とすと、テナントへの請求も予算ガードの集計も、実際の支出より低い側へずれ続けます。しかも思考の量は問い合わせごとに大きく揺れるので、平均値で補正することもできません。請求に使う数字は、見えているものからではなく、課金されるものから取る ——これを線引きにしました。コードは thoughts_token_count を足す形に直してあります。SDK のバージョンやモデルによっては値が None になるので、or 0 を付けています。
なお、コード中の gemini-2.5-* のモデル名は例です。2026年9月の公式 changelog で 2.5 系は最近使った利用者に限定されたため、新しく始める場合は現行の Flash 系のモデル ID に置き換え、単価も公式の料金ページから読み込む形にしてください。
もう一点あります。finally で記録する失敗した呼び出しは、コードの上では出力 0 トークンで残ります。実際には課金が発生していても、手元の記録は 0 のままです。この差は実装では埋められないので、月に1度、usage_records の合計を Cloud Billing の請求データ(BigQuery へのエクスポート)と突き合わせて、ずれの大きさを確認する運用を組み込んでおくと安心です。ずれが小さいうちは放置でよく、一定の割合を超えたら記録の取りこぼしを疑う、という目安で十分です。
recorder.persist() の実装は、PostgreSQL の usage_records テーブルへの INSERT が無難です。テーブル設計の例を載せます。
-- migrations/001_usage_records.sql
CREATE TABLE usage_records (
id BIGSERIAL PRIMARY KEY ,
tenant_id TEXT NOT NULL ,
user_id TEXT NOT NULL ,
request_id TEXT NOT NULL UNIQUE , -- 二重課金防止
model TEXT NOT NULL ,
input_tokens INTEGER NOT NULL ,
output_tokens INTEGER NOT NULL ,
cost_usd NUMERIC ( 10 , 6 ) NOT NULL ,
success BOOLEAN NOT NULL ,
error_code TEXT ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW ()
);
CREATE INDEX idx_usage_tenant_created
ON usage_records (tenant_id, created_at DESC );
-- 月次集計を高速化したいなら以下のような Materialized View を併用
CREATE MATERIALIZED VIEW usage_monthly AS
SELECT
tenant_id,
date_trunc( 'month' , created_at) AS month ,
SUM (input_tokens) AS input_tokens,
SUM (output_tokens) AS output_tokens,
SUM (cost_usd) AS cost_usd,
COUNT ( * ) FILTER ( WHERE success) AS ok_count,
COUNT ( * ) FILTER ( WHERE NOT success) AS error_count
FROM usage_records
GROUP BY tenant_id, date_trunc( 'month' , created_at);
request_id を UNIQUE 制約付きで持つのが地味に重要です。リトライで同じリクエストが2回 INSERT されると、月末の請求が二重になります。アプリケーションの入口で request_id を発行し、最後の INSERT までそれを引き回しましょう。
なお、本番では PostgreSQL に直接 INSERT すると書き込みボトルネックになる可能性があるため、Cloud Pub/Sub または Kafka 経由で非同期に流して BigQuery に蓄積するパターンも有効です。SaaS の規模が日次1万リクエストを超え始めたら検討してください。
設計判断その5: 月予算ガードとサーキットブレーカー
レート制限が「短期の暴走」を防ぐ仕組みなら、予算ガードは「中長期の暴走」を止める仕組み です。テナントが「秒間1リクエスト」しか送っていなくても、それを24時間365日続けたら月末には大きな額になります。
私が組み込む2層の防御をご紹介します。
# budget_guard.py
import asyncio
from datetime import datetime, timezone
import asyncpg
class TenantBudgetGuard :
"""テナント単位で『今月使ったコスト』を短時間キャッシュし、
月予算の80%で警告、105%で拒否する。"""
# プランごとの月間予算(USD)
PLAN_BUDGETS = {
"free" : 0.50 ,
"starter" : 20.0 ,
"pro" : 200.0 ,
"enterprise" : 2000.0 , # ハードキャップ。事前合意が必要
}
GRACE_RATIO = 1.05 # 5%までは許容(集計の遅れで境界の呼び出しを不当に弾かないため)
def __init__ (self, pool: asyncpg.Pool, alerter):
self .pool = pool
self .alerter = alerter
self ._cache: dict[ str , tuple[ float , datetime]] = {}
self ._locks: dict[ str , asyncio.Lock] = {} # ロックはテナントごと
self ._alerted: set[tuple[ str , str , str ]] = set () # (テナント, 月, 種別)
async def _current_spend (self, tenant_id: str ) -> float :
# 60秒キャッシュ。完全な厳密性より、頻繁な DB 叩きの抑制を優先。
lock = self ._locks.setdefault(tenant_id, asyncio.Lock())
async with lock:
cached = self ._cache.get(tenant_id)
now = datetime.now(timezone.utc)
if cached and (now - cached[ 1 ]).total_seconds() < 60 :
return cached[ 0 ]
month_start = now.replace( day = 1 , hour = 0 , minute = 0 , second = 0 , microsecond = 0 )
row = await self .pool.fetchrow(
"SELECT COALESCE(SUM(cost_usd), 0) AS spend "
"FROM usage_records "
"WHERE tenant_id = $1 AND created_at >= $2" ,
tenant_id, month_start,
)
spend = float (row[ "spend" ])
self ._cache[tenant_id] = (spend, now)
return spend
async def _alert_once (self, kind: str , tenant_id: str , spend: float , budget: float ):
# 同じ月・同じ種別の通知は1回だけ。kind は "warning" か "blocked"
key = (tenant_id, datetime.now(timezone.utc).strftime( "%Y-%m" ), kind)
if key in self ._alerted:
return
self ._alerted.add(key)
await getattr ( self .alerter, f "notify_ { kind } " )(tenant_id, spend, budget)
async def allow (self, ctx) -> bool :
budget = self . PLAN_BUDGETS [ctx.plan]
spend = await self ._current_spend(ctx.tenant_id)
if spend >= budget * self . GRACE_RATIO :
await self ._alert_once( "blocked" , ctx.tenant_id, spend, budget)
return False
if spend >= budget * 0.80 :
await self ._alert_once( "warning" , ctx.tenant_id, spend, budget)
return True
最初の版に入れていたコードには、見直すまで気づかなかった穴が3つありました。
1つ目は、ロックが全テナント共通だったことです。asyncio.Lock() を1つだけ持ち、DB 集計のあいだも握ったままでした。ホットなテナントの集計が遅れると、無関係なテナントの allow() まで順番待ちになります——テナントを分けるための仕組みが、テナントをまたいで詰まっていたわけです。上のコードではロックをテナントごとに持たせました。DB の応答に0.05秒かかる疑似環境で10テナントを同時に通したところ、共通ロックでは約0.5秒、テナント別では約0.05秒でした。
2つ目は、警告の条件です。「80%以上85%未満」のときだけ通知する書き方だと、キャッシュの更新の合間に支出が79%から90%へ跳ねたとき、警告が一度も出ません。逆に85%未満にとどまるあいだは、リクエストのたびに通知が飛びます。条件を「80%以上」に直し、(テナント, 月, 種別) ごとに1回だけ通知する形にしました。この集合はプロセス内のメモリなので、API サーバーが複数台あれば台数ぶん通知が届きます。台数が増えたら Redis の SET NX に置き換えてください。
3つ目が、60秒のキャッシュが許してしまう超過額です。次の項で数字にします。
キャッシュの60秒で、どこまで超えられるか
_current_spend をリクエストごとに DB 集計すると、ホットなテナントで DB が焼けます。60秒キャッシュは「ほぼリアルタイムだが完全リアルタイムではない」精度を許容する現実解ですが、その「ほぼ」の中身を数字にしたことがありませんでした。
ガードが見ているのは、60秒前までに確定した支出だけです。しかも usage_records への書き込みは呼び出しが終わってからなので、実行中のリクエストぶんも見えていません。ガードが「まだ余裕あり」と判断した直後の60秒間に、設計判断その3のレート制限が通す上限いっぱいまで呼び出されたら、どこまで超えるのか。プラン別の上限(バーストの容量と、60秒ぶんの補充)から計算しました。1回の呼び出しは、単価表のサンプル値で入力5万・出力4千トークンの Pro(約0.1025ドル)とします。
free の1,435%は極端に見えますが、「free が Pro を呼べる設計なら、こうなりうる」という計算です。free と starter の呼び出し先を Flash 系に限ると、同じ計算で free は約0.46ドル、starter は約2.31ドルに収まります。プラン別のレート制限と、プラン別に使えるモデルは、セットで決める必要があるのだと気づいたのは、この表を並べてみたときでした。
なお、これは「ガードが予算の直前で古い数字を見ていた」最悪の並びの計算です。毎回ここまで超えるわけではありません。それでも、月予算を守る仕組みの精度が「60秒 × 通過レート × 1回の費用」で決まっている事実は変わりません。
予算ガードが見張るべきなのは、「いくら使ったか」ではなく「次の60秒でいくらまで使われうるか」です。
使われうる額を抑える方法は2つあります。キャッシュの秒数を縮めるか、呼び出しの前に最悪ケースの費用を先に予約しておくかです。前者は DB の負荷とのトレードオフになるので、後者のコードを載せます。Redis に月ごとの概算支出を持ち、呼び出しの前に最悪ケースの費用を加算し、予算を超えるなら戻して拒否、呼び出しのあとに実績で精算します。
# spend_ledger.py
from datetime import datetime, timezone
class SpendLedger :
"""Redis 上の『今月の概算支出』。呼び出し前に最悪ケースを予約し、
呼び出し後に実績で精算する。"""
def __init__ (self, redis_client, db_spend, budgets: dict , grace: float = 1.05 ):
self .redis = redis_client
self .db_spend = db_spend # async (tenant_id) -> 今月の確定支出(DB 集計)
self .budgets = budgets
self .grace = grace
async def reserve (self, ctx, est_usd: float ) -> str | None :
"""予約できれば Redis キーを返し、予算超過なら None を返す。"""
key = f "spend: { ctx.tenant_id } : { datetime.now(timezone.utc): % Y % m } "
if not await self .redis.exists(key):
# 初回だけ DB の集計値で初期化。NX なので並行しても1回だけ入る
await self .redis.set(key, await self .db_spend(ctx.tenant_id),
nx = True , ex = 40 * 86400 )
after = float ( await self .redis.incrbyfloat(key, est_usd))
if after > self .budgets[ctx.plan] * self .grace:
await self .redis.incrbyfloat(key, - est_usd) # 予約を戻す
return None
return key
async def settle (self, key: str , est_usd: float , actual_usd: float ) -> None :
"""見積りと実績の差を反映する。失敗した呼び出しは actual_usd=0 で予約を解放。"""
await self .redis.incrbyfloat(key, actual_usd - est_usd)
呼び出しの側は、budget_guard.allow() の代わりに次の流れを通します。
MAX_OUT = 4096 # max_output_tokens。思考トークンを含む上限
p = MODEL_PRICING [model]
est = ( len (prompt) * p[ "input" ] + MAX_OUT * p[ "output" ]) / 1_000_000
key = await ledger.reserve(ctx, est)
if key is None :
raise PermissionError ( f "tenant { ctx.tenant_id } over monthly budget" )
try :
resp = await client.aio.models.generate_content(
model = model, contents = prompt,
config = types.GenerateContentConfig( max_output_tokens = MAX_OUT ),
)
... # 実績コストを record.cost_usd に入れる
finally :
await ledger.settle(key, est, record.cost_usd) # 失敗時は 0 で解放
最悪ケースの見積りは、入力側を文字数で、出力側を max_output_tokens で置きます。文字数は多くの言語でトークン数以上に収まりやすい目安であって、確実な保証ではありません。出力の上限には、公式ドキュメントのとおり思考トークンも含まれます。上限に当たった応答は途中で切れるので、UX との相談になります。
手元の試験では、DB 集計が190ドルのテナントに0.1025ドルの予約を200回投げると、195回で止まりました。合計は209.99ドルで、予算の105%の手前に収まっています。195回すべてが失敗として精算されると、190ドルに戻りました(Redis は代用の簡易クラスで確認しています)。Redis のカウンタは DB と少しずつ差が出るので、毎晩 usage_records の集計で上書きして整合を取る運用を組み合わせてください。
加えて、全テナント横断のサーキットブレーカー も入れておきます。Gemini 側で 5xx が連続している、Cloud Billing が想定外に増えている、といったイベントを検知したら、ヘルスチェック用の Redis キーを down にして、すべての呼び出しを即座に短絡させます。これは Gemini API のコストスパイク防御パターン で詳しく扱っているので、合わせて参考にしてみてください。
ありがちな落とし穴(私が踏んだもの)
ここからは、設計図ではなく傷の話です。
落とし穴1: ストリーミング応答のトークン数を取り損ねる
generate_content_stream() でストリーミング応答を返している場合、usage_metadata は 最後のチャンクにしか入っていない ことがあります。途中で break したり、最後のチャンクを破棄するコードを書いてしまうと、トークン数が常に0で記録されます。月末の請求を見て初めて気づきました。なお、クライアント側で break して接続を切ったあと、サーバー側で生成された分がどう課金に反映されるかは、手元のログだけでは確かめられません。この差も、月次の突合で大きさを見ておくと安心です。
# 悪い例
async for chunk in client.aio.models.generate_content_stream( ... ):
yield chunk.text
if some_condition:
break # ここで抜けると usage_metadata を取り損ねる
# 良い例
last_chunk = None
async for chunk in client.aio.models.generate_content_stream( ... ):
last_chunk = chunk
yield chunk.text
if last_chunk and last_chunk.usage_metadata:
record.input_tokens = last_chunk.usage_metadata.prompt_token_count
record.output_tokens = last_chunk.usage_metadata.candidates_token_count
落とし穴2: テナントID をログに残し忘れる
例外発生時のスタックトレースだけでは、どのテナントの問題か分かりません。logging の extra に tenant_id / user_id / request_id を必ず含める運用にしないと、本番障害の切り分けが地獄になります。私は contextvars でリクエスト全体に貼り付けて、logging.Filter で自動付与しています。
落とし穴3: BYOK のキー漏洩
C プラン(BYOK)に進化させたとき、テナントから預かった API キーを DB に平文で保存して情報漏洩、というのが業界で繰り返されています。最低でも GCP の Cloud KMS、できれば HashiCorp Vault のような秘密管理基盤を使い、暗号化キーをアプリケーションコードと分離してください。それでも抜かれる可能性は0ではないので、預かったキーは 必ず Google Cloud Console から「読み取り専用 / 月予算上限付き」で発行してもらう運用フロー にすることをセットで提示します。
落とし穴4: 監査ログの保持期間を決めていない
usage_records を「保存しているから安心」と思って数年放置すると、テーブルが TB 級になって運用が破綻します。最低でも (a) 24ヶ月保持→アーカイブ、(b) 月次集計テーブルへの転送、(c) BigQuery への ETL、のどれかを最初に決めてください。法令対応上の保管期間(日本なら国税関連の7年など)にも注意します。
監査・可視化の最低ライン
ここまで実装すれば、月末に困らない情報が揃います。最低限ダッシュボード化したいのは以下の5指標です。
テナント別月間支出(USD) : 上位10テナントを並べ、プラン超過率も併記します
モデル別使用比率(input/output トークン) : コスト構造を理解するための基礎データです
エラー率(テナント別 / 全体) : 429・500・タイムアウトの内訳を見ます
平均レイテンシ(テナント別) : 巻き添え障害の早期検知に効きます
予算アラート発火履歴 : 80%/100%を通過したテナントの一覧と時刻です
私は Grafana + PostgreSQL でこれを組んでいますが、Looker Studio + BigQuery でも同じことが可能です。スプレッドシートでも構いません。「何を見れば異常がわかるか」を最初に決めて、最低限のグラフだけ作る のが現実的な始め方です。
FastAPI で全パーツを1つに束ねる
ここまでの部品(認証・レート制限・課金計測・予算ガード)が、実際のサーバーでどう噛み合うのかを、最小の FastAPI サービスとして示します。チャットエンドポイントを1本だけ持つ構成です。
# app.py
from fastapi import FastAPI, Depends, HTTPException, Request
from contextvars import ContextVar
import uuid
from tenant_context import TenantContext
from tenant_rate_limiter import TenantRateLimiter
from usage_recorder import GeminiClientWithMetering
from budget_guard import TenantBudgetGuard
# 実運用では起動時に1度だけ構築します
limiter: TenantRateLimiter
gemini: GeminiClientWithMetering
guard: TenantBudgetGuard
# ログ付与のため、リクエストごとにテナントコンテキストを束ねます
current_ctx: ContextVar[TenantContext | None ] = ContextVar( "current_ctx" , default = None )
async def get_tenant_context (request: Request) -> TenantContext:
api_key = request.headers.get( "Authorization" , "" ).removeprefix( "Bearer " ).strip()
if not api_key:
raise HTTPException( 401 , "missing bearer token" )
# 自前のテナントテーブルを引きます。この前に Gemini を呼んではいけません
row = await app.state.db.fetchrow(
"SELECT tenant_id, user_id, plan FROM api_keys WHERE token_hash = $1" ,
sha256_hex(api_key),
)
if row is None :
raise HTTPException( 401 , "invalid token" )
ctx = TenantContext(
tenant_id = row[ "tenant_id" ],
user_id = row[ "user_id" ],
plan = row[ "plan" ],
monthly_token_budget = 10 ** 9 , # 概算。実際の上限は予算ガードが強制します
request_id = str (uuid.uuid4()),
)
current_ctx.set(ctx)
return ctx
app = FastAPI()
@app.post ( "/v1/chat" )
async def chat (payload: dict , ctx: TenantContext = Depends(get_tenant_context)):
if not await limiter.try_acquire(ctx, cost = 1 ):
# 行儀の良いクライアントが正しくバックオフできるようヒントを返します
raise HTTPException( 429 , "tenant rate limit exceeded" , headers = { "Retry-After" : "1" })
try :
text = await gemini.generate(ctx, model = payload[ "model" ], prompt = payload[ "prompt" ])
except PermissionError as e:
raise HTTPException( 402 , str (e)) # 予算超過は 402 Payment Required で返す
return { "text" : text, "request_id" : ctx.request_id}
この接着コードで強調したい点が3つあります。
Depends(get_tenant_context) を入口に置くと、新しいエンドポイントを書く人が「テナントの特定を忘れる」ことが構造的にできなくなります。依存を受け取るか、明示的に認証をスキップすると宣言するか、どちらかを強制されるためです。
current_ctx の ContextVar は、ロギングフィルタが tenant_id を自動付与するための土台になります。
そして予算超過時に 429 ではなく 402 Payment Required を返すこと。これでクライアント側が「レート制限で絞られた」のか「予算が尽きた」のかをエラーコードだけで区別できます。ダッシュボードで両者を別系列として可視化したいときに効いてきます。
本物の Gemini クレジットを消費せずにマルチテナントを検証する
マルチテナントのコードには、競合状態でしか表面化しない厄介なバグが潜みます。私が助けられたテストパターンを2つ紹介します。
1つ目は、決定的なトークン数を返す偽の genai.Client です。これがあると、実際の課金を発生させずにレート制限と予算のアサーションを書けます。
# tests/fakes.py
from dataclasses import dataclass
from types import SimpleNamespace
@dataclass
class FakeUsage :
prompt_token_count: int
candidates_token_count: int
total_token_count: int
class FakeGeminiAio :
def __init__ (self, prompt_tokens = 100 , output_tokens = 200 ):
self .prompt_tokens = prompt_tokens
self .output_tokens = output_tokens
self .calls = 0
@ property
def models (self):
outer = self
class _Models :
async def generate_content (self, model, contents):
outer.calls += 1
return SimpleNamespace(
text = "ok" ,
usage_metadata = FakeUsage(
outer.prompt_tokens,
outer.output_tokens,
outer.prompt_tokens + outer.output_tokens,
),
)
return _Models()
class FakeGeminiClient :
def __init__ (self):
self .aio = FakeGeminiAio()
テストで FakeGeminiClient() を GeminiClientWithMetering に差し込めば、「100回呼んだ後のテナントAの記録コストは 100 × 1回あたり単価に等しい」ことを、Google に一切触れずに検証できます。
2つ目は、敵対的なワークロードを再生する pytest フィクスチャです。「良いテナント」が秒間1リクエストを送る一方で、「悪いテナント」が秒間100リクエストを撃ち込む状況を作ります。テストでは、良いテナントの成功率が100%を保ちつつ、悪いテナントだけが綺麗に絞られることをアサートします。コピーしてきた Lua スクリプトに微妙なオフバイワンがあること、あるいはトークンバケットの容量が想定バーストを吸収しきれないことに気づくのは、たいていこの種のテストです。
エッジケース: サブテナントとチーム
規模の大きい顧客は、いずれ自社テナントをチームやプロジェクトに細分化し、それぞれの利用額を個別に見たいと言ってきます。幸い、ここまでの設計は素直にスケールします。usage_records に team_id 列を1つ足せば、同じマテリアライズドビューが (tenant_id, team_id) 単位で集計してくれます。レート制限はテナント単位のまま据え置けます(守っているのは単一プロジェクトに乗った Gemini クオータだからです)。課金の可視性だけをより細かくします。
厄介になるのは、チームが親テナントとは別の 予算上限を持ちたいときです。2つの設計が考えられます。
階層型 : チームごとに予算を持たせ、親テナントのハードキャップがチーム上限を上書きします。概念は綺麗ですが、予算ガードで DB 読み取りが1回増えます
フラット+ハードキャップ : 予算を持つのはテナントだけで、チームはダッシュボードで自分の利用分を見られますが、独自の上限は強制できません。実装が容易で、多くの顧客はこれで満足します
まずはフラット型から始めてください。実際の顧客がエスカレーションしてきたときに、初めて階層型へ移ればよいのです。
PII を Gemini に届く前に処理する
ユーザー入力をそのまま Gemini に渡すマルチテナント SaaS は、いずれ必ず「責任を持って扱ってくれるはず」と顧客が信じた個人情報を含むリクエストを受け取ります。安全側の既定値は、どのプロンプトにも PII が含まれていると仮定して動く ことです。
私が運用で持ちこたえている軽量なやり方は、高信頼度の PII パターン(メールアドレス・電話番号・クレジットカード番号・地域別の公的 ID)を検出する高速な前段フィルタを走らせ、その場でマスキングするか、明確なエラーで拒否するかのどちらかを選ぶことです。マスキングのほうが顧客に優しい挙動です。プロンプトを転送する前に john@example.com を [email] に置換し、置換した「件数」だけを利用記録に残します(元の値は決して残しません)。
規制下のプラン(医療・金融)のテナントには、前段フィルタのモードをプラン定義の一部にします。enterprise プランは pii_mode = "block" を設定して PII を検出したプロンプトを拒否でき、pro プランは既定で pii_mode = "redact" とする、といった具合です。これを tenant_config に永続化しておくと、全エンドポイントで一貫した挙動を保てます。
見落としがちな PII リスクは、応答側 にもあります。テナントがチャットボットを作って LLM 出力をエンドユーザーへストリーミングする場合、LLM はときに、もっともらしい名前・メール・住所を幻覚で生成します。同じパターンを応答からも除去するオプトインの出力フィルタを用意してください。わずかなレイテンシと引き換えに、大きな法的安心が得られます。
request_id に独立した節を割く理由
この設計に登場するすべての ID のなかで、request_id こそがシステムをデバッグ可能にするかどうかを分けています。あらゆる層がこれをログに残し、あらゆるリトライがこれを再利用し、あらゆる保存行がこれを参照する必要があります。
私は request_id を、ビジネスロジックが走る前に API ゲートウェイ(あるいは前述の FastAPI ミドルウェア)で ULID として発行します。そこから先は次のように流れます。
Gemini 呼び出しラッパーが usage_records.request_id に書き込みます
構造化ロガーが contextvars 経由で全行に request_id=... を出力します
429/402 のエラー応答が本文にこれを含めるので、サポート窓口は ID を1つ貼るだけで関連ログ行をすべて辿れます
冪等性を意識したリトライが同じ request_id を再利用するので、usage_records の UNIQUE 制約が自動的に重複を排除します
request_id が可視性の背骨になると、障害後の解析時間が数時間から数分に縮みます。この ID で初めて1件の障害を綺麗に辿れたとき、コールスタックに通すために費やした手間は十分に報われます。
移行ロードマップ — どの順序で組み込むか
ゼロからこの構成を作るのはオーバーキルです。私が推奨する段階的な導入順序を示します。
第1週: TenantContext と usage_records テーブル : 認証ミドルウェアでコンテキストを発行し、Gemini 呼び出し前後でトークン数(思考トークンを含む)を記録します。これだけで「誰がいくら使っているか」が見えるようになります
第2週: テナント別レート制限(トークンバケット) : Redis を導入して、プランごとの RPS 制限を入れます。最低限 free/pro の2階層から始めて、必要に応じて細分化します
第3週: 予算ガード(月予算80%/100%でアラート/ブロック) : メール・Slack 通知も同時に組み込みます。余裕があれば予約方式(SpendLedger)もここで入れます
第4週: 監査ダッシュボードの最低5指標 : グラフを見ながら、しきい値や上限を実データに合わせて調整します
第5週以降: BYOK 対応・PII マスキング・サーキットブレーカーの拡充 : 顧客の声を聞きながら、必要なものから進めます
Gemini API のレート制限とクォータ運用ガイド の内容と組み合わせると、API レイヤーとアプリレイヤーの両側から守る構成になります。Cloud Run でサーバーレス実装する場合は Gemini API を Cloud Run で本番運用する構成 も参考になるはずです。
次の一歩
まずは usage_records テーブルを1枚作って、Gemini 呼び出しの前後で input_tokens と output_tokens を記録するところから始めてください。テナントが1社しかないうちでも、月末に「先月いくら使ったか」が SQL 一発で出せる状態を最初に作ると、その後のすべての設計判断が早くなります。