平均TTFTが520msと出ているダッシュボードを眺めながら、サポートには「たまに固まる」という報告が届き続ける — この食い違いに半年ほど悩まされました。個人開発のチャット機能で Gemini API を本番に載せてしばらく経った頃のことです。平均は嘘をつきませんが、平均は「全員が体験している速さ」を表してはいません。実際にユーザーが文句を言うのは、100回に数回だけ訪れる遅い応答です。
この記事は「Gemini を速くする一般論」ではありません。平均はもう十分速い、けれど末尾(テール)の遅さがユーザー体験を壊しているという、一段階先の状況に向けた運用メモです。p95/p99 という指標を軸に、計測・ルーティング・タイムアウト・リトライ・キャッシュ会計をどう組み直したかを、実際に効いた順に共有します。
なぜ平均TTFTを見ても問題が見えないのか
レイテンシの分布は正規分布ではなく、右に長い裾を引きます。多くのリクエストは速く返り、ごく一部が極端に遅い。この形のとき、平均値は「速い側の塊」に引っ張られて低く出ます。つまり平均が520msでも、p99(上位1%の遅さ)が4,000msということは普通に起こります。
体感を決めるのは平均ではなく、この裾の厚さです。チャットUIでは、ひとりのユーザーが1セッションで10〜20回リクエストを投げます。1リクエストあたりp99が1%なら、20回のうち少なくとも1回が「固まる」確率は約18%です。ほぼ5人に1人が、1セッション中に一度はもっさりを体験する計算になります。平均だけ見ていると、この体感を永遠に取りこぼします。
まず必要なのは、平均ではなく分位点(パーセンタイル)で記録するテレメトリです。1リクエストごとに数値を吐き、後からヒストグラムに畳む形にしておきます。
# pip install google-genai
import time, json, math
from google import genai
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
def timed_stream(model: str, prompt: str, request_id: str):
"""1リクエストのレイテンシ内訳を構造化ログとして1行吐く。
後段でp50/p95/p99に畳むことを前提に、生の数値だけを残す。"""
t0 = time.perf_counter()
t_first = None
out_tokens = 0
status = "ok"
try:
stream = client.models.generate_content_stream(model=model, contents=prompt)
for chunk in stream:
if t_first is None:
t_first = time.perf_counter()
if chunk.text:
out_tokens += len(chunk.text)
except Exception as e:
status = type(e).__name__
t_end = time.perf_counter()
rec = {
"request_id": request_id,
"model": model,
"ttft_ms": round((t_first - t0) * 1000) if t_first else None,
"e2e_ms": round((t_end - t0) * 1000),
"out_chars": out_tokens,
"status": status,
}
print(json.dumps(rec, ensure_ascii=False)) # 構造化ログ基盤へ
return recポイントは、アプリ内で平均を計算しないことです。平均を先に出してしまうと、後から「p95だけ見たい」と思っても元の分布が戻ってきません。生の ttft_ms を残し、集計はログ基盤(BigQuery でも、手元の Python でも)側で分位点として行います。
def percentiles(values, ps=(50, 95, 99)):
"""ソート済み配列から分位点を線形補間で求める。
依存を増やさず、ログを流し込んだ直後の点検に使う。"""
xs = sorted(v for v in values if v is not None)
if not xs:
return {}
out = {}
for p in ps:
k = (len(xs) - 1) * (p / 100)
lo, hi = math.floor(k), math.ceil(k)
out[f"p{p}"] = round(xs[lo] + (xs[hi] - xs[lo]) * (k - lo))
return out
# 例: ttfts = [行ログから集めた ttft_ms のリスト]
# print(percentiles(ttfts)) -> {'p50': 480, 'p95': 1700, 'p99': 4200}この p50 と p99 の比(テール比)が私の最重要メトリクスです。p99/p50 が3を超えると、平均をいくら下げても体感は改善しません。裾を直接叩く必要があります。
テール時間予算を起点に設計を逆算する
裾を叩くうえで一番効いたのは、テクニックの足し算ではなく、1つの数字を先に決めることでした。それが「テール時間予算」です。
具体的には「このリクエストは何msまでに最初のトークンを返せなければ、待たせるより打ち切って手を打つべきか」を決めます。私のチャットUIでは、TTFTの予算を1,200msに置きました。p50が480msなので普段は余裕がありますが、この1,200msがすべての設計判断の起点になります。
予算が決まると、各レイヤーの上限が自動的に決まります。
| レイヤー | 予算配分 | 超過時の打ち手 |
|---|---|---|
| クライアント→エッジ | ~150ms | リージョン同居・接続再利用 |
| 入力処理(TTFT) | ~900ms | キャッシュ・Thinking予算0・モデル格下げ |
| 打ち切り判定の余白 | ~150ms | タイムアウト発火 → フォールバック |
重要なのは、予算を超えたときに「ただ待つ」のではなく明示的に打ち切ることです。Gemini クライアントの呼び出しを asyncio.wait_for で包み、TTFT予算を超えたら速い構成へ切り替えます。
import asyncio
from google import genai
aclient = genai.Client(api_key="YOUR_GEMINI_API_KEY").aio
async def first_token_within(model, prompt, budget_s):
"""budget_s 以内に最初のトークンが来たらそのストリームを返す。
来なければ TimeoutError を投げ、呼び出し側でフォールバックさせる。"""
stream = await aclient.models.generate_content_stream(model=model, contents=prompt)
agen = stream.__aiter__()
first = await asyncio.wait_for(agen.__anext__(), timeout=budget_s)
return first, agen
async def answer(prompt):
try:
first, rest = await first_token_within("gemini-2.5-flash", prompt, 1.2)
except (asyncio.TimeoutError, StopAsyncIteration):
# 予算超過: 速い構成に逃がす(Thinking無効 + 軽量モデル)
first, rest = await first_token_within("gemini-2.5-flash-lite", prompt, 2.0)
yield first.text
async for chunk in rest:
if chunk.text:
yield chunk.textこの「予算を超えたら格下げして再挑戦」というパターンは、平均を多少犠牲にしてでも p99 を劇的に縮めます。私の環境では、フォールバックを入れる前後で p99 TTFT が4,200msから1,900msまで下がりました。フォールバックが発火するのは全体の2%程度なので、平均はほとんど動きません。裾だけを選んで叩けていることが数字で確認できます。