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期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?