- はじめに
- プロバイダー別Structured Output比較
- 2026年8月時点で変わったこと
- Pydanticによるスキーマ検証の自動化
- LiteLLMによるプロバイダー統合
- Instructorライブラリの活用
- プロダクションパイプラインの構築
- 実行すると実際に何が起きるのか
- エンドツーエンドの例: サポートチケット分類器
- スキーマの制約 — プロバイダーごとに対応範囲が違う
- 失敗事例 — 症状から原因へ
- スキーマを使わないほうがいいとき
- まとめ
- 参考資料
- クイズ

はじめに
LLMの出力をプログラム的に処理するには、構造化された形式(JSON、XMLなど)が必須です。プロンプトに「JSONで応答して」と入れるだけでは不十分です — スキーマの不一致、フィールドの欠落、型の誤りなど、さまざまな問題が発生します。
この記事では、主要LLMプロバイダーのStructured Output機能を比較し、プロダクションで安定的に使用する方法を解説します。
プロバイダー別Structured Output比較
OpenAI: response_format + Structured Outputs
from openai import OpenAI
from pydantic import BaseModel
from typing import List, Optional
client = OpenAI()
# 方法1: JSON Mode(基本)
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "応答をJSONで返してください。"},
{"role": "user", "content": "ソウルの有名なレストランを3つ推薦して"}
],
response_format={"type": "json_object"}
)
# JSONは保証されるが、スキーマは保証されない
# 方法2: Structured Outputs(スキーマ保証)
class Restaurant(BaseModel):
name: str
cuisine: str
price_range: str
rating: float
address: str
class RestaurantList(BaseModel):
restaurants: List[Restaurant]
total_count: int
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "ソウルの有名なレストランを推薦してください。"},
{"role": "user", "content": "韓国料理のレストラン3つ"}
],
response_format=RestaurantList
)
result = response.choices[0].message.parsed
print(result.restaurants[0].name) # 型安全!
Anthropic: Tool Useを使ったStructured Output
import anthropic
from typing import List
client = anthropic.Anthropic()
# AnthropicはTool Useを活用したStructured Output
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
tools=[
{
"name": "extract_restaurants",
"description": "レストラン情報を構造化された形式で抽出",
"input_schema": {
"type": "object",
"properties": {
"restaurants": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"cuisine": {"type": "string"},
"price_range": {
"type": "string",
"enum": ["$", "$$", "$$$", "$$$$"]
},
"rating": {"type": "number"},
"address": {"type": "string"}
},
"required": ["name", "cuisine", "price_range"]
}
},
"total_count": {"type": "integer"}
},
"required": ["restaurants", "total_count"]
}
}
],
tool_choice={"type": "tool", "name": "extract_restaurants"},
messages=[
{"role": "user", "content": "ソウルの韓国料理レストラン3つ推薦して"}
]
)
# Tool Useの結果から構造化データを抽出
tool_use = next(
block for block in response.content
if block.type == "tool_use"
)
restaurants = tool_use.input["restaurants"]
Google Gemini: responseSchema
import google.generativeai as genai
genai.configure(api_key="YOUR_API_KEY")
model = genai.GenerativeModel(
"gemini-2.0-flash",
generation_config=genai.GenerationConfig(
response_mime_type="application/json",
response_schema={
"type": "object",
"properties": {
"restaurants": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"cuisine": {"type": "string"},
"rating": {"type": "number"}
}
}
}
}
}
)
)
response = model.generate_content("ソウルの韓国料理レストラン3つ")
import json
data = json.loads(response.text)
2026年8月時点で変わったこと
上のプロバイダー別の例は2026年3月に書いたものです。ほとんどは今もそのまま動きます。だから削らずに残しています。ただしその間に、3つのプロバイダーのうち2つが推奨経路を変更しました。以下は2026-08-16に各プロバイダーのドキュメントで確認した内容です。
Anthropic — ネイティブStructured OutputsがGA
この記事で最も大きく間違っている部分です。上ではAnthropicがTool Useを回避策として使うと書きましたが、そのパターンは今も動作するものの、もはや必要ありません。スキーマを直接渡す output_config パラメータが正式リリース(GA)され、ベータヘッダーも不要です。
from anthropic import Anthropic
client = Anthropic()
response = client.messages.create(
model="claude-opus-5",
max_tokens=16000,
messages=[{"role": "user", "content": "..."}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"plan": {"type": "string"},
},
"required": ["name", "plan"],
"additionalProperties": False,
},
}
},
)
tool定義も、レスポンスから tool_use ブロックを取り出すコードも不要になります。Pydanticを使うならヘルパーがもう一つあります。client.messages.parse() に output_format としてモデルクラスを渡すと、検証まで済んだインスタンスが response.parsed_output に入って返ってきます。
from pydantic import BaseModel
class Account(BaseModel):
name: str
plan: str
response = client.messages.parse(
model="claude-opus-5",
max_tokens=16000,
messages=[{"role": "user", "content": "..."}],
output_format=Account,
)
account = response.parsed_output # 検証済みのAccountインスタンス
名前が重なっていて誰もが一度は間違える箇所があります。.parse() ヘルパーの引数名は output_format で正しいのですが、.create() の正式なパラメータは output_config 配下の format です。.create() の旧トップレベル output_format はこれに置き換えられ、deprecatedになりました。
Tool Useを使い続ける場合、strict キーは input_schema の中ではなく、name、description、input_schema と同じレベルのトップレベルキーです。
tools = [
{
"name": "extract_restaurants",
"description": "レストラン情報を構造化された形式で抽出",
"strict": True, # input_schemaの中ではなくここ
"input_schema": {
"type": "object",
"properties": {
"restaurants": {"type": "array", "items": {"type": "object"}},
"total_count": {"type": "integer"},
},
"required": ["restaurants", "total_count"],
"additionalProperties": False,
},
}
]
最も頻繁につまずく違いは required です。Anthropicはすべてのプロパティを required に入れることを要求しません。OpenAIは要求します。
OpenAI — ペイロードの形がAPIごとに違う
OpenAI側は機能よりもペイロードの形で事故が起きます。同じ設定をどこに入れるかが、2つのAPIで異なります。
# Chat Completions — json_schemaがもう一段ネストし、nameは内側にある
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "person",
"strict": True,
"schema": {"type": "object", "properties": {}},
},
}
# Responses API — text.formatの下にフラットに置かれ、nameはtypeと同じレベル
"text": {
"format": {
"type": "json_schema",
"name": "person",
"strict": True,
"schema": {"type": "object", "properties": {}},
}
}
例をコピーして呼び出すAPIだけ変えると、設定が静かに無視されるか400になります。公式の推奨は、Chat Completionsも引き続きサポートするが新規プロジェクトはResponsesを使えというものです。
SDKヘルパーも整理されました。上の本文にある client.beta.chat.completions.parse は現行SDKには存在しません。今は client.chat.completions.parse を使います。Responses側の対応物は client.responses.parse で、スキーマは text_format として渡します。どのバージョンで beta の経路が消えたかは確認できなかったので、バージョン番号で断定せず、実際にimportして確かめてください。
from openai import OpenAI
client = OpenAI()
# Chat Completions — betaの下ではなくここ
completion = client.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "韓国料理のレストラン3つ"}],
response_format=RestaurantList,
)
result = completion.choices[0].message.parsed
# Responses API — 新規プロジェクトの推奨経路
response = client.responses.parse(
model="gpt-4o-mini",
input=[{"role": "user", "content": "韓国料理のレストラン3つ"}],
text_format=RestaurantList,
)
# パース結果を取り出す属性名はSDKのバージョンによって異なります。
# 正確なAPIは使用中のバージョンのドキュメントで確認してください。
JSON Modeについても整理しておきます。json_object タイプは有効なJSONであることだけを保証し、スキーマは保証しません。さらに会話の中にJSONという単語が含まれている必要があります。公式ドキュメントはStructured OutputsをJSON modeの進化形と表現していますが、JSON Modeが正式にdeprecatedになったわけではありません。
strict を有効にすると、additionalProperties を false にすることに加えて、すべてのプロパティを required に入れる必要があります。オプションのフィールドは、型を文字列とnullの組み合わせにして模倣します。
Google — 移行が二度あった
Gemini側は二世代分遅れています。一つ目はSDKです。上の例の google-generativeai は現在積極的にメンテナンスされておらず、レガシーライブラリ群は2025年11月30日付でdeprecatedになりました。今インストールすべきは google-genai で、パッケージ名もimportパスもすべて変わります。二つ目はAPIです。2026年6月にInteractions APIがGAとなり、構造化出力のドキュメントは今こちらを先に示しています。
from google import genai
client = genai.Client()
interaction = client.interactions.create(
model="gemini-3.6-flash",
input=prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": Recipe.model_json_schema(), # Pydanticモデルはそのまま渡せない
},
)
recipe = Recipe.model_validate_json(interaction.output_text)
generateContent APIは今も完全にサポートされているとされていますが、現在はレガシーとみなされると明記されており、推奨は新規開発にはすべてInteractions APIを使うことです。
ここにも罠が一つあります。Interactionsの経路ではPydanticモデルをそのまま渡せないので、.model_json_schema() を呼んで辞書にする必要があります。逆にレガシーの generate_content の経路では BaseModel をそのまま渡せます。
from google import genai
from google.genai import types
client = genai.Client(api_key="...")
# レガシー経路 — 引き続きサポートされ、ここではPydanticモデルをそのまま渡せる
response = client.models.generate_content(
model="gemini-3.6-flash",
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=CountryInfo,
),
)
確認できなかったことも書いておきます。response_schema と response_json_schema が別々に共存するフィールドなのかは、SDKドキュメントサイトとGitHubのREADMEで説明が食い違っており確定できませんでした。レスポンスからパース済みオブジェクトを直接取り出す属性があるかどうかも同様です。正確なAPIは使用中のバージョンのドキュメントで確認してください。
Pydanticによるスキーマ検証の自動化
基本パターン
from pydantic import BaseModel, Field, validator
from typing import List, Optional, Literal
from enum import Enum
import json
class PriceRange(str, Enum):
CHEAP = "$"
MODERATE = "$$"
EXPENSIVE = "$$$"
VERY_EXPENSIVE = "$$$$"
class Restaurant(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
cuisine: str = Field(..., description="料理の種類")
price_range: PriceRange
rating: float = Field(..., ge=0.0, le=5.0)
address: Optional[str] = None
tags: List[str] = Field(default_factory=list, max_length=10)
@validator('rating')
def round_rating(cls, v):
return round(v, 1)
class RestaurantResponse(BaseModel):
restaurants: List[Restaurant] = Field(..., min_length=1, max_length=20)
query: str
total_count: int
# LLMレスポンスのパース + 検証
def parse_llm_response(raw_json: str) -> RestaurantResponse:
"""LLMレスポンスをパースしてPydanticで検証"""
try:
data = json.loads(raw_json)
return RestaurantResponse(**data)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}")
except Exception as e:
raise ValueError(f"Schema validation failed: {e}")
リトライパターン(Self-Healing)
from tenacity import retry, stop_after_attempt, retry_if_exception_type
class StructuredOutputParser:
def __init__(self, client, model: str, schema: type[BaseModel]):
self.client = client
self.model = model
self.schema = schema
@retry(
stop=stop_after_attempt(3),
retry=retry_if_exception_type(ValueError)
)
def parse(self, prompt: str) -> BaseModel:
"""スキーマ検証失敗時にエラーメッセージを含めてリトライ"""
schema_json = self.schema.model_json_schema()
messages = [
{
"role": "system",
"content": f"以下のJSONスキーマに従って応答してください:\n{json.dumps(schema_json, indent=2)}"
},
{"role": "user", "content": prompt}
]
# 前回の試行でエラーがあれば含める
if hasattr(self, '_last_error'):
messages.append({
"role": "user",
"content": f"前回の応答でエラーが発生しました: {self._last_error}\n正しいJSONで再度応答してください。"
})
response = self.client.chat.completions.create(
model=self.model,
messages=messages,
response_format={"type": "json_object"}
)
raw = response.choices[0].message.content
try:
data = json.loads(raw)
result = self.schema(**data)
if hasattr(self, '_last_error'):
del self._last_error
return result
except Exception as e:
self._last_error = str(e)
raise ValueError(str(e))
# 使用方法
parser = StructuredOutputParser(client, "gpt-4o", RestaurantResponse)
result = parser.parse("ソウルの韓国料理レストラン3つ推薦")
LiteLLMによるプロバイダー統合
import litellm
from pydantic import BaseModel
class ExtractedInfo(BaseModel):
summary: str
key_points: list[str]
sentiment: str
confidence: float
# OpenAI
response = litellm.completion(
model="gpt-4o",
messages=[{"role": "user", "content": "Kubernetes 1.35リリースを要約して"}],
response_format=ExtractedInfo
)
# Anthropic(自動的にTool Useに変換)
response = litellm.completion(
model="claude-sonnet-4-20250514",
messages=[{"role": "user", "content": "Kubernetes 1.35リリースを要約して"}],
response_format=ExtractedInfo
)
# Gemini
response = litellm.completion(
model="gemini/gemini-2.0-flash",
messages=[{"role": "user", "content": "Kubernetes 1.35リリースを要約して"}],
response_format=ExtractedInfo
)
# 同じコードで3つのプロバイダーを使用可能!
Instructorライブラリの活用
# pip install instructor
import instructor
from openai import OpenAI
from pydantic import BaseModel
from typing import List
client = instructor.from_openai(OpenAI())
class Step(BaseModel):
explanation: str
output: str
class MathSolution(BaseModel):
steps: List[Step]
final_answer: str
confidence: float
# Pydanticモデルを直接response_modelとして使用
solution = client.chat.completions.create(
model="gpt-4o",
response_model=MathSolution,
messages=[
{"role": "user", "content": "2x + 5 = 15を解いて"}
],
max_retries=3 # 自動リトライ
)
print(solution.steps[0].explanation)
print(f"答え: {solution.final_answer}")
# Anthropicも同様にサポート
import anthropic
anthropic_client = instructor.from_anthropic(anthropic.Anthropic())
solution = anthropic_client.messages.create(
model="claude-sonnet-4-20250514",
response_model=MathSolution,
max_tokens=1024,
messages=[
{"role": "user", "content": "3x - 7 = 20を解いて"}
]
)
プロダクションパイプラインの構築
FastAPI + Structured Output
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
import instructor
from openai import OpenAI
app = FastAPI()
client = instructor.from_openai(OpenAI())
class ProductReview(BaseModel):
sentiment: str # positive, negative, neutral
score: float
key_phrases: List[str]
summary: str
language: str
class ReviewRequest(BaseModel):
text: str
model: str = "gpt-4o-mini"
@app.post("/analyze", response_model=ProductReview)
async def analyze_review(request: ReviewRequest):
try:
result = client.chat.completions.create(
model=request.model,
response_model=ProductReview,
messages=[
{
"role": "system",
"content": "製品レビューを分析してください。"
},
{"role": "user", "content": request.text}
],
max_retries=2
)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
バッチ処理パイプライン
import asyncio
from typing import List
from openai import AsyncOpenAI
import instructor
async_client = instructor.from_openai(AsyncOpenAI())
class ExtractedEntity(BaseModel):
name: str
entity_type: str
confidence: float
class EntityExtractionResult(BaseModel):
entities: List[ExtractedEntity]
text_length: int
async def extract_entities(text: str) -> EntityExtractionResult:
return await async_client.chat.completions.create(
model="gpt-4o-mini",
response_model=EntityExtractionResult,
messages=[
{"role": "system", "content": "テキストからエンティティを抽出してください。"},
{"role": "user", "content": text}
]
)
async def batch_extract(texts: List[str], concurrency: int = 5):
"""同時実行数を制限してバッチ処理"""
semaphore = asyncio.Semaphore(concurrency)
async def limited_extract(text):
async with semaphore:
return await extract_entities(text)
tasks = [limited_extract(text) for text in texts]
results = await asyncio.gather(*tasks, return_exceptions=True)
successes = [r for r in results if not isinstance(r, Exception)]
failures = [r for r in results if isinstance(r, Exception)]
print(f"成功: {len(successes)}, 失敗: {len(failures)}")
return successes
# 実行
texts = ["ソウルに新しいAIスタートアップが...", "サムスン電子が半導体...", ...]
results = asyncio.run(batch_extract(texts))
実行すると実際に何が起きるのか
スキーマを付けたリクエストは、普通のリクエストとは違う挙動をします。この違いを知らないと、正常な動作を障害と誤解し、本物の障害を正常として見送ってしまいます。
最初のリクエストは遅い
2つのプロバイダーが同じことを言っています。OpenAIドキュメントの表現は、どんなスキーマでも最初のリクエストはAPIがスキーマを処理する間に追加のレイテンシが発生するが、以降のリクエストには発生しないというものです。Anthropicも最初のリクエストで文法コンパイルの遅延があると案内しています。コンパイル済みの文法は最終使用時点から24時間キャッシュされ、スキーマ構造やtool集合が変わると無効化されますが、name や description だけを直した場合は無効化されません。
レイテンシを計測するときは最初の呼び出しを捨てるべきです。そうしないとp99がスキーマのコンパイル時間をそのまま反映します。
レスポンスで必ず見るべきフィールド
Anthropicで stop_reason が refusal の場合、出力がスキーマと一致しない可能性があります。max_tokens ならJSONが途中で切れたという意味で、パースは必ず失敗します。答えはリトライではなく max_tokens を上げることです。同じプロンプトを投げ直せば同じ長さでまた切れます。
OpenAI Chat Completionsでは finish_reason が stop、length、content_filter、tool_calls のいずれかで返ります。アシスタントメッセージには文字列かnullの refusal フィールドが別にあります。parse() ヘルパーは、長さ超過なら LengthFinishReasonError を、コンテンツフィルターなら ContentFilterFinishReasonError を投げます。
静かな罠はResponses APIです。ここには finish_reason がありません。代わりに response.status が incomplete かどうか、response.incomplete_details.reason が max_output_tokens かどうかを見る必要があります。Chat Completions時代のコードをそのまま移すと、その分岐は永遠に真になりません。
# 同じ事故を3つの経路それぞれで捕まえる方法
# Chat Completions
if completion.choices[0].finish_reason == "length":
raise RuntimeError("出力が切れた — max_tokensを上げること")
refusal = completion.choices[0].message.refusal
if refusal:
raise RuntimeError(f"モデルが拒否した: {refusal}")
# Responses API — finish_reasonがそもそも無い
if response.status == "incomplete":
if response.incomplete_details.reason == "max_output_tokens":
raise RuntimeError("出力が切れた — max_output_tokensを上げること")
# Anthropic
if message.stop_reason == "max_tokens":
raise RuntimeError("JSONが切れた — max_tokensを上げること")
if message.stop_reason == "refusal":
raise RuntimeError("拒否応答 — 出力がスキーマと一致しない可能性がある")
エンドツーエンドの例: サポートチケット分類器
断片を一つにつなげてみます。サポートチケットを受け取り、カテゴリ、深刻度、一行要約、人が見るべきかどうかを取り出す分類器です。前の2つのフィールドは値の集合が固定されていて、下流ではその値でルーティングするため、タイプミス一つでチケットを丸ごと失います。
1. スキーマの定義
from typing import Literal, Optional
from pydantic import BaseModel, Field
class TicketTriage(BaseModel):
category: Literal["billing", "bug", "feature_request", "account", "other"]
severity: Literal["p0", "p1", "p2", "p3"]
summary: str = Field(description="一文の要約、60文字以内")
needs_human: bool
suggested_owner: Optional[str] = None
Literal を使った2つのフィールドが核心です。JSON Schemaに落ちるときに enum になり、制約デコーディングがその値以外を生成できないようにします。プロンプトはお願いで、enum は強制です。
2. 呼び出し
from anthropic import Anthropic
client = Anthropic()
TICKET = """
決済が二重に請求されました。カードの明細に8月3日付で同じ金額が
2件記載されています。注文番号はA-91823です。返金をお願いします。
"""
response = client.messages.parse(
model="claude-sonnet-5",
max_tokens=2000,
messages=[
{"role": "user", "content": f"次のサポートチケットを分類してください。\n\n{TICKET}"}
],
output_format=TicketTriage,
)
if response.stop_reason == "max_tokens":
raise RuntimeError("切れた — max_tokensを上げること")
triage = response.parsed_output
print(triage.category, triage.severity, triage.needs_human)
3. 返ってくるもの
response.parsed_output はすでに検証を通った TicketTriage インスタンスです。シリアライズするとこういう形になります。
{
"category": "billing",
"severity": "p1",
"summary": "8月3日に同額の二重請求、注文番号A-91823、返金依頼",
"needs_human": true,
"suggested_owner": "billing-ops"
}
category が定義した5つの値の中に収まることは制約デコーディングが保証します。severity が妥当かどうかは保証されません。形式だけが保証され、判断の質は別の話です。
suggested_owner はプロバイダーによって分かれます。同じ TicketTriage をOpenAIに渡すと、strict のルールによってこのフィールドも required に入り、代わりに型が文字列とnullの両方を許す形になります。OpenAIではキーが常に存在して値がnullになりうる一方、Anthropicではキー自体が欠けることがあります。辞書の角括弧で読むと片方だけでKeyErrorになります。Pydanticインスタンスとして受け取れば、どちらも None に正規化されます。
4. 同じスキーマをOpenAI Responsesで
from openai import OpenAI
client = OpenAI()
response = client.responses.parse(
model="gpt-4o-mini",
input=[{"role": "user", "content": f"次のサポートチケットを分類してください。\n\n{TICKET}"}],
text_format=TicketTriage,
)
if response.status == "incomplete":
if response.incomplete_details.reason == "max_output_tokens":
raise RuntimeError("切れた — max_output_tokensを上げること")
変わったのはクライアント、引数名(messages と input、output_format と text_format)、そして切れを検知する方法だけです。TicketTriage の定義は一文字も触っていません。スキーマをPydanticモデル一つに保ち、呼び出し部分だけを薄く包めば、プロバイダー乗り換えのコストはこの程度に収まります。
スキーマの制約 — プロバイダーごとに対応範囲が違う
JSON Schemaは標準ですが、2つのプロバイダーが対応する部分集合は互いに異なり、包含関係にもありません。enum と anyOf は両方で使えます。問題は残りです。
| 機能 | OpenAI | Anthropic |
|---|---|---|
allOf | 非対応 | 対応(内部参照との併用は不可) |
minimum / maximum | 対応 | 非対応 |
multipleOf | 対応 | 非対応 |
minLength / maxLength | 非対応 | 非対応 |
minItems | 対応 | 0または1のみ |
maxItems | 対応 | 対応リストに記載なし |
additionalProperties | false が必須 | false 以外の値は非対応 |
Anthropicは文字列の format としてdate-time、date、duration、email、hostname、uri、ipv4、ipv6、uuidに対応し、const と default も使えます。内部参照は可能ですが外部参照は不可で、再帰スキーマにも対応していません。ツリーのように自分自身を参照するモデルは別の方法を探す必要があります。OpenAI側には明示された上限があります。オブジェクトのプロパティは全体で5000個、ネストは10段階、文字列長の合計は120,000文字、enum値はすべてのenumプロパティを合わせて1000個までです。
結論は非対称性です。OpenAIは数値の制約に対応する一方で文字列長の制約には対応せず、Anthropicはどちらにも対応しません。片方で問題なく通っていたスキーマが、もう片方では400で拒否されうるということです。マルチプロバイダーを想定するなら、スキーマを2つの範囲の共通部分に絞り、長さや範囲の検証はPydanticの段階でかけるほうが移植性が高くなります。
失敗事例 — 症状から原因へ
ログに先に出るのが症状で、原因は常にその後ろにあります。
ウォームアップしてもレイテンシが跳ね続ける
キャッシュが毎回無効化されています。リクエストごとにスキーマの辞書を作り直してキーの順序が変わったり、ユーザー入力に応じてenumのリストを動的に埋めるコードが代表的です。スキーマは起動時に一度作って定数として持つべきです。
400 — additionalProperties関連のエラー
OpenAIで strict を有効にしたのに additionalProperties が無い、または true の場合です。false に、しかもネストしたすべてのオブジェクトに個別に明示する必要があり、すべてのプロパティが required に入っている必要もあります。Anthropicで問題なく動いていたスキーマをそのまま持ってきたときに最初に壊れる箇所です。
400 — citationsと併用したとき
Anthropicでは構造化出力とcitationsを併用できず400になります。根拠の追跡が必要な段階と構造化が必要な段階を一回の呼び出しにまとめようとするところから問題が始まります。
JSONのパースが断続的に失敗する
入力が長いときや結果の配列が大きいときだけ失敗するなら、ほぼ確実に切れです。前節の3つのシグナルを順に確認し、出力トークンの上限を上げても足りなければ、一度に取り出す項目数を減らしてバッチを分割します。逆にパースは通るのに値がおかしい場合は、stop_reason が refusal のケースを見落としています。
LiteLLMに切り替えたら検証が消えた
InstructorとLiteLLMは似て見えますが、返すものが違います。Instructorは検証まで済んだオブジェクトを返します。LiteLLMの completion に response_format としてPydanticモデルを渡すと受け付けられますが、返ってくるのはJSONのテキストで、検証は自分でやる必要があります。
from litellm import completion, get_supported_openai_params, supports_response_schema
resp = completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "..."}],
response_format=EventsList,
)
# ここまでは文字列。検証は自分の仕事。
events = EventsList.model_validate_json(resp.choices[0].message.content)
この違いを知らずに乗り換えると、検証の段階が丸ごと消えたまま本番に出ます。型ヒントはそのままなので静的解析にも引っかかりません。LiteLLMには get_supported_openai_params と supports_response_schema というヘルパーがあるので、起動時に一度確認しておくとよいです。
Instructorの現在の推奨エントリポイントは instructor.from_provider です。from_openai と from_litellm もそのままあり、from_anthropic や from_genai は該当プロバイダーのパッケージがインストールされているときに使えます。instructor.patch は残っていてdeprecatedでもありませんが、ドキュメントは手動パッチを推奨していません。
スキーマを使わないほうがいいとき
スキーマはタダではありません。合わない場所に無理に入れると、出力の品質が落ちてデバッグが難しくなります。
自由な生成が目的のとき。記事や要約のように成果物そのものが散文である作業にスキーマを被せると、モデルは形式を満たすことに手一杯で内容に使う余裕を失います。散文のフィールドを一つ包むだけのスキーマは、たいてい飾りです。
カーディナリティが高い抽出のとき。値の種類が数百から数千あるフィールドを enum で固定しようとする試みは、たいてい失敗します。リストが長くなるほどスキーマがプロンプトを押しのけます。自由な文字列で取り出して、後段で辞書照合や埋め込みマッチングによって正規化するほうが正確です。
普通のプロンプトにリトライ一回のほうが安いとき。一日に数回動くスクリプトや、人が目で確認する分析であれば、スキーマの設計と維持のコストが得られるものを上回ります。スキーマが本領を発揮するのは、結果が人を経由せずそのまま次のシステムへ流れるときです。
スキーマが作業と衝突するとき。モデルが知りえない情報を必須フィールドで強制すると、空欄のままにできず、それらしい値を作り出します。制約デコーディングは文法に合うトークンだけを通すので、知らないフィールドについても形式だけは合う答えを必ず作ります。これはスキーマが生んだハルシネーションです。確信のないフィールドはオプションにするか、信頼度のフィールドを併せて持つべきです。
ネストが深いとき。10段階の制限に近づくスキーマは、たいてい設計が間違っています。段階を分けて各呼び出しが浅いスキーマを扱うようにするほうが、精度とデバッグの両面で優れています。
まとめ
Structured Outputは、LLMをプロダクションシステムに統合する上で核心となる技術です:
- OpenAI:
response_format+ Structured Outputsでスキーマ100%保証 - Anthropic: Tool Useを活用した間接的な方式だが安定的
- Instructor/LiteLLM: プロバイダー統合によるコード再利用
- Pydantic: スキーマ定義 + 検証の標準
- リトライパターン: Self-healingによる安定性確保
付け加えると、この分野は半年でドキュメントが変わります。この記事の前半も、5か月で2つのプロバイダーの推奨経路が変わりました。
参考資料
この記事を書き直しながら実際に確認したドキュメントです。確認日から離れるほど、原文を先に見るのが正解です。
- Anthropic Structured Outputs — https://platform.claude.com/docs/en/build-with-claude/structured-outputs (2026-08-16 確認)
- OpenAI Structured Outputs — https://developers.openai.com/api/docs/guides/structured-outputs (2026-08-16 確認)。以前のplatform.openai.comのパスは現在こちらへ301リダイレクトされます。
- Gemini APIライブラリ — https://ai.google.dev/gemini-api/docs/libraries (2026-08-16 確認)
- Gemini構造化出力 — https://ai.google.dev/gemini-api/docs/structured-output (2026-08-16 確認)
ここで説明したバージョンは openai 3.1.0、instructor 1.15.4、litellm 1.96.2、そして google-genai です。モデルIDは claude-opus-5、claude-sonnet-5、claude-haiku-4-5、claude-fable-5 を使いました。ここに無いメソッド名やフィールドは推測せず、正確なAPIは使用中のバージョンのドキュメントで確認してください。
クイズ(6問)
Q1. OpenAIのJSON ModeとStructured Outputsの違いは? JSON Modeは有効なJSONのみを保証、Structured Outputsは指定したスキーマまで保証
Q2. AnthropicでStructured Outputを実装する方式は? Tool Use(Function Calling)を活用し、input_schemaで構造化された出力を受け取る
Q3. PydanticのField(ge=0.0, le=5.0)は何を意味するか? 値が0.0以上5.0以下でなければならないという検証条件
Q4. instructorライブラリのmax_retries機能とは? スキーマ検証失敗時に自動的にリトライして正しい形式を取得する
Q5. バッチ処理におけるasyncio.Semaphoreの役割は? 同時API呼び出し数を制限してrate limitの超過を防止
Q6. LiteLLMを使用する最大の利点は? 同じコードでOpenAI、Anthropic、Geminiなど複数のプロバイダーを切り替え可能
クイズ
Q1: 「LLM Structured Output 実践ガイド — JSON Mode、Tool
Use、Pydanticスキーマ検証」の主なトピックは何ですか?
OpenAI、Anthropic、GoogleのStructured Output方式を比較し、Pydanticスキーマ検証からプロダクションパイプライン構築まで実践コードで解説します。
Q2: プロバイダー別Structured Output比較とは何ですか?
OpenAI: response_format + Structured Outputs Anthropic: Tool Useを使ったStructured Output Google
Gemini: responseSchema
Q3: Pydanticによるスキーマ検証の自動化の核心的な概念を説明してください。
基本パターン リトライパターン(Self-Healing)
Q4: プロダクションパイプラインの構築の主な手順は何ですか?
FastAPI + Structured Output バッチ処理パイプライン