はじめに
LLMに「JSON形式で出力して」とお願いした経験はないでしょうか。しかし、実際にやってみると次のような問題に直面します。
- キー名が「category」になったり「カテゴリ」になったりする(形式のゆれ)
- 数値を期待したのに文字列で返される(型のゆれ)
- 必須フィールドが抜けることがある(フィールドの欠落)
- JSONの前後に説明文がつく(余分なテキスト)
LangChainの とPydanticを組み合わせれば、これらの問題を解決し、AIの応答を型安全に受け取れます。本記事ではその方法を解説します。
Pydanticで出力スキーマを定義する
PydanticはPythonの型ヒントを使ってデータモデルを定義し、型の検証と変換を自動で行うライブラリです。FastAPIでも広く使われている事実上の標準ライブラリと言えます。
まずは出力の構造をPydanticモデルで定義します。
from pydantic import BaseModel, Field
class InquiryAnalysis(BaseModel):
"""問い合わせ内容の分析結果"""
category: str = Field(description="問い合わせのカテゴリ(請求、技術、返品、その他)")
priority: str = Field(description="優先度: high, medium, low のいずれか")
summary: str = Field(description="問い合わせ内容の要約(50文字以内)")
ここで重要なのが です。この説明文はLLMに渡され、AIが「どのフィールドに何を入れるべきか」を判断する手がかりになります。つまり、Pydanticのスキーマ定義は単なる型定義ではなく、AIへの出力指示書でもあるのです。
入れ子モデルと任意フィールド
実務では複雑なデータ構造が必要になることがよくあります。モデルを入れ子にしたり、任意フィールドを設定したりできます。
from typing import List, Optional
from pydantic import BaseModel, Field
class CustomerInfo(BaseModel):
"""顧客情報"""
name: str = Field(description="顧客の氏名")
email: str = Field(description="顧客のメールアドレス")
is_premium: bool = Field(description="プレミアム会員かどうか")
class SupportTicket(BaseModel):
"""サポートチケット"""
customer: CustomerInfo = Field(description="問い合わせた顧客の情報")
category: str = Field(description="問い合わせのカテゴリ")
priority: str = Field(description="優先度: high, medium, low")
related_products: List[str] = Field(description="関連する商品名のリスト")
summary: str = Field(description="問い合わせ内容の要約")
note: Optional[str] = Field(default=None, description="追加メモ(なければ省略可)")
と を使えば任意フィールドに、 でリスト型を表現できます。これで複雑な構造も簡潔に定義できます。
with_structured_outputで構造化出力チェーンを作る
LangChain 0.3系では、 メソッドにPydanticモデルを渡すだけで、LLMの出力がPydanticのインスタンスとして返ってきます。
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
class InquiryAnalysis(BaseModel):
"""問い合わせ内容の分析結果"""
category: str = Field(description="問い合わせのカテゴリ(請求、技術、返品、その他)")
priority: str = Field(description="優先度: high, medium, low のいずれか")
summary: str = Field(description="問い合わせ内容の要約(50文字以内)")
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(InquiryAnalysis)
result = structured_llm.invoke("先月の請求額が二重に引き落とされているようです。至急確認してください。")
print(type(result)) # <class 'InquiryAnalysis'>
print(result.category) # 請求
print(result.priority) # high
print(result.summary) # 先月の請求額が二重に引き落とされているとの問い合わせ
JSON文字列を自分でパースする必要はありません。LLMが返したJSONがそのままPydanticのインスタンスに変換されています。
プロンプトテンプレートとの組み合わせ
LCELのパイプ演算子 を使えば、プロンプトの組み立てから構造化出力までを一つのチェーンにまとめられます。
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([
("system", "あなたは感情分析の専門家です。与えられたテキストの感情を分析し、指定された形式で出力してください。"),
("human", "以下のテキストの感情を分析してください。
テキスト: {text}")
])
class SentimentResult(BaseModel):
"""感情分析の結果"""
sentiment: str = Field(description="感情: positive, negative, neutral のいずれか")
confidence: float = Field(description="確信度(0.0-1.0)")
reason: str = Field(description="その判定理由(30文字以内)")
structured_llm = llm.with_structured_output(SentimentResult)
chain = prompt | structured_llm
result = chain.invoke({"text": "今日買ったケーキが想像以上に美味しくて、思わず笑顔になった。"})
print(result.sentiment) # positive
print(result.confidence) # 0.95
print(result.reason) # 美味しいケーキで喜びを感じているため
複数件のデータを一度に抽出する
リストを含むモデルを定義すれば、一度の呼び出しで複数件のデータを構造化して取り出せます。
class EventInfo(BaseModel):
"""個別の事件情報"""
name: str = Field(description="事件名")
location: str = Field(description="発生場所")
date: str = Field(description="発生日(YYYY年MM月DD日形式)")
class EventList(BaseModel):
"""事件情報のリスト"""
events: List[EventInfo] = Field(description="抽出した事件情報のリスト")
prompt = ChatPromptTemplate.from_messages([
("system", "あなたはニュース記事の分析を行うアシスタントです。記事から事件情報を抽出してください。"),
("human", "以下の記事から事件情報を抽出してください。
記事: {article}")
])
structured_llm = llm.with_structured_output(EventList)
chain = prompt | structured_llm
article = "3日午後、新宿区で商店街の火事が発生。同日夕方、大阪市でビル解体工事中の事故が報告。"
result = chain.invoke({"article": article})
for event in result.events:
print(f"{event.name} / {event.location} / {event.date}")
出力方式の選択とエラー対処
method引数で方式を切り替える
は背後で「関数呼び出し(function calling)」または「JSONモード」を使っています。 引数で明示的に指定可能です。
# 関数呼び出し方式(デフォルト・推奨)
structured_llm = llm.with_structured_output(SentimentResult, method="function_calling")
# JSONモード方式(関数呼び出し非対応モデル向け)
structured_llm = llm.with_structured_output(SentimentResult, method="json_mode")
基本的はデフォルトのままで問題ありません。利用するモデルが関数呼び出しをサポートしていない場合のみ を指定します。
スキーマ不合致時のエラーハンドリング
LLMがスキーマに合わない出力をした場合は が発生します。
from pydantic import ValidationError
try:
result = chain.invoke({"text": "感情が読み取れないテキスト"})
except ValidationError as e:
print("出力の検証に失敗しました")
print(e)
エラーが頻発する場合は、プロンプトの指示をより明確にするか、必須フィールドを任意フィールドに変更するのが有効です。
モデル間の互換性 -- 同じスキーマを使い回せる
はOpenAI・Anthropic・Googleのいずれのモデルでも動作します。モデルを切り替えてもPydanticスキーマとチェーン構造はそのまま流用できます。
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_google_genai import ChatGoogleGenerativeAI
models = {
"openai": ChatOpenAI(model="gpt-4o-mini", temperature=0),
"anthropic": ChatAnthropic(model="claude-3-5-sonnet-20241022", temperature=0),
"google": ChatGoogleGenerativeAI(model="gemini-1.5-flash", temperature=0),
}
for name, model in models.items():
structured_llm = model.with_structured_output(SentimentResult)
chain = prompt | structured_llm
result = chain.invoke({"text": "素晴らしいサービスに感謝しています。"})
print(f"{name}: {result.sentiment}")
プロバイダごとに異なるAPI仕様を意識する必要はなく、LangChainが背後で差を吸収してくれます。これがLangChainを使う大きな利点の一つです。
まとめ
- Pydanticの はAIへの出力指示として機能する
- にPydanticモデルを渡すだけで、LLMの出力が型安全なインスタンスとして返る
- LCELのパイプ演算子でプロンプトテンプレートと構造化出力を一つのチェーンにまとめられる
- リストを含むモデルで複数件のデータ抽出が可能
- OpenAI・Anthropic・Googleのどのモデルでも同じスキーマを使い回せる
構造化出力を活用すれば、LLMの応答をそのまま後続の処理(データベース保存、API呼び出しなど)に渡せるようになります。ぜひ実際のプロジェクトで試してみてください。
読者特典(無料)
本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。
著者: 葉山悠希
参考: LangChain x AI実践入門(Amazon)(Kindle・読み放題対象)