مرکزی مواد پر جائیں
友田 陽大
Pydantic & type-safe validation
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構造化出力を実コードで解説します。

Published
Reading time
13 min read
Author
友田 陽大
شیئر کریں

導入: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 を活用して高速かつ高品質に実装します。お気軽にご相談ください。

友田

友田 陽大

وزیر (METI) ایوارڈ یافتہ پروڈکٹ کے ڈویلپر۔ TypeScript + Python + AWS کے ساتھ میں SaaS، صنعتی ڈیجیٹل تبدیلی اور پروڈکشن کے قابل جنریٹو اے آئی (RAG) تنہا ابتدا سے انتہا تک فراہم کرتا ہوں — تقاضوں کی تعیین سے انفراسٹرکچر اور آپریشن تک۔

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

پروجیکٹ کی بنیاد پر کام اور تکنیکی مشاورت دونوں کے لیے دستیاب ہوں۔ 30 منٹ کی مفت مشاورت سے آغاز کریں۔

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

یہ بھی پڑھنے کے قابل