Python & AI Tutorials Logo
LangChain & LangGraph

7. Pydanticを使った構造化出力

これまでの章では、LLMの出力を生のテキスト文字列として扱ってきました。これは人間が応答を読むチャットボットには問題ありませんが、プログラムがLLMの出力を解析して解釈する必要があるAIエージェントを構築する場合、予測可能で構造化されたデータが必要です。この章では、Pydanticスキーマ(schema)を使用してLLMに構造化されたPythonオブジェクトを返させる方法を学びます。

7.1) なぜ構造化出力が必要なのか?

自由形式のLLM出力の問題点

まず、生のテキスト応答が実際のアプリケーションでどのような問題を引き起こすかを理解することから始めましょう。次のような一般的なシナリオを考えてみましょう:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
# LLMに製品について質問します
message = HumanMessage(content="""
次のテキストから製品情報を抽出してください:
"UltraWidget Proの価格は$299.99で、現在在庫があります。"
""")
 
response = llm.invoke([message])
print(response.content)

出力:

製品名: UltraWidget Pro
価格: $299.99
在庫状況: 在庫あり

出力は良さそうに見えます。しかし、このデータをPythonアプリケーションで使用する必要があるとします。価格を数値として抽出するにはどうすればよいでしょうか? 在庫状況をプログラムで確認するにはどうすればよいでしょうか? 次のような文字列解析を試みるかもしれません:

python
# 脆弱な解析アプローチ
text = response.content
price_line = [line for line in text.split('\n') if '価格:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str)  # 脆弱 - フォーマットが変わったらどうなる?

この解析は機能するように見えます。しかし、実際には機能しません。その理由は次のとおりです:

このアプローチが失敗する理由:

  1. LLMは次回、応答を異なる形式でフォーマットする可能性があります("価格: 299.99 USD"や"小売価格: $299.99"など)

同じプロンプトに対して発生する可能性のある異なる出力の例を次に示します:

# 例1
"製品はUltraWidget Proで、価格は$299.99、在庫があります。"
 
# 例2
"製品: UltraWidget Pro
価格: 299.99ドル
状態: 在庫あり"
 
# 例3
"UltraWidget Pro - $299.99 (在庫あり)"
 
# 例4
"UltraWidget Proを見つけました。価格は$299.99で、現在購入可能です。"

LLMの応答が変わると、まったく異なる解析ロジックが必要になります。これにより、信頼性の高いアプリケーションを構築することが困難になります。

  1. LLMの応答は予測不可能です: 同じプロンプトでも毎回異なる形式を生成する可能性があります
  2. 文字列解析は見た目よりも難しいです: $、スペース、改行、カンマなどを処理する必要があります
  3. 型安全性がまったくありません: priceがfloat、string、Noneのいずれであるかを確認できません
  4. エラー処理が困難です: LLMが"価格は利用できません"と言った場合、float()呼び出しがクラッシュします
  5. 保守が困難です: プロンプトをわずかに変更すると、すべての解析コードを書き直す必要があります

核心的な考え方: Pythonには契約が必要で、散文は不要

PythonでAPIサーバーと通信する場合を考えてみましょう。特定のREST APIを呼び出すとき、定義されたJSON応答が返されることを期待します:

python
# この構造を期待します
{
    "product_name": "UltraWidget Pro",
    "price": 299.99,
    "in_stock": true
}

AIアプリケーションを構築する場合も、同じ原則が必要です。LLMの出力は、毎回自由形式のテキストではなく、定義された構造を持つデータとして返されるべきです。

散文 vs 契約:

  • 散文: 自由形式の自然なテキスト。人間が読むには良いですが、プログラムが処理するのは困難です。
  • 契約: 定義された構造と型を持つデータ。"これらのフィールドがこれらの型で存在する"という約束です。

構造化出力は契約を定義することを意味します: "LLM、正確にこれらのフィールドを、正確にこれらの型で、正確にこの形式で必要です。"

ここでPydanticが登場します。PydanticはPythonで最も人気のあるデータ検証ライブラリであり、LangChainはこれを使用してLLMの出力を構造化された形式で受け取ります。

メンタルモデルの転換:

  • 以前: "LLM、この製品について教えてください" → 予測不可能なテキストを解析
  • 以後: "LLM、定義された形式で応答してください" → 構造化されたPythonオブジェクトを受け取る

この散文から契約への転換は、信頼性の高いAIエージェントを構築するための基本です。エージェントがLLMの応答に基づいて次のアクションを決定する必要がある場合(例: 在庫がある場合は購入、ない場合は通知登録)、定義された形式で応答を受け取る必要があります。

手動解析

構造化出力

頻繁に壊れる

型安全

LLMテキスト出力

脆弱な文字列ロジック

型付きPythonオブジェクト

実行時エラー

信頼性の高いコード

7.2) 最初の構造化出力

