- AI ⚙️
- 技術 💻
LLMの構造化出力とJSON|4方式の違いとPydantic検証の実行結果
LLMの構造化出力とは、モデルの回答を自由な文章ではなく、決められた形のJSONなどで受け取るための仕組みです。 プログラムで回答を扱うなら、「JSONとして読めること」と「必要な項目が決められた型でそろっていること」の2つが欠かせません。
いまは主要なAPIがJSON Schema(JSONの形を定義する書式)によるスキーマ強制に対応しており、プロンプトで「JSONで返して」と頼むだけの時代より、ずっと確実に扱えるようになりました。この記事では、4つの方式の違いと選び方、OpenAI・Claude・GeminiでのJSON Schemaの指定方法(2026年9月時点の公式ドキュメントで確認)、そしてPydanticで壊れたJSONや型違いを検出してリトライする実行結果をまとめます。各社のAPIはAPIキーがない環境のため未実行ですが、SDKに該当する関数や引数があることは実際にインストールして確認しました。Claude Codeの claude -p --json-schema は実行した出力を載せています。
LLMでJSONを出力させる4方式の早見表
| 方式 | JSONとして読める保証 | スキーマどおりの保証 | 指定のしかた | 向いている場面 |
|---|---|---|---|---|
| プロンプトだけ | なし | なし | 指示文に「JSONで」「例:」を書く | 試作、対応APIがないモデル |
| JSONモード | あり | なし | json_object や MIMEタイプ application/json |
形が緩くてよい出力 |
| スキーマ強制(Structured Outputs) | あり | あり(対応範囲内) | JSON Schemaを渡す | 本番のデータ抽出・分類 |
| ツール呼び出し(strict) | あり | あり(strict指定時) | ツールの入力スキーマで定義 | 回答をそのまま関数の引数に使う |
迷ったら「スキーマ強制+Pydanticでの検証」が基本形です。スキーマ強制でも、途中で出力が打ち切られた場合などは形が崩れることがあるため、受け取る側の検証は残しておきます。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「LLMの構造化出力とJSON|4方式の違いとPydantic検証の実行結果」
4つの方式はどう違うのか
早見表の4方式は、「どこまでを生成時に保証し、どこからを受け取り側で確かめるか」が違います。
- プロンプトだけ:多くの場合うまくいきますが、前置きの文章がつく、Markdownのコードブロック記号で囲まれる、項目が抜けるといった揺れが起きます
- JSONモード:JSONとして読めることだけを保証します。どの項目が入るか、型が何かまでは保証しません
- スキーマ強制:モデルが生成する文字列そのものをスキーマに合う形に制約します。必須項目の欠けや
enum(選択肢)以外の値を防げますが、使えるキーワードには各社で制限があります - ツール呼び出し(strict):スキーマを「関数の引数」として定義します。複数の処理から選ばせたいときや、回答をそのまま処理関数に渡したいときに向いています
選び方をフローにすると次のとおりです。
flowchart TD
A[回答をプログラムで使う?] -->|いいえ| P[プロンプトだけで十分]
A -->|はい| B[項目と型を決めたい?]
B -->|いいえ| J[JSONモード]
B -->|はい| C[複数の処理から選ばせたい?<br>回答を関数の引数にしたい?]
C -->|はい| T[ツール呼び出し strict]
C -->|いいえ| S[スキーマ強制 Structured Outputs]
S --> V[受け取り側でPydantic検証]
T --> V
各社APIでのJSON Schemaの指定方法(2026年9月時点)
3社とも、Pydanticのモデル(クラス)からスキーマを作って渡せます。 主な指定場所は次のとおりです。
| API | スキーマ強制の指定場所 | Pythonでの便利な書き方 | JSONモード相当 |
|---|---|---|---|
| OpenAI(Responses API) | text.format に type: "json_schema"、strict: true |
client.responses.parse(text_format=モデル) |
type: "json_object" |
| Anthropic(Claude API) | output_config.format に type: "json_schema" |
client.messages.parse(output_format=モデル) |
(スキーマ強制を使う) |
| Google(Gemini API) | response_format に mime_type: "application/json" と schema |
schema=モデル.model_json_schema() で渡し、model_validate_json で受け取る |
スキーマなしの application/json |
以下のコードは各社の公式ドキュメントの例に沿って書いています。APIキーがないため実際のAPI呼び出しは未実行です。そのかわり、次のSDKをインストールし、該当する関数や引数があることを確認しました(Python 3.13)。
| SDK | バージョン | 確認できたこと |
|---|---|---|
| openai | 3.19.2 | Responses.parse に text_format 引数がある |
| anthropic | 1.8.0 | Messages.parse に output_format、Messages.create に output_config がある |
| google-genai | 2.25.0 | Client.interactions があり、response_format を受け取る |
モデル名は公式ドキュメントの例に合わせています。使えるモデルは各社のモデル一覧で確認してください。
OpenAI(Responses API)
from pydantic import BaseModel
from openai import OpenAI
client = OpenAI()
class CalendarEvent(BaseModel):
name: str
date: str
participants: list[str]
response = client.responses.parse(
model="gpt-6-astra",
input=[
{"role": "system", "content": "Extract event information."},
{"role": "user", "content": "Alice and Bob attend a science fair Friday."},
],
text_format=CalendarEvent,
)
event = response.output_parsed # CalendarEvent のインスタンス
スキーマを直接書く場合は、text: {format: {type: "json_schema", name: "event", strict: true, schema: {...}}} の形です。strictモードでは、すべての項目を required に入れ、additionalProperties: false にする必要があります。省略可能な項目は、["string", "null"] のように null との組み合わせで表します。安全上の理由でモデルが回答を断った場合は、断ったことがプログラムから判別できる形で返ります。
Anthropic(Claude API)
Claude APIではスキーマ強制(JSON outputs)が一般提供になっており、output_config.format で指定します。以前の output_format パラメーター(ベータヘッダーが必要)は非推奨です。
from pydantic import BaseModel
from anthropic import Anthropic
class ContactInfo(BaseModel):
name: str
email: str
plan_interest: str
demo_requested: bool
client = Anthropic()
response = client.messages.parse(
model="claude-opus-5-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": "Extract the key information from this email: John Smith (john@example.com) is interested in our Enterprise plan and wants to schedule a demo for next Tuesday at 2pm.",
}],
output_format=ContactInfo,
)
print(response.parsed_output)
Python SDKの parse() は、output_format を内部で output_config.format に変換します。ほかの言語のSDKやHTTPで直接呼ぶ場合は、output_config を使います。
ツール呼び出しで使う場合は、ツール定義に "strict": true を付け、input_schema にスキーマを書きます(required と additionalProperties: false を忘れずに)。
Claude APIで押さえておきたい点は次のとおりです。
| 注意点 | 内容 |
|---|---|
| 使えないキーワード | minimum・maximum・minLength・maxLength・pattern、oneOf・allOf など。SDKが数値の範囲などを説明文に移して送る |
format |
date・time・date-time・email・uuid・uri などに限られる |
| スキーマに合わない出力になる場合 | stop_reason が refusal(安全上の拒否)または max_tokens(途中で打ち切り) |
enum の大文字小文字 |
先頭の大文字・小文字が変わることがあるので、比較は大文字小文字を区別せずに行う |
| 複雑さの上限 | strictツールは1リクエスト20個まで、省略可能な項目は合計24個まで など |
| 併用できないもの | 引用(citations)、アシスタント応答の先頭埋め(prefill) |
Google(Gemini API)
Geminiの公式ドキュメントは、Interactions API(client.interactions.create)での書き方を中心に案内しています。
from google import genai
from pydantic import BaseModel, Field
class Recipe(BaseModel):
recipe_name: str = Field(description="The name of the recipe.")
ingredients: list[str]
client = genai.Client()
interaction = client.interactions.create(
model="gemini-3.8-flash",
input="Please extract the recipe from the following text. ...",
response_format={
"type": "text",
"mime_type": "application/json",
"schema": Recipe.model_json_schema(),
},
)
recipe = Recipe.model_validate_json(interaction.output_text)
従来の client.models.generate_content でも構造化出力は使えます。google-genai 2.25.0 の GenerateContentConfig では、response_mime_type と response_json_schema が引き続き定義されていますが、説明文に「response_format を使うように」と非推奨の案内が付いていました。新しく書くならドキュメントの最新の例に合わせるのが安心です。Geminiは minimum・maximum・minItems なども扱えますが、対応していないキーワードは無視され、大きすぎる・深すぎるスキーマは拒否されることがあります。
3社で通るスキーマ設計のコツ
各社の制限の「共通部分」に寄せておくと、モデルを切り替えても同じスキーマを使い回せます。
| コツ | 理由 |
|---|---|
すべての項目を required に入れる |
省略可能な項目の扱いが各社で違う。省略可は null を許す形で表す |
additionalProperties: false を付ける |
想定外の項目を防ぐ。OpenAIのstrictでは必須 |
選択肢は enum で書く |
自由記述より揺れが減る |
| 範囲・桁数・正規表現は受け取り側で検証する | minimum や pattern は非対応の場合がある |
description に意味と単位を書く |
例:「見積もり時間(時間単位の整数)」 |
| 入れ子は浅く、項目数は必要最小限に | 複雑さの上限やコンパイル時間の負担を避ける |
Pydanticで検証する(実行結果つき)
スキーマ強制を使っても、打ち切りや拒否のときは形が崩れます。プロンプトだけの方式ならなおさらです。そこで、受け取り側でPydanticのモデルに通して検証します。
以下は、LLMの応答を模したサンプル文字列を検証したコードと、実際の実行結果です(Python 3.13.7、pydantic 2.13.5)。
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class Task(BaseModel):
model_config = ConfigDict(extra="forbid") # 想定外の項目も弾く
title: str = Field(min_length=1)
priority: Literal["low", "medium", "high"]
estimate_hours: int = Field(ge=0)
tags: list[str]
# LLMの応答を模したサンプル文字列(実際のAPI応答ではありません)
samples = {
"正常": '{"title": "ログイン画面の文言修正", "priority": "low", "estimate_hours": 2, "tags": ["ui"]}',
"壊れたJSON": '{"title": "検索APIの改修", "priority": "high", "estimate_hours": 8, "tags": ["api"],}',
"途中で切れた": '{"title": "CSV出力の追加", "priority": "medium", "estim',
"項目が欠けている": '{"title": "README更新", "priority": "low", "tags": []}',
"型と値が違う": '{"title": "バッチの高速化", "priority": "urgent", "estimate_hours": "三時間", "tags": "perf"}',
"余計な項目": '{"title": "権限チェック", "priority": "high", "estimate_hours": 5, "tags": [], "note": "急ぎ"}',
}
for label, text in samples.items():
try:
task = Task.model_validate_json(text)
print(f"[OK] {label}: {task}")
except ValidationError as e:
print(f"[NG] {label}: {e.error_count()}件のエラー")
for err in e.errors():
loc = ".".join(str(x) for x in err["loc"]) or "(全体)"
print(f" - {loc}: {err['type']} / {err['msg']}")
実行結果:
[OK] 正常: title='ログイン画面の文言修正' priority='low' estimate_hours=2 tags=['ui']
[NG] 壊れたJSON: 1件のエラー
- (全体): json_invalid / Invalid JSON: trailing comma at line 1 column 90
[NG] 途中で切れた: 1件のエラー
- (全体): json_invalid / Invalid JSON: EOF while parsing a string at line 1 column 60
[NG] 項目が欠けている: 1件のエラー
- estimate_hours: missing / Field required
[NG] 型と値が違う: 3件のエラー
- priority: literal_error / Input should be 'low', 'medium' or 'high'
- estimate_hours: int_parsing / Input should be a valid integer, unable to parse string as an integer
- tags: list_type / Input should be a valid array
[NG] 余計な項目: 1件のエラー
- note: extra_forbidden / Extra inputs are not permitted
model_validate_json を使うと、JSONの読み込みと型の検証を1回で行えます。 エラーには項目名(loc)と種類(type)が付くので、ログの集計やリトライ時の指示にそのまま使えます。
文字列の "4" は通る:strictモードとの違い
Pydanticは標準で型を柔軟に変換する(laxモード)ため、"estimate_hours": "4" のような文字列の数字は整数4として通ります。厳密に弾きたい場合は strict=True にします。実行結果は次のとおりです。
class Strict(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
estimate_hours: int
'{"estimate_hours": "4"}' を、標準のモデル(Lax)とこの Strict に通した結果:
Lax : estimate_hours=4
Strict: int_type / Input should be a valid integer
また、extra="forbid" にしたモデルから model_json_schema() でスキーマを作ると、"additionalProperties": false が自動で入ることも確認できました。Pydanticのモデルを「スキーマの原本」にしておくと、APIに渡すスキーマと検証ルールを一致させやすくなります。
検証エラーを伝えてリトライする(実行結果つき)
検証に失敗したら、エラー内容をモデルに伝えて出し直してもらうのが定番です。次のコードでは、本物のAPIの代わりに、決まった応答を順番に返すダミー関数 call_llm を使っています。1回目は「コードブロックで囲まれ、priority が大文字、tags が欠けた」応答、2回目は正しい応答です。
import json
from pydantic import ValidationError
# Task クラスは前の節と同じ定義を使います
_fake_replies = iter([
'```json\n{"title": "通知メールの改善", "priority": "High", "estimate_hours": "4"}\n```',
'{"title": "通知メールの改善", "priority": "high", "estimate_hours": 4, "tags": ["mail"]}',
])
def call_llm(messages: list[dict]) -> str: # 本番では各社APIを呼ぶ
return next(_fake_replies)
def strip_code_fence(text: str) -> str:
t = text.strip()
if t.startswith("```"):
t = t.split("\n", 1)[1] if "\n" in t else ""
t = t.rsplit("```", 1)[0]
return t.strip()
def generate_task(prompt: str, max_attempts: int = 3) -> Task:
schema = json.dumps(Task.model_json_schema(), ensure_ascii=False)
messages = [{"role": "user", "content": f"{prompt}\n次のJSON Schemaに合うJSONだけを返してください:\n{schema}"}]
for attempt in range(1, max_attempts + 1):
raw = call_llm(messages)
try:
task = Task.model_validate_json(strip_code_fence(raw))
print(f"attempt {attempt}: OK")
return task
except ValidationError as e:
problems = "; ".join(f"{'.'.join(map(str, x['loc']))}: {x['msg']}" for x in e.errors())
print(f"attempt {attempt}: NG -> {problems}")
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"次の点を直して、JSONだけを返してください: {problems}"},
]
raise RuntimeError("最大試行回数を超えました")
print(generate_task("次の依頼をタスクにしてください:通知メールの文面を改善したい"))
実行結果:
attempt 1: NG -> priority: Input should be 'low', 'medium' or 'high'; tags: Field required
attempt 2: OK
title='通知メールの改善' priority='high' estimate_hours=4 tags=['mail']
1回目は、コードブロックの除去には成功したうえで、priority の大文字(High)と tags の欠けを検出しています。estimate_hours の "4" は、前の節のとおりlaxモードなので通っています。リトライは回数の上限を必ず決め、上限を超えたら例外にして人や別処理に回すのが安全です。
リトライ設計のチェックリストです。
| チェック項目 | ポイント |
|---|---|
| 上限回数を決めたか | 2〜3回が目安。無限ループを防ぐ |
| エラー内容を具体的に伝えているか | 項目名とメッセージをそのまま渡す |
打ち切り(max_tokens)を見分けているか |
形の問題ではないので、上限トークンを増やして再実行する |
拒否(refusal)を見分けているか |
同じ依頼を繰り返しても結果は変わりにくい。別の処理に回す |
| 失敗をログに残しているか | エラーの type で集計するとスキーマ改善のヒントになる |
Claude Codeの claude -p --json-schema で試す(実行結果)
Claude Code(v2.1.283)の非対話モードでは、--output-format json と --json-schema を組み合わせると、結果のJSONの structured_output にスキーマどおりの値が入ります。手元で構造化出力の感触をつかむのに便利です。実際に次のコマンドを1回実行しました(ツールは使わせない設定です)。
SCHEMA='{"type":"object","properties":{"title":{"type":"string"},"priority":{"type":"string","enum":["low","medium","high"]},"estimate_hours":{"type":"integer"},"tags":{"type":"array","items":{"type":"string"}}},"required":["title","priority","estimate_hours","tags"],"additionalProperties":false}'
claude -p "次の依頼を1件のタスクにしてください:通知メールの文面が読みにくいので、半日くらいで改善したい" \
--model sonnet --tools "" \
--output-format json --json-schema "$SCHEMA"
出力の一部(jq で主要な項目だけを抜き出したもの):
{
"type": "result",
"subtype": "success",
"is_error": false,
"result": "{\"title\":\"通知メールの文面を読みやすく改善する\",\"priority\":\"medium\",\"estimate_hours\":4,\"tags\":[\"notification\",\"email\",\"content\"]}",
"structured_output": {
"title": "通知メールの文面を読みやすく改善する",
"priority": "medium",
"estimate_hours": 4,
"tags": ["notification", "email", "content"]
},
"num_turns": 2
}
「半日くらい」が estimate_hours: 4 に変換され、priority も選択肢の中から選ばれています。この structured_output を前の節の Task モデルに通すと、エラーなく検証を通りました。
スキーマ自体が正しくない場合は、実行前にエラーで止まります。"type": "objekt" と誤記したスキーマを渡した結果は次のとおりです(終了コード1)。
Error: --json-schema is not a valid JSON Schema: data/type must be equal to one of the allowed values, data/type must be array, data/type must match a schema in anyOf
なお公式ドキュメントによると、--json-schema では "format": "email" などの format は注釈として扱われ、強制はされません。
よくある質問
Q. スキーマ強制を使えば、受け取り側の検証は不要ですか?
A. 検証は残しておくのがおすすめです。安全上の拒否や max_tokens での打ち切りのときはスキーマに合わない出力になりますし、数値の範囲や正規表現のように、スキーマ強制では扱えない条件もあります。
Q. JSONモードとスキーマ強制は、どう使い分ければよいですか? A. 項目と型が決まっているならスキーマ強制です。JSONモードは「JSONとして読めればよい」ゆるい用途や、スキーマ強制に対応していないモデルで使います。
Q. 構造化出力とツール呼び出しは、どちらを選べばよいですか? A. 回答そのものをデータとして受け取りたいなら構造化出力、複数の処理から選ばせたい・回答を関数の引数として使いたいならツール呼び出しが向いています。Claude APIでは両方を同じリクエストで併用することもできます。
まとめ
- 構造化出力は、LLMの回答を決められた形のJSONで受け取る仕組み
- 4方式のうち、本番の基本は「スキーマ強制+Pydantic検証」
- OpenAIは
text_format、Claudeはoutput_config.format(Pythonではoutput_format)、Geminiはresponse_formatでスキーマを渡す - 3社共通の書き方(全項目required、
additionalProperties: false、enum)に寄せると使い回しやすい - Pydanticは壊れたJSON・欠けた項目・型違い・余計な項目を1回で検出できた(実行結果あり)
- 文字列の "4" はlaxモードでは通る。厳密にしたいなら
strict=True - リトライは上限回数を決め、打ち切りと拒否は別扱いにする
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「LLMの構造化出力とJSON|4方式の違いとPydantic検証の実行結果」
株式会社RJCでは、LLMをシステムに組み込むときの確かな設計を、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。