レベルエンター山本大のブログ

面白いプログラミング教育を若い人たちに

BLOCKVROCKリファレンス目次はこちら

Gemini API の generateContent が特定リクエストだけ無限にハングする問題を追った記録

 
LLMも容易に解決できなかった手強いバグに出会って、昔みたいにエラーログなどでネット検索して調査しようとすると全然最新情報にヒットしない。世の中のエンジニアがみんなLLMで問題解決して記事を書かなくなったんだと実感する。
 
将来的にLLMがだんだんアホになる可能性もある。
なので、LLMを使ってデバッグしたら、LLMに結果をまとめてもらって記事を書くという運動をしていきたい。
 

ということで、この記事はAIを使ってデバッグした内容を、最後にAIにまとめてもらったものです。

はじめに

採点支援システムで Gemini API(@google/genai SDK)を使い、生徒の回答画像を含むプロンプトを generateContent に送って AI 採点を行っている。ある時期から、特定の回答データに対してのみ generateContent が応答を返さずハングし、最終的に fetch failed(undici の ECONNRESET)でタイムアウトする現象が繰り返し発生するようになった。

本記事はその原因調査と対処の記録である。

システム構成

  • Next.js + Inngest によるバッチ採点基盤
  • Gemini Files API で回答画像(PDF→PNG変換済み、1-2MB程度)をアップロードし、generateContent に画像URIとプロンプトテキストを渡す
  • 1試験あたり最大5並列で採点を実行(Inngest の concurrency.limit: 5
  • リトライは Promise.race による単体タイムアウト(120秒)+ 指数バックオフで最大5回

症状

  • 15件の回答のうち、特定の1件(id: 101465)だけが常にタイムアウトする
  • 他の14件は50-70秒で正常に完了する
  • タイムアウト後にリトライしても同じ回答が同じように失敗する
  • 回答データを削除して再作成すると即座に成功する
  • 回答画像のサイズや枚数は他と同程度(1枚、約2MB)で、データ上の異常はない

調査で行ったこと

1. 構造化ログの導入

Files API のアップロード、ポーリング、generateContent の各フェーズにトレースログを埋め込み、どのフェーズでハングしているかを特定した。

logGeminiInvestigationTrace({
phase: "generateContent",
sub: "attempt_start",
attempt,
maxAttempts,
label,
perCallTimeoutMs,
...traceCtx,
});

結果、ファイルアップロードは正常に完了しており、generateContent の呼び出し自体がハングしていることが確認できた。

2. DB(llm_token_logs)による時系列分析

採点リクエストごとにDBに started / end / error のログを記録しており、エラーが発生する回答の request_id とタイムスタンプを突き合わせた。

token_log_id | answer_id | status | elapsed
5196 | 101465 | end | 54s ← 2並列時は成功
5198 | 101465 | started | 564s+ ← 5並列時にハング
5199 | 101459 | end | 58s ← 同じバッチの他は成功
5200 | 101474 | end | 69s

このデータから、同一の回答でも並列度や実行タイミングによって成否が変わることがわかった。

3. 試行錯誤で得た手がかり

試行した対策と、その結果を時系列で記す。

対策 結果
リトライ回数を増やす 全リトライが同様にハング。効果なし
Files API のアップロード displayName を毎回ユニークにする 効果なし
Promise.all を Promise.allSettled に変更し、アップロード失敗時のファイルリークを修正 別の問題(残留ファイル)は解消したが、ハング自体は解消せず
回答データを削除して再作成 即座に成功。ただし一時的で、再び同じ回答でエラーが再発することがある
プロンプトテキストにユニークなソルト(HTMLコメント + UUID)を付与 効果あり。以前は常に失敗していた回答が成功するようになった

核心:Gemini の内部ルーティングとコンテンツベースのキャッシュ

ソルト付与で改善したことから、Gemini の内部でリクエストのコンテンツ(テキスト+画像)に基づくルーティングまたはキャッシュが行われていると推測した。

具体的には以下の挙動が観測された:

  1. 同一プロンプト + 同一画像の組み合わせで generateContent を呼ぶと、Gemini 内部で同一の処理パイプラインにルーティングされる
  2. そのパイプラインが何らかの理由でスタックすると、以降の同一コンテンツリクエストも全て同じスタック先に送られる
  3. テキスト部分を変えると別のパイプラインにルーティングされ、正常に処理される
  4. 回答データを削除・再作成すると成功するのは、画像の再アップロードにより Files API 上の URI が変わるため

この仮説は以下の事実と整合する:

  • displayName の変更は効果なし → Gemini はメタデータではなくコンテンツ本体でルーティングしている
  • テキストソルト付与は効果あり → テキストが変われば別ルートに乗る
  • データ再作成で治る → 画像URI(Files API 上の参照先)が変わるため

最終的な対処

プロンプトテキストの末尾に、毎回ユニークなHTMLコメントをソルトとして付与する。

const salt = "\n<!-- scoring-session: " + createUUID() + " -->";
const contents = [
createUserContent([
{ text: bodyText + salt },
...imageParts,
]),
];

ポイントは、このソルト生成をリトライの operation コールバック内で行うことである。

当初はソルトをリトライループの外で1回だけ生成していたが、これでは1回目のリクエストがスタックした場合、2回目以降のリトライも同一コンテンツとなり、同じスタックしたパイプラインに送られてしまう。

// リトライラッパーの operation 内で毎回新しいソルトを生成
const response = await withGeminiGenerateContentRetry(
() => {
const salt = "\n<!-- scoring-session: " + createUUID() + " -->";
const freshContents = [
createUserContent([{ text: bodyText + salt }, ...imageParts]),
];
return this.googleAi.models.generateContent({
model: modelName,
contents: freshContents,
config: generationConfig,
});
},
{ label: `generateContent:${modelName}`, maxAttempts: 5 },
);

こうすることで、仮に1回目がスタックしても、2回目は別のソルトで別のルーティングパスを通る。

 

所感

  • Gemini API(に限らず LLM API 全般)には、公式ドキュメントに記載されていない内部的なルーティングやキャッシュの挙動がある。同一コンテンツの繰り返しリクエストが同一の処理パスに固着する現象は、通常のHTTPリトライの前提を崩す
  • リトライでは「同じリクエストを再送する」のが一般的だが、LLM API の場合は「意味的に同等だがバイト列が異なるリクエスト」に変えることが有効な場合がある
  • ソルトの挿入位置は、LLM の出力品質に影響しない場所(HTMLコメント等)を選ぶ必要がある
  • 構造化ログと DB ログの突き合わせが、フェーズ特定に最も有効だった。外部 API のブラックボックス部分を調査する場合、まず「どこまで自分のコードが正常に動いているか」の境界を明確にすることが重要