7.1では、LLMが自由形式のテキストではなく定義された構造で応答すべき理由を学びました。では、実際にこれを実装する方法を見てみましょう。

重要な考え方: 単にLLMに"この形式で応答してください"と依頼するだけでは不十分です。Pythonコードで正確なデータ構造を定義し、LangChainがそれをLLMに渡す必要があります。この定義されたデータ構造をスキーマ(schema)と呼びます。

スキーマとは何か?

スキーマ(schema)は、データの構造を定義する設計図です。次のことを指定します:

  • どのフィールドが存在する必要があるか
  • 各フィールドがどの型であるべきか(文字列、数値、ブール値など)
  • どのような制約が適用されるか(オプション vs 必須、有効な範囲など)

Pythonでは、PydanticのBaseModelクラスを使用してスキーマを定義します。最も単純な例を次に示します:

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

このスキーマは次のことを示しています: "ProductInfoオブジェクトには、product_name(文字列)、price(浮動小数点数)、in_stock(ブール値)の正確に3つのフィールドが必要です。"

3ステップパターン: 定義、バインド、呼び出し

構造化出力の使用は簡単です。3つのステップを覚えておくだけです:

  1. 定義: Pydanticクラスでスキーマを作成
  2. バインド: .with_structured_output()を使用してスキーマをLLMに接続
  3. 呼び出し: .invoke()を呼び出して型付きオブジェクトを取得

これは、ほとんどの構造化抽出タスクで使用する標準テンプレートです:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
 
# ステップ1: スキーマを定義
class ProductInfo(BaseModel):
    product_name: str = Field(description="完全な製品名")
    price: float = Field(description="USD単位の価格")
    in_stock: bool = Field(description="製品が利用可能かどうか")
 
# ステップ2: スキーマをLLMにバインド
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
 
# ステップ3: 呼び出して型付きオブジェクトを取得
message = HumanMessage(content="""
次のテキストから製品情報を抽出してください:
"UltraWidget Proの価格は$299.99で、現在在庫があります。"
""")
 
result = structured_llm.invoke([message])
 
# resultは文字列ではなくProductInfoオブジェクトです
print(type(result))  # <class '__main__.ProductInfo'>
print(result.product_name)  # UltraWidget Pro
print(result.price)  # 299.99
print(result.in_stock)  # True

何が起こったのか?

  1. スキーマ定義: 必要なフィールドと型を定義しました
  2. バインド: .with_structured_output(ProductInfo)がLLMを構造化出力を使用するように設定しました
  3. 呼び出しと応答: .invoke()が呼び出されると、LangChainはJSON SchemaをLLMに渡し、LLMはその構造に一致するJSONで応答します
  4. 自動変換: LangChainはJSONをProductInfoオブジェクトに変換します - 解析コードは不要です

解析なし。型変換なし。エラーなし。

このテンプレートを3ステップパターンとして使用してください。 構造化出力が必要なときはいつでもこれに従ってください。

フィールドの説明: LLMをガイドする鍵

上記のスキーマ定義例では、Field(description="...")を使用しました。この説明は単なるドキュメントではありません。LLMが読んで従う指示です

通常のPydanticの使用では、Fieldの説明はオプションです:

python
# 通常のPydantic - 説明は人間のためのドキュメント
class User(BaseModel):
    name: str = Field(description="ユーザーの名前")  # なくても問題なく動作

しかし、LLMと連携する場合、それらは不可欠です:

python
# LLMと連携 - 説明がLLMの動作を決定
class CustomerFeedback(BaseModel):
    sentiment: str = Field(
        description="全体的な感情: 'positive'、'negative'、または'neutral'"
    )

LLMはこの説明を読み、応答方法を決定するために使用します。

実際の動作を見てみましょう:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
 
class CustomerFeedback(BaseModel):
    sentiment: str = Field(
        description="全体的な感情: 'positive'、'negative'、または'neutral'"
    )
    main_issue: str = Field(
        description="主な苦情または懸念事項(ある場合)。問題が言及されていない場合は'none'を使用"
    )
    urgency: str = Field(
        description="問題の緊急度: 'low'、'medium'、または'high'"
    )
 
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
 
message = HumanMessage(content="""
次の顧客フィードバックを分析してください:
"製品は問題なく動作しますが、配送に3週間かかりました。プロジェクトの締め切りにすでに間に合いませんでした。すぐに対応してください。"
""")
 
