LiteLLMとLangChainでPydanticスキーマに沿った構造化出力を実現してみる

LiteLLMとLangChainでPydanticスキーマに沿った構造化出力を実現してみる

2026.05.13

はじめに

データ事業本部のkobayashiです。

エージェントから返ってくるテキストをそのまま使うのではなく、Pydanticスキーマに沿った型安全なオブジェクトとして扱いたいケースは多いです。今回はLiteLLMとLangChainで構造化出力(Structured Output)を実現する3パターンを紹介します。

構造化出力とは

LLMの自然言語応答をPython側で扱うとき、テキストパースは脆弱です。構造化出力は、LLMにJSON Schemaに従って答えさせることで、確実にパースできる出力を得る技術です。

主なアプローチ:

  1. LiteLLM response_format: 各プロバイダーの Structured Outputs / JSON Schema 機能を統一インターフェースで利用
  2. LangChain with_structured_output: チャットモデルに Pydantic スキーマを渡し、構造化された Pydantic オブジェクトとして取得
  3. 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_response_format.py
"""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     : ['営業', '提案書']

FielddescriptionがそのままスキーマのドキュメントとしてLLMに渡るため、フィールド名と説明文が抽出精度に直結します。high / medium / low のいずれかのような制約は明記すると守られやすいです。

ChatLiteLLM の with_structured_output

LangChain側のインターフェースを使う場合はwith_structured_output()が標準です。Pydanticオブジェクトが直接返るので、JSONパース処理は不要です。

with_structured_output.py
"""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の要件をそのまま満たせます。

with_structured_output_openai.py
"""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_yearField(...) で必須にしつつ型を int | None のままにすることで、設立年が文中にあるケース(ケース1)では 2004 が入り、文中に無いケース(ケース2)では Pythonの None(JSON的には null)が返るようになりました。OpenAIを主軸にする場合は、最初からこのパターンでスキーマを設計しておくと、後でモデルを Claude に切り替えても問題なく動作します。

ネストしたスキーマ

Pydanticはネストしたモデルもサポートしています。会議の議事録のような複合的な構造を一括抽出できます。

nested_schema.py
"""ネストした 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の強みは「ロールごとに最適なプロバイダーへ振り分けつつ、コードはほぼ変えなくて良い」点です。ChatLiteLLMmodel= 文字列を変えるだけでプロバイダーが切り替わるので、上記のように 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)と組み合わせれば、ノード間で型安全に構造化データを受け渡せる点も、このパターンの大きな利点です。

最後まで読んでいただきありがとうございました。


生成AI活用はクラスメソッドにお任せ

過去に支援してきた生成AIの支援実績100+を元にホワイトペーパーを作成しました。御社が抱えている課題のうち、どれが解決できて、どのようなサービスが受けられるのか?4つのフェーズに分けてまとめています。どうぞお気軽にご覧ください。

生成AI資料イメージ

無料でダウンロードする

この記事をシェアする

関連記事