LLM構造化出力 2026年完全ガイド: 正規表現でJSONをパースするのはもうやめよう
みなさん経験あるはずです。GPTに「ユーザー名、メール、感情スコアをJSONで返して」とお願いしたら、きれいなJSON が返ってくる...マークダウンのコードブロックに包まれて。親切な説明付きで。AIであるという免責事項まで添えて。
だからコードフェンスを除去する正規表現を書く。末尾の解説を削る正規表現も書く。そしたら突然JSONじゃなくてJSONLで返ってくる。{"result": ...}で勝手にラップされることもある。10,000リクエストは完璧に動いて、10,001回目でユーザー名にクォートが含まれていてJSON全体が壊れる。
これが構造化出力の問題なんですが、2026年においてこれを手作業で解決する理由はもうないんです。
主要LLMプロバイダーはすべてネイティブ構造化出力をサポートし、ツーリング(PythonのPydantic、TypeScriptのZod)も十分に成熟しました。それなのに、多くの開発者がまだ生の文字列をパースしたり、Function Callingをハックとして使ったりしているんですよね。
このガイドでは、構造化出力の仕組みから、OpenAI・Anthropic・Geminiでの具体的な実装、Python・TypeScript両方のエコシステム、そしてプロダクションで実際にハマる落とし穴まで、まるっと解説します。
構造化出力、思っている以上に大事です
LLMをプロダクションに入れるとき、根本的な問題があります:
LLMはテキスト生成器。
あなたのアプリはデータ構造体が必要。
この2つのギャップにバグが棲む。
LLMのレスポンスをJSON.parse()で直接パースするとき、危険な仮定をいくつもしているんです:
- 出力が有効なJSONである(そうじゃないかもしれない)
- JSONに期待するフィールドがある(ないかもしれない)
- フィールドの型が正しい(文字列 vs 数値 vs 真偽値)
- 値が期待範囲内(sentiment: -1〜1ではなく "positive" が来るかも)
- リクエストしていない余分なフィールドがない
- 異なる入力でもレスポンスフォーマットが一貫している
構造化出力はこの6つの問題をすべて解決します。事後処理ではなく、トークン生成の段階で出力を制約するんです。
出力制御の3レベル
レベル1: プロンプトエンジニアリング(不安定)
「name, email, scoreフィールドのJSONを返して」
→ 80-95%の確率で動く
→ エッジケースでサイレントに失敗
→ 型保証なし
レベル2: Function Calling / Tool Use(マシ)
関数スキーマを定義 → モデルがそれを「呼び出す」
→ 95-99%の確率で動く
→ スキーマはヒントであって制約ではない
→ 有効な型の中で不正な値が返ることがある
レベル3: ネイティブ構造化出力(ベスト)
JSON Schemaによる制約デコーディング
→ 100%動作(スキーマ準拠保証)
→ 有限状態マシンで無効なトークンをマスク
→ 型も値も生成時に強制
プロダクションならレベル3一択です。
中身はどうなっているのか
多くの開発者がブラックボックスとして扱っています。「スキーマを渡せば有効なJSONが返ってくる」と。でもメカニズムを理解することでデバッグも最適化もできるんです。
制約デコーディングの仕組み
LLMがテキストを生成するとき、~100,000以上のトークン語彙から次のトークンを予測します。通常はどのトークンでも次に来られますが、構造化出力は制約レイヤーを追加します:
通常の生成:
トークン確率: {"hello": 0.3, "{": 0.1, "The": 0.2, ...}
→ どのトークンでも選択可能
制約付き生成(JSONオブジェクトの開始を期待する状態):
トークン確率: {"hello": 0.3, "{": 0.1, "The": 0.2, ...}
マスク: {"hello": 0, "{": 1, "The": 0, ...}
→ "{"とホワイトスペーストークンのみ有効
→ モデルは必ず "{"を出力
これを**有限状態マシン(FSM)**で実装します。JSON スキーマ内の現在位置を追跡するんです:
{"name": string, "age": integer}スキーマのステートマシン:
開始 → "{"を期待
→ "\"name\""を期待
→ ":"を期待
→ 文字列値を期待
→ ","または"}"を期待
→ ","なら: "\"age\""を期待
→ ":"を期待
→ 整数値を期待
→ "}"を期待
→ 完了
各ステートでFSMがスキーマに違反するトークンをすべてマスクします。構造を保証しつつ、モデルは最も確率の高い有効なトークンを選択できるので、品質も維持されます。
プロンプトエンジニアリングとの違い
プロンプト: 「'score'が0から1の数値のJSONオブジェクトを返して」
制約デコーディングなし:
出力例: {"score": "0.85"} ← 数値じゃなくて文字列
出力例: {"score": 0.85, "confidence": "high"} ← 余分なフィールド
出力例: {"score": 85} ← 範囲外
出力例: はい!JSONです: {"score": 0.85} ← 前置きが付く
制約デコーディングあり:
出力: {"score": 0.85} ← 常に有効
実装: OpenAI
OpenAIの構造化出力は最も成熟しています。Chat Completions APIのresponse_formatで利用できます。
基本的な使い方
from openai import OpenAI from pydantic import BaseModel client = OpenAI() class SentimentAnalysis(BaseModel): sentiment: str # "positive", "negative", "neutral" confidence: float key_phrases: list[str] reasoning: str response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "与えられたテキストの感情を分析してください。"}, {"role": "user", "content": "この製品は本当に最悪です。人生最悪の買い物でした。"} ], response_format=SentimentAnalysis, ) result = response.choices[0].message.parsed print(result.sentiment) # "negative" print(result.confidence) # 0.95 print(result.key_phrases) # ["本当に最悪", "人生最悪の買い物"]
Enumとネストされたオブジェクト
from enum import Enum from pydantic import BaseModel, Field class Sentiment(str, Enum): positive = "positive" negative = "negative" neutral = "neutral" mixed = "mixed" class Entity(BaseModel): name: str type: str = Field(description="person, organization, product, or location") sentiment: Sentiment class FullAnalysis(BaseModel): overall_sentiment: Sentiment confidence: float = Field(ge=0.0, le=1.0) entities: list[Entity] summary: str = Field(max_length=200) topics: list[str] = Field(min_length=1, max_length=5) response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "テキストから構造化された分析を抽出してください。"}, {"role": "user", "content": "AppleのMacBook Proは最高だけど、Tim Cookのキーノートは退屈だった。"} ], response_format=FullAnalysis, ) result = response.choices[0].message.parsed # result.entities = [ # Entity(name="Apple", type="organization", sentiment="positive"), # Entity(name="MacBook Pro", type="product", sentiment="positive"), # Entity(name="Tim Cook", type="person", sentiment="negative"), # ]
TypeScript + Zod
import OpenAI from 'openai'; import { z } from 'zod'; import { zodResponseFormat } from 'openai/helpers/zod'; const client = new OpenAI(); const SentimentSchema = z.object({ sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed']), confidence: z.number().min(0).max(1), entities: z.array(z.object({ name: z.string(), type: z.enum(['person', 'organization', 'product', 'location']), sentiment: z.enum(['positive', 'negative', 'neutral']), })), summary: z.string(), topics: z.array(z.string()).min(1).max(5), }); type Sentiment = z.infer<typeof SentimentSchema>; const response = await client.beta.chat.completions.parse({ model: 'gpt-5-mini', messages: [ { role: 'system', content: 'テキストから構造化された分析を抽出してください。' }, { role: 'user', content: '新しいReactコンパイラはすごいけどマイグレーションドキュメントが足りない。' }, ], response_format: zodResponseFormat(SentimentSchema, 'sentiment_analysis'), }); const result: Sentiment = response.choices[0].message.parsed!; console.log(result.sentiment); // "mixed"
実装: Anthropic (Claude)
Anthropicは**ツール使用(tool use)**で構造化出力を提供します。ツールにJSONスキーマを定義すると、Claudeがそのツールを呼び出す形で構造化データを返してくれます。
基本的な使い方
import anthropic from pydantic import BaseModel client = anthropic.Anthropic() class ExtractedData(BaseModel): name: str email: str company: str role: str urgency: str # "low", "medium", "high", "critical" response = client.messages.create( model="claude-sonnet-4-20260514", max_tokens=1024, tools=[{ "name": "extract_contact", "description": "メールから連絡先情報を抽出します。", "input_schema": ExtractedData.model_json_schema(), }], tool_choice={"type": "tool", "name": "extract_contact"}, messages=[{ "role": "user", "content": """このメールから連絡先を抽出してください: こんにちは、DataFlow Inc.のSarah Chenです。プロダクション パイプラインがダウンしていて、至急対応が必要です。 [email protected] に連絡ください。VP of Engineeringです。""", }], ) tool_result = next( block for block in response.content if block.type == "tool_use" ) data = ExtractedData(**tool_result.input) print(data.name) # "Sarah Chen" print(data.urgency) # "critical"
TypeScript + Zod + Anthropic
import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const client = new Anthropic(); const ContactSchema = z.object({ name: z.string(), email: z.string().email(), company: z.string(), role: z.string(), urgency: z.enum(['low', 'medium', 'high', 'critical']), }); const response = await client.messages.create({ model: 'claude-sonnet-4-20260514', max_tokens: 1024, tools: [{ name: 'extract_contact', description: 'メールから連絡先情報を抽出します。', input_schema: zodToJsonSchema(ContactSchema) as Anthropic.Tool.InputSchema, }], tool_choice: { type: 'tool' as const, name: 'extract_contact' }, messages: [{ role: 'user', content: '抽出してください: こんにちは、Acme Corp CTOのJohn Parkです ([email protected])。急ぎではありません。', }], }); const toolBlock = response.content.find( (block): block is Anthropic.ToolUseBlock => block.type === 'tool_use' ); const data = ContactSchema.parse(toolBlock!.input); console.log(data.urgency); // "low"
実装: Google Gemini
Geminiはresponse_schemaパラメータでネイティブ構造化出力をサポートしています。OpenAIと同様に制約デコーディングを使います。
基本的な使い方
import google.generativeai as genai from pydantic import BaseModel from enum import Enum class Priority(str, Enum): low = "low" medium = "medium" high = "high" critical = "critical" class TaskExtraction(BaseModel): title: str assignee: str priority: Priority deadline: str | None tags: list[str] model = genai.GenerativeModel( "gemini-2.5-flash", generation_config=genai.GenerationConfig( response_mime_type="application/json", response_schema=TaskExtraction, ), ) response = model.generate_content( "タスクを抽出してください: 'Johnが金曜日までにログインバグを直す必要があります。本番ブロッキングです。backendとauthでタグ付けしてください。'" ) import json result = TaskExtraction(**json.loads(response.text)) print(result.priority) # "critical" print(result.tags) # ["backend", "auth"]
プロバイダー比較表
構造化出力のプロバイダー選びの前に、機能比較を確認しましょう:
機能 OpenAI Anthropic Gemini
───────────────── ────────────── ────────────── ──────────────
方式 ネイティブSO ツール使用 ネイティブSO
制約デコーディング 対応 部分対応 対応
100%スキーマ有効 保証 99%+ 保証
ストリーミング対応 対応 対応 対応
Pydanticネイティブ .parse()対応 スキーマ手動変換 スキーマ手動変換
Zodネイティブ ヘルパー関数あり 手動変換必要 手動変換必要
ネストオブジェクト 対応 対応 対応
Enum 対応 対応 対応
Optionalフィールド 対応 対応 対応
再帰スキーマ 制限あり 対応 制限あり
最大スキーマ深度 5レベル 制限なし 制限なし
拒否(Refusal)処理 対応 該当なし 該当なし
おすすめ: スキーマ100%準拠が必要ならOpenAIかGeminiのネイティブ構造化出力を使いましょう。すでにClaudeを使っているならツール使用パターンでも十分ですが、Pydantic/Zodの検証をセーフティネットとして追加してください。
プロダクションで使える実践パターン
パターン1: バリデーションは二重にかける
構造化出力をつけていても、LLMの出力をそのまま信用するのはNGです。必ず検証を入れましょう。
from pydantic import BaseModel, Field, field_validator from openai import OpenAI client = OpenAI() class ProductReview(BaseModel): rating: int = Field(ge=1, le=5) title: str = Field(min_length=5, max_length=100) pros: list[str] = Field(min_length=1, max_length=5) cons: list[str] = Field(max_length=5) would_recommend: bool @field_validator('title') @classmethod def title_not_generic(cls, v: str) -> str: generic_titles = ['good', 'bad', 'ok', 'fine', 'great'] if v.lower().strip() in generic_titles: raise ValueError(f'タイトルが汎用的すぎます: {v}') return v def extract_review(text: str) -> ProductReview: response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "構造化された製品レビューを抽出してください。"}, {"role": "user", "content": text}, ], response_format=ProductReview, ) result = response.choices[0].message.parsed if response.choices[0].message.refusal: raise ValueError(f"モデルが拒否: {response.choices[0].message.refusal}") # OpenAIがスキーマ準拠を保証していても再検証 # (JSON Schemaでは表現できないビジネスロジック違反をキャッチ) return ProductReview.model_validate(result.model_dump())
パターン2: リトライ + エスカレーション
構造化出力が失敗するケースはレアですが、ゼロではありません。失敗時にきれいにフォールバックできるようにしておきましょう:
from tenacity import retry, stop_after_attempt, wait_exponential @retry( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=1, max=10), ) def extract_with_retry(text: str, schema: type[BaseModel]) -> BaseModel: try: response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "system", "content": "構造化データを正確に抽出してください。"}, {"role": "user", "content": text}, ], response_format=schema, ) result = response.choices[0].message.parsed return schema.model_validate(result.model_dump()) except Exception as e: print(f"試行失敗: {e}") raise # 使い方 try: review = extract_with_retry(user_text, ProductReview) except Exception: # フォールバック: よりシンプルなスキーマまたは手動処理 review = extract_with_retry(user_text, SimpleReview)
パターン3: マルチプロバイダーフォールバック
1つのプロバイダーだけに頼るのは危険です。フォールバックチェーンを作っておきましょう:
import OpenAI from 'openai'; import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; import { zodResponseFormat } from 'openai/helpers/zod'; import { zodToJsonSchema } from 'zod-to-json-schema'; const schema = z.object({ intent: z.enum(['question', 'complaint', 'feedback', 'request']), urgency: z.enum(['low', 'medium', 'high']), summary: z.string().max(200), action_required: z.boolean(), }); type TicketClassification = z.infer<typeof schema>; async function classifyTicket(text: string): Promise<TicketClassification> { // まずOpenAIを試す(最速の構造化出力) try { const openai = new OpenAI(); const response = await openai.beta.chat.completions.parse({ model: 'gpt-5-mini', messages: [ { role: 'system', content: 'サポートチケットを分類してください。' }, { role: 'user', content: text }, ], response_format: zodResponseFormat(schema, 'ticket'), }); return schema.parse(response.choices[0].message.parsed); } catch (openaiError) { console.warn('OpenAI失敗、Claudeにフォールバック:', openaiError); } // Anthropicフォールバック try { const anthropic = new Anthropic(); const response = await anthropic.messages.create({ model: 'claude-sonnet-4-20260514', max_tokens: 512, tools: [{ name: 'classify', description: 'チケットを分類します。', input_schema: zodToJsonSchema(schema) as Anthropic.Tool.InputSchema, }], tool_choice: { type: 'tool' as const, name: 'classify' }, messages: [{ role: 'user', content: `分類してください: ${text}` }], }); const toolBlock = response.content.find( (b): b is Anthropic.ToolUseBlock => b.type === 'tool_use' ); return schema.parse(toolBlock!.input); } catch (anthropicError) { console.error('両プロバイダーとも失敗:', anthropicError); throw new Error('すべてのLLMプロバイダーで構造化出力が失敗'); } }
パターン4: ストリーミング構造化出力
長い構造化レスポンスでは、部分結果をストリーミングしましょう:
from openai import OpenAI from pydantic import BaseModel client = OpenAI() class Article(BaseModel): title: str sections: list[dict] # {"heading": str, "content": str} tags: list[str] word_count: int # 構造化出力 + ストリーミング with client.beta.chat.completions.stream( model="gpt-5", messages=[ {"role": "system", "content": "詳細なセクション付きの記事アウトラインを生成してください。"}, {"role": "user", "content": "2026年のWebAssemblyについて書いてください。"} ], response_format=Article, ) as stream: for event in stream: snapshot = event.snapshot if snapshot and snapshot.choices[0].message.content: partial = snapshot.choices[0].message.content print(f"受信中: {len(partial)} chars...") final = stream.get_final_completion() article = final.choices[0].message.parsed print(f"タイトル: {article.title} ({article.word_count}語)")
知らないとハマる落とし穴たち
落とし穴1: スキーマが重いとレイテンシーも重い
スキーマに制約を追加するほどレイテンシーが上がります。深くネストされた複雑なスキーマはレスポンスタイムを2〜3倍にすることがあります。
スキーマ複雑度 vs レイテンシー (gpt-5-mini, 平均):
スキーマ tok/s 初回トークン 合計時間
────────────────────────────── ────────── ──────────── ──────────
スキーマなし(フリーテキスト) 85 tok/s ~200ms ~500ms
シンプル(3フィールド) 78 tok/s ~250ms ~550ms
ミディアム(10フィールド+Enum 1) 65 tok/s ~350ms ~800ms
複雑(20+フィールド、ネスト) 45 tok/s ~500ms ~1.5s
非常に複雑(再帰) 30 tok/s ~800ms ~3s
解決策: 複雑なスキーマを小さな複数の呼び出しに分割しましょう:
# ❌ 巨大なスキーマ1つ class FullDocumentAnalysis(BaseModel): entities: list[Entity] # 各20+フィールド sentiment: SentimentDetail # 10+フィールド summary: Summary # 5フィールド classification: Classification # 8フィールド # ✅ 小さなスキーマのパイプライン class Step1_Entities(BaseModel): entities: list[SimpleEntity] class Step2_Sentiment(BaseModel): overall: str confidence: float aspects: list[str] class Step3_Classification(BaseModel): category: str subcategory: str priority: str # 並列実行 import asyncio entities, sentiment, classification = await asyncio.gather( extract(text, Step1_Entities), extract(text, Step2_Sentiment), extract(text, Step3_Classification), )
落とし穴2: スキーマを変えたら壊れた
アプリが進化すればスキーマも進化します。でもLLMは先週の火曜日にuser_nameをnameに変えたことを知りません。
# バージョン1(1月デプロイ) class UserProfile_v1(BaseModel): user_name: str email_address: str age: int # バージョン2(2月デプロイ) class UserProfile_v2(BaseModel): name: str # リネーム! email: str # リネーム! age: int location: str | None # 新フィールド
解決策: 明示的なスキーマバージョニングとマイグレーションを使いましょう:
from pydantic import BaseModel, Field from typing import Literal class UserProfile(BaseModel): schema_version: Literal["2.0"] = "2.0" name: str = Field(alias="user_name") # 旧フィールド名も受け入れ email: str = Field(alias="email_address") age: int location: str | None = None class Config: populate_by_name = True # エイリアスとフィールド名の両方を受け入れ
落とし穴3: 空配列トラップ
LLMは、本当に何も見つからないときでも空配列を返すのが苦手です。「何か入れなきゃ」と思って存在しないエントリをでっち上げてきます。
# 入力: 「今日はいい天気ですね」 # 期待: {"entities": [], "topics": ["weather"]} # 実際: {"entities": [{"name": "天気", "type": "concept"}], "topics": ["weather"]} # 解決策: 空配列が有効であることを明示的に伝える class Extraction(BaseModel): entities: list[Entity] = Field( default_factory=list, description="テキスト内の固有名詞。見つからない場合は空リスト[]を返してください。" )
落とし穴4: 似たEnum値はモデルも迷う
class Priority(str, Enum): low = "low" medium = "medium" high = "high" critical = "critical" urgent = "urgent" # ← "critical"と何が違う? # モデルが"critical"と"urgent"の間で迷い続ける # モデル自身も違いがわからないんです # 解決策: 明確に区別できる少数のEnum値 + 説明 class Priority(str, Enum): low = "low" # 数日〜数週間後でOK medium = "medium" # 今スプリントで対応 high = "high" # 今日中に対応 critical = "critical" # 本番ダウン、今すぐ対応
落とし穴5: トークン切れでJSONが壊れる
構造化出力だからといってトークン制限が無視されるわけではありません。max_tokensに達する前にJSONが完成しないと、不正なJSONになります。
解決策: max_tokensを十分に大きく設定し、finish_reasonをチェックしましょう:
response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[...], response_format=MySchema, max_tokens=4096, # 余裕を持って ) if response.choices[0].finish_reason == "length": raise ValueError("レスポンスが切り詰められました — max_tokensを増やすかスキーマを簡略化してください")
落とし穴6: モデルが回答を拒否するケース(OpenAI限定)
OpenAIの構造化出力は、入力がセーフティフィルターに引っかかるとコンテンツ生成を拒否することがあります。
response = client.beta.chat.completions.parse( model="gpt-5-mini", messages=[ {"role": "user", "content": "この顧客クレームを分析してください: [センシティブな可能性がある内容]"} ], response_format=Analysis, ) parsed = response.choices[0].message.parsed refusal = response.choices[0].message.refusal if refusal: # クラッシュさせないで!グレースフルに処理しましょう。 print(f"モデルが拒否: {refusal}") elif parsed: process(parsed)
Pydantic vs Zod: 決定版の比較
機能 Pydantic (Python) Zod (TypeScript)
─────────────────── ──────────────────── ────────────────────
型推論 アノテーションベース .infer<>ベース
ランタイム検証 組み込み 組み込み
JSON Schemaエクスポート .model_json_schema() zodToJsonSchema()
デフォルト値 Field(default=...) .default(value)
カスタムバリデーター @field_validator .refine() / .transform()
ネストオブジェクト ネイティブ ネイティブ
判別共用体 対応 .discriminatedUnion()
再帰スキーマ 対応 z.lazy()
シリアライゼーション .model_dump() N/A(プレーンオブジェクト)
ORM連携 対応 (SQLAlchemy) Drizzle/Prisma
OpenAIネイティブ対応 .parse()対応 zodResponseFormat対応
Anthropic連携 .model_json_schema() zodToJsonSchema()
Pydanticが得意なケース
# 複雑なデータパイプラインでPydanticが真価を発揮: from pydantic import BaseModel, Field, field_validator, model_validator class Invoice(BaseModel): items: list[LineItem] subtotal: float tax_rate: float = Field(ge=0, le=0.5) total: float @model_validator(mode='after') def validate_total(self) -> 'Invoice': expected = self.subtotal * (1 + self.tax_rate) if abs(self.total - expected) > 0.01: raise ValueError( f'Total {self.total}が' f'subtotal {self.subtotal} × (1 + {self.tax_rate}) = {expected}と一致しません' ) return self
Zodが得意なケース
// API検証と型安全パイプラインでZodが真価を発揮: const InvoiceSchema = z.object({ items: z.array(LineItemSchema), subtotal: z.number().positive(), taxRate: z.number().min(0).max(0.5), total: z.number().positive(), }).refine( (data) => Math.abs(data.total - data.subtotal * (1 + data.taxRate)) < 0.01, { message: 'totalがsubtotal × (1 + taxRate)と一致しません' } ); // 型が自動推論される — 別途interfaceを定義する必要なし type Invoice = z.infer<typeof InvoiceSchema>;
実践パターン: スキーマを組み合わせてパイプラインを作る
実際のプロダクションでは、1つのスキーマで完結することはほとんどありません。マルチステップで組み合わせるパターンを紹介します:
from pydantic import BaseModel, Field from enum import Enum from openai import OpenAI client = OpenAI() # ステップ1: クイック分類(安くて高速なモデル) class TicketType(str, Enum): bug = "bug" feature = "feature" question = "question" billing = "billing" class QuickClassification(BaseModel): type: TicketType language: str = Field(description="該当するプログラミング言語、なければ'N/A'") needs_human: bool # ステップ2: 詳細抽出(バグの場合のみ、賢いモデルを使用) class BugReport(BaseModel): title: str = Field(max_length=100) steps_to_reproduce: list[str] = Field(min_length=1) expected_behavior: str actual_behavior: str environment: dict[str, str] severity: str = Field(description="minor, major, or critical") # ステップ3: 自動ルーティング class RoutingDecision(BaseModel): team: str = Field(description="backend, frontend, infra, or billing") priority: int = Field(ge=1, le=5) suggested_assignee: str | None auto_reply: str = Field(max_length=500) async def process_ticket(text: str): classification = await extract(text, QuickClassification, model="gpt-5-mini") if classification.needs_human: return route_to_human(text) details = None if classification.type == TicketType.bug: details = await extract(text, BugReport, model="gpt-5") context = f"タイプ: {classification.type}. " if details: context += f"重大度: {details.severity}. 再現手順: {details.steps_to_reproduce}" routing = await extract(context, RoutingDecision, model="gpt-5-mini") return { "classification": classification, "details": details, "routing": routing, }
コスト最適化: 構造化出力はタダじゃない
構造化出力にはオーバーヘッドがあります:
構造化出力のコスト要因:
1. スキーマトークン: JSONスキーマがシステムプロンプトに含まれる
シンプル(3フィールド): ~50トークン ($0.00001)
複雑(20フィールド): ~500トークン ($0.0001)
非常に複雑(ネスト): ~2000トークン ($0.0004)
2. 出力トークン: 構造化出力はフリーテキストよりトークンが多い
「感情はポジティブです」 = 5トークン
{"sentiment": "positive"} = 7トークン(~40%増)
3. レイテンシーオーバーヘッド: 制約デコーディングで~10-30%増
月間コスト影響(1日100万リクエスト):
────────────────────────────────────────────
アプローチ トークン/リクエスト 月額コスト レイテンシー
フリーテキスト 50 $1,500 200ms
シンプル構造化 70 $2,100 250ms
複雑構造化 200 $6,000 400ms
節約戦略:
→ 構造化出力は本当に必要な場所だけで使う
→ 要約にはフリーテキスト、データ抽出には構造化
→ レスポンスを積極的にキャッシュ
→ 分類にはgpt-5-mini、複雑な抽出にはgpt-5
使いどころの判断基準
なんでもかんでも構造化出力にする必要はありません:
構造化出力を使うべきとき:
✅ 出力がそのままコードに渡される(APIレスポンス、DB挿入)
✅ 型の保証が必要(数値が文字列だとダメなケース)
✅ 複数のダウンストリームが一貫したフォーマットを期待
✅ 自動化パイプライン(人の介入なし)
✅ 非構造化テキストからのデータ抽出
構造化出力が不要なとき:
❌ 出力をユーザーに直接表示(チャット、コンテンツ生成)
❌ 自由で創造的なレスポンスが必要
❌ スキーマがタスクより複雑になる場合
❌ プロトタイピング中でスキーマが毎日変わる
❌ コスト重視でフリーテキストで十分な場合
これからどうなるか
2026 Q1-Q2(今)
- ✅ OpenAI構造化出力GA + ストリーミング
- ✅ Anthropicツール使用、Claude Sonnet/Opusで安定
- ✅ Gemini 2.5ネイティブJSONモード + スキーマ強制
- 🔄 Pydantic v3ベータ、ネイティブLLM連携フック
- 🔄 Zod v4、JSON Schema互換性改善
2026 Q3-Q4
- クロスプロバイダーのスキーマポータビリティ
- ストリーミング部分オブジェクト + フィールドレベルコールバック
- TypeScriptインターフェースからのスキーマ自動生成
- 画像・音声の制約デコーディング(マルチモーダル構造化出力)
2027以降
- 構造化出力がデフォルトに(フリーテキストがオプトイン)
- LLMがランタイムでスキーマ変更をネゴシエーション
- モデル重みに検証ロジックを直接組み込み
まとめ
2026年のプロダクションLLMアプリにおいて、構造化出力はもはやオプションではありません。GPTのレスポンスを正規表現でパースして祈る時代は終わりました。
重要なポイント:
- ネイティブ構造化出力を使う(OpenAIの
.parse()、Geminiのresponse_schema)。自前のJSONパーサーを書かない。 - 必ず検証する。プロバイダーがスキーマ準拠を保証していてもPydanticかZodで。JSON Schemaでは捕捉できないビジネスロジック違反をキャッチできます。
- コストを意識する。複雑なスキーマはコストがかかる。小さな並列呼び出しに分割する。
- エッジケースに対処する: 拒否、トランケーション、空配列、Enum混同がプロダクションで襲ってきます。
- フォールバックチェーンを構築する。単一のプロバイダーが100%信頼できるわけではない。クリティカルパスにはマルチプロバイダーパターンを。
本当の問いは「構造化出力を使うべきか?」ではありません。**「2026年にもなって、なぜまだフリーテキストを正規表現でパースしているのか?」**です。
関連ツールを見る
Pockitの無料開発者ツールを試してみましょう