result = structured_llm.invoke([message])
print(result.sentiment)    # negative
print(result.main_issue)   # 配送が遅い
print(result.urgency)      # high

説明がLLMの決定をどのように形作るか:

  • sentimentの説明 → LLMは有効な値が'positive'、'negative'、'neutral'であることを学習 → 配送問題が締め切りに間に合わなかったため、'negative'を選択
  • main_issueの説明 → LLMは"主な苦情を見つける"よう指示される → "配送が遅い"を問題として特定
  • urgencyの説明 → LLMは緊急度が'low'、'medium'、'high'でなければならないことを学習 → "すぐに対応してください"を見て'high'を選択

説明がない場合はどうなるか?

python
sentiment: str  # 説明なし

LLMは"negative"、"bad"、"unsatisfied"、"2/5"、"disappointed"などの予測不可能な形式で返す可能性があり、コードで値を処理することが困難になります。

重要なポイント: フィールドの説明は、LLMの動作を制御するコードの一部です。明確かつ具体的に記述してください。

カテゴリカルフィールド: 許可される値の指定

上記の例では、sentimentフィールドは'positive'、'negative'、'neutral'の3つの値のみを持つことができます。特定の値のセットのいずれかでなければならないフィールドをカテゴリカルフィールド(categorical fields)と呼びます。

カテゴリカルフィールドの場合、説明にすべての可能な値をリストしてください:

python
sentiment: str = Field(
    description="感情: 正確に'positive'、'negative'、または'neutral'(小文字)"
)

"正確に"と"(小文字)"を指定することで、LLMがこれら3つの値のいずれか1つで正確に応答すべきであることを強調します。

ただし、LLMが常に指定された値のいずれかで応答する保証はありません。そのため、防御的なコードを書く必要があります。

LLMが予期しない値を返すケース:

python
result.sentiment = "Positive"    # 大文字
result.sentiment = "NEGATIVE"    # すべて大文字
result.sentiment = "good"        # まったく異なる単語

防御的なコードの記述:

python
allowed = {"positive", "negative", "neutral"}
 
# 小文字に変換して確認
sentiment = result.sentiment.lower()
 
if sentiment not in allowed:
    sentiment = "neutral"  # 予期しない値にはデフォルトを使用
 
# これでsentimentは許可された値のいずれかであることが保証されます

重要なポイント:

  1. 説明で許可される値を指定 → LLMが正しく応答する可能性が高くなる
  2. コードで検証 → 予期しない値を安全に処理

注: 第18章では、Pythonのenumを使用したより強力な強制パターンを示します。

比較: 手動解析 vs 構造化出力

手動解析と構造化出力で同じタスクを比較して、違いを見てみましょう:

手動解析:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
llm = ChatOpenAI(model="gpt-4o-mini")
 
message = HumanMessage(content="""
次から製品名、価格、在庫状況を抽出してください:
"UltraWidget Proの価格は$299.99で、現在在庫があります。"
形式: 名前 | 価格 | 在庫状況
""")
 
response = llm.invoke([message])
text = response.content
 
# 手動解析
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = '在庫あり' in availability or '利用可能' in availability
 
print(f"名前: {product_name}")
print(f"価格: ${price}")
print(f"在庫: {in_stock}")

構造化出力:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
 
class ProductInfo(BaseModel):
    product_name: str = Field(description="完全な製品名")
    price: float = Field(description="USD単位の価格")
    in_stock: bool = Field(description="製品が利用可能かどうか")
 
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
 
message = HumanMessage(content="""
次から製品情報を抽出してください:
"UltraWidget Proの価格は$299.99で、現在在庫があります。"
""")
 
result = structured_llm.invoke([message])
 
print(f"名前: {result.product_name}")
print(f"価格: ${result.price}")
print(f"在庫: {result.in_stock}")

主な違い:

  1. 解析ロジックなし: 構造化バージョンには解析コードがゼロ
  2. 型安全性: result.priceはfloatであることが保証される
  3. よりシンプルなコード: 正規表現なし、文字列分割なし、手動型変換なし
  4. 検証: Pydanticはすべての必須フィールドが存在することを保証
  5. 保守性: スキーマの変更は解析ロジックの更新よりも簡単

7.3) スキーマ設計の考慮事項

構造化出力の使用方法がわかったので、優れたスキーマを設計する方法を学びましょう。このセクションでは、必須フィールドとオプションフィールドを区別するための実用的な設計原則について説明します。

必須フィールド

