メインコンテンツへスキップ
友田 陽大
Pydantic・型安全バリデーション
Python
Pydantic
LLM
構造化出力
型安全
バリデーション

Pydantic で作る LLM 構造化出力:JSON Schema 生成・検証・自己修復ループを生APIで実装する

Pydantic v2公式ドキュメントに忠実に、model_json_schemaによるLLMツールスキーマ生成、Field(description/examples)でモデルを誘導する設計、model_validate_jsonでの検証、ValidationError.errorsを使った再プロンプトの自己修復ループ、TypeAdapterのexperimental_allow_partialによる部分検証まで、プロバイダ非依存のLLM構造化出力を実コードで解説します。

公開日
読了時間
13分
著者
友田 陽大
シェア

導入:LLM の出力も「検証されていない外部入力」である

請求書の画像から金額と明細を抽出する。問い合わせメールを「緊急度・カテゴリ・要約」に分類する。議事録から ToDo を構造化する——LLM の実務応用の多くは、**「自由なテキストから、決まった形のデータを取り出す」**ことに行き着きます。そしてここに、バックエンドエンジニアにとって馴染み深い問題が再来します。LLM の出力は、検証されていない外部入力にすぎない、ということです。

PydanticAI 実践ガイド では、この問題をフレームワークで解く方法を扱いました。本記事は、その一段下のレイヤー——フレームワークに頼らず、Anthropic / OpenAI の生 API と Pydantic だけで構造化出力を実装する方法を扱います。なぜ生 API を知るべきか? 既存のコードベースに最小の依存で組み込みたい、プロバイダ固有の機能(プロンプトキャッシュ等)を細かく制御したい、あるいは「何が起きているか」を完全に把握したい——そういう実務の要求に、抽象化の中身を理解していることが効くからです。

Pydantic が担う役割は、たった一つの原則に集約されます——「Pydantic モデルは、スキーマの送り手であり、応答の検証者である」。一つの BaseModel から、①LLM に渡す JSON Schema を生成し(送る)、②返ってきた JSON を検証する(受ける)。この往復を 公式ドキュメント に忠実に、それより一段わかりやすく実装します。

💡 TypeScript 派の方へ:同じ思想を Zod で実装する話は 構造化出力の信頼性設計TypeScript 型安全の規律 で扱っています。本記事はその Python / Pydantic 版です。


1. 一つのモデルが二役を果たす

まず、抽出したいデータの形を BaseModel で宣言します。これが**唯一の真実源(Single Source of Truth)**になります。

from pydantic import BaseModel, Field


class Invoice(BaseModel):
    vendor_name: str = Field(description="請求元の会社名")
    total_amount: int = Field(description="税込の合計金額(円、整数)", ge=0)
    due_date: str = Field(description="支払期日。YYYY-MM-DD 形式")
    line_items: list[str] = Field(description="明細の品目名リスト")

この一つのクラスから、①送る用のスキーマを生成できます。

schema = Invoice.model_json_schema()
# {
#   "type": "object",
#   "properties": {
#     "vendor_name": {"type": "string", "description": "請求元の会社名"},
#     "total_amount": {"type": "integer", "minimum": 0, "description": "..."},
#     ...
#   },
#   "required": ["vendor_name", "total_amount", "due_date", "line_items"]
# }

そして同じクラスで、②受ける用の検証もできます。

raw = '{"vendor_name":"Acme","total_amount":50000,"due_date":"2026-07-31","line_items":["設計費"]}'
invoice = Invoice.model_validate_json(raw)  # 検証して型付きオブジェクトに

なぜこれが優れているのか? LLM 連携で最もありがちなバグは、「LLM に伝えたスキーマ」と「コードが期待する型」がズレることです。スキーマを手書きの dict で管理し、検証を別の関数で書いていると、片方を直してもう片方を忘れる——これは DRY 違反が生む典型的な事故。Pydantic モデルを真実源にすれば、スキーマも検証も同じ定義から導出されるため、構造的にズレようがありません。フィールドを一つ足せば、送るスキーマと受ける検証が同時に更新されます。


2. スキーマで LLM を誘導する:descriptionexamples が命

LLM がスキーマに従ってデータを返す精度は、スキーマに込めた説明文の質で決まります。Field(description=...) は単なるドキュメントではなく、LLM が読む抽出指示そのものです。

from typing import Annotated, Literal
from pydantic import BaseModel, Field


