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

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

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, Pydantic, LLM, 構造化出力, 型安全, バリデーション
- URL: https://tomodahinata.com/blog/pydantic-llm-structured-output-json-schema-validation-guide

## 要点

- Pydanticモデルは「スキーマ生成（送る）」と「応答検証（受ける）」の二役を一つの真実源で担う。LLM構造化出力の中核はこの往復にある
- model_json_schema()でツール/関数定義のJSONスキーマを生成し、Field(description=...)とexamplesでLLMの抽出精度を上げる。スキーマの説明文がそのまま誘導になる
- 応答はmodel_validate_jsonで検証。strict=Trueは厳密だがLLMは数値を文字列で返しがちでリトライが増えるため、境界では既定のlaxが現実的
- ValidationError.errors(include_url=False)を整形してLLMに差し戻す自己修復ループで、形の崩れを構造的に回収する。リトライには必ず上限を設ける
- ストリーミングの部分検証はTypeAdapterのexperimental_allow_partial限定。BaseModelには効かずネストもしないため、list[TypedDict]で包む

---

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

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

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

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

> 💡 **TypeScript 派の方へ**：同じ思想を Zod で実装する話は [構造化出力の信頼性設計](/blog/structured-output-reliability-constrained-decoding-semantic-validation) と [TypeScript 型安全の規律](/blog/typescript-type-safety-discipline-zod-nevererror-no-any) で扱っています。本記事はその Python / Pydantic 版です。

---

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

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

```python
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="明細の品目名リスト")
```

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

```python
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"]
# }
```

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

```python
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 を誘導する：`description` と `examples` が命**

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

```python
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` も同様にスキーマへ載ります。

```python
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 なら、ツールを定義してその呼び出しを強制します。

```python
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_schema` に `model_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 実践ガイド](/blog/claude-api-ai-sdk-v6-production-ai-features) を参照してください。

---

## **4. 応答を検証する：`strict` の使いどころ**

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

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

```python
# 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 実践ガイド](/blog/pydantic-v2-production-validation-type-safety) 第4章）。

---

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

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

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

```python
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
```

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

```python
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 でも、検証できる範囲だけを返します**。

```python
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_partial` は `TypeAdapter` のメソッド限定**。`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 実践ガイド](/blog/pydantic-ai-agent-framework-production-guide) 第6章）。生 API で部分検証まで作り込むのは相応にコストがかかるので、**そこまで要るなら PydanticAI を検討**するのが賢明です。

---

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

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

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

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

```python
# 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=...)` と `examples`、`Literal`** で LLM を誘導する。説明文がそのまま抽出精度を決める（`by_alias` と `$ref` の扱いに注意）。
3. **応答は検証してから信頼する**。`strict` は LLM 相手ではリトライを増やすため、境界では既定の lax が現実的。
4. **`ValidationError.errors(include_url=False)` を再プロンプトに変換**し、自己修復ループを組む。リトライには必ず上限を。
5. **部分検証は `TypeAdapter` ＋ `experimental_allow_partial` 限定**。`BaseModel` では効かない。本格的に要るなら PydanticAI。
6. 自前・`instructor`・PydanticAI を、**要件の重さで使い分ける**。

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

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

- [JSON Schema](https://pydantic.dev/docs/validation/latest/concepts/json_schema/)
- [Serialization / Validation](https://pydantic.dev/docs/validation/latest/concepts/serialization/)
- [Partial Validation（experimental）](https://pydantic.dev/docs/validation/latest/concepts/experimental/)
- [Validation Errors](https://pydantic.dev/docs/validation/latest/errors/validation_errors/)

---

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

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