デフォルトでは、Pydanticモデルのすべてのフィールドは必須です。これは、LLMがユーザーのプロンプトからすべての必須フィールドの値を抽出または推測し、応答で提供する必要があることを意味します。

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

このスキーマを使用すると、LLMは入力テキストから3つのフィールド(product_namepricein_stock)すべての値を見つけようとします。

しかし、プロンプトに必須フィールドの情報がない場合はどうなるでしょうか?

次のような動作を期待するかもしれません:

  1. LLMはプロンプトで情報を見つけられない
  2. LLMはそのフィールドを応答から省略する
  3. LangChainは有効なProductInfoインスタンスを作成できない
  4. ValidationErrorが発生する

しかし、これは常に起こるわけではありません。

理由は、異なるLLMが欠落情報を異なる方法で処理する可能性があるためです。

一部のLLM(OpenAIモデルなど)は、必要な情報がプロンプトに存在しない場合でも値を生成する傾向があります。この場合、ValidationErrorは発生しませんが、Pythonアプリケーションが捏造された情報を実際のものとして処理する可能性があるため、より大きな問題が発生する可能性があります。

この問題を解決する方法については、セクション7.4: 問題が発生したときで説明します。

今のところ、すべてのLLMが欠落情報を同じ方法で処理するわけではないことを認識しておいてください。

オプションフィールド

通常の場合でも、正当に存在する場合と存在しない場合があるフィールドが必要な場合があります。たとえば、配送メモ(delivery_note)は、有効な注文であっても、顧客によって提供される場合と提供されない場合があります。

Optionalを使用する場合:

  • データ自体が存在しない可能性がある(例: 匿名レビューが許可されている場合、匿名レビューにはレビュアー名がない)
  • LLMに値を捏造するのではなく、欠落情報を明示的に示してほしい

フィールドをオプションにするには、typingモジュールのPythonのOptional型を使用します:

python
from typing import Optional
 
class ProductReview(BaseModel):
    rating: int
    review_text: str
    reviewer_name: Optional[str] = None  # 匿名レビューにはレビュアー名がない

注: Python 3.10以降のユーザーは、Optional[str]の代わりにstr | Noneを使用できます。

フィールドがOptionalの場合:

  • LLMはプロンプトで情報が見つからない場合、応答から省略できます
  • 省略されたフィールドはデフォルト値(None)に設定されます

完全な例を次に示します:

python
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool
    discount_percentage: Optional[float] = None
    warranty_years: Optional[int] = None
 
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
 
message = HumanMessage(content="""
製品情報を抽出してください: "UltraWidget Proの価格は$299.99で、在庫があります。"
""")
 
result = structured_llm.invoke([message])
print(result.product_name)  # UltraWidget Pro
print(result.price)  # 299.99
print(result.in_stock)  # True
print(result.discount_percentage)  # None (言及されていない)
print(result.warranty_years)  # None (言及されていない)

スキーマ設計チェックリスト

スキーマを確定する前に、次のことを自問してください:

フィールドの選択:

  • 必須フィールドは本当に不可欠ですか? (このフィールドがプロンプトから欠落している場合はどうなりますか?)
  • オプションフィールドは通常の場合でも正当に欠落する可能性がありますか?

フィールドの仕様:

  • 各フィールドには明確な説明がありますか?
  • カテゴリカルフィールドは明示的に制約されていますか? (例: "正確に'A'、'B'、または'C'でなければならない")

7.4) 問題が発生したとき

構造化出力を使用する際に発生する可能性のある2つの問題があります:

  1. LLMが必須フィールド値を省略する → ValidationErrorが発生
  2. LLMが欠落情報を捏造する → ValidationErrorなし、しかしコードが不正なデータを処理

このセクションでは、それぞれの処理方法について説明します。

検証エラーの理解

スキーマで定義された必須フィールドの情報がユーザープロンプトに欠けている場合、LLMはそれらのフィールドの値を提供できません。その後、PythonプログラムはValidationErrorを発生させます:

python
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool
 
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
 
# 必要な情報が欠落している入力
message = HumanMessage(content="""
次から製品情報を抽出してください: "このウィジェットは素晴らしい! 強くお勧めします。"
""")
 
try:
    result = structured_llm.invoke([message])
    print(result)
except ValidationError as e:
    print("ValidationErrorが発生しました")

注: プロンプトに必須フィールドの情報がない場合、一部のLLMは値を捏造して応答で提供する可能性があります。この場合、ValidationErrorは発生しませんが、より大きな問題が発生します。これについては次のセクションで説明します。

プロンプトに必須フィールドの情報がなく、ValidationErrorが発生する場合、これは実際にはPythonアプリケーションにとって有用です。アプリケーションは問題が発生したことを検出し、制御された方法でエラーを処理できます。エラー回復戦略については、第14章(エージェントレベルのエラー回復)と第17章(状態管理を使用した再試行ロジック)で説明します。

より大きな問題: LLMが欠落情報を捏造する

セクション7.3で説明したように、一部のLLMはより危険な動作を示します: プロンプトに情報が欠落している場合、値を捏造して応答で提供します

問題の発生方法:

  1. プロンプトに必要な情報が欠落している
  2. LLMはとにかくもっともらしい値を生成する
  3. ValidationErrorは発生しない
  4. Pythonアプリケーションは捏造されたデータを実際のものとして処理する

例:

python
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool
 
message = HumanMessage(content="""
次から製品情報を抽出してください: "このウィジェットは素晴らしい!"
""")
 
# 一部のLLMがproduct_name、price、in_stockの値を捏造する場合
result = structured_llm.invoke([message])
# エラーは発生しません!
print(result.product_name)  # "widget" (テキストから抽出)
print(result.price)  # 0.0 (捏造!)
print(result.in_stock)  # False (捏造!)
 
# 問題: どの値が実際のもので、どの値が捏造されたものかわからない

これはValidationErrorよりも悪い理由:

  • Pythonアプリケーションは不正なデータで実行を続ける
  • どのフィールドが実際のもので、どのフィールドが捏造されたものかわからない
  • ダウンストリームロジックが偽のデータに基づいて誤った決定を下す可能性がある

解決策: 検証付きのOptionalフィールドを使用する

解決策は、すべての必須フィールドをOptionalとして定義し、バリデータを使用してすべての必須フィールドに値があることを確認することです。

python
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
 
class ProductInfo(BaseModel):
    # これらは実際には必須ですが、Optionalとして宣言されています
    # 実際の検証は以下のバリデータで行われます
    product_name: Optional[str] = None
    price: Optional[float] = None
    in_stock: Optional[bool] = None
    
    @model_validator(mode='after')
    def check_required_fields(self):
        """すべての必須フィールドが存在することを検証します"""
        if self.product_name is None or self.price is None or self.in_stock is None:
            raise ValueError("すべてのフィールド(product_name、price、in_stock)を提供する必要があります")
        return self
 
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
 
# 不完全なデータでテスト
message = HumanMessage(content="""
次から製品情報を抽出してください: "このウィジェットは素晴らしい!"
""")
 
try:
    result = structured_llm.invoke([message])
    # ここに到達した場合、すべてのフィールドが存在することが保証されます
    print(f"製品: {result.product_name}")
    print(f"価格: ${result.price}")
except ValidationError as e:
    # 必須フィールドが欠落している - 抽出失敗
    print(f"不完全な抽出: {e}")

ここで何が起こっているか:

  • @model_validatorは、カスタム検証ロジックを追加するPydanticのデコレータです
  • mode='after'は、すべてのフィールドが解析された後に検証が実行されることを意味します
  • いずれかのフィールドがNoneの場合、不完全なデータを示すためにValueErrorを発生させます
  • PydanticはこのValueErrorを自動的にValidationErrorでラップします

これが機能する理由:

フィールドの情報がプロンプトに欠落している場合:

  • LLMは値を捏造せず、応答からそれらのフィールドを省略します
  • この場合、それらのフィールドはNoneになります
  • それらのフィールドが実際に必須である場合、PydanticバリデータはValueErrorを発生させます
  • PydanticはそれをValidationErrorとしてラップします

このようにして、Pythonアプリケーションは捏造されたデータではなく、処理すべき明示的なエラーを受け取ります。

重要なポイント: プロンプトに必須フィールドの情報が欠落している場合、ValidationErrorを取得することは完全に正常で予想されることです。本当の危険は捏造されたデータです。Optionalフィールドとバリデータを使用して、LLMが欠落情報を捏造するのを防ぎ、必須フィールドが欠落している場合を明示的に検出します。


章のまとめ:

この章では、LLMの出力を信頼性の高いPythonオブジェクトに変換する方法を学びました:

  • 重要性: 自由形式のテキスト解析は脆弱です; スキーマベースの出力は型安全性を提供します
  • 使用方法: PydanticのBaseModelでスキーマを定義 → .with_structured_output()でバインド
  • 設計原則: 必須フィールドとオプションフィールドを選択し、フィールドの説明でLLMをガイド
  • 問題の処理: ValidationErrorは正常です; 本当の危険は捏造されたデータです