Python AI構造化出力実戦:LLMから安定したJSONを取得する5つのコアパターン

AI与大数据

AI構造化出力:なぜLLMの「自由テキスト」は本番環境の悪夢なのか

LLMはデフォルトで自由テキストを出力します。JSONを要求すると説明文が混ざり、整数を要求すると文字列"42"が返り、Schemaを定義してもフィールドがランダムに欠落します。構造化出力(Structured Output)は、LLMに事前定義されたSchemaに厳密に従ってデータを返させる仕組みです——これはAIが「チャット玩具」から「本番システム」へ進むための重要なステップです。2026年、OpenAIはStructured Outputをネイティブサポートし、Instructorライブラリは自動リトライを実現し、Outlinesは制約デコーディングで100%のフォーマット準拠を保証します。

本記事では5つのコアパターンから、Pydantic Schema定義→Function Calling→Instructorリトライ→制約デコーディング→マルチモデル適応のフルパイプライン実戦を解説します。


コア概念

概念 説明
Structured Output LLMが事前定義Schemaに従って構造化データ(JSONなど)を出力
Pydantic Model Pythonデータ検証ライブラリ、型アノテーションで出力Schemaを定義
Function Calling OpenAI APIの関数呼び出しメカニズム、出力フォーマットを制約
Instructor PydanticベースのLLM構造化出力ライブラリ、自動リトライ対応
Constrained Decoding トークンレベルで出力がSchemaに準拠することを保証する制約デコーディング
JSON Schema JSONデータ構造を記述する仕様、構造化出力の基盤
Output Validation LLM出力を検証し、型とフィールドの完全性を保証
Multi-Model Adapter 異なるLLMプロバイダー間で統一的な構造化出力を実現するアダプター層

問題分析:LLMの非構造化出力における5つの課題

  1. フォーマットの不安定性:同じPromptでも、LLMはJSONを返したりMarkdownでラップされたJSONを返したりする
  2. 型の信頼性不足:integerを要求しても、LLMは文字列"42"や浮動小数点42.0を返す可能性がある
  3. フィールドの欠落:Schemaで10個のフィールドを定義しても、LLMはランダムに2-3個省略する
  4. ハルシネーション内容:LLMがJSON値に説明テキストを混入させる、例:"price": "約100円くらい"
  5. パースのクラッシュ:本番環境でのJSONパース失敗率は5-15%に達し、毎回の手動介入が必要

ステップバイステップ:5つのAI構造化出力コアパターン

パターン1:Pydantic Schema定義 + 基本構造化出力

pip install pydantic==2.11 openai==1.82
from pydantic import BaseModel, Field
from openai import OpenAI
import json

class ProductInfo(BaseModel):
    name: str = Field(description="製品名")
    price: float = Field(description="製品価格(円)")
    category: str = Field(description="製品カテゴリ")
    in_stock: bool = Field(description="在庫ありかどうか")
    tags: list[str] = Field(description="製品タグリスト")

client = OpenAI()

# 方法1:response_formatパラメータの使用(OpenAIネイティブ構造化出力)
response = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "あなたは製品情報抽出アシスタントです。ユーザーの説明から構造化された製品情報を抽出してください。"},
        {"role": "user", "content": "このMacBook Pro 14インチは149,800円で、ノートパソコンカテゴリに属し、現在在庫あり、タグはApple、プレミアム、生産性ツールです"},
    ],
    response_format=ProductInfo,
)

product = response.choices[0].message.parsed
print(f"製品: {product.name}")
print(f"価格: {product.price}")
print(f"カテゴリ: {product.category}")
print(f"在庫: {product.in_stock}")
print(f"タグ: {product.tags}")
print(f"型検証: priceは{type(product.price).__name__}")

# 方法2:手動JSON Schema + パース検証
class OrderItem(BaseModel):
    item_name: str = Field(description="商品名")
    quantity: int = Field(gt=0, description="購入数量")
    unit_price: float = Field(ge=0, description="単価")

class CustomerOrder(BaseModel):
    order_id: str = Field(description="注文番号")
    customer_name: str = Field(description="顧客名")
    items: list[OrderItem] = Field(description="注文明細リスト")
    total_amount: float = Field(ge=0, description="注文合計金額")

response2 = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "注文情報を抽出し、厳密にJSONで返してください。"},
        {"role": "user", "content": "注文ORD-20260621、顧客田中太郎、キーボード2個各4,980円、モニター1台49,800円、合計59,760円"},
    ],
    response_format={"type": "json_object"},
)

raw_json = json.loads(response2.choices[0].message.content)
order = CustomerOrder.model_validate(raw_json)
print(f"\n注文: {order.order_id}, 顧客: {order.customer_name}")
for item in order.items:
    print(f"  {item.item_name} x{item.quantity} @ {item.unit_price}")
print(f"合計: {order.total_amount}")

パターン2:OpenAI Function Calling構造化出力

from pydantic import BaseModel, Field
from openai import OpenAI
import json

class WeatherQuery(BaseModel):
    city: str = Field(description="都市名")
    temperature: float = Field(description="気温(摂氏)")
    condition: str = Field(description="天気状況:晴れ/曇り/雨/雪")
    humidity: int = Field(ge=0, le=100, description="湿度パーセンテージ")
    wind_speed: float = Field(ge=0, description="風速(km/h)")

class TravelPlan(BaseModel):
    destination: str = Field(description="目的地")
    days: int = Field(gt=0, description="旅行日数")
    budget: float = Field(ge=0, description="予算(円)")
    activities: list[str] = Field(description="おすすめアクティビティリスト")
    weather_advice: str = Field(description="天気に関するアドバイス")

client = OpenAI()

# Function Callingで構造化出力を強制
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_travel_plan",
            "description": "旅行プランを生成",
            "parameters": TravelPlan.model_json_schema(),
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "天気情報を取得",
            "parameters": WeatherQuery.model_json_schema(),
        },
    },
]

response = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "あなたは旅行プランニングアシスタントです。ユーザーのニーズに基づいて旅行プランと天気情報を生成してください。"},
        {"role": "user", "content": "京都に3日間旅行する予定です、予算は10万円"},
    ],
    tools=tools,
    tool_choice="auto",
)

for tool_call in response.choices[0].message.tool_calls:
    func_name = tool_call.function.name
    func_args = json.loads(tool_call.function.arguments)

    if func_name == "get_travel_plan":
        plan = TravelPlan.model_validate(func_args)
        print(f"目的地: {plan.destination}")
        print(f"日数: {plan.days}")
        print(f"予算: {plan.budget}")
        print(f"アクティビティ: {', '.join(plan.activities)}")
        print(f"天気アドバイス: {plan.weather_advice}")
    elif func_name == "get_weather":
        weather = WeatherQuery.model_validate(func_args)
        print(f"\n{weather.city}: {weather.temperature}°C, {weather.condition}, 湿度{weather.humidity}%, 風速{weather.wind_speed}km/h")

パターン3:Instructorライブラリによるリトライベース構造化出力

pip install instructor==1.7 pydantic==2.11
import instructor
from pydantic import BaseModel, Field
from openai import OpenAI

class SentimentResult(BaseModel):
    text: str = Field(description="分析対象テキスト")
    sentiment: str = Field(description="感情:positive/negative/neutral")
    confidence: float = Field(ge=0, le=1, description="信頼度0-1")
    keywords: list[str] = Field(description="キーワードリスト")

class ArticleSummary(BaseModel):
    title: str = Field(description="記事タイトル")
    summary: str = Field(min_length=50, max_length=200, description="50-200文字の要約")
    key_points: list[str] = Field(min_length=2, max_length=5, description="2-5個の要点")
    reading_time_minutes: int = Field(gt=0, description="推定読了時間(分)")
    difficulty: str = Field(description="難易度:beginner/intermediate/advanced")

# instructorでOpenAIクライアントをラップ
client = instructor.from_openai(OpenAI())

# 自動リトライ:出力がSchemaに合わない場合、instructorが自動的にリトライ
sentiment = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "あなたは感情分析の専門家です。"},
        {"role": "user", "content": "この製品は本当に使いやすい!インターフェースがシンプルで機能が強力、強くおすすめします!"},
    ],
    response_model=SentimentResult,
    max_retries=3,
)

print(f"感情: {sentiment.sentiment}")
print(f"信頼度: {sentiment.confidence}")
print(f"キーワード: {', '.join(sentiment.keywords)}")

# ネストされたモデル + 厳格な検証
summary = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "あなたは記事要約ジェネレーターです。"},
        {"role": "user", "content": "Python 3.13が2024年10月に正式リリースされ、フリースレッドモード(PEP 703)、改良されたインタラクティブインタープリタ、改善されたエラーメッセージなどの主要なアップデートが含まれています。フリースレッドモードにより、PythonはGILなしで実行できるようになり、マルチスレッドパフォーマンスが大幅に向上します。新しいREPLは複数行編集とシンタックスハイライトをサポートし、開発体験を大きく向上させています。"},
    ],
    response_model=ArticleSummary,
    max_retries=3,
)

print(f"\nタイトル: {summary.title}")
print(f"要約: {summary.summary}")
for i, point in enumerate(summary.key_points, 1):
    print(f"  要点{i}: {point}")
print(f"読了時間: {summary.reading_time_minutes}分")
print(f"難易度: {summary.difficulty}")

パターン4:Outlines/構造化生成(制約デコーディング)

pip install outlines==0.1 pydantic==2.11 transformers
import outlines
from pydantic import BaseModel, Field
import json

class EntityExtraction(BaseModel):
    person_names: list[str] = Field(description="人名リスト")
    organizations: list[str] = Field(description="組織名リスト")
    locations: list[str] = Field(description="場所リスト")
    dates: list[str] = Field(description="日付リスト")

class CodeAnalysis(BaseModel):
    language: str = Field(description="プログラミング言語")
    complexity: str = Field(description="複雑度:low/medium/high")
    functions: list[str] = Field(description="関数名リスト")
    imports: list[str] = Field(description="インポートモジュールリスト")
    issues: list[str] = Field(description="潜在的な問題リスト")

# Outlinesで制約デコーディング(ローカルモデル)
# 注意:初回実行時はモデルをダウンロードします(約1.5GB)
model = outlines.models.transformers("microsoft/Phi-3-mini-4k-instruct")

# 方法1:JSON Schemaベースの制約生成
generator = outlines.generate.json(model, EntityExtraction.model_json_schema())

text = "2026年3月15日、田中さんは東京でGoogle開発者カンファレンスに参加し、佐藤さんと大阪でのAI技術応用について議論しました。"
result = generator(text)
print(f"人名: {result['person_names']}")
print(f"組織: {result['organizations']}")
print(f"場所: {result['locations']}")
print(f"日付: {result['dates']}")

# 方法2:正規表現ベースの制約生成
# 出力フォーマットの100%準拠を保証
phone_generator = outlines.generate.regex(model, r"\d{3}-\d{4}-\d{4}")
phone = phone_generator("私の電話番号は")
print(f"\n電話番号: {phone}")

# 方法3:Choiceベースの制約生成(分類タスク)
choice_generator = outlines.generate.choice(model, ["positive", "negative", "neutral"])
sentiment = choice_generator("この製品はとても使いやすい、大満足です!")
print(f"感情分類: {sentiment}")

# 方法4:Pydantic Modelベースの制約生成
code_generator = outlines.generate.json(model, CodeAnalysis.model_json_schema())
code_text = """
import os
import sys
from typing import List

def process_data(items: List[str]) -> dict:
    result = {}
    for item in items:
        result[item] = len(item)
    return result

def main():
    data = process_data(["hello", "world"])
    print(data)
"""
code_result = code_generator(code_text)
print(f"\n言語: {code_result['language']}")
print(f"複雑度: {code_result['complexity']}")
print(f"関数: {code_result['functions']}")

パターン5:マルチモデル構造化出力アダプター

from pydantic import BaseModel, Field
from openai import OpenAI
import anthropic
import google.generativeai as genai
import json
from abc import ABC, abstractmethod
from typing import TypeVar, Type

T = TypeVar("T", bound=BaseModel)

class BookRecommendation(BaseModel):
    title: str = Field(description="書籍名")
    author: str = Field(description="著者名")
    genre: str = Field(description="ジャンル")
    rating: float = Field(ge=0, le=5, description="評価0-5")
    reason: str = Field(description="おすすめ理由")

class StructuredOutputAdapter(ABC):
    """マルチモデル構造化出力アダプター基底クラス"""

    @abstractmethod
    def generate(self, prompt: str, response_model: Type[T]) -> T:
        pass

class OpenAIAdapter(StructuredOutputAdapter):
    def __init__(self, api_key: str, model: str = "gpt-4o-2024-08-06"):
        self.client = OpenAI(api_key=api_key)
        self.model = model

    def generate(self, prompt: str, response_model: Type[T]) -> T:
        response = self.client.beta.chat.completions.parse(
            model=self.model,
            messages=[
                {"role": "system", "content": "Schemaに厳密に従ってデータを返してください。"},
                {"role": "user", "content": prompt},
            ],
            response_format=response_model,
        )
        return response.choices[0].message.parsed

class AnthropicAdapter(StructuredOutputAdapter):
    def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
        self.client = anthropic.Anthropic(api_key=api_key)
        self.model = model

    def generate(self, prompt: str, response_model: Type[T]) -> T:
        schema = response_model.model_json_schema()
        response = self.client.messages.create(
            model=self.model,
            max_tokens=1024,
            messages=[{"role": "user", "content": prompt}],
            tools=[{
                "name": "structured_output",
                "description": "構造化データを返す",
                "input_schema": schema,
            }],
            tool_choice={"type": "tool", "name": "structured_output"},
        )
        for block in response.content:
            if block.type == "tool_use":
                return response_model.model_validate(block.input)
        raise ValueError("No tool use in response")

class GeminiAdapter(StructuredOutputAdapter):
    def __init__(self, api_key: str, model: str = "gemini-2.0-flash"):
        genai.configure(api_key=api_key)
        self.model_name = model

    def generate(self, prompt: str, response_model: Type[T]) -> T:
        model = genai.GenerativeModel(
            self.model_name,
            generation_config={"response_mime_type": "application/json"},
        )
        schema_instructions = f"以下のJSON Schemaに厳密に準拠したデータを返してください:\n{json.dumps(response_model.model_json_schema(), ensure_ascii=False)}"
        response = model.generate_content(f"{schema_instructions}\n\n{prompt}")
        raw = json.loads(response.text)
        return response_model.model_validate(raw)

# 統一呼び出しインターフェース
def get_recommendation(adapter: StructuredOutputAdapter, query: str) -> BookRecommendation:
    return adapter.generate(query, BookRecommendation)

# 使用例(対応するAPI Keyの設定が必要)
# openai_adapter = OpenAIAdapter(api_key="sk-xxx")
# result = get_recommendation(openai_adapter, "AI初心者向けの本を推薦してください")
# print(f"推薦: 『{result.title}』 by {result.author}, 評価{result.rating}")

# 汎用検証レイヤー:モデルに関わらず、最終的にPydanticで検証
def safe_parse(raw_json: dict, model: Type[T]) -> T:
    """汎用セーフパース:検証 + デフォルト値補充"""
    try:
        return model.model_validate(raw_json)
    except Exception as e:
        print(f"検証失敗: {e}")
        defaults = {}
        for field_name, field_info in model.model_fields.items():
            if field_info.is_required():
                raise ValueError(f"必須フィールド {field_name} が欠落")
            defaults[field_name] = field_info.default
        return model.model_validate(defaults)

よくある落とし穴

落とし穴1:Pydanticフィールドにdescriptionがない

# ❌ 間違い:descriptionがない、LLMが何を入力すべきか分からない
class BadModel(BaseModel):
    x1: str
    x2: float
    x3: list[str]

# ✅ 正しい:すべてのフィールドにdescriptionを追加
class GoodModel(BaseModel):
    product_name: str = Field(description="製品のフルネーム")
    price: float = Field(description="価格(円)")
    tags: list[str] = Field(description="製品タグ、例:'電子機器''セール'")

落とし穴2:response_formatを設定せずにLLMの自動JSON出力に依存

# ❌ 間違い:PromptだけでJSONを要求、フォーマットが不安定
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "製品情報をJSON形式で返してください"}],
)

# ✅ 正しい:response_formatを明示的に設定
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[{"role": "user", "content": "製品情報をJSON形式で返してください"}],
    response_format={"type": "json_object"},
)

落とし穴3:Pydantic検証エラーを無視

# ❌ 間違い:json.loadsのまま検証なし
data = json.loads(response.choices[0].message.content)
price = data["price"]  # 文字列"42.0"の可能性があり、floatではない

# ✅ 正しい:Pydantic検証を使用
from pydantic import ValidationError
try:
    product = ProductInfo.model_validate_json(response.choices[0].message.content)
    price = product.price  # float型であることが保証される
except ValidationError as e:
    print(f"検証失敗: {e}")

落とし穴4:ネストが深すぎてLLMの出力が混乱

# ❌ 間違い:3層以上のネスト、LLMがエラーを起こしやすい
class DeepNested(BaseModel):
    level1: "Level1Model"  # -> level2: Level2Model -> level3: Level3Model

# ✅ 正しい:フラットな設計、ネストは最大2層
class OrderItem(BaseModel):
    name: str = Field(description="商品名")
    qty: int = Field(description="数量")

class Order(BaseModel):
    items: list[OrderItem] = Field(description="商品リスト")  # ネストは1層のみ
    total: float = Field(description="合計金額")

落とし穴5:Function Callingでのtool_choiceの不適切な設定

# ❌ 間違い:tool_choice="auto"ではLLMが関数を呼ばない可能性がある
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    tools=tools,
    tool_choice="auto",  # LLMがプレーンテキストで応答する可能性
)

# ✅ 正しい:特定の関数の呼び出しを強制
response = client.chat.completions.create(
    model="gpt-4o",
    messages=[...],
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "get_travel_plan"}},
)

エラートラブルシューティング

# エラーメッセージ 原因 解決方法
1 ValidationError: missing field LLM出力に必須フィールドが欠落 Field(default=...)を使用するかmax_retriesを増やす
2 json.decoder.JSONDecodeError LLM出力が有効なJSONではない response_format={"type": "json_object"}を設定
3 ValidationError: value is not a valid float LLMが数値の代わりに文字列を返す Pydanticの自動変換またはField(coerce_numbers=True)を使用
4 InstructorRetryError: max retries exceeded N回リトライ後もSchemaに合わない Schemaを簡素化するかPromptの説明を最適化
5 TypeError: 'NoneType' object is not subscriptable LLMが空の内容を返す API Keyのクォータとモデルの可用性を確認
6 ValidationError: list should have at least N items リストフィールドの要素が不足 Field(min_length=N)を使用しPromptを最適化
7 StructuredOutputError: schema too complex Schemaが複雑すぎる モデルをフラット化し、ネストレベルを減らす
8 RateLimitError API呼び出し頻度が制限を超過 バックオフリトライを追加するか同時実行を減らす
9 ValidationError: string too long/short 文字列の長さが制約に合わない max_length/min_lengthを調整するかPromptを最適化
10 ToolCallNotFoundError LLMがtoolを呼び出さなかった tool_choiceを設定して強制呼び出し

高度な最適化

  1. ストリーミング構造化出力:instructorのcreate_partialメソッドでストリーミングパースを実現、生成と同時に検証
  2. Schemaキャッシュ:同じSchemaのリクエストに対してJSON Schemaをキャッシュし、重複シリアライズのオーバーヘッドを削減
  3. マルチSchemaルーティング:ユーザーの意図に基づいて異なるPydantic Modelを動的に選択し、柔軟な構造化出力を実現
  4. 出力後処理パイプライン:Pydantic検証→ビジネスルールチェック→デフォルト値補充→ログ記録、完全なデータ品質保証チェーンを形成
  5. A/BテストSchema:同じタスクに異なる粒度のSchemaを使用し、LLM出力品質とトークン消費を比較

比較分析

次元 OpenAI Structured Output Instructor Outlines LMQL Guidance
フォーマット保証 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
自動リトライ ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
ローカルモデル対応 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
マルチモデル適応 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐
学習曲線 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ ⭐⭐⭐
型安全性 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐

まとめ:AI構造化出力は「チャット型AI」から「本番級AI」への必須の道です。Pydantic Schema→Function Calling→Instructorリトライ→制約デコーディング→マルチモデル適応の五位一体、これが2026年のPython AI構造化出力のベストプラクティスです。コア原則:Schemaは契約、検証は保証、リトライはフォールトトレランス


オンラインツールおすすめ

ブラウザローカルツールを無料で試す →

#结构化输出#AI JSON#Pydantic#Python AI#2026#AI与大数据