Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Gemini の思考トークンが出力上限を食い応答が空になる

公開 読了時間 約5分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Gemini の思考トークンは maxOutputTokens の枠を消費するため、明示的に 0 へ絞らない限り、構造化出力の最初のトークンが生成される前に上限へ達し、応答が空のまま終わることがある。

結論

Gemini の思考(thinking)トークンは maxOutputTokens の枠を消費します。 明示的に思考を絞らない限り、構造化出力の最初のトークンが生成される前に思考だけで上限に達し、エラーも警告も無いまま応答が空で終わることがあります。

会話 AI の画面では、これが「考えています」の表示が消えないまま止まる本番ハングとして現れました。修正は1箇所ではなく、クライアントの終端検知・サーバのストール中断・thinking budget の明示指定という三層です。

症状

  • 会話 AI に送信すると「考えています」の表示が出る
  • 一部のケースで、そのまま応答が返ってこない
  • エラー画面には遷移しない。ローディング表示のまま固まって見える
  • 再読み込みするまで、ユーザーは失敗したのか待てばよいのか判断できない

ログを見ると、Vertex 呼び出しは失敗していません。meta フレームは送られています。その先の応答本文だけが来ないという形でした。

原因

原因は重なった3つの要因でした。

1. 思考トークンが出力枠を食う

streamObject に渡す maxOutputTokens は、Gemini 2.5 では思考トークンと出力トークンの合算です。思考が長引くと、構造化出力(replyschedules などのフィールド)が始まる前に枠を使い切ります。呼び出し自体は正常に完了として扱われるため、モデル層は何も異常を報告しません。「思考中に力尽きた」状態と「正常に何も生成しなかった」状態が、呼び出し側からは区別できません。

2. サーバの接続が黙って閉じる

Cloud Run の既定リクエストタイムアウトは300秒です。モデルが無応答のまま接続だけを開けておくと、meta フレームだけ送った状態で接続がタイムアウトまで固定されます。能動的に切る仕組みが無いと、サーバ側は「まだ生成中」として何もしません。

3. クライアントが終端を待ち続ける

クライアントの読み取りループは、doneerror のフレームを受け取って初めて statusstreaming から進めます。サーバ側の接続がタイムアウトやプロキシ切断で終端フレームを送らないまま閉じると、クライアントはそれを知る手段が無く、statusstreaming に残り続けます。「考えています」が消えないのは、UI がストリームの異常終了を検知していないからです。

直す

3つの要因それぞれに対応する層を作りました。1つを直しても残り2つが単独で同じ症状を起こすため、3層すべてが要ります

層1: サーバのストール中断

partial が届くたびにタイマを張り直し、一定時間(60秒)進捗が無ければ AbortController で Vertex 呼び出し自体を能動的に中断します。初回トークン待ちと生成途中のストールを、同じ仕組みで監視します。

const stallController = new AbortController();
let stallTimer: ReturnType<typeof setTimeout> | undefined;
const armStall = () => {
  if (stallTimer) clearTimeout(stallTimer);
  stallTimer = setTimeout(() => stallController.abort(), stallMs);
};

中断されると partialStreamdone が reject し、stream_failed としてクライアントへ畳まれます。Cloud Run の300秒を待たず、十分手前で切ります。

層2: クライアントの終端検知

読み取りループが終わった時点で status がまだ streaming なら、再試行可能な失敗として確定させます。

export function finalizeUnterminatedTurn(state: ChatState): ChatState {
  if (state.status !== "streaming") {
    return state;
  }
  return { ...state, status: "error", error: { reason: "stream_failed" } };
}

doneerror を既に受け取っている場合はそのまま返す、つまり正常完了や既知の拒否を上書きしないのがポイントです。ここは「サーバが必ず終端フレームを送る」ことを前提にせず、送られてこなかった場合の確定処理を UI 側にも持たせるという設計です。

層3: thinking budget を明示する

前段2つは「起きたことにどう対処するか」でした。3つ目は根本原因の緩和です。GEMINI_THINKING_BUDGET=0 を Terraform 経由で環境変数に配線し、思考そのものを無効化しました。

0   … 思考を無効化
正の値 … 思考トークンの上限
未設定 … SDK 既定の動的割り当て

構造化下書きは最初のトークンが速く出るようになり、maxOutputTokens を思考が食い潰す事象そのものが起きなくなります。

再発防止

3層に分けたのは、どれか1つでは残り2つの経路で同じ症状が再現するとわかっていたためです。

  • thinking budget を0にするだけでは、別の理由でモデルが無応答になったときにサーバ側が開けたままになる
  • サーバのストール中断だけでは、中断そのものが終端フレームを送り損ねればクライアントは気づけない
  • クライアントの終端検知だけでは、Vertex 呼び出しが延々と生きたままサーバのリソースを保持し続ける

「考えています」が固まって見えるという1つの症状の裏に、モデル層・サーバ層・クライアント層それぞれで独立に壊れうる経路があったため、層を1つに絞らず、症状が発生しうる箇所すべてに確定処理を置く形にしています。

よくある質問

Q1なぜエラーも出ずに応答が空になるのですか?

モデル呼び出し自体は成功して終わるためです。Gemini 2.5 は出力の前に内部で思考トークンを生成でき、この思考トークンも maxOutputTokens の枠を消費します。思考だけで上限に達すると、構造化出力の最初のトークンが出る前に生成が打ち切られ、呼び出しは正常終了・出力は空という形になります。例外も reject も発生しません。

Q2SSE が閉じたのに「考えています」の表示が消えないのはなぜですか?

クライアントは done か error のフレームを受け取って初めて status を streaming から抜けさせます。サーバ側の接続が途中で切れる(Cloud Run のリクエストタイムアウトやプロキシ切断)と、この終端フレームが届かないまま接続だけが閉じ、status が streaming に残ります。読み取りループの終了後に status がまだ streaming なら再試行可能な失敗へ畳む処理を別に持たないと、UI は永久に固まったままになります。

Q3サーバ側のタイムアウトだけで十分ではないのですか?

Cloud Run の既定リクエストタイムアウトは 300 秒あり、接続を meta フレームだけ送った状態で開けたまま待たせるには長すぎます。partial の到着ごとにタイマを張り直し、一定時間(60秒)進捗が無ければ能動的に abort する仕組みが別に要ります。初回トークン待ちと生成途中のストールの両方を、同じタイマで監視できます。

Q4thinking budget を 0 にすると応答の質は落ちませんか?

その懸念は残されたコメントにも書かれています。実トラフィックでの質を見ながら、必要なら 256 のような小さい正の値へ戻す前提で、まずは無応答を防ぐために 0 で無効化しています。0 は思考を無効化、正の値は思考トークンの上限、未設定なら SDK 既定の動的割り当てに戻る三値です。