壁紙アプリのカテゴリ分類を Gemini にやらせようとして、最初に壁にぶつかったのは「カテゴリの木構造をスキーマで表現できない」という一点でした。自然(風景 → 山 → 雪山)のように、親カテゴリの下に子があり、その下にさらに孫がある。深さは画像によって変わります。ところが responseSchema に自己参照を書こうとしても $ref を受け付けてくれません。仕方なく3階層まで手でインライン展開し、4階層目が来たら諦める、という妥協をしていました。
この妥協を解いてくれたのが responseJsonSchema です。responseSchema とは別のフィールドで、JSON Schema の $ref と $defs を解釈します。共有定義を1回書いて使い回せますし、自己参照で再帰も宣言できます。App Store と Google Play で配信している壁紙アプリのために、多言語かつ入れ子の構造化出力を個人開発で扱うなら、この差は運用のしやすさに直結します。両者の違いと移行手順、そして実際に踏んだ落とし穴を、順に整理していきます。
responseSchema が表現できないもの
responseSchema が受け取るのは OpenAPI 3.0 スキーマオブジェクトのサブセットです。type・properties・items・enum・nullable・propertyOrdering・後から追加された anyOf あたりは使えます。しかし JSON Schema の中核である $ref(他の定義への参照)と $defs(再利用可能な定義の置き場)は解釈しません。
これが効いてくるのは、次の2つのケースです。
第一に、同じ形のオブジェクトを複数の場所で使い回したいとき。私の場合は「日本語と英語のペア」を表す LocalizedText を、カテゴリ名・説明文・タグの3か所で使いたい。responseSchema ではこれを3回インラインで書くしかなく、スキーマ本体が縦に伸び、後から ja/en の必須指定を直すたびに3か所を漏れなく直す必要が出ます。
第二に、再帰構造。カテゴリ木のように「自分自身を子に持つ」形は、参照がないと表現できません。固定深さまで展開する回避策はありますが、深さが可変のデータでは必ずどこかで破綻します。
観点 responseSchema responseJsonSchema
スキーマ方言 OpenAPI 3.0 のサブセット JSON Schema($ref/$defs 対応)
共有定義の再利用 不可(インライン展開) $defs + $ref で可能
再帰・自己参照 不可 可能
propertyOrdering 指定できる 指定の概念がない
併用 同時指定は不可(どちらか一方のみ)
$defs で多言語オブジェクトを1回だけ定義する
まず、共有定義の再利用から見ていきます。google-genai(新しい Python SDK)を使い、LocalizedText を $defs に1回だけ書いて、複数の場所から $ref で参照します。
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_API_KEY" )
# LocalizedText を一度だけ定義し、複数箇所から参照する
schema = {
"type" : "object" ,
"$defs" : {
"LocalizedText" : {
"type" : "object" ,
"properties" : {
"ja" : { "type" : "string" },
"en" : { "type" : "string" },
},
"required" : [ "ja" , "en" ],
}
},
"properties" : {
"name" : { "$ref" : "#/$defs/LocalizedText" },
"description" : { "$ref" : "#/$defs/LocalizedText" },
"tags" : {
"type" : "array" ,
"items" : { "$ref" : "#/$defs/LocalizedText" },
},
},
"required" : [ "name" , "description" , "tags" ],
}
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "雪をかぶった山の壁紙にカテゴリ名・説明・タグを日英で付けてください。" ,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_json_schema = schema,
),
)
print (resp.text)
期待する出力(整形済み)は次のようになります。
{
"name" : { "ja" : "雪山" , "en" : "Snowy Mountain" },
"description" : { "ja" : "雪をかぶった山の風景" , "en" : "A mountain landscape covered in snow" },
"tags" : [
{ "ja" : "自然" , "en" : "Nature" },
{ "ja" : "冬" , "en" : "Winter" }
]
}
LocalizedText の定義は1か所だけです。後から「ja/en に加えて zh も必須にする」と決めても、直すのは $defs の中の1ブロックだけで済みます。responseSchema でインライン展開していた頃は、同じ変更を3か所に手で反映していました。定義が増えるほど、この差は効いてきます。実際、LocalizedText を3か所にインライン展開していたスキーマは約60行ありましたが、$defs に切り出して参照へ置き換えると約35行、およそ40%短くなりました。
$ref の自己参照で再帰的なカテゴリ木を宣言する
次が本題の再帰です。カテゴリノードが「ラベル+子ノードの配列」を持ち、子ノードもまた同じ CategoryNode である、という自己参照を $ref で書きます。
tree_schema = {
"type" : "object" ,
"$defs" : {
"LocalizedText" : {
"type" : "object" ,
"properties" : {
"ja" : { "type" : "string" },
"en" : { "type" : "string" },
},
"required" : [ "ja" , "en" ],
},
"CategoryNode" : {
"type" : "object" ,
"properties" : {
"label" : { "$ref" : "#/$defs/LocalizedText" },
# children の要素は CategoryNode 自身 — ここが再帰
"children" : {
"type" : "array" ,
"items" : { "$ref" : "#/$defs/CategoryNode" },
},
},
"required" : [ "label" , "children" ],
},
},
"properties" : { "root" : { "$ref" : "#/$defs/CategoryNode" }},
"required" : [ "root" ],
}
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "壁紙のカテゴリを『自然→山→雪山』のように木構造で3階層まで作ってください。" ,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_json_schema = tree_schema,
),
)
ここで大事なのは、スキーマ側で深さを固定していない点です。3階層が欲しいときはプロンプトで依頼しますが、スキーマ自体は任意の深さを許容します。データの実態に合わせて深さが変わっても、スキーマを書き直す必要がありません。固定深さのインライン展開でやっていた頃は、想定より深い木が来ると末端が切り落とされていました。
受け取った木は、深さが可変なので再帰で歩くのが素直です。
import json
def walk (node, depth = 0 ):
label = node[ "label" ][ "ja" ]
print ( " " * depth + f "- { label } " )
for child in node.get( "children" , []):
walk(child, depth + 1 )
data = json.loads(resp.text)
walk(data[ "root" ])
# 出力例:
# - 自然
# - 山
# - 雪山
木構造をスキーマで固定できると、後段の処理が単純になります。分類結果を Sheets へ書き出す際のカラム設計や、フィールド順が揺れて崩れる問題については、propertyOrdering でフィールド順を固定する実装メモ で扱った内容とあわせて設計すると安定します。
検証を挟んで「型どおり」を保証する
構造化出力は「スキーマを渡したから必ず型どおり」ではありません。私は本番の取り込み前に必ずローカルで JSON Schema 検証を通しています。responseJsonSchema に渡したスキーマは通常の JSON Schema なので、jsonschema ライブラリでそのまま再検証できるのが利点です。
import json
from jsonschema import Draft202012Validator, ValidationError
def parse_validated (resp_text: str , schema: dict ) -> dict :
data = json.loads(resp_text)
validator = Draft202012Validator(schema)
errors = sorted (validator.iter_errors(data), key =lambda e: e.path)
if errors:
# 最初のエラーだけ具体的に見せて、リトライ判断に使う
first = errors[ 0 ]
path = "/" .join( str (p) for p in first.path) or "(root)"
raise ValidationError( f " { path } : { first.message } " )
return data
# API に渡したのと同一の schema を検証にも使い回せる
data = parse_validated(resp.text, tree_schema)
API に渡したスキーマと検証に使うスキーマが同一のオブジェクトである、という点が地味に効きます。responseSchema の OpenAPI サブセットは jsonschema にそのまま渡せないため、検証用に別スキーマを起こすか変換をかける必要がありました。移行後はこの二重管理が消えます。検証と抑制つきリトライを計測しながら回す設計は、構造化出力が静かにスキーマから外れるときの運用メモ に詳しくまとめています。
よくある間違いと落とし穴
移行の途中で実際に踏んだものを挙げます。
responseSchema と responseJsonSchema の同時指定。 この2つは排他です。両方を GenerateContentConfig に渡すとエラーになります。移行時は古い response_schema を必ず外してください。私は最初、コメントアウトし忘れて 400 を返され、しばらく原因を探しました。
propertyOrdering を持ち込もうとする。 propertyOrdering は responseSchema 側の概念で、responseJsonSchema には対応する指定がありません。フィールド順を厳密に制御したい要件がある場合、この一点だけは responseSchema に分がある、という判断材料になります。順序が本質的に重要なタスクは、無理に移行しない選択もあります。
すべての JSON Schema キーワードが効くと思い込む。 $ref/$defs は扱えますが、JSON Schema のあらゆるキーワード(複雑な条件分岐や一部のフォーマット制約など)が全面的に効くとは限りません。公式ドキュメントで対応キーワードを確認し、凝った制約はスキーマに任せきらず後段の検証で担保するのが安全です。私は「スキーマは骨格、細かい制約は jsonschema 側」と役割を分けています。
thinking を切ったモデルで深い木を求める。 再帰の深いスキーマは、モデルにとって負荷の高い生成です。極端に浅い設定のモデルで深い木を求めると、途中で子を打ち切ることがあります。深い構造化が要る場面では、推論に余力のあるモデルを選ぶ方が安定しました。入力種別ごとに型を切り替える設計そのものは、anyOf 判別ユニオンの実運用メモ が参考になります。
どちらを選ぶかの判断
私の中の基準はシンプルです。共有定義の再利用か再帰が要るなら responseJsonSchema、フィールド順の厳密な固定が要るなら responseSchema。どちらも要らない平坦な構造なら、慣れている方でかまいません。多言語かつ入れ子のデータを個人開発で扱う私のようなケースでは、$defs による一元管理の恩恵が大きく、移行して正解でした。
移行そのものは、次の3ステップで進めました。
一番深い(再帰または再利用のある)スキーマを1つ選び、共有部分を $defs に切り出す。
response_schema を外し、response_json_schema に載せ替えて1件だけ通す。
返ってきた JSON を、API へ渡したのと同一のスキーマで jsonschema 検証にかけ、型どおりであることを確認する。
なお、ここで示した対応状況は執筆時点のものです。Gemini の API は更新が速いので、実装前に対応キーワードとモデル別の可否を公式ドキュメントで一度確かめることをおすすめします。まずは手元の一番深いスキーマを $defs に切り出し、responseJsonSchema に載せ替えて1件通してみてください。インライン展開の重複が消える手応えが、移行を進める後押しになるはずです。
お読みいただきありがとうございました。