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

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

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 のブラックボックス部分を調査する場合、まず「どこまで自分のコードが正常に動いているか」の境界を明確にすることが重要

 

AI時代の人材育成パラドックス:IT人材育成

AIの時代にもプログラマの教育は必要だと思うというスタンスで書く。

 

 AIがコーディングすれば人がいらなくなるという雰囲気がある。この数年のAI進化をみると、そうはならんとは言い切れない。短期的な人件費削減と効率化の観点から見れば、一定の説得力があるように見える。

 しかし、その短絡的な空気感を信じてIT人材、特に若手に対する育成投資を停止したり縮小したりすることは、大きなリスクがある。

 AIは人の知性を拡張する強力なツールであるものの、システム全体を見通すアーキテクチャ設計能力、ビジネス上の複雑な要求を解釈し、倫理的かつ技術的にガードレールを設定する能力など、上流工程や非技術的な側面に求められる高度な能力を代替することはできない 。

 

■ AIで人が不要になる?

 コーディングに人が必要なくなっただけで、コンピューターサイエンスなどの知識を持った人が重要であることに変わりない。人の役割が変わるとしても、育成しなくて良いわけではない。

 重要となるのは、AIが生成したコードの品質を評価し、複雑なシステムアーキテクチャに統合する能力、そして技術的な解決策をビジネス戦略、データ、感情、論理といった異なる要素と結びつけ、新たな価値を創造する役割である。

 この役割の変換の構造的な問題は、高付加価値な知性を習得するためには、実務経験(OJT)を通じての反復的な学習や、文脈理解、非定型的な問題解決の経験が不可欠であるという点にある。AIによるコーディングの自動化は、この高付加価値な知性を獲得するための初期訓練の機会、つまり「泥臭い」現場でのOJTを奪うというパラドックスを生み出している。

 

 もしこの育成機会の空洞化が進むならば、企業は短期的な効率を得る代わりに、将来的に複雑な問題に対応できる、あるいはAIを戦略的に活用できる高度人材の供給を途絶えさせることになる。この空白期間は、かつての特定世代における雇用機会の不足が引き起こした人材の断絶と同様に、日本のデジタル競争力を決定的に損なうリスクを抱えている。

 

 すごくできるエンジニアや要件定義ができる人だけが生き残る。それもそうかもしれない。しかし、根拠を求められると薄い「勘」としか言いようがないが、エンジニア、というかコンピューターに指示をだして仕事をする人はこれからもずっと重要で必要だと感じている。

 

■ 若手の育成機会がすごく減ってる件

AIによる「経験の価値」の破壊

 若手の育成機会は、AIの登場で大きく減っている。研修や学校教育で補えないOJTレベルの仕事が全くなくなってる。

 先輩やリーダーが無理やり仕事を作って、若手の育成のために分け与える。それはこれまでも同じだった。しかし、無理やり作ってもらった成長機会を、AIを使ってこなすもんだから成長機会になってない。

 これまでのプログラマ育成でのOJT(On-the-Job Training)の価値は、単にタスクをこなすことではなく、非効率な作業や業務を通じて技術的負債を理解し、失敗から文脈と暗黙知を学ぶことにもあった。

 若手は、初期段階で非効率な手法を通じて泥臭いコーディングやデバッグを経験することで、なぜ特定の設計が採用されたのか、なぜこのコードが複雑なのかといった「技術の裏側にある文脈」を把握することができた。AIは、この「非効率だが価値ある学習プロセス」を代替してしまう。AIは若手の持つツールとして利用され、すぐに結果だけを生み出してしまうため、若手は技術的負債や非機能要件の裏にある顧客の「感情」といった、真に難しいが成長に必須な要素に触れることなく、目の前のタスクを完了してしまう。

 AIは短期的なタスク完了能力を高めるが、長期的にはシステム全体を見る能力、非定型な問題解決能力をつけられなくしている。

 

