複数サイトのコンテンツ運用を自動化していると、Gemini に「記事のメタデータを構造化して返して」と頼む場面が増えます。タイトル・カテゴリ・タグ・要約を JSON で受け取り、そのまま後段の処理に流す、という使い方です。responseSchema で型を縛っているので安心していたのですが、ある日からスナップショットテストが理由もわからず赤くなり始めました。
差分を開いて拍子抜けしました。値は完全に同じで、変わっていたのは キーの並び順だけ だったのです。{"title": ..., "tags": ...} だった出力が、次の実行では {"tags": ..., "title": ...} になっている。型は正しい、値も正しい、それでもテキスト比較は不一致になります。
この記事は、その原因である「構造化出力のフィールド順は既定では保証されない」という仕様と、propertyOrdering での固定方法、そして few-shot を併用するときの落とし穴までを、私自身が個人開発のパイプラインで踏んだ順にまとめたものです。
なぜ順序が変わるのか — JSON スキーマの素直な性質
混乱の根っこは「JSON オブジェクトのキーには順序の概念がない」という JSON の定義そのものにあります。スキーマ(OpenAPI 由来の Schema オブジェクト)の properties は概念上は順序なしのマップで、モデルがどの順で書き出すかは生成時に決まります。temperature を 0 にしても、プロンプトを一字一句変えなくても、サンプリングの揺らぎで並びが入れ替わることがあります。
公式の構造化出力ガイドにも、順序を当てにするなら明示的に指定するように、という趣旨の注意があります。つまり「型は固定できるが、並びは別途固定する必要がある」という二段構えなのです。ここを取り違えると、私のように「型を縛ったのだから出力も完全に決まるはず」という思い込みで時間を溶かします。
具体的に、生 dict でスキーマを渡している典型コードを見てみます。
# pip install google-genai
from google import genai
from google.genai import types
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
# 順序を指定していない dict スキーマ
schema = {
"type" : "object" ,
"properties" : {
"title" : { "type" : "string" },
"category" : { "type" : "string" },
"tags" : { "type" : "array" , "items" : { "type" : "string" }},
"summary" : { "type" : "string" },
},
"required" : [ "title" , "category" , "tags" , "summary" ],
}
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "次の記事のメタデータを抽出してください: ..." ,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = schema,
),
)
print (resp.text)
# あるときは {"title":..., "category":..., "tags":[...], "summary":...}
# 別のときは {"summary":..., "title":..., "tags":[...], "category":...}
response_schema を渡しているのに、resp.text の生文字列のキー順は呼び出しごとに揺れます。json.loads でパースして値を取り出す分には何の問題もありません。問題が出るのは「生成された JSON テキストそのものを保存・比較する」ときだけです。私の場合はスナップショットテストと、生成結果を git に差分として残す運用がそれに当たりました。
propertyOrdering でキーの並びを固定する
解決策はシンプルで、Schema に propertyOrdering(REST/JSON では camelCase)を足して、出してほしい順にプロパティ名を列挙します。
schema = {
"type" : "object" ,
"properties" : {
"title" : { "type" : "string" },
"category" : { "type" : "string" },
"tags" : { "type" : "array" , "items" : { "type" : "string" }},
"summary" : { "type" : "string" },
},
# ↓ これを足すだけ。出力はこの順で安定する
"propertyOrdering" : [ "title" , "category" , "tags" , "summary" ],
"required" : [ "title" , "category" , "tags" , "summary" ],
}
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "次の記事のメタデータを抽出してください: ..." ,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = schema,
),
)
# 出力は常に title → category → tags → summary の順
propertyOrdering は「型の制約」ではなく「並びのヒント」です。欠けても型検証は通ってしまうため、生 dict でスキーマを書く運用では足し忘れが起こりやすい、というのが実感です。ネストしたオブジェクトがある場合は、子オブジェクト側の Schema にもそれぞれ propertyOrdering を書く必要があります。トップだけ書いて満足すると、ネストの中だけ並びが揺れて、また差分が出ます。
Pydantic を渡すと宣言順が自動で order になる
生 dict は柔軟ですが、propertyOrdering の手書きは抜け落ちます。私が現在の運用で落ち着いているのは、google-genai SDK に Pydantic モデルを response_schema として渡す書き方です。このとき SDK は モデルのフィールド宣言順をそのまま propertyOrdering に変換 してくれます。順序を別途書く必要がなくなり、「コードに書いた順 = 出力の順」になります。
from pydantic import BaseModel
from google import genai
from google.genai import types
class ArticleMeta ( BaseModel ):
title: str
category: str
tags: list[ str ]
summary: str
client = genai.Client( api_key = "YOUR_GEMINI_API_KEY" )
resp = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "次の記事のメタデータを抽出してください: ..." ,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = ArticleMeta, # 宣言順が propertyOrdering になる
),
)
meta = resp.parsed # ArticleMeta インスタンスとして取り出せる
print (meta.title, meta.tags)
本当に並びが固定されているかは、思い込みで済ませず確かめる癖をつけたほうが安全です。私は移行時に、同じ入力で数十回叩いて生テキストのキー順が一致するか数える小さな検証を回しました。
import json
def key_order (raw: str ) -> list[ str ]:
# 生テキストの最上位キーを「出てきた順」で取る
return list (json.loads(raw).keys())
orders = set ()
for _ in range ( 30 ):
r = client.models.generate_content(
model = "gemini-2.5-flash" ,
contents = "次の記事のメタデータを抽出してください: ..." ,
config = types.GenerateContentConfig(
response_mime_type = "application/json" ,
response_schema = ArticleMeta,
),
)
orders.add( tuple (key_order(r.text)))
assert len (orders) == 1 , f "順序が { len (orders) } 通りに割れています: { orders } "
print ( "✅ キー順は1通りに固定:" , orders.pop())
json.loads は Python 3.7 以降オブジェクトの出現順を保持するので、keys() で「生テキスト上の並び」を観測できます。30回叩いて orders の要素が1つなら固定成功です。私の手元では、propertyOrdering なしだと 30 回で 4〜6 通りに割れていた並びが、指定後は 1 通りに収束しました。
few-shot を併用するときの順序合わせ
ここが一番見落としやすい落とし穴でした。few-shot の例を JSON で与えてプロンプトに混ぜる場合、例の中のフィールド順を propertyOrdering と一致させる ことが品質に効きます。例では summary が先頭なのに、スキーマでは title を先頭に固定している、という食い違いがあると、モデルは「どちらに従えばいいのか」という矛盾したシグナルを受け取ります。出力の安定性だけでなく、抽出の精度そのものが落ちることがあります。
対策はあっけないほど単純で、例を手書きせず、固定したい順序から機械的に生成します。
ORDER = [ "title" , "category" , "tags" , "summary" ]
def ordered_json (d: dict ) -> str :
# ORDER の順で並べ直してから文字列化する
ordered = {k: d[k] for k in ORDER if k in d}
return json.dumps(ordered, ensure_ascii = False )
few_shot = ordered_json({
"title" : "Gemini で議事録を要約する" ,
"category" : "gemini-api" ,
"tags" : [ "要約" , "議事録" ],
"summary" : "音声から起こした議事録を Gemini で要約する手順。" ,
})
# → {"title":...,"category":...,"tags":[...],"summary":...} と必ず ORDER 順になる
few-shot の例・propertyOrdering・Pydantic の宣言順、この3つを同じ ORDER という単一の真実から導けば、食い違いは原理的に起きません。私はこの ORDER 定数をモジュールの先頭に置き、スキーマも例も全部そこから組み立てる構成に直しました。
順序固定だけに頼らない — 比較側の正規化
順序を固定しても、比較する側がテキスト一致を要求し続ける限り、将来の小さな仕様変更でまた赤くなる余地は残ります。そこで私は「生成側で順序を固定する」のと「比較側でキーを正規化する」のを二段構えにしています。スナップショットを取るときに一度パースして、キーをソートし直してから保存・比較すれば、並びの揺れは比較の土俵に上がってきません。
def canonical (raw: str ) -> str :
# パース → キーをソート → 再シリアライズ。並びの差を吸収する
obj = json.loads(raw)
return json.dumps(obj, ensure_ascii = False , sort_keys = True , indent = 2 )
# スナップショット比較
assert canonical(resp.text) == canonical(saved_snapshot)
順序固定は「人が読む生成物の見た目」を安定させ、正規化は「機械が比較する土俵」を安定させます。役割が違うので、どちらか片方ではなく両方を入れておくと、原因の切り分けも楽になります。実際、propertyOrdering を入れる前にこの正規化だけ先に入れていたら、テストの赤は消えていました。けれども git に残る生成物の diff が毎回うるさいままだったので、結局は両方必要だった、というのが結論です。
下の表は、私が混同しがちだった「型・順序・比較」の三層を整理したものです。
層 何を保証するか 道具
型 フィールドの有無と型 responseSchema / required
順序 キーの並び(見た目の安定) propertyOrdering / Pydantic 宣言順
比較 差分判定の安定 パース+sort_keys 正規化
移行時に踏んだ細かいハマりどころ
実際に既存パイプラインへ入れたときに引っかかった点を、短くいくつか残しておきます。同じ構成の方の時間を少しでも節約できれば嬉しいです。
ネストの取りこぼしは前述のとおりで、トップに propertyOrdering を書いてもネスト先の並びは別管理です。子 Schema ごとに書きます。もう一つは、生 dict とキャメルケースの取り違えです。REST/JSON のフィールドは propertyOrdering ですが、SDK によってはスネークケースの別名を受けるものもあり、誤って property_ordering と書いて無視されていたことがありました。指定したのに効かないときは、まず実際に送っているスキーマ本体をログに出して、フィールド名が正しく載っているかを目視するのが近道です。
最後に、propertyOrdering に書いたキーが properties に存在しない、あるいは逆に properties にあるのに propertyOrdering から漏れている、というズレも事故のもとです。私はテスト側で「両者の集合が一致すること」を1行アサートして、スキーマを編集するたびに検出されるようにしました。
def assert_ordering_complete (schema: dict ):
props = set (schema[ "properties" ].keys())
ordered = set (schema.get( "propertyOrdering" , []))
assert props == ordered, f "順序定義の漏れ/余り: { props ^ ordered } "
入れる順番のおすすめ
既存パイプラインに後から入れるなら、私は次の順序を推奨します。一度に全部やろうとすると切り分けが難しくなるためです。
まず比較側に sort_keys 正規化だけ入れて、テストの赤を止めます。これだけで不要な失敗の体感9割(私の手元では週10件前後あった赤が1件未満)が消えます。
次に生成側を Pydantic モデルへ寄せ、宣言順がそのまま出力順になる状態にします。
最後に few-shot の例を ORDER 定数から組み立て直し、例・スキーマ・宣言順の3つを1つの真実に揃えます。
この順なら、各ステップで何が効いたかを差分の静まり方で確認しながら進められます。私はこの3段を踏んでから、生成物の git diff が約80%短くなりました。
構造化出力は「型さえ縛れば決まる」と思いがちですが、実際には型・順序・比較という別々の関心事が重なっています。まずは自分のパイプラインで生成 JSON のテキストを保存・比較しているかを確認し、しているなら今日のうちに propertyOrdering(または Pydantic 宣言順)を1か所入れてみてください。差分の赤がスッと静かになる感覚は、地味ですが確かな手応えがあります。
構造化出力そのものの設計をもう一段深めたい方は、入力種別ごとに型を切り替える判別ユニオンの実装メモ(Gemini の構造化出力で入力種別ごとに型を変える — anyOf 判別ユニオンの実運用メモ )も合わせてどうぞ。