導入: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 を誘導する:description と examples が命
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_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 実践ガイド を参照してください。
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
各エラーは type(int_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_partialはTypeAdapterのメソッド限定。BaseModel.model_validate_jsonでは使えません("You can only passexperimental_allow_partialto 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 モデルをスキーマと検証の単一の真実源に据える——この境界設計に尽きます。本記事の要点を再掲します。
- 一つの
BaseModelが二役を果たす:model_json_schema()で送り、model_validate_json()で受ける。 Field(description=...)とexamples、Literalで LLM を誘導する。説明文がそのまま抽出精度を決める(by_aliasと$refの扱いに注意)。- 応答は検証してから信頼する。
strictは LLM 相手ではリトライを増やすため、境界では既定の lax が現実的。 ValidationError.errors(include_url=False)を再プロンプトに変換し、自己修復ループを組む。リトライには必ず上限を。- 部分検証は
TypeAdapter+experimental_allow_partial限定。BaseModelでは効かない。本格的に要るなら PydanticAI。 - 自前・
instructor・PydanticAI を、要件の重さで使い分ける。
「動く LLM 機能」と「本番で信頼できる LLM 機能」を分けるのは、出力を型システムの内側に取り込めているかです。Pydantic は、その取り込み口に立つ門番です。
公式の一次情報として、以下を本記事の観点で再読することをお勧めします。
LLM 構造化抽出パイプラインのご相談
筆者は、国内大手放送事業者向けの社内 AI プラットフォームで、長時間の AI ジョブと構造化抽出を本番品質で運用してきました。請求書・契約書・問い合わせ・議事録といった非定型データから、検証済みの構造化データを安定して取り出す——その信頼性は、スキーマ設計・検証境界・自己修復・可観測性の積み重ねで決まります。Pydantic / PydanticAI / Claude API を用いたLLM 構造化抽出・分類・RAG パイプラインの構築を、生成 AI を活用して高速かつ高品質に実装します。お気軽にご相談ください。