個人開発で運用している壁紙アプリの投稿画像に付いた説明文から、タグを1つだけ選ばせる小さな処理を書き直していた夕方のことです。もとは OpenAI の SDK で書いたコードで、base_url とモデル名だけを Gemini に向けて動かしておりました。返ってくるのは JSON に見える文字列なのに、ときどき前後に説明文が付き、json.loads がそこで止まります。
最初に私がしたのは、プロンプトに「JSON だけを返してください」と三度書き足すことでした。結果は芳しくありませんでした。原因はプロンプトではなく、スキーマがそもそも送られていなかったことにあったのです。
同じように「OpenAI 互換のまま試したら構造化出力が効かない」と感じている方に向けて、効いているかの確かめ方と、既存コードを捨てずに Gemini SDK の response_json_schema へ寄せる手順を書き残します。
最初に確かめること——スキーマは本当に送られているか
Google の公式ドキュメント「OpenAI compatibility」には、互換レイヤーで構造化出力を使う例が載っています。一方で、Google AI Developers Forum には「互換レイヤー経由だと structured output が効かない」「propertyOrdering が守られない」という報告がいくつも上がっています。
私のコードを見直すと、response_format={"type": "json_object"} だけを渡していました。これは「JSON で返してほしい」という指定で、どの形の JSON かまでは伝えていません。キー名や許可値はプロンプトの文章頼みで、モデルが少し気を利かせると形が崩れます。
確かめ方は単純です。互換レイヤーに渡している response_format を、次の3つのどれかに当てはめてみてください。
| 渡しているもの | モデルに伝わること | 形が揃うか |
|---|---|---|
| 何も渡していない | 文章で返す | プロンプト次第 |
| type に json_object | JSON で返す(形は未指定) | キー名・値はぶれうる |
| type に json_schema と schema 本体 | この形の JSON で返す | 形は揃いやすい(値の意味は別途検証) |
二行目に当てはまるなら、まずは三行目に直すだけで症状の多くは消えます。それでも揃わないときに、経路そのものを見直すのが順番として無理がありません。
段階1——互換レイヤーのまま、スキーマを渡して検証する
最初の段階では SDK を替えません。Pydantic でモデルを定義し、その JSON Schema を json_schema として渡したうえで、返ってきた文字列を同じモデルで検証します。
import logging
import os
from typing import Literal, Optional
from openai import OpenAI
from pydantic import BaseModel, ValidationError
# モデル名は使う前に ai.google.dev の Models ページで提供状況を確かめてください
MODEL = "gemini-4-hydrogen"
SYSTEM = "壁紙の説明文から、最も近いタグを1つだけ選んでください。"
class WallpaperTag(BaseModel):
label: Literal["landscape", "portrait", "abstract", "other"]
reason: str
compat = OpenAI(
api_key=os.environ["GEMINI_API_KEY"],
base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
)
def classify_compat(caption: str) -> Optional[WallpaperTag]:
completion = compat.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": caption},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "wallpaper_tag",
"schema": WallpaperTag.model_json_schema(),
},
},
)
text = completion.choices[0].message.content or ""
try:
return WallpaperTag.model_validate_json(text)
except ValidationError as e:
# 形が崩れた応答は捨てずに残す。どの経路で崩れたかを後で数えるため
logging.warning("compat schema mismatch: %s | raw=%r", e.errors()[0]["type"], text[:200])
return Noneここで大事なのは、検証に失敗した応答を握りつぶさずにログへ残すことです。失敗の件数が数えられれば、次の段階へ進むかどうかを感覚ではなく記録で決められます。
段階2——関数の境界はそのまま、中身を Gemini SDK に替える
段階1で失敗が残るとき、あるいは optional やフィールド順の扱いを Gemini 側の仕様どおりに揃えたいときは、呼び出し経路を Gemini SDK(google-genai)に移します。呼び出し側のコードを触らずに済むよう、関数の名前・引数・戻り値の型は段階1と同じにしておきます。
from typing import Optional
from google import genai
from google.genai import types
from pydantic import ValidationError
# 先ほどの MODEL・SYSTEM・WallpaperTag をそのまま使う
native = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
def classify_native(caption: str) -> Optional[WallpaperTag]:
res = native.models.generate_content(
model=MODEL,
contents=caption,
config=types.GenerateContentConfig(
system_instruction=SYSTEM,
response_mime_type="application/json",
# Pydantic の定義から作った JSON Schema を、互換レイヤーと同じものとして渡す
response_json_schema=WallpaperTag.model_json_schema(),
),
)
try:
return WallpaperTag.model_validate_json(res.text or "")
except ValidationError as e:
logging.warning("native schema mismatch: %s | raw=%r", e.errors()[0]["type"], (res.text or "")[:200])
return Noneスキーマを WallpaperTag.model_json_schema() の1か所から作っているのは、送る形と検証する形を二重に書かないためです。経路を替えても、正とみなす基準は Pydantic の定義ひとつに置いておきます。
なお 2026年11月時点の公式ドキュメントでは、generateContent 側の Structured outputs ページに「Legacy」の表記が付き、Interactions API 側にも構造化出力のページが用意されています。今回は既存コードとの差分を小さくするため generateContent で書きましたが、まったく新しく書く処理なら Interactions API のページも先に読んでおくと迷いが減ると思います。
段階3——環境変数で切り替え、しばらく両方を並べる
いきなり全部を切り替えると、戻したくなったときに胃が痛くなります。私自身は環境変数ひとつで経路を選べるようにし、手元の説明文で両方を並べてから既定値を変えました。
import os
from typing import Optional
def classify(caption: str) -> Optional[WallpaperTag]:
backend = os.environ.get("TAG_BACKEND", "compat")
if backend == "native":
return classify_native(caption)
return classify_compat(caption)
def shadow_compare(captions: list[str]) -> None:
"""同じ入力を両経路に送り、ラベルの食い違いと検証失敗を数える"""
diff = fail_compat = fail_native = 0
for c in captions:
a, b = classify_compat(c), classify_native(c)
fail_compat += a is None
fail_native += b is None
if a and b and a.label != b.label:
diff += 1
print(f"DIFF {a.label} / {b.label} : {c[:60]}")
print(f"件数={len(captions)} 食い違い={diff} 互換の失敗={fail_compat} SDKの失敗={fail_native}")shadow_compare は本番の経路には入れず、手元で一度流すだけの道具です。私の場合、食い違いが出たのは「夜景の街並み」のように landscape と abstract のどちらとも取れる説明文で、経路の差というより、私の許可値の定義が曖昧だったことのほうが目立ちました。互換レイヤーを疑う前に、自分の定義を疑うべきだったのかもしれません。
経路を替える前に、正とみなす基準をひとつに決めておく。 今回の書き直しで、いちばん効いたのはこの線引きでした。
つまずきやすい点
json_objectのままスキーマを渡したつもりになる——型の指定だけでは形は伝わりません。json_schemaと schema 本体の組で渡してください。- Pydantic の Optional を「キーが無くてもよい」と読み違える——Optional は null を許すだけで、キーの欠落とは別です。欠落と null の扱いはスキーマを変えても壊れない構造化出力の設計で詳しく扱っています。
- enum を書けば許可値以外は来ないと思い込む——形が揃っても値の意味までは保証されません。responseSchema の enum 指定なのに違う文字列が返るときの対処が、そのまま互換経路にも当てはまります。
res.textが空のまま検証して例外になる——安全性のフィルタなどで本文が無い応答もあり得ます。上のコードのように空文字を渡して検証に落とし、ログへ回すかたちにしておくと原因を追えます。
JSON の解析そのもので止まる場合は、構造化出力の JSON エラーを直す手順も合わせてご覧ください。
結び
互換レイヤーは、既存のコードを大きく変えずに Gemini を試せる、ありがたい入口です。ただ、入口のまま本番を任せるかどうかは、検証の記録を見てから決めても遅くはありません。
まずは段階1のコードで、互換レイヤーに json_schema を渡したうえで検証の失敗を1日分だけログに残してみていただければと思います。その件数が、SDK を替えるかどうかを決めるいちばん確かな材料になるはずです。