採用側の投資意欲低下と構造変化

 若手育成のためのOJTはもともと短期的には人件費コストであり、短期的な効率化を求める企業にとって、負担と見なされやすい。AIの導入は、この短期的な効率化圧力をさらに強める。その結果、企業(特にコストに敏感な中堅・中小企業)は、若手育成のための「非効率なコスト」(先輩社員が時間をかけて指導するコスト)を削減する方向に強く動くことになる。

 これは、育成機会の二極化を生み出す。育成コストを戦略的に負担できる「よほどの大手企業」など、ごく一部の組織のみが、AI時代に求められる複雑な文脈理解や戦略的思考を教えることができる高度なメンタリング環境を提供し続けることになる。

 一方で、それ以外の企業は、短期的なタスクをこなすAI依存型のエンジニアしか育成できなくなり、産業構造全体のデジタル成熟度に格差が生じ、日本経済の二層化が加速する。

 

 さらには、学生や若手も米国のGAFAMなどからの大量のリストラなどのニュースに触れて、エンジニアになりたくないと、そもそも人気が急落してるところもある。(ある人曰く、GAFAMなどは業界に変化があったときにいつもリストラしてるだけで今回が特別ではないという)

 

■ どうやって若手をコンピューターサイエンティストやITエンジニアや、エンジニアとして育てられるか

上述のように、プロの現場でのOJTが構造的に空洞化しているため、IT人材育成における教育機関(大学、専門学校、企業内研修)の役割を根本的に再定義する必要がある。

従来の教育機関が基礎知識の習得に重点を置いていたのに対し、今後は「プロの現場で経験すべき初期段階の失敗と反復、そして文脈理解」を安全かつ効率的にシミュレートする機能を担うべきである。

これは、皮肉にも、育成機会を奪ったAIそのものを、基礎能力の急速な習得と、高度スキルの実践的訓練のためのツールとして活用するというアプローチが不可欠だ。

 

アダプティブ・ラーニングによる基礎能力の「一足飛び」育成


 AI技術を活用したアダプティブ・ラーニング(個別最適化学習)は、基礎教育における効果的な解決策となる。アダプティブ・ラーニングは、学習者の習熟度や理解度に応じて、最適なコンテンツや学習パスを柔軟に調整する。これにより、若手は定型的なコーディングスキルや基礎知識の習得時間を劇的に短縮できる。

 

AIアジャスティブなシミュレーション環境の構築

 基礎能力をアダプティブ・ラーニングで加速させた後、若手育成戦略の核心となるのは、AIを組み込んだ高度な実践シミュレーション環境の構築である。この戦略は二段階で実行される。

フェーズ1:基礎力加速 AIが個別最適化された指導を通じて、定型的なコーディングや基礎知識の習得時間を短縮する。これにより、指導者側のリソースを基礎的な説明から解放する。

フェーズ2:文脈理解の経験 教育機関や企業内研修が、AIを活用して以下のようなプロの現場の困難な状況を仮想的に生成する。

 

技術的負債のシミュレーション: AIが意図的に曖昧な仕様や技術的負債を抱えた仮想プロジェクトコードを生成する。

 

要件定義の不確実性の訓練: 仮想顧客(AIエージェント)が、矛盾した、あるいは潜在的なニーズしか表現しない曖昧な要件を提示し、学生に複雑な交渉・合意形成を強いる。

 

安全な失敗の経験: 学生は、現実の現場では許されないような「大きな失敗」を安全に経験し、その失敗から技術的、文脈的な教訓を引き出す。

 

 この環境では、AIは「コーディングの代行者」ではなく、「評価者」「メンター」「文脈の提供者」として機能する。 アダプティブ・ラーニングの導入は、若手育成の「効率化」ではなく「高付加価値化」を目的とする。

 

令和の「ゲームばっかりしてないで本でも読みなさい」

僕らが子供の頃は、「テレビばかり見ないで『本』でも読みなさい」と言われた。

 

僕が子供に言ってるのは「子供達よ『Youtube』ばっかり見てないで『テレビ』でもみなさい」だ。

 

