ローカルの開発サーバーでは、生成された文字が1文字ずつ気持ちよく流れていました。ところが Cloudflare の後ろにデプロイした途端、ユーザーには「数秒固まってから全文が一気に出る」ように見える。エラーは一切出ていません。ログを見ても 200 が返っている。けれど体験としては、ストリーミングを実装した意味が完全に消えていました。
ストリーミングが本番で壊れるとき、その壊れ方はたいてい「沈黙」です。例外も 5xx も出ないまま、最初のトークンが届くまでの体感だけが悪化する。だからこそ最初にやるべきは設定をいじることではなく、どこで詰まっているかを数字で見ることです。この記事は、Antigravity 経由で Gemini のストリーミングを配信する構成を本番に載せたときに私が踏んだ詰まりと、それを計測してから一つずつ潰していった運用メモです。
最初に疑うべきは「モデル」ではなく「経路」
ストリーミングの体感が悪いとき、人はまずモデルが遅いと考えます。けれど多くの場合、モデルはすでに最初のチャンクを 0.5 秒前後で吐き出していて、それがあなたのブラウザに届くまでの経路で溜め込まれているだけです。
切り分けの軸は2つだけ持てば十分です。
ひとつは TTFB(最初のチャンクがクライアントに届くまでの時間) 。もうひとつは チャンク間ギャップ(連続するチャンクの到着間隔) です。サーバー側で「モデルから受け取った時刻」、クライアント側で「画面に届いた時刻」を両方記録すると、詰まりがモデル側か経路側かが一目で分かります。
# server: モデルから各チャンクを受け取った時刻を計測する
from google import genai
import time
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
def measure_stream (prompt: str ):
t0 = time.monotonic()
first = None
last = t0
gaps = []
stream = client.models.generate_content_stream(
model = "gemini-3.5-flash" ,
contents = prompt,
)
for chunk in stream:
now = time.monotonic()
if first is None :
first = now - t0 # TTFB(モデル→サーバー)
gaps.append(now - last) # チャンク間ギャップ
last = now
gaps.sort()
p95 = gaps[ int ( len (gaps) * 0.95 )] if gaps else 0
print ( f "TTFB(model->server)= { first * 1000 :.0f } ms gap_p95= { p95 * 1000 :.0f } ms chunks= { len (gaps) } " )
サーバー側で測った TTFB が 400〜700ms なのに、ブラウザの体感 TTFB が 4〜6 秒なら、犯人は確実に経路です。実測では、この経路バッファだけで体感 TTFB が 8〜10 倍に膨らんでいました。モデルは仕事をしています。溜め込んでいるのは間にいる誰かです。
経路のどこでチャンクが溜まるか
「間にいる誰か」は1人ではありません。リクエストが通る各ホップが、それぞれ独立にバッファを持ち得ます。私が実際に遭遇した順に、犯人になりやすい箇所を整理します。
溜め込む箇所 症状 止め方
レスポンス圧縮(gzip/brotli) 圧縮バッファが一定量たまるまで送出しない SSE 応答だけ圧縮を無効化する
Nginx / リバースプロキシ proxy_buffering on が全レスポンスを溜める当該ロケーションで proxy_buffering off
CDN(Cloudflare 等) エッジが本文をバッファし一括転送 Cache-Control: no-transform と非圧縮、チャンク転送維持
WSGI サーバー(gunicorn 同期ワーカー) ジェネレーターを最後まで回してから返す ASGI(uvicorn)+ 非同期ジェネレーターにする
アプリの書き込み flush 不足でOSバッファに残るチャンクごとに明示フラッシュ
重要なのは、これらは AND 条件 だということです。Nginx のバッファだけ切っても、その手前で圧縮が溜めていれば体感は変わりません。だから「1つ直してダメだった」で諦めず、TTFB の数字が改善するまで上から順に潰します。本番運用では、私は次の順で切り分けて回避策を当てています。
SSE 応答に限って圧縮を外し、再計測する
リバースプロキシのバッファリングを当該ロケーションだけ無効化する
CDN を非変換・チャンク転送維持にし、エッジが溜めていないか確認する
この3手で TTFB の数字が動かなければ、原因はアプリ自身のフラッシュ不足か WSGI 同期ワーカーに絞り込めます。順番を守るのは、下の層を直す前に上の層が溜めていると、対処の効果が数字に表れず判断を誤るからです。
サーバー側 — 詰まらせない SSE の最小実装
FastAPI(ASGI)で、フラッシュとアンチバッファリングのヘッダーを正しく付けた形がこれです。media_type を text/event-stream にするだけでは本番では足りません。X-Accel-Buffering: no と Cache-Control: no-cache, no-transform まで添えて初めて、各ホップに「これは溜めるな」と伝わります。
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from google import genai
import anyio
app = FastAPI()
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
async def sse_stream (prompt: str , request: Request):
# コメント行(:)を1本流して経路をすぐ開かせる = TTFBを下げる
yield ": open \n\n "
loop_stream = await anyio.to_thread.run_sync(
lambda : client.models.generate_content_stream(
model = "gemini-3.5-flash" , contents = prompt
)
)
last_beat = anyio.current_time()
for chunk in loop_stream:
# クライアントが離脱したら即やめる(無駄な生成と課金を止める)
if await request.is_disconnected():
break
text = getattr (chunk, "text" , "" ) or ""
if text:
yield f "data: { text }\n\n "
# 15秒無音ならハートビートを送ってアイドル切断を防ぐ
now = anyio.current_time()
if now - last_beat > 15 :
yield ": ping \n\n "
last_beat = now
yield "event: done \n data: [DONE] \n\n "
@app.get ( "/stream" )
async def stream (prompt: str , request: Request):
return StreamingResponse(
sse_stream(prompt, request),
media_type = "text/event-stream" ,
headers = {
"Cache-Control" : "no-cache, no-transform" ,
"X-Accel-Buffering" : "no" ,
"Connection" : "keep-alive" ,
},
)
3点だけ補足します。先頭の : open というコメント行は、本文が来る前に経路を開かせて体感 TTFB を下げるための小技です。request.is_disconnected() は、ユーザーがタブを閉じた瞬間に生成を止めるために要ります — これを入れないと、誰も読んでいない応答をモデルが最後まで生成し続け、トークン課金だけが積み上がります。そしてハートビートのコメント行は、長い思考時間でモデルが無音になったときに、手前のプロキシが「死んだ接続」と誤認して切るのを防ぎます。
アイドル切断という、もうひとつの沈黙
バッファリングを全部潰しても、まだ「途中で固まる」ことがあります。今度の犯人はアイドルタイムアウトです。プロキシやロードバランサーは、一定時間データが流れない接続を勝手に閉じます。モデルが長い推論に入って数十秒チャンクを出さないと、その沈黙が切断のトリガーになります。
厄介なのは、切られた側にエラーが伝わらないことです。サーバーのジェネレーターは次の yield で初めて切断に気づくか、あるいは気づかないまま回り続けます。クライアントの画面は、最後に届いた文字のまま静止します。
対策は前掲のハートビートです。SSE ではコロンで始まる行がコメントとして無視されるので、: ping\n\n を定期的に流せば、中身を汚さずに接続を生かし続けられます。私は本番のアイドルタイムアウトを実測してから、その半分の間隔でハートビートを打つようにしています。タイムアウトが 30 秒なら 15 秒ごと、という具合です。経験的にはこのマージンで切断はほぼ消えました。
再接続が静かに二重生成を起こす
ここが一番ハマった落とし穴です。ブラウザの EventSource は接続が切れると 自動で再接続します 。これは便利な仕様に見えて、生成系 API と組み合わせると危険です。再接続のたびにサーバーの /stream が再度呼ばれ、同じプロンプトでもう一度生成が走る。ユーザーには文章が巻き戻って二重に出て見え、こちらにはトークン課金が二重に来ます。
EventSource は再接続を止められないので、二重生成はサーバー側で冪等に弾くのが確実です。リクエストに冪等キーを持たせ、同じキーの生成が進行中・完了済みなら新規生成をしないようにします。
import hashlib, json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
# 本番は Redis 等。ここでは概念を示すためのインメモリ
_inflight: dict[ str , str ] = {}
def idem_key (prompt: str , cid: str ) -> str :
return hashlib.sha256( f " { cid } : { prompt } " .encode()).hexdigest()[: 16 ]
@app.get ( "/stream" )
async def stream (prompt: str , cid: str , request: Request):
key = idem_key(prompt, cid)
if _inflight.get(key) == "running" :
# 再接続中の二重呼び出し。新規生成せず終了を伝える
async def already ():
yield "event: dup \n data: in-progress \n\n "
return StreamingResponse(already(), media_type = "text/event-stream" )
_inflight[key] = "running"
async def guarded ():
try :
async for evt in sse_stream(prompt, request):
yield evt
finally :
_inflight.pop(key, None )
return StreamingResponse(
guarded(),
media_type = "text/event-stream" ,
headers = { "Cache-Control" : "no-cache, no-transform" , "X-Accel-Buffering" : "no" },
)
クライアント側では、cid(会話 + メッセージを一意にする ID)を URL に載せ、done を受け取ったら 自分で close() する のを忘れないでください。これがないと、正常終了後にも EventSource が再接続を試みて、上の冪等ガードを無駄に叩き続けます。
const cid = crypto. randomUUID ();
const es = new EventSource ( `/stream?cid=${ cid }&prompt=${ encodeURIComponent ( prompt ) }` );
es. onmessage = ( e ) => { output.textContent += e.data; };
es. addEventListener ( "done" , () => es. close ()); // ← 正常終了で必ず閉じる
es. onerror = () => es. close (); // ← 自動再接続に任せない
個人開発でアプリと Dolice の複数サイトを並行して回している私自身、この二重生成にしばらく気づけませんでした。エラーログには何も出ず、ただ月末のトークン消費だけが想定より膨らんでいて、原因をたどったら再接続でした。沈黙する不具合は、エラー監視ではなく「数が合わない」という違和感から見つかることが多いと、このとき改めて思いました。
計測を残して、再発を早期に拾う
一度直しても、CDN の設定変更やライブラリ更新で経路バッファはまた復活します。だから直して終わりにせず、TTFB とチャンク間ギャップの p95 を本番でも継続記録し、しきい値を超えたら気づける状態にしておきます。私の場合は「サーバー側 TTFB と クライアント側 TTFB の差」を一番のアラート指標にしています。この差が開いた瞬間が、経路のどこかがまた溜め始めた合図だからです。
次の一手としては、いま動いている SSE エンドポイントに先ほどの計測コードを差し込み、サーバー側 TTFB とブラウザ側 TTFB を1回だけ並べて記録してみてください。その2つの数字の差が、あなたの構成で「沈黙」がどこに潜んでいるかを最初の5分で教えてくれます。