OpenAI API で構造化出力(JSON mode / Structured Outputs)を使う
概要
LLM をアプリに組み込むとき、最初にぶつかるのが「返ってくるテキストが毎回少しずつ違う」という問題です。プロンプトで「JSON で返して」と頼んでも、前後に前置きが付いたり、コードフェンスで囲まれたり、キー名が揺れたりします。そのたびに後処理のパースを頑張ることになります。
OpenAI API には、この問題を API 側で吸収する仕組みが 2 段階あります。
- JSON mode — 出力が必ず妥当な JSON になることを保証する(スキーマは保証しない)
- Structured Outputs — JSON Schema を渡し、出力がそのスキーマに厳密一致することを保証する
本記事では、この 2 つの違いと使い分け、Python での実装、ハマりどころを整理します。
環境
- Python 3.11
openai(Python SDK v1 系)pydantic
pip install openai pydantic
APIキーは環境変数 OPENAI_API_KEY で渡す前提です(コードに直書きしない)。
from openai import OpenAI client = OpenAI() # OPENAI_API_KEY を環境変数から読む
発生した問題
素朴に「JSON で返して」とだけ頼むと、実運用では次の揺れが起きます。
- コードフェンス(```
`json```)で囲まれる - 先頭に「はい、分類しました:」のような前置きが付く
- キー名が
labelだったりsentimentだったり安定しない - まれに JSON が壊れている
これを json.loads() にそのまま渡すと例外が飛び、リトライやサニタイズを書くことになり、コードが防御的になっていきます。
原因
これは LLM の確率的な性質そのものが原因です。プロンプトは「出力の傾向」を誘導できますが、構文的な保証はできません。保証が欲しいなら、トークン生成の段階で制約をかける仕組みが必要です。それが JSON mode と Structured Outputs です。
対応方法
1. JSON mode(妥当な JSON を保証する)
resp = client.chat.completions.create(
model="gpt-4o-mini",
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": "あなたはJSONのみを返すAPIです。"},
{"role": "user", "content": "『思ったより良かった』の感情を label と score のJSONで返して。"},
],
)
print(resp.choices[0].message.content) # {"label": "positive", "score": 0.82}
JSON として妥当であることは保証されます。ただし次の制約があります。
- スキーマ(キー名・型・必須)は保証されない → プロンプト頼み
- プロンプトに「JSON」という単語を含める必要がある
- 出力上限に達すると JSON が途中で切れることがある
2. Structured Outputs(スキーマまで保証する)
strict: true を指定すると、出力が JSON Schema に厳密一致することが保証されます。
schema = {
"name": "sentiment",
"strict": True,
"schema": {
"type": "object",
"properties": {
"label": {"type": "string", "enum": ["positive", "negative", "neutral"]},
"score": {"type": "number"},
},
"required": ["label", "score"],
"additionalProperties": False,
},
}
resp = client.chat.completions.create(
model="gpt-4o-mini",
response_format={"type": "json_schema", "json_schema": schema},
messages=[{"role": "user", "content": "『思ったより良かった』の感情を分類して。"}],
)
print(resp.choices[0].message.content) # {"label":"positive","score":0.82}
label は必ず enum の 3 値、score は必ず number、両方 required、余計なキーは生えません。
3. Pydantic で書く(実務ではこれが楽)
from pydantic import BaseModel
from enum import Enum
class Label(str, Enum):
positive = "positive"
negative = "negative"
neutral = "neutral"
class Sentiment(BaseModel):
label: Label
score: float
resp = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "『思ったより良かった』の感情を分類して。"}],
response_format=Sentiment,
)
result = resp.choices[0].message.parsed # Sentiment 型のインスタンス
print(result.label, result.score) # => Label.positive 0.82
parsed は最初から Pydantic モデルのインスタンスなので、型補完もバリデーションも効きます。
実装例:情報抽出
from pydantic import BaseModel
class Inquiry(BaseModel):
category: str
urgency: int # 1-5
needs_human: bool
summary: str
resp = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "問い合わせ文を分類・要約する。"},
{"role": "user", "content": "電源が入らない初期不良。今日中に交換可否を知りたい。"},
],
response_format=Inquiry,
)
inq = resp.choices[0].message.parsed
if inq.urgency >= 4 and inq.needs_human:
print("→ 即エスカレーション:", inq.summary)
出力が型に収まっているので、分岐をそのまま書けます。LLM の出力をビジネスロジックに直結できるのが構造化出力の価値です。
確認方法
msg = resp.choices[0].message
if msg.refusal: # 安全上の理由で拒否された場合
print("拒否されました:", msg.refusal)
else:
print(msg.parsed)
- JSON mode:
json.loads()が通ること+中身を別途アサート - Structured Outputs:
parsedがNoneでないこと+Pydantic バリデーション通過 finish_reasonがlengthなら途中切断を疑う
注意点
- 対応モデルを確認する。
json_schema+strictは比較的新しい世代のモデルで利用可。古いモデルは JSON mode まで。 - strict の前提: 全プロパティ
required+additionalProperties: false。手書きで省くとエラー。Pydantic 経由なら自動で満たされる。 - スキーマ制約: 一部の JSON Schema キーワードや過度な再帰は未対応のことがある。
- 出力上限での切断に注意。構造化出力でも
max_tokensに達すると途中で止まる。 - 保証されるのは「形」であって「中身の正しさ」ではない。型は守られても値の妥当性は別問題。
まとめ
- とにかく壊れない JSON が欲しいなら JSON mode。
- キー・型・必須まで保証してアプリの型に直結したいなら Structured Outputs(実務は Pydantic 経由)。
- 構造化出力を使うと後処理のパースから解放され、出力をそのまま分岐・保存・連携に回せます。
- ただし保証されるのは「形」だけ。中身の妥当性は従来どおり自分で検証する必要があります。
LLM をアプリの部品として扱う第一歩は、出力を「文字列」から「型」に変えることです。構造化出力はそのための最も実用的な入口です。