class SupportTicket(BaseModel):
    """ユーザーからの問い合わせを構造化したもの。"""  # docstring はスキーマの説明になる

    category: Literal["bug", "billing", "feature_request", "other"] = Field(
        description="問い合わせの分類。判断に迷う場合は other を選ぶ。"
    )
    urgency: int = Field(
        description="緊急度を1(低)〜5(高)で。サービス停止に言及があれば5。",
        ge=1, le=5,
    )
    summary: str = Field(
        description="問い合わせ内容の日本語1文要約。",
        examples=["決済画面でエラーが出てログインできない"],
    )

公式ドキュメントで確認できるとおり、description は生成される JSON Schema にそのまま反映され、examples も同様にスキーマへ載ります。

SupportTicket.model_json_schema()
# urgency → {"type": "integer", "minimum": 1, "maximum": 5, "description": "緊急度を..."}
# summary → {"type": "string", "description": "...", "examples": ["決済画面で..."]}

Literal を使えば**列挙(enum)としてスキーマに表現され、LLM の出力候補を構造的に絞れます。examples は LLM にとっての少数事例(few-shot)**として働き、出力形式のブレを抑えます。

⚠️ by_alias$ref の落とし穴model_json_schema() は既定で by_alias=True。つまり Field(alias=...) を付けたフィールドは、スキーマのキーが**別名(alias)**になります。LLM はその別名で返してくるので、検証側も整合させる必要があります。また、ネストしたモデルはスキーマ内で $defs$ref で表現されますが、一部の LLM プロバイダの「厳密な構造化出力モード」は $ref を嫌います。その場合はスキーマを平坦化する前処理が要ります(Pydantic は平坦化までは面倒を見ません)。OpenAPI 互換にしたいなら ref_template="#/components/schemas/{model}" を指定します。


3. プロバイダに渡す:Anthropic の tool use を例に

生成したスキーマを LLM に渡す最も確実な方法は、ツール(function calling)の入力スキーマとして使うことです。Anthropic Messages API なら、ツールを定義してその呼び出しを強制します。

import anthropic
from pydantic import BaseModel, Field


class Invoice(BaseModel):
    vendor_name: str = Field(description="請求元の会社名")
    total_amount: int = Field(description="税込の合計金額(円)", ge=0)


client = anthropic.Anthropic()  # API キーは環境変数から(ハードコードしない)

message = client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=1024,
    tools=[
        {
            "name": "save_invoice",
            "description": "抽出した請求書データを保存する。",
            "input_schema": Invoice.model_json_schema(),  # ← ここが要
        }
    ],
    tool_choice={"type": "tool", "name": "save_invoice"},  # 必ずこのツールを呼ばせる
    messages=[{"role": "user", "content": "請求書テキスト: ..."}],
)

# ツール呼び出しブロックから input(dict)を取り出して検証する
tool_use = next(b for b in message.content if b.type == "tool_use")
invoice = Invoice.model_validate(tool_use.input)  # ← 検証して初めて信頼する
print(invoice.total_amount)  # int として保証される

ポイントは 2 つ。input_schemamodel_json_schema() を渡すこと、そして tool_choice でそのツールの呼び出しを強制することです。これで「自由文ではなくスキーマに沿った構造化データ」をモデルに返させられます。tool_use.input は dict なので model_validate で検証します(生の JSON 文字列なら model_validate_json を使う、第1章参照)。

💡 OpenAI でも発想は同じ:OpenAI の Structured Outputs(response_format の JSON Schema)でも、Model.model_json_schema() で生成したスキーマを渡し、返ってきた JSON を model_validate_json で検証する——という往復は変わりません。プロバイダが変わってもパターンは不変であることが、生 API を理解しておく価値です。Anthropic / Claude API の詳細は Claude API 実践ガイド を参照してください。


4. 応答を検証する:strict の使いどころ

返ってきたデータの検証には model_validate(dict から)または model_validate_json(JSON 文字列から)を使います。第1章で触れたとおり、JSON 文字列なら model_validate_jsonパースと検証を一気通貫で行うため効率的です。

ここで実務的な判断が一つ。strict=True を使うかです。

# lax(既定):LLM が "50000" と文字列で返しても int 50000 に変換してくれる
Invoice.model_validate_json(raw)

# strict:型の完全一致を要求。"50000"(文字列)は拒否される
Invoice.model_validate_json(raw, strict=True)

