はじめに
Gemini API で Google 検索グラウンディング(google_search ツール)を使いつつ、返答を responseSchema の JSON で受け取りたい。ごく自然な要求ですが、この 2 つを同じリクエストに入れると HTTP 400 で弾かれます。公式ドキュメントの Grounding with Google Search にも Structured output にも、この組み合わせが不可という記述は見当たりません(2026-08-06 時点で確認)。
そこで実際に API を叩き、併用の可否と、通らなかった場合の回避策 2 つを同じ条件で測りました。結果が次の表です。
対象読者
Gemini API で「最新の外部情報を取ってきて、そのまま構造化データとしてアプリに流し込みたい」実装を書いている方を想定しています。
| 実装パターン | HTTP | グラウンディング | JSON 構造の保証 | 中央値レイテンシ | 値の正確さ(n=5) |
|---|---|---|---|---|---|
1. google_search + responseSchema を同時指定 |
400 | — | — | — | — |
2. google_search + プロンプトで JSON を指示 |
200 | あり | なし(モデル任せ) | 4,312 ms | 4/5 正解 |
| 3. 2 パス(検索 → 別リクエストで schema 抽出) | 200 / 200 | 1 パス目のみ | あり | 5,468 ms(2 回合計) | 正解(書式は揺れる) |
いずれも gemini-2.5-flash に同一プロンプトを投げた実測値です。以降で 1 つずつ中身を見ていきます。
検証環境
- 実行環境: Linux(Node.js v22.22.2 / Python 3.11.15)
- エンドポイント:
https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent - 呼び出し: Python 標準ライブラリの
urllib.requestのみ(SDK 非依存の生 REST) - 対象モデル:
gemini-2.5-flash/gemini-3.5-flash/gemini-3.6-flash - 課金プラン: 無料枠(この点が後述の Gemini 3 系の挙動に効きます)
- 検証日: 2026-08-06 JST
質問は「Node.js の最新 LTS のメジャーバージョン番号と、そのリリース日を答えてください。」に固定しました。学習データだけでは古い答えが返り、検索すれば正解が取れる、という答え合わせのしやすいお題を選んでいます。
正解は Node.js 公式の Previous Releases にある v24(Krypton)で、初回リリースは 2025-05-06 です。
なぜ併用したいのか
まず「グラウンディングなしで構造化出力だけ」を 3 モデルで試しました。JSON は綺麗に返ってきます。ただし中身が揃いません。
| モデル | major_version |
release_date |
|---|---|---|
| gemini-3.6-flash | 22 | 2024-04-24 |
| gemini-3.5-flash | 20 | 2023-04-18 |
| gemini-2.5-flash | 20 | 2023-10-24 |
3 モデルとも 200 で返り、3 モデルとも正解(24 / 2025-05-06)から外れました。しかも外し方がバラバラです。スキーマは「形」しか守らせないので、値の鮮度はモデルの学習時点に丸投げになります。だからこそ検索グラウンディングと構造化出力を同時に効かせたくなるわけです。
同じモデルに google_search だけを付けて聞くと、こちらは正解が返ります。
Node.jsの最新LTS(Long Term Support)のメジャーバージョン番号は24です。
Node.js 24は2025年5月6日にリリースされ、2025年10月28日にLTSに昇格しました。
レスポンスの groundingMetadata.webSearchQueries には ["Node.js 最新 LTS メジャーバージョン リリース日", "Node.js LTS schedule"] が入っており、実際に検索が走ったことが確認できます。欲しいのは「この正確さ」と「さっきの JSON」の両立です。
パターン1: 公式に併用する
tools に google_search、generationConfig に responseMimeType: "application/json" と responseSchema を同時に入れます。
body = {
"contents": [{"role": "user", "parts": [{"text": PROMPT}]}],
"tools": [{"google_search": {}}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": {
"type": "object",
"properties": {
"major_version": {"type": "integer"},
"release_date": {"type": "string"},
},
"required": ["major_version", "release_date"],
},
},
}
返ってきたのは 400 で、メッセージは次の 1 行でした。
HTTP 400: Tool use with a response mime type: 'application/json' is unsupported
エラーが指しているのは responseSchema ではなく responseMimeType です。つまり「ツール使用と JSON MIME タイプの同居」が拒否されている形です。応答は 317 ms で返っており、リクエストの検証段階で落ちていることが分かります。トークンを消費してから失敗するのではないので、フェイルファストとしては素直な挙動です。
パターン2: プロンプトでJSONを指示する
responseMimeType を外し、代わりに本文で出力形式を指定します。ツール使用は残せるので、グラウンディングは効いたままです。
body = {
"contents": [{"role": "user", "parts": [{"text": PROMPT + JSON_INSTRUCTION}]}],
"tools": [{"google_search": {}}],
}
JSON_INSTRUCTION は「必ず次の JSON だけを出力してください(前後に説明やコードフェンスを付けない): {"major_version": <整数>, "release_date": ""}」としました。
5 回連続で実行した結果、5 回とも json.loads() がそのまま通りました。コードフェンスの混入もゼロで、フェンス剥がしの後処理は今回は不要でした。レイテンシは 3,540 / 4,164 / 4,312 / 4,520 / 5,505 ms(中央値 4,312 ms)です。
ただし値は 5 回中 4 回が正解で、1 回だけ {"major_version": 24, "release_date": "2026-08-03"} が返りました。この 2026-08-03 という日付は、Node.js の Previous Releases 表で v24 の「Last Updated」列に載っている値と一致します(同表を筆者が確認した 2026-08-06 時点)。ランダムに湧いた数字ではなく、同じ表の隣の列を拾った取り違え と見るのが自然です。
ここがこのパターンの弱点です。JSON として妥当かどうかはパースで判定できますが、「取ってきた値が意図したフィールドか」はパースでは分かりません。スキーマ検証を諦めた分、release_date に何が入っていても構造上は正しい JSON になってしまいます。
パターン3: 2パスに分ける
1 回目は google_search だけで自然文の答えを取り、2 回目でその文章を入力に responseSchema で抽出します。ツールとスキーマが同じリクエストに同居しないので 400 は起きません。
grounded, _, _ = post(MODEL, {"contents": [...], "tools": [{"google_search": {}}]})
extracted, _, _ = post(MODEL, {
"contents": [{"role": "user", "parts": [{"text": f"次の文章から情報を抽出してください。\n\n---\n{grounded_text}\n---"}]}],
"generationConfig": {"responseMimeType": "application/json", "responseSchema": SCHEMA},
})
両方 200 で通り、合計 5,468 ms でした。値も major_version: 24 で正解です。一方で release_date は "2025年5月6日" という和暦混じりの表記で返りました。スキーマ側で型を string としか指定していないため、書式の揺れは素通りします。ISO 形式に寄せたいなら、description で書式を明示するか pattern を足す必要があります。
コストは素直に 2 倍かかります。検索を伴う 1 パス目は課金対象の検索クエリを消費し、2 パス目は入力トークンとして 1 パス目の出力を丸ごと再投入するためです。
Gemini 3系では検索ツール自体が無料枠で弾かれた
想定外だったのが Gemini 3 系の挙動です。gemini-3.6-flash と gemini-3-flash-preview に google_search を付けて投げると、いずれも 429 が返りました。
HTTP 429: You exceeded your current quota, please check your plan and billing details.
(status: RESOURCE_EXHAUSTED、応答は 232〜333 ms)
一方、同じ gemini-3.6-flash に構造化出力だけを投げたリクエストは 200 で通っています。つまり「モデル全体が枯渇している」のではなく、検索グラウンディングを伴うリクエストだけが別枠で弾かれている と読めます。無料枠でグラウンディング込みの検証をするなら、2.5 系にモデルを落とすのが現実的でした。
なお公式ドキュメントには、Gemini 3 系のグラウンディングについて「モデルが実行を決めた検索クエリごとに課金される」と明記されています。無料枠でこの枠がゼロに近いのだとすれば、429 が即座に返るのは筋の通った挙動です。
どのパターンを選ぶか
実測を踏まえた住み分けは次のようになりました。
- 構造の厳密さが最優先(スキーマ違反をアプリに流したくない): パターン3。レイテンシとコストが 2 倍になるのを許容する
- レイテンシと実装の単純さが優先(1 リクエストで済ませたい): パターン2。ただし受け取り側で JSON パース失敗と値の妥当性チェックを必ず書く
- パターン1 は現時点で選択肢に入らない。将来サポートされたときにすぐ移れるよう、リクエスト組み立てを 1 箇所に閉じ込めておくのが安全です
パターン2 を採る場合、パース失敗時のリトライだけでは不十分でした。今回の外れ値は「パースは通るが値が違う」ケースだったためです。日付なら範囲チェック、バージョン番号なら整数の妥当域チェックのように、アプリ側の意味的なバリデーションが実質必須 になります。
実際に触ってみて感じたこと
一番の収穫は、パターン2 の外れ値がハルシネーションではなく「隣の列を拾った」ものだったと特定できた点です。グラウンディングは「正しいページに到達させる」ところまでは仕事をしていて、そのページのどのフィールドを答えとするかは別の失敗モード でした。ここを混同すると「検索を付けたのに嘘をつく」という雑な結論になり、対策が「モデルを変える」方向に逸れてしまいます。実際に必要だったのは、プロンプトで「リリース日であって最終更新日ではない」と区別させることでした。
もう 1 つは、400 のエラーメッセージが responseSchema ではなく responseMimeType を名指ししていた点です。この差が分かると、「スキーマを外してプロンプトで JSON を指示する」というパターン2 の回避策が自然に導けます。エラー文面を要約せずそのまま読む価値がありました。
検証できなかった範囲
- 有料プランでの Gemini 3 系グラウンディング: 無料枠の 429 に阻まれ、3 系で併用時に 400 が返るのか、それとも 3 系では通るのかは確認できていません。2.5 系で 400 が返ることのみ実測しています
-
google_search以外のツール(Google Maps グラウンディング、関数呼び出し)との組み合わせ: 今回は検索のみを対象にしています - 長期的な安定性: パターン2 の 5/5 パース成功は 1 プロンプト・1 モデル・5 試行の結果です。プロンプトが複雑になればフェンス混入や説明文の付与が起きる可能性は残ります
関連記事
- Gemini 3のthinking_level4段階を実測、旧Flashは非対応だった
- Gemini API、モデル廃止スケジュール5件と13本の実装記事を整理した
- Gemini Managed Agentsのバックグラウンド実行で見つけた2つの罠