壁紙アプリのユーザー投稿画像を「風景/人物/抽象/その他」の4分類に振り分けようとしていたとき、responseSchema で enum: ["landscape", "portrait", "abstract", "other"] と書いたのに、レスポンスに "natural" や "scenery" が混ざる現象に遭遇しました。AdMob 収益を支える壁紙アプリでは、分類ミスがそのまま検索体験の劣化につながります。最初は仕様変更を疑って何度もリトライしたものの、原因はもう少し地味なところにありました。
ここでは同じ症状に当たった方が短時間で根本対処に進めるよう、再現条件・原因・3パターンの回避策を順に整理していきます。
症状の最小再現
次のような Python コードで、本来は4値しか返ってこないはずの enum フィールドに、想定外の値が混ざります。
from google import genai
from pydantic import BaseModel
from typing import Literal
class Classification(BaseModel):
label: Literal["landscape", "portrait", "abstract", "other"]
confidence: float
client = genai.Client()
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[
"Classify this wallpaper image.",
image_part,
],
config={
"response_mime_type": "application/json",
"response_schema": Classification,
},
)
result = Classification.model_validate_json(response.text)
# 期待: label は4値のいずれか
# 実際: 稀に "natural", "scenic", "city" などが返り pydantic.ValidationErrorPydantic で Literal を使っているにも関わらず、Gemini が schema を完全に守ってくれないため、ValidationError で例外が飛びます。私の手元では 5,000 件中 12 件程度の混入で、リトライしても同じ画像で同じ誤った enum が再現したケースもありました。
なぜ enum が無視されるのか
公式ドキュメントには propertyOrdering と enum のサポートが明記されていますが、内部的にはあくまで「制約付きデコーディングのヒント」として扱われる場面があります。とくに以下の3条件が重なると、enum 外の値が滲み出やすくなります。
- 画像や PDF などのマルチモーダル入力で、入力トークン数が長くなる
- enum 値が「自然言語として近接した語」を含む(例:
landscapeとscenic) - プロンプトに enum 候補そのものが明示されていない
裏側のロジック自体は非公開なので断言は避けますが、私が gemini-2.5-flash と gemini-2.5-pro で同じ画像セットを比較したかぎり、プロンプト本文に許可値を埋め込むだけで誤分類率は 1/3 から 1/5 まで減りました。schema は「強い指示」ではなく「整形のためのヒント」と捉え、プロンプト側で意味的な制約をかける方が安定します。これは OpenAI の response_format や Anthropic の Tool Use とも共通する設計感覚で、構造化出力を持つ各社の API はいずれも「プロンプトと schema の二段構え」を取ったほうが堅牢になります。
ちなみに gemini-2.5-flash と gemini-2.5-pro の挙動を比較すると、私のデータでは pro の方が enum 順守率は高いものの、画像 100 枚あたりのコストは約 5 倍に膨らみます。AdMob 収益と相談しながら、コストが許容できる範囲で flash のリトライ戦略を組むのが現実的でした。
対処1: プロンプトに許可値を明示する
これがいちばん効きました。schema に書くだけでなく、プロンプト本文で許可値を箇条書きにします。
ALLOWED = ["landscape", "portrait", "abstract", "other"]
prompt = f"""\
Classify the wallpaper image into exactly one of these labels:
- landscape (natural scenery, mountains, sea, sky)
- portrait (a person or animal as the main subject)
- abstract (pattern, gradient, geometric art)
- other (anything that does not fit the above)
Return JSON. The "label" field MUST be exactly one of: {ALLOWED}.
Do NOT invent new labels.
"""
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[prompt, image_part],
config={
"response_mime_type": "application/json",
"response_schema": Classification,
},
)「許可値の意味的な定義」と「逸脱禁止の明示」をプロンプト本体に置くだけで、私のテストセットでは混入率がほぼゼロになりました。schema と二重に書くのは冗長に見えますが、現時点ではいちばん再現性のある安定策です。
対処2: 受け取り側でバリデーション+1回だけリトライ
それでも稀に外れることがあるので、受け取った enum 値を検証し、不一致のときだけ「自己訂正プロンプト」を1回投げます。無限リトライにすると API コストが跳ねるため、必ず上限を設けます。
def classify_with_retry(image_part, max_retry: int = 1) -> Classification:
last_text = ""
for attempt in range(max_retry + 1):
instruction = prompt if attempt == 0 else (
f"Your previous label '{last_text}' was not in {ALLOWED}. "
f"Re-classify using ONLY these four labels."
)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=[instruction, image_part],
config={
"response_mime_type": "application/json",
"response_schema": Classification,
},
)
last_text = response.text
try:
result = Classification.model_validate_json(last_text)
if result.label in ALLOWED:
return result
except Exception:
pass
# フォールバック: other 扱い(パイプラインを止めない)
return Classification(label="other", confidence=0.0)1回のリトライで救えるケースが大半で、最後まで弾けなかった場合は other に倒すことで分類パイプライン全体は止まりません。2014年から個人開発で運営している壁紙アプリでは、こうした「失敗してもデータが流れ続ける」設計が大切でした。
対処3: SDK バージョンによる挙動差を疑う
google-genai v0.6 以前と v1.x 以降で、response_schema の解釈が変わったタイミングがありました。Pydantic v2 のモデルを直接渡せるようになったのは比較的最近のバージョンで、それ以前は dict 形式の JSON Schema を渡す必要があります。
pip show google-genai | grep Version
# 古いバージョンなら更新
pip install --upgrade google-genai私が運用している累計 5,000万ダウンロード規模のアプリ群でも、CI が古いバージョンに固定されたままで enum 順序の指示が効かないものがありました。「同じコードでも環境によって混入率が違う」ときは、SDK バージョン差を最初に疑うのが近道です。
運用上の優先順位
実運用では以下の順で組み合わせています。
- まず対処1(プロンプト明示)を必ず入れる
- 対処2(自己訂正リトライ1回)でセーフティネットを敷く
- 障害切り分けの際は対処3(SDK バージョン確認)を最初に見る
3つのうちどれが効くかは、データセットと運用フェーズで変わります。初期は対処1で十分でも、配信規模が増えてエッジケースが見えてくる頃には対処2のリトライ網が必要になり、SDK 更新のタイミングで対処3を見直すという順に進みました。
このパターンに揃えてから、壁紙アプリのカテゴリ振り分けに対する人手チェックは月1回ほどに減り、AdMob の検索エクスペリエンスも安定しました。enum 違反が混ざること自体は完全には防げないという前提で「監視 → リトライ → フォールバック」の三層を敷いておくと、Gemini のモデル更新があっても運用は止まらず続けられます。
ドリフトを早期検出するロギング
混入は完全にゼロにはできないため、運用側で「ドリフト率」を可視化しておきます。私の壁紙アプリでは BigQuery に enum 出力をそのまま投げ、許可値外の出現割合を週次でモニタリングしています。
def log_classification(label: str, allowed: list[str]):
is_drift = label not in allowed
# Cloud Logging / BigQuery / Sentry のいずれでも
print({
"event": "enum_classification",
"label": label,
"is_drift": is_drift,
})ドリフト率が突然上がったときは、
- モデルバージョンが変わった(
gemini-2.5-flash→ 新スナップショット) - 入力分布が変わった(新しいシリーズの壁紙を投入した)
- SDK を更新した
のどれかが原因のことが多く、原因切り分けが30分以内で済むようになりました。
可視化を入れる前は「なんとなく分類精度が落ちた気がする」を毎週手動で確認しており、判断に丸1日かかっていたこともあります。週次グラフに置き換えてからは、ドリフト発生時の対応がプロンプト調整 → リトライ → モデル切り替えの順で機械的に進められるようになりました。
似た症状で勘違いしやすいケース
このエラーは他のスキーマ違反と症状が似ていて、初見で原因を取り違えがちです。私自身、以下と混同して時間を溶かしました。
response_mime_type未指定 → JSON ではなく Markdown コードフェンス付きで返ってくる- ネストした
oneOfを使った → 子フィールドだけ常に第一候補が返る propertyOrdering未指定の長文 JSON → フィールド順序が安定せずパースが揺れる
まず response.text を生のまま print して、JSON か Markdown か、enum 違反か順序違反かを切り分けてからにすると、対処の選択ミスが減ります。
responseSchema は便利ですが、現状は「整形のヒント」と割り切ってプロンプト側にも責任を持たせるのが堅実です。同じ症状で行き詰まっている方の手元で、最短ルートで解消の助けになれば嬉しく思います。