
OpenAI Structured OutputsのPydantic連携を実装してみました
はじめに
こんにちは、DIVX R&Dエンジニア兼広報室室長のyasunaです。
AIをシステムに組み込む時に「AIからの応答をJSON形式で受け取りたい」という場面があると思います。私たちが開発している、OCRで読み取った図面や領収書から特定の情報(図面番号、作成日など)を抽出するシステムも、まさにそれでした。
元にやったこととしては、プロンプトで「必ずJSONで返してください」と強く指示したり、response_format={"type": "json_object"}を指定する「JSONモード」を利用したりしていました。
しかし、これらの方法では、構文的には正しいJSONが返ってくるものの、定義したスキーマ(設計図)に準拠している保証まではありませんでした。必須のキーが欠落したり、期待しないデータ型で返ってきたりすることがあり、そのたびにアプリケーション側でJSONDecodeErrorをキャッチし、複雑なバリデーションやリトライ処理を実装する必要がありました。
今回は、OpenAI APIの「Structured Outputs」機能によって解決することができたのでまとめていきたいと思います。
この記事を読んで分かること
この記事では、OpenAI APIの「Structured Outputs」機能、特にPythonのPydanticライブラリと連携するSDKのヘルパーメソッド(client.responses.parse)を使った具体的な実装方法を紹介します。
なぜ従来の「JSONモード」では不十分だったのか、そしてこのアプローチがどのようにスキーマの準拠を保証し、コードをシンプルにしてくれたのか、その過程と理由を整理します。
背景
以前は、response_format={"type": "json_object"}を指定していました。これは、モデルが構文的に有効なJSON文字列を生成することを保証してくれる便利な機能です。
しかし、これはあくまで「JSONとして壊れていない」ことを保証するだけでした。私たちが本当に欲しかったのは、「drawing_numberというキーが必ず文字列として存在し、creation_dateがYYYY-MM-DD形式で返ってくる」といった、スキーマ(設計図)への準拠です。
JSONモードではこの保証がなかったため、モデルが{"drawing_no": "A-123"}のようにスキーマと異なるキーで返したり、必須項目を省略したりすることがありました。その結果、アプリケーション側には「AIの出力の揺らぎ」を吸収するためのエラーハンドリングや、キーの存在確認を行うコードが存在していました。
試したこと
Structured Outputsの導入は、これまでの実装を置き換える形で行いました。
1. Pydanticでスキーマを定義する
まず、AIに抽出させたいデータの「設計図」を、PydanticのBaseModelを使ってPythonのクラスとして厳密に定義します。
from pydantic import BaseModel
from typing import Optional
# AIに返してほしいJSONのスキーマをPydanticモデルとして定義
class DrawingMetadata(BaseModel):
drawing_number: str
creation_date: str
author: Optional[str] # 任意項目は Optional を使うこのクラス定義そのものが、AIに対する正確な「指示書」になります。
2. client.responses.parse を使う
次に、APIの呼び出し部分を、従来のclient.chat.completions.createからclient.responses.parse(またはclient.chat.completions.parse)に切り替えます。
このメソッドのtext_format引数に、先ほど定義したPydanticクラス(DrawingMetadata)をそのまま渡すのがポイントです。
from openai import OpenAI
# (DrawingMetadataクラスの定義は省略)
client = OpenAI()
def extract_metadata_from_ocr(ocr_text: str) -> Optional[DrawingMetadata]:
"""
Structured Outputs (Pydantic連携) を使ってメタデータを抽出する
"""
try:
# 従来の .create ではなく .parse を使用
response = client.responses.parse(
model="gpt-4o-2024-08-06", # スキーマ準拠に対応したモデル
input=[
{"role": "system", "content": "図面のOCRテキストから情報を抽出し、指定されたスキーマで回答します。"},
{"role": "user", "content": f"以下のテキストから情報を抽出してください: {ocr_text}"}
],
# Pydanticクラスを text_format に渡す
text_format=DrawingMetadata
)
# .output_parsed に検証済みのPydanticオブジェクトが直接入る
# もう json.loads() は必要ありません
return response.output_parsed
except Exception as e:
# スキーマ適応エラー (Refusal) やAPIエラー
print(f"メタデータの抽出に失敗しました: {e}")
return Noneこの変更により、json.loads()を使った手動のパース処理や、返ってきた辞書(dict)のキーを検証するコードをすべて削除できました。
response.output_parsedには、SDKがAPIからの応答をパースし、DrawingMetadataクラスで検証が済んだPydanticオブジェクトが直接格納されています。
考察
今回のStructured Outputsへの移行で得られた気づきは、「JSONモード」と「Structured Outputs」は似ているようで、その目的が全く異なるということでした。
- JSONモード: 「構文的に正しいJSON」を保証する。スキーマは保証しない。
- Structured Outputs: 「指定されたJSONスキーマへの準拠」を保証する。
Structured Outputsは、AIにアプリケーションの仕様を理解させる」ための仕組みでした。検証の責任をAIとAPI側が担ってくれるため、アプリケーション側のコードはとてもシンプルになります。
また、モデルが安全上の理由などで回答を拒否した場合、スキーマに合わない中途半端なJSONを返すのではなく、APIが明確にrefusalという種別で応答を返してくれます。これにより、パースエラーと業務上のエラー(抽出不可)をプログラムで明確に区別して扱えるようになり、システムの堅牢性が向上したと感じました。
まとめ
OpenAI APIのStructured Outputs機能、特にPydanticと連携するSDKは、AIを本番システムに組み込む上での信頼性を高めてくれます。
プロンプトで「JSONで返して」と祈るように指示を書くのではなく、Pydanticのクラス定義という、よりエンジニアリング的なアプローチでAIと対話できるようになりました。
ぜひ参考にしていただけると嬉しいです。


