LiteLLMとLangChainでPydanticスキーマに沿った構造化出力を実現してみる
はじめに
データ事業本部のkobayashiです。
エージェントから返ってくるテキストをそのまま使うのではなく、Pydanticスキーマに沿った型安全なオブジェクトとして扱いたいケースは多いです。今回はLiteLLMとLangChainで構造化出力(Structured Output)を実現する3パターンを紹介します。
構造化出力とは
LLMの自然言語応答をPython側で扱うとき、テキストパースは脆弱です。構造化出力は、LLMにJSON Schemaに従って答えさせることで、確実にパースできる出力を得る技術です。
主なアプローチ:
- LiteLLM
response_format: 各プロバイダーの Structured Outputs / JSON Schema 機能を統一インターフェースで利用 - LangChain
with_structured_output: チャットモデルに Pydantic スキーマを渡し、構造化された Pydantic オブジェクトとして取得 - Tool Calling 経由: Pydantic を tool スキーマとして登録し、ツール呼び出し引数で構造化情報を受け取る
環境
Python 3.13
litellm 1.83.14
langchain-litellm 0.6.4
langchain-core 1.3.2
pydantic 2.9.0
LiteLLM の response_format で構造化
LiteLLMのcompletion()に Pydantic モデルを渡すだけで、プロバイダーごとの Structured Outputs を自動的に有効化してくれます。
"""LiteLLM の completion(response_format=...) で構造化出力を得る。
Pydantic モデルを response_format に渡すと、JSON Schema に従った出力が
保証される(プロバイダー側の Structured Outputs 機能を活用)。
"""
from litellm import completion
from pydantic import BaseModel, Field
class TaskExtraction(BaseModel):
"""ユーザーの自然文からタスクを抽出する。"""
title: str = Field(..., description="タスクの短いタイトル")
priority: str = Field(..., description="high / medium / low のいずれか")
due_date: str | None = Field(
None, description="期限。YYYY-MM-DD 形式、なければnull"
)
tags: list[str] = Field(default_factory=list, description="関連するタグ")
user_input = "明日までに取引先に提案書を送る、優先度高め。タグは営業と提案書"
response = completion(
model="openai/gpt-5.4",
messages=[
{"role": "system", "content": "ユーザー入力からタスク情報を抽出してください。"},
{"role": "user", "content": user_input},
],
response_format=TaskExtraction,
)
raw = response.choices[0].message.content
task = TaskExtraction.model_validate_json(raw)
print("=== 構造化されたタスク ===")
print(f" title : {task.title}")
print(f" priority : {task.priority}")
print(f" due_date : {task.due_date}")
print(f" tags : {task.tags}")
実行結果は以下のようになります。
$ python litellm_response_format.py
=== 構造化されたタスク ===
title : 取引先に提案書を送る
priority : high
due_date : 2026-05-04
tags : ['営業', '提案書']
FieldのdescriptionがそのままスキーマのドキュメントとしてLLMに渡るため、フィールド名と説明文が抽出精度に直結します。high / medium / low のいずれかのような制約は明記すると守られやすいです。
ChatLiteLLM の with_structured_output
LangChain側のインターフェースを使う場合はwith_structured_output()が標準です。Pydanticオブジェクトが直接返るので、JSONパース処理は不要です。
"""ChatLiteLLM.with_structured_output() で Pydantic スキーマに変換する。
LangChain インターフェースを使う場合のスタンダードな書き方。
"""
import litellm
from langchain_litellm import ChatLiteLLM
from pydantic import BaseModel, Field
litellm.modify_params = True
class CompanyInfo(BaseModel):
"""会社情報。"""
name: str = Field(..., description="会社名")
industry: str = Field(..., description="業界")
founded_year: int | None = Field(None, description="設立年")
headquarters: str = Field(..., description="本社所在地")
public: bool = Field(..., description="上場しているかどうか")
# Optional フィールドを含むスキーマは Claude のほうが扱いやすい(後述)
llm = ChatLiteLLM(model="anthropic/claude-sonnet-4-6")
structured_llm = llm.with_structured_output(CompanyInfo)
text = """
クラスメソッド株式会社は2004年7月に設立された日本のIT企業で、
クラウドコンピューティング・データ分析を中心とする業界に属しています。
本社は東京都にあり、株式は非公開です。
"""
result: CompanyInfo = structured_llm.invoke(text)
print("=== 抽出結果 ===")
print(f" name : {result.name}")
print(f" industry : {result.industry}")
print(f" founded_year : {result.founded_year}")
print(f" headquarters : {result.headquarters}")
print(f" public : {result.public}")
with_structured_output()は内部的にプロバイダー別の最適な仕組み(OpenAIのresponse_format、Anthropicのtool useなど)を選択してくれます。LiteLLM経由のため、モデル名を変えるだけで他社プロバイダーに切り替えられます。
実行結果は以下のようになります。
$ python with_structured_output.py
=== 抽出結果 ===
name : クラスメソッド株式会社
industry : クラウドコンピューティング・データ分析
founded_year : 2004
headquarters : 東京都
public : False
OpenAI Strict mode への対応
上記コードのモデルを openai/gpt-5.x に切り替えると Invalid schema for response_format ... 'required' is required to be supplied and to be an array including every key in properties. Missing 'founded_year'. というエラーで弾かれます。OpenAIのStrict modeは「全フィールドを required に含める」ことを強制するため、founded_year: int | None = Field(None, ...) のように Optional + デフォルト値 を持つフィールドが原因で弾かれます。Claudeは tool use ベースで構造化出力を実現するため Optional がそのまま許容され、日本語業務で出てくる「期限が無い」「設立年が不明」のようなケースに馴染みます。
OpenAIで通したい場合は、Optionalなフィールドにも Field(...) を渡して required 扱いにし、不明な値は null で表現 する設計に寄せます。Pydantic 2 では int | None = Field(...) の形でJSON Schemaが anyOf: [integer, null] + required に入る形で生成されるため、Strict modeの要件をそのまま満たせます。
"""ChatLiteLLM.with_structured_output() を OpenAI Strict mode 対応で書く。
OpenAI の Structured Outputs (Strict mode) は「全フィールドを required に
含める」ことを強制するため、Optional 相当のフィールドも Field(None) ではなく
Field(...) を使って required 扱いにし、不明なら null を入れさせる設計に寄せる。
"""
import litellm
from langchain_litellm import ChatLiteLLM
from pydantic import BaseModel, Field
litellm.modify_params = True
class CompanyInfo(BaseModel):
"""会社情報(OpenAI Strict mode 互換: 全フィールド required + nullable で表現)。"""
name: str = Field(..., description="会社名")
industry: str = Field(..., description="業界")
# OpenAI Strict mode は Optional でも required に含める必要があるため、
# Field(None) ではなく Field(...) で必須にし、不明なら null を返させる。
founded_year: int | None = Field(..., description="設立年。不明なら null")
headquarters: str = Field(..., description="本社所在地")
public: bool = Field(..., description="上場しているかどうか")
llm = ChatLiteLLM(model="openai/gpt-5.5")
structured_llm = llm.with_structured_output(CompanyInfo)
# ケース1: 設立年が文中にある
text1 = """
クラスメソッド株式会社は2004年7月に設立された日本のIT企業で、
クラウドコンピューティング・データ分析を中心とする業界に属しています。
本社は東京都にあり、株式は非公開です。
"""
# ケース2: 設立年が文中に無い -> founded_year は null になるはず
text2 = """
スターアップ株式会社はAIを活用した業務支援サービスを提供する企業で、
本社は大阪市にあります。設立年は不明、株式は非公開です。
"""
for label, text in [("ケース1(設立年あり)", text1), ("ケース2(設立年なし)", text2)]:
result: CompanyInfo = structured_llm.invoke(text)
print(f"=== {label} ===")
print(f" name : {result.name}")
print(f" industry : {result.industry}")
print(f" founded_year : {result.founded_year}")
print(f" headquarters : {result.headquarters}")
print(f" public : {result.public}")
print()
実行結果は以下のようになります。
$ python with_structured_output_openai.py
=== ケース1(設立年あり) ===
name : クラスメソッド株式会社
industry : クラウドコンピューティング・データ分析
founded_year : 2004
headquarters : 東京都
public : False
=== ケース2(設立年なし) ===
name : スターアップ株式会社
industry : AIを活用した業務支援サービス
founded_year : None
headquarters : 大阪市
public : False
founded_year を Field(...) で必須にしつつ型を int | None のままにすることで、設立年が文中にあるケース(ケース1)では 2004 が入り、文中に無いケース(ケース2)では Pythonの None(JSON的には null)が返るようになりました。OpenAIを主軸にする場合は、最初からこのパターンでスキーマを設計しておくと、後でモデルを Claude に切り替えても問題なく動作します。
ネストしたスキーマ
Pydanticはネストしたモデルもサポートしています。会議の議事録のような複合的な構造を一括抽出できます。
"""ネストした Pydantic モデルでより複雑な情報を抽出する。
会議の議事録から、参加者・アクション項目・決定事項を一括抽出する例。
"""
import litellm
from langchain_litellm import ChatLiteLLM
from pydantic import BaseModel, Field
litellm.modify_params = True
class Person(BaseModel):
name: str
role: str | None = None
class ActionItem(BaseModel):
description: str = Field(..., description="やるべきこと")
assignee: str = Field(..., description="担当者")
due: str | None = Field(None, description="期限")
class Meeting(BaseModel):
"""議事録の構造化。"""
title: str = Field(..., description="会議タイトル")
date: str = Field(..., description="開催日")
participants: list[Person] = Field(default_factory=list)
decisions: list[str] = Field(default_factory=list, description="決定事項")
actions: list[ActionItem] = Field(default_factory=list)
minutes = """
2026年5月3日 週次定例
参加者: 田中(PM)、佐藤(エンジニア)、山田(デザイナー)
議題:
- 新機能リリースの最終確認 -> 5/15リリース確定
- バグ#234の対応 -> 佐藤が5/8までに修正
- ロゴ刷新 -> 山田が5/20までに案を3つ提示
その他:
- 来週の定例は山田休暇のため水曜開催に変更
"""
llm = ChatLiteLLM(model="anthropic/claude-sonnet-4-6").with_structured_output(Meeting)
meeting: Meeting = llm.invoke(f"以下の議事録を構造化してください:\n{minutes}")
print(f"=== {meeting.title} ({meeting.date}) ===")
print("\n参加者:")
for p in meeting.participants:
print(f" - {p.name} ({p.role or '-'})")
print("\n決定事項:")
for d in meeting.decisions:
print(f" - {d}")
print("\nアクション:")
for a in meeting.actions:
print(f" - [{a.assignee}] {a.description} (期限: {a.due or '未定'})")
実行結果は以下のようになります。
$ python nested_schema.py
=== 週次定例 (2026年5月3日) ===
参加者:
- 田中 (PM)
- 佐藤 (エンジニア)
- 山田 (デザイナー)
決定事項:
- 新機能リリースを5月15日に確定
- 来週の定例を山田の休暇のため水曜開催に変更
アクション:
- [佐藤] バグ#234の修正 (期限: 2026年5月8日)
- [山田] ロゴ刷新案を3つ提示 (期限: 2026年5月20日)
ネストした構造もそのままPythonオブジェクトに変換され、for p in meeting.participantsのように扱えます。Claude Sonnet 4.6は議事録の文中表現(例: 「5/8までに修正」)を 2026年5月8日 のように年情報を補って整形してくれており、抽出と同時に軽い正規化も行われています。
LiteLLMでのモデル使い分け
| ロール | スクリプト | モデル | 理由 |
|---|---|---|---|
| 単発の構造化抽出 | litellm_response_format | openai/gpt-5.4 |
OpenAIのStructured Outputsを使うサンプル |
| Optionalフィールド込みの抽出 | with_structured_output | anthropic/claude-sonnet-4-6 |
OpenAI Strict modeはOptional NG、Claudeは寛容 |
| OpenAI Strict mode 対応 | with_structured_output_openai | openai/gpt-5.5 |
Optional を Field(...) + nullable union で表現し Strict mode に通す |
| ネスト構造の抽出 | nested_schema | anthropic/claude-sonnet-4-6 |
複雑なスキーマ+日本語の正規化はClaudeが安定 |
LiteLLMの強みは「ロールごとに最適なプロバイダーへ振り分けつつ、コードはほぼ変えなくて良い」点です。ChatLiteLLM の model= 文字列を変えるだけでプロバイダーが切り替わるので、上記のように OpenAI Strict mode の制約が問題になる場合だけ Claude に逃がすといった使い分けが容易です。
まとめ
LiteLLM × Pydanticでの構造化出力を、completion(response_format=...) を直接呼ぶパターン、ChatLiteLLM().with_structured_output() でPydanticオブジェクトを受け取るパターン、ネストしたスキーマで議事録のような複合情報を一括抽出するパターンの3つで紹介しました。
OpenAIのStrict modeは全フィールドを required に含めることを強制するため Field(None) の Optional が弾かれる一方、Claudeはtool useベースで Optional を寛容に扱える、というプロバイダー差を実機で確認できました。Field(...) + nullable union でスキーマを書いておけば Strict mode でも通るため、最初からその設計に寄せておくのが安心です。LangGraphのステート定義(TypedDict)と組み合わせれば、ノード間で型安全に構造化データを受け渡せる点も、このパターンの大きな利点です。
最後まで読んでいただきありがとうございました。