語彙力アップには、YouTubeよりテレビだ。お気に入りの人ばかりの動画を見続けるYoutubeでは語彙力がそだってない。

 

そんなことを言う未来が来るとは思っていなかった。

 

...いや、少し思っていた。

 

うちらの親世代は「『本』ばっかり読んでないで」勉強しなさい or 働きなさい。と言われてきたらしい。

僕らは、「テレビばっかり見てないで、本でも読みなさい」だった。

 

徐々に推奨メディアが変わってる。時代の変化を体感してる。

 

ソフトウェア開発者の自分としては、子供にいうことに次がある。」

「『Youtube』ばっかり見てないで、『ゲーム』でもしなさい」だ。

 

コンピューターリテラシーの基礎教育としてゲームが重要だ。

 

例えば、僕と僕の息子(小2)にとって、フラグという概念を体験的に学んだのはドラクエ2のサマルトリアの王子である。

どこへいっても居ないサマルトリアの王子は、1サマルトリア王、2泉、3ローレシア王のそれぞれの話を聞かないとリリザの街に現れない。

幼少期の僕は、この時点でレベル15になって途方にくれてた。


はなしがそれた。

 

多分この先「メタバースばっかりやってないでゲームでもしなさい」とか

「AIとばっかりはなしてないで、生身の人と話しなさい」とか

「空間コンピューティングばっかりやってないで、PCを触りなさい」とかが出てくるのだろう。

 

 

AframeのマーカーARでa-cameraやa-marker-cameraを使うとマーカーと物体位置がズレる

なぜかなと思って、ずっと別の方法で誤魔化していたが解決したので記。

現象

ダメなパターンは以下、a-cameraを使っている。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
    <script type="text/javascript" src="https://aframe.io/releases/1.2.0/aframe.min.js "></script>
    <script type="text/javascript" src="https://raw.githack.com/AR-js-org/AR.js/master/aframe/build/aframe-ar-nft.js" ></script>
  </head>
  <body style="margin: 0; overflow: hidden; height: 100%">
    <a-scene
      embedded
      arjs="trackingMethod: best; sourceType: webcam; detectionMode:mono_and_matrix; debugUIEnabled: false;"
    >
      <a-camera></a-camera>
      <a-marker type="barcode" value="1" >
        <a-box position="0 0.5 0" wireframe="true" color="black"></a-box>
      </a-marker>
    </a-scene>
  </body>
</html> 

実行するとこんな感じ

解決

変えたところだけ。 これを

<a-camera></a-camera>

こう変える

 <a-entity camera></a-entity>

ピッタリ来た。

原因

a-cameraはVRようにY=1.6になってる。

<a-entity camera>でプレーンなカメラを作ることで解消するということ

わかってみれば簡単だが、長らくわからなくてドキュメントもGPTもあてにならなかった。

a-marker-cameraも同様にYを持ってるので厄介。 すっきりしたのでよかった・。

astro+Restのサービスを作る時にCORSにならない方法

Astroからaxiosやfetchやらで、RestAPIを呼ぼうとするとCORSの制限に引っかかる。

ローカルだとClientとServerをそれぞれポートを分けて立てたりするから。

仮に以下のようにするとする

Astro => http://localhost:3000/

Server => http://localhost:8080/api/

Astro側のpackage.json"proxy": "http://localhost:8080/と、サーバーのホストを知らせてやれば良い。

{

 "name": "sampleservice",
 "type": "module",
 "proxy": "http://localhost:8080/,
 "version": "0.0.1",
 ・・・
}

サーバーサイドにも

import cors from "cors";

// 中略


// CORSをexpressに設定
const origin ="http://localhost:3000";
const app = express();
app.use(
  cors({
    origin: origin,
    credentials: true,
    preflightContinue: true,
  })
);

// 中略

const server = app.listen(config.get("server_port"), () => {
  console.log(config);
  console.log("listen to " + config.get("server_port"));
});