⚠️ LLM 相手では strict がリトライを増やす:LLM は、スキーマで integer と指定しても、しばしば数値を文字列で返してきます("50000")。境界で strict=True にすると、こうした出力が軒並み検証エラーになり、リトライが頻発してコストと遅延が膨らみます。LLM 出力の検証では、既定の lax モードで型強制に任せるのが現実的です。「絶対に文字列を数値として受けたくない」特定フィールドだけ、Field(strict=True) でピンポイントに厳格化する——この使い分けが落としどころです(strict と型強制の詳細は Pydantic v2 実践ガイド 第4章)。


5. 自己修復ループ:検証エラーを LLM に差し戻す

検証に失敗したとき、ただ例外を投げて終わるのは「LLM 連携」としては未熟です。ValidationError が持つ詳細なエラー情報を、LLM への次のプロンプトに変換すれば、モデルは自分で修正できます。これが自己修復ループの肝です。

ValidationError.errors() は、何がどう間違ったかを構造化して返します。

from pydantic import ValidationError

try:
    Invoice.model_validate({"vendor_name": "Acme", "total_amount": "とても高い"})
except ValidationError as e:
    for err in e.errors(include_url=False):
        print(err["loc"], err["type"], err["msg"])
    # ('total_amount',) int_parsing  Input should be a valid integer, ...
    # ('due_date',)     missing      Field required

各エラーは typeint_parsing / missing 等)・loc(場所)・msg(人間可読の説明)・input(実際に来た値)を持ちます。これをそのまま LLM へのフィードバックにします。

import json
from pydantic import BaseModel, ValidationError


def extract_with_retry(client, prompt: str, model: type[BaseModel], max_retries: int = 2):
    messages = [{"role": "user", "content": prompt}]
    for attempt in range(max_retries + 1):
        raw = call_llm(client, messages, model)  # ツール経由で JSON を得る(第3章)
        try:
            return model.model_validate_json(raw)
        except ValidationError as e:
            if attempt == max_retries:
                raise  # 上限到達。これ以上は粘らない
            # url を落としてトークンを節約しつつ、エラーを構造化して差し戻す
            feedback = e.errors(include_url=False, include_input=True)
            messages.append({"role": "assistant", "content": raw})
            messages.append({
                "role": "user",
                "content": f"出力に検証エラーがありました。修正して再出力してください:\n"
                           f"{json.dumps(feedback, ensure_ascii=False, default=str)}",
            })
    raise RuntimeError("unreachable")

なぜこれが優れているのか? ValidationError は、1 回の検証で見つかった全エラーをまとめて返します(フィールドごとに例外が飛ぶわけではない)。だから一度のフィードバックで「金額が不正、かつ期日が欠落」を同時に伝えられ、LLM は一発で直せる可能性が高い。include_url=False で各エラーに付く errors.pydantic.dev/... の URL を落とせば、フィードバックのトークン量も節約できます。Pydantic のエラー構造が、そのまま LLM への教師信号になる——これが構文的検証を再プロンプトに繋ぐ設計です。

⚠️ リトライ上限は必須:自己修復ループは強力ですが、上限がなければコストと遅延が青天井になります。max_retries を必ず設け、それでも直らないならスキーマか description の設計を見直す(第2章)のが正攻法です。リトライは「たまの揺らぎ」を吸収する保険であって、設計不良の常用薬ではありません。


6. ストリーミングの部分検証:TypeAdapter 限定の experimental_allow_partial

チャット UI で「生成途中の不完全な JSON」を逐次検証したい場面があります。Pydantic には**部分検証(partial validation)**の実験的機能があり、途中で切れた JSON でも、検証できる範囲だけを返します

from typing import TypedDict
from pydantic import TypeAdapter


class Item(TypedDict):
    a: int
    b: float


adapter = TypeAdapter(list[Item])

# 途中で切れた JSON("b" の値が未到達)でも、完成した要素までを返す
adapter.validate_json('[{"a": 1, "b": 2.0}, {"a": 1', experimental_allow_partial=True)
# → [{'a': 1, 'b': 2.0}]  ← 不完全な2件目は捨てられる

ただし、この機能には重大な制約があります。公式が明示しているとおり:

  • experimental_allow_partialTypeAdapter のメソッド限定BaseModel.model_validate_json では使えません"You can only pass experimental_allow_partial to TypeAdapter methods")。
  • 対応する型は list / set / dict / TypedDict(非必須フィールド)など。BaseModel を通ってネストしたコレクションには伝播しません
  • 「実験的機能であり、proof of concept と考えるべき」。入力末尾のエラーはすべて無視されます。

⚠️ 設計の勘所:ストリーミング部分検証をやるなら、出力を BaseModel でなく TypeAdapter(list[YourTypedDict]) で包むこと。BaseModel.model_validate_json(..., experimental_allow_partial=True)動きません——これは多くのブログ記事が間違える点です。なお、PydanticAI を使えば構造化出力のストリーミング検証(stream_output)がフレームワーク側で提供されます(PydanticAI 実践ガイド 第6章)。生 API で部分検証まで作り込むのは相応にコストがかかるので、そこまで要るなら PydanticAI を検討するのが賢明です。


7. どこまで自前で作り、どこからフレームワークに任せるか

ここまで生 API での実装を見てきましたが、現実には選択肢が 3 段階あります。要件に応じて選ぶのが正解です。

方式何をする向いている場面
生 API + Pydantic(本記事)スキーマ生成・検証・リトライを自前で組む最小依存・細かい制御・既存コードへの組み込み
instructor(サードパーティ)response_model=Model を渡すだけで検証+自動リトライ検証付き抽出を手早く。複数プロバイダ対応
PydanticAIエージェント・ツール・DI・可観測性・耐久実行本格的なエージェント/長時間ワークフロー

instructor は、本記事のパターン(モデル → スキーマ → 検証 → リトライ)を薄くラップしたライブラリです(※ Pydantic 公式ではなくサードパーティ)。

# instructor を使うと、本記事の往復が数行に圧縮される(非公式ライブラリ)
import instructor

client = instructor.from_provider("anthropic/claude-sonnet-4-6")
invoice = client.create(response_model=Invoice, messages=[{"role": "user", "content": "..."}])
# 内部で JSON Schema 生成・検証・失敗時の自動リトライを行ってくれる

判断基準はシンプルです。 単発の抽出・分類なら生 API + Pydantic か instructor で十分。ツール連鎖・状態・人間の承認・長時間実行が絡むなら PydanticAI。「賢い当てずっぽう」を「本番に載る部品」に変えるという目的は同じで、その達成手段の重さが違うだけです。


結論:LLM 出力を、型システムの内側に取り込む

LLM の構造化出力を堅牢にする鍵は、特別な魔法ではありません。「LLM の出力もまた検証されていない外部入力だ」と認め、Pydantic モデルをスキーマと検証の単一の真実源に据える——この境界設計に尽きます。本記事の要点を再掲します。

  1. 一つの BaseModel が二役を果たす:model_json_schema() で送り、model_validate_json() で受ける。
  2. Field(description=...)examplesLiteral で LLM を誘導する。説明文がそのまま抽出精度を決める(by_alias$ref の扱いに注意)。
  3. 応答は検証してから信頼するstrict は LLM 相手ではリトライを増やすため、境界では既定の lax が現実的。
  4. ValidationError.errors(include_url=False) を再プロンプトに変換し、自己修復ループを組む。リトライには必ず上限を。
  5. 部分検証は TypeAdapterexperimental_allow_partial 限定BaseModel では効かない。本格的に要るなら PydanticAI。
  6. 自前・instructor・PydanticAI を、要件の重さで使い分ける

「動く LLM 機能」と「本番で信頼できる LLM 機能」を分けるのは、出力を型システムの内側に取り込めているかです。Pydantic は、その取り込み口に立つ門番です。

公式の一次情報として、以下を本記事の観点で再読することをお勧めします。


LLM 構造化抽出パイプラインのご相談

筆者は、国内大手放送事業者向けの社内 AI プラットフォームで、長時間の AI ジョブと構造化抽出を本番品質で運用してきました。請求書・契約書・問い合わせ・議事録といった非定型データから、検証済みの構造化データを安定して取り出す——その信頼性は、スキーマ設計・検証境界・自己修復・可観測性の積み重ねで決まります。Pydantic / PydanticAI / Claude API を用いたLLM 構造化抽出・分類・RAG パイプラインの構築を、生成 AI を活用して高速かつ高品質に実装します。お気軽にご相談ください。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

国内大手放送事業者の番組制作を支援する社内AIプラットフォーム(FastAPI + Cloud Workflows で長時間AIジョブを運用)

ケーススタディを見る