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を参照していただきたい。