powered by TechFeed
表示モード
ハウツー

PydanticとOpenAIを組み合わせると、LLMのJSON出力が型保証済みPythonオブジェクトに直接変わる — JSON Schema手書きから解放される実装パターン

7月14日、Toward Data Scienceが「Pydantic + OpenAI: The Cleanest Way to Get Structured Outputs from LLMs」と題した記事を公開した。LLMからの構造化出力を扱う際、JSON Schemaの手書きやパース後の型チェックに追われた経験を持つ開発者は多いはずだ。この記事では、PydanticとOpenAIのStructured Outputs機能を組み合わせることで、そのボイラープレートを丸ごと排除する実装パターンを紹介している。本記事はPydantic v2を前提とした実装例であり、v1ではFieldの引数仕様やBaseModelの挙動に一部差異があるため注意が必要だ。

7月14日、Toward Data Scienceが「Pydantic + OpenAI: The Cleanest Way to Get Structured Outputs from LLMs」と題した記事を公開した。LLMからの構造化出力を扱う際、JSON Schemaの手書きやパース後の型チェックに追われた経験を持つ開発者は多いはずだ。この記事では、PydanticとOpenAIのStructured Outputs機能を組み合わせることで、そのボイラープレートを丸ごと排除する実装パターンを紹介している。本記事はPydantic v2を前提とした実装例であり、v1ではFieldの引数仕様やBaseModelの挙動に一部差異があるため注意が必要だ。


なぜPydanticが必要なのか

OpenAIのStructured Outputs機能は、モデルが返すJSONをスキーマに準拠させることを保証する。だが、それでも返ってくるのはただの文字列だ。Pythonコード側でパース、型キャスト、バリデーション、欠損キーの処理——これらをすべて自前で書く必要がある。

PydanticはPythonの型アノテーションを使ったデータバリデーションライブラリだ。クラス定義でスキーマを書くと、渡されたデータが型定義に合っているかを自動検証し、合っていなければ明確なエラーを出す。v2系ではコアがRustで再実装されており、v1と比べてバリデーション速度が大幅に向上している。

PydanticとOpenAI SDKの組み合わせが効くのは、この2つが統合されているからだ。OpenAI SDKがPydanticモデルからJSON Schemaを自動生成し、APIレスポンスを直接Pythonオブジェクトとして返す。開発者はJSON Schemaを手書きする必要がない。

なお、LLMの構造化出力を扱うアプローチは複数存在する。LangChain.with_structured_output()や、Pydanticとの統合に特化したInstructorライブラリも同種の課題を解決する。ただし、LangChainは抽象レイヤーが厚くなりがちで、Instructorは別途依存を追加する必要がある。OpenAI SDKとPydanticだけで完結させたい場合、この記事が紹介するパターンは依存を最小限に抑えた選択肢となる。


Before / Afterで見る差

Pydantic導入前(Function Callingで手書き):

from openai import OpenAI
import json

client = OpenAI(api_key="your_api_key")

tools = [
    {
        "type": "function",
        "function": {
            "name": "extract_person_info",
            "strict": True,
            "parameters": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "age": {"type": "integer"},
                    "city": {"type": "string"}
                },
                "required": ["name", "age", "city"],
                "additionalProperties": False
            }
        }
    }
]

response = client.chat.completions.create(
    model="gpt-4o-mini",
    tools=tools,
    tool_choice={"type": "function", "function": {"name": "extract_person_info"}},
    messages=[
        {"role": "user", "content": "Extract info from: 'Maria is 32 years old and lives in Athens.'"}
    ]
)

result = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
print(result["name"])   # キーが欠けていればKeyError
print(result["age"])    # "32"(文字列)になるかもしれない

Pydantic導入後:

from openai import OpenAI
from pydantic import BaseModel

client = OpenAI(api_key="your_api_key")

class PersonInfo(BaseModel):
    name: str
    age: int
    city: str

response = client.beta.chat.completions.parse(
    model="gpt-4o-mini",
    messages=[
        {"role": "user", "content": "Extract info from: 'Maria is 32 years old and lives in Athens.'"}
    ],
    response_format=PersonInfo
)

result = response.choices[0].message.parsed
print(result.name)   # "Maria" — 常にstr
print(result.age)    # 32 — 常にint
print(result.city)   # "Athens" — 常にstr

.parse()メソッドは通常の.create()と異なり、Pydanticモデルを前提に設計されている。JSON Schemaの生成、strict: TrueでのAPIコール、レスポンスのパース——この一連のパイプラインをSDKが内部で処理する。コード行数の削減以上に、KeyErrorや型ミスマッチが構造的に発生しない点が本質的な利点だ。


3つの実践的な機能

1. ネストされたモデルとリスト

JSON Schemaでネスト構造を手書きすると煩雑になるが、Pydanticでは別クラスを定義するだけだ:

class Address(BaseModel):
    street: str
    city: str
    country: str

class ContactInfo(BaseModel):
    name: str
    email: str
    address: Address
    phone_numbers: List[str]

contact.address.cityのようなドット記法でアクセスでき、result["address"]["city"]のように中間キーが欠けてKeyErrorが出るリスクがない。

2. Fieldによるバリデーション制約

Fieldを使えば、型チェックを超えた制約を加えられる。以下はPydantic v2の引数仕様に基づいた例だ(v1ではmin_length/max_length等の引数名が異なる場合がある):

class ProductReview(BaseModel):
    rating: int = Field(ge=1, le=5, description="Rating from 1 to 5 stars")
    summary: str = Field(max_length=200, description="A brief summary of the review")
    sentiment: str = Field(description="One of: positive, neutral, negative")

ge=1, le=5はratingを1〜5の範囲に制限し、max_length=200はsummaryの文字数を抑える。descriptionフィールドはモデルへの指示としても機能し、Function Callingで手書きしていたスキーマのdescriptionと同等の役割を果たす。

3. モデルの拒否(Refusal)への対応

.parse()を使うと、モデルがリクエストを拒否した場合の処理も構造化される。parsedフィールドがNoneになり、refusalフィールドに拒否理由が入る:

message = response.choices[0].message
if message.refusal:
    print(f"Model refused: {message.refusal}")
else:
    job = message.parsed

本番稼働するアプリケーションでは、ユーザーが何を入力するか予測できない。拒否理由を構造化して取得できるのは実用上の価値がある。.create()では同等の処理を自前で実装する必要がある点と対照的だ。


実装例:求人票の情報抽出

記事ではネストモデル・Optionalフィールド・Listを組み合わせた求人票パーサーの実装例が示されている。SalaryRangeモデルをネストして、給与情報がない場合はNoneにフォールバックする構成だ:

class SalaryRange(BaseModel):
    min_salary: Optional[int] = Field(default=None, description="Minimum salary in USD per year")
    max_salary: Optional[int] = Field(default=None, description="Maximum salary in USD per year")
    currency: str = Field(default="USD", description="Currency code")

class JobPosting(BaseModel):
    job_title: str = Field(description="The job title or role name")
    company_name: str = Field(description="The name of the hiring company")
    required_skills: List[str] = Field(description="List of required technical skills")
    years_of_experience: Optional[int] = Field(default=None)
    salary: Optional[SalaryRange] = Field(default=None)
    remote_friendly: bool

出力例:

Title: Senior Python Engineer
Company: pialgorithms
Location: Athens, Greece / Remote
Remote: True
Skills: Python, FastAPI, LangChain, vector databases, OpenAI API, Pydantic, Docker, AWS, GCP
Experience: 5 years
Salary: 60000 - 85000 EUR

この結果はそのままDBに挿入できる状態で、追加の後処理が不要だ。Optionalフィールドのフォールバック処理もPydanticが自動で行うため、給与情報が記載されていない求人票でもNoneとして安全に扱える。


まとめ

Pydanticが担う役割は「PythonコードとOpenAI APIの間のスキーマ定義レイヤー」だ。JSON Schemaの手書き不要、型保証、バリデーション、ドット記法アクセス——これらを.parse()一本で実現できる。LangChainやInstructorといった選択肢も存在するが、外部依存を増やさずにOpenAI SDKとPydantic(v2)だけで完結させたい場合、この記事が紹介するパターンは導入コストの低い実用的なアプローチだ。実運用に載せる際は、使用しているPydanticのバージョンを確認した上でコードを適用してほしい。

詳細はPydantic + OpenAI: The Cleanest Way to Get Structured Outputs from LLMsを参照していただきたい。