5 月のはじめ、壁紙アプリの管理画面に組み込んだ Gemini API のストリーミング応答が、特定のリクエストだけ途中で切れる症状に出会いました。stream のループを最後まで回したつもりが、トークン途中で止まり、UI 側に「...」が出たまま終わってしまう状態です。ネットワークなのか、SDK なのか、サーバー側なのか、どこから疑うかで丸 1 日溶かしたので、原因切り分けの順序を整理して残しておきます。
廣川政樹と申します。アーティスト・個人開発者として 2014 年から壁紙アプリ(累計 5,000 万ダウンロード超)の運営をしながら、Gemini API は記事生成と内部管理画面の補助に使っています。これから書くのは、その内部管理画面で実際に踏んだ「ストリーミング途中切断」の話です。
まず確かめたい 3 つの事実
切り分けに入る前に、現象が「本当にストリーミング途中の切断」なのか確認しておきます。ここをサボると、別のバグ(例えば JSON parse 失敗)を「ストリーミング切断」と誤認します。次の 3 つを 5 分以内に確かめます。
- SDK のストリーミングイテレータで最後に受け取ったチャンクの
finish_reasonは何か - サーバーから戻ってきた累計トークン数は、UI に表示された文字数と整合しているか
- 同じプロンプトを連続 3 回投げて、症状が再現するか / 散発か
私の場合は finish_reason = "OTHER" で、累計トークンは UI 表示の倍ほどあって、症状が散発でした。この時点で「サーバー側は完了したと言っているが、クライアント側で受信が止まっている」種類の問題だと判定できました。
原因切り分けの順序
私が今後使うことになる切り分けの順序は次の通りです。安いところから順に潰します。
1. クライアント側のタイムアウト
最初にイベントループの設定を疑います。私の場合は aiohttp のセッションに timeout=ClientTimeout(total=30) を入れていて、長い応答の途中でタイムアウトしていました。ストリーミングでは total ではなく sock_read に分けるか、ストリーミング専用に長めの値を設定する必要があります。
import aiohttp
# ストリーミング用には total 設定を外し、読み取り間隔だけ制限
timeout = aiohttp.ClientTimeout(total=None, sock_read=60)
async with aiohttp.ClientSession(timeout=timeout) as s:
...これで散発症状の 6 割が消えました。
2. プロキシ・CDN のバッファリング
次に疑うのはネットワークの中間装置です。私は Cloudflare の Worker 経由でリクエストを通している経路があり、長時間アイドルが続くとコネクションが落ちるケースに踏みました。Gemini SDK のストリーミングはサーバー側で keepalive パケットを定期的に送ってくれますが、それが途切れる程度に出力が止まるプロンプト(例えば長い JSON 整形)では、中間 CDN がコネクションを切ります。
対処としては、長時間出力が想定されるリクエストではプロンプトに「段階的に出力する」指示を入れて、最低でも 5 秒に 1 回はトークンが流れるよう仕向けています。これでも完全には防げないので、3 番に進みます。
3. SDK 側の再接続戦略
ストリーミング応答が途中で切れた場合、Gemini SDK は基本的に再接続しません。再接続をするには、自分でラッパーを書く必要があります。
async def stream_with_retry(prompt: str, max_retry: int = 2):
accumulated = ""
for attempt in range(max_retry + 1):
try:
async for chunk in client.aio.models.generate_content_stream(
model="gemini-2.5-flash", contents=accumulated + prompt if accumulated else prompt,
):
accumulated += chunk.text
yield chunk.text
return
except Exception:
if attempt == max_retry:
raise
await asyncio.sleep(0.5)このラッパーは「途切れたところから続き」を再要求する形になっていて、UI 側は通常のストリーム同様にチャンクを受け取れます。私の管理画面ではこれを入れてから散発症状が体感ゼロになりました。
4. プロンプトの安全フィルタによる打ち切り
最後に確認するのは、安全フィルタによる打ち切りです。finish_reason が SAFETY や RECITATION の場合は別の対処が必要で、再接続では解決しません。私の場合は壁紙アプリのレビュー要約で稀に RECITATION が出るので、プロンプト側で引用形式の出力を避ける指示を入れて回避しています。
切り分けの記録テンプレート
私はこの種の障害が起きるたびに、次のような短い記録を残すようにしています。
- 発生日時 / 再現性(連続 N 回中 M 回)
finish_reason- 入出力のトークン数(実測)
- ネットワーク経由(直 / プロキシ / Worker など)
- 取った対処
これを 5 件ほど貯めると、自分の運用環境でいちばん効きやすい対処が見えてきます。私の場合は「sock_read の設定変更」が最も費用対効果が高く、SDK ラッパーは保険でした。
私が今後採用しない選択肢の整理
検討したものの今回採用しなかった対処を 2 つほど書いておきます。ひとつは「ストリーミングをやめてバッチ応答に戻す」やり方です。安全策としては有効ですが、管理画面の体感速度が大幅に落ちて運営側の負担が増えます。壁紙アプリの運営は私 1 人なので、操作レスポンスの体感はそのまま運営コストに直結します。
もうひとつは「max_tokens を半分に絞って 1 リクエストの長さを短くする」やり方です。これは効くケースもありますが、トークン制限の弊害でレスポンスが論理的に途中で終わってしまうリスクと表裏一体です。私の場合は管理画面で「途中で終わる長さ」は許容しがたく、結局リトライ層を入れる方を選びました。
切断が起きるたびに、私は自分の運用環境のどこにいちばんリスクが眠っているかを少しずつ知っていくような感覚があります。sock_read の調整は 5 分で済む一方、CDN 経路の問題に気付くまでは半日かかりました。落ち着いて切り分けの順序を回せば、複雑な障害でも 1 日以内に絞り込める印象です。
切断症状を「監視のシグナル」として使う
私が 5 月から組み込んだ習慣で、地味に効いているのは「切断症状を監視のシグナルとして可視化する」やり方です。具体的には、ストリーミング応答が途中で切れた回数を毎日カウントして、AdMob の収益レポートと並べて見るようにしました。
切断が増える日は、不思議と AdMob の eCPM も微妙に下がる傾向があり、これは中間ネットワーク(ISP やキャリア)の調子が悪い日と相関しているのではないか、と私は推測しています。確証はありませんが、切断が 1 日 30 回を超える日は AdMob のレポートも要チェック、というルールを内部で運用しています。
5,000 万ダウンロードの壁紙アプリ群を 1 人で見ていると、こうした「数値のクセ」を 1 つでも増やしておくことが、結果的に運営の安定につながると感じています。Gemini API のストリーミング切断は、当初は単なる UX 不具合に見えましたが、実は運用全体の健全性を測る指標として使えると気付いてからは、症状を恐れる必要がなくなりました。
次に確かめてみてほしいこと
今ストリーミング切断で詰まっているなら、最初に finish_reason を毎チャンクで print してみてください。たいていの問題は、finish_reason の値だけで原因領域が 3 つに絞られます。
ジョブの停止は、表に出ない収益ロスを少しずつ積み上げる種類のトラブルです。共に切り分けの引き出しを増やしていけたら嬉しいです。