AIエージェント開発

Structured Outputs実装完全ガイド2026

Structured Outputs実装完全ガイド2026

この記事の結論

OpenAI Structured OutputsとAnthropic Tool UseのJSON Schema strict modeを徹底比較。Pydantic/Zod連携、Streaming、エラーハンドリング、エンタープライズ運用での型安全設計を実装コード付きで解説。

この記事の結論(先読み)

  • OpenAI Structured Outputsresponse_format={"type":"json_schema", ..., "strict": true}100%スキーマ準拠を文法レベルで保証。GPT-4o-2024-08-06以降で利用可能。
  • Anthropic Tool Use は厳密な「strict mode」フラグこそないが、tool_choice で関数呼び出しを強制し、input_schema で JSON Schema を渡せば実用上 99% 以上の精度で構造化出力が得られる。
  • Pydantic v2(Python)Zod(TypeScript)model_json_schema() / z.toJSONSchema() 出力を直接 LLM に渡せる時代になった。Python 公式 SDK の client.responses.parse(text_format=YourModel) や Vercel AI SDK の generateObject({ schema }) がデファクト。
  • Streaming + Structured は OpenAI が stream=True + Responses API の response.output_text.delta で部分パース、Anthropic も input_json_delta イベントで途中受信可。両者ともパーシャル JSON は未完成のまま到達することを前提に設計する。
  • エンタープライズ運用では「スキーマ差分管理 → CI で $schema 検証 → LLM 出力をアプリケーション側で 再 validate」の三層が必須。LLM 側の “strict” を信用しきらない。

はじめに:なぜ「文字列ストリーム」から「型」へ移行するのか

2024 年までの LLM アプリケーション開発で最も時間を奪っていた工程は、間違いなく「自然言語応答から JSON を取り出す」処理だった。プロンプトの末尾に「以下の JSON 形式で答えてください」と書き、温度を下げ、出力を json.loads() に食わせ、9 割は通るが 1 割は壊れて JSONDecodeError を吐く——この「JSON parsing roulette」が、エージェント開発の本質ではない部分で開発者の体力を削っていた。

2024 年 8 月 6 日、OpenAI が Structured Outputs を一般提供すると発表した時、AIエージェント設計の前提が静かに変わった。OpenAI の公式アナウンス(Introducing Structured Outputs in the API)は、評価セットにおける JSON Schema 準拠率を「GPT-4 の 35.9% から 100% に引き上げた」と明記している。これは出力後にバリデーションするのではなく、デコード段階で文法的に逸脱できないようにするという、根本的に異なるアプローチだ。

同じ頃、Anthropic Claude も Tool Use の精度を着実に高め、Claude 3.5 Sonnet 以降では input_schema に書いた JSON Schema をほぼ完全に守るようになった。両者の方法論はわずかに違うが、向かう先は同じ——LLM の出力を「文字列」ではなく「型」として扱う世界だ。

本稿では、OpenAI と Anthropic の Structured Outputs / Tool Use を、実装コード・Schema 設計マトリクス・Pydantic/Zod 連携・Streaming・エンタープライズ運用の 5 軸で深掘りする。情報源は OpenAI 公式ドキュメント(platform.openai.com / openai.com)、Anthropic 公式ドキュメント(docs.anthropic.com / anthropic.com)に限定し、コミュニティの噂レベルは排除する。

1. Structured Outputs と Tool Use — 用語と概念の整理

1-1. 「構造化出力」が指すもの

「構造化出力(Structured Output)」という用語は、ベンダーによって微妙に範囲が違う。整理するとこうなる。

用語 OpenAI Anthropic 共通の意味
JSON Mode あり(旧来)
response_format={"type":"json_object"}
なし(プロンプトでJSON強制) 「何らかのJSONを返す」だけ保証
Structured Outputs あり(2024-08-06〜)
response_format={"type":"json_schema", "strict": true}
Tool Use 経由で実質達成 「指定したJSON Schemaに100%準拠」
Function Calling / Tool Use tools=[...] + strict=true tools=[...] + tool_choice 「特定の関数を呼ばせる」と「引数を構造化する」が一体化

重要なのは、「Structured Outputs」と「Tool Use」は技術的にほぼ同じことをしているという点だ。OpenAI の Structured Outputs は内部的には Function Calling の制約ロジック(Constrained Decoding)を、自由テキスト応答にも適用したものだ。Anthropic の Tool Use は、構造化データを返したい時に「ダミーの関数」を定義し、その引数として欲しい構造を受け取る、という設計になっている。

1-2. Constrained Decoding(制約付きデコード)— 内部で起きていること

OpenAI が「100% 準拠」を主張できる根拠は、出力時のトークンサンプリングを JSON Schema が許す範囲のトークンだけに制限しているからだ。スキーマを正規表現/文法ツリーに変換し、各ステップで「次に来てよいトークン集合」をマスクする。スキーマに違反するトークンは確率分布から除外されるため、文法的にスキーマを満たさない出力は原理的に生成されない。

これがプロンプトエンジニアリングと根本的に違う点だ。プロンプトは「お願い」だが、Constrained Decoding は「物理的不可能化」である。OpenAI のエンジニアリングブログでもこのアプローチが言及されており、JSON Schema を context-free grammar に変換してから sampling に適用していると説明されている。

2. OpenAI Structured Outputs 完全実装

2-1. Responses API(推奨・2026年現在のデファクト)

2024年後半に登場した Responses API は、Chat Completions API の上位互換として位置づけられ、Structured Outputs を最も自然に書ける。Python SDK では client.responses.parse() が用意され、Pydantic モデルを直接渡せる。

from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Literal

client = OpenAI()

class ResearchPaper(BaseModel):
    title: str
    authors: list[str]
    publication_year: int = Field(ge=1900, le=2100)
    venue: Literal["NeurIPS", "ICML", "ICLR", "ACL", "EMNLP", "other"]
    key_findings: list[str] = Field(min_length=3, max_length=5)
    citation_count: int | None = None

response = client.responses.parse(
    model="gpt-4o-2024-08-06",
    input=[
        {"role": "system", "content": "あなたは学術論文の要約専門アシスタントです。"},
        {"role": "user", "content": "Attention Is All You Need の構造化サマリーを返してください。"}
    ],
    text_format=ResearchPaper,
)

paper: ResearchPaper = response.output_parsed
print(paper.title)        # "Attention Is All You Need"
print(paper.authors)      # ["Ashish Vaswani", ...]
print(paper.venue)        # "NeurIPS"

このコードの肝は text_format=ResearchPaper の一行だ。SDK が Pydantic モデルから JSON Schema を自動生成し、response_formatstrict: true で組み立て、返ってきた JSON を再度 Pydantic でパースして output_parsed に格納する。開発者は型を一度定義するだけで、リクエスト・レスポンス両方の型安全が手に入る。

2-2. Chat Completions API での生 JSON Schema 指定

レガシーシステムや細かい制御が必要な場面では、Chat Completions に直接 JSON Schema を渡す。

from openai import OpenAI

client = OpenAI()

schema = {
    "type": "object",
    "properties": {
        "ticket_priority": {
            "type": "string",
            "enum": ["P0", "P1", "P2", "P3"]
        },
        "category": {
            "type": "string",
            "enum": ["bug", "feature_request", "question", "incident"]
        },
        "summary": {"type": "string", "maxLength": 200},
        "suggested_owner": {
            "type": "string",
            "enum": ["frontend", "backend", "infra", "data", "qa"]
        },
        "estimated_hours": {"type": "number", "minimum": 0, "maximum": 80}
    },
    "required": ["ticket_priority", "category", "summary", "suggested_owner", "estimated_hours"],
    "additionalProperties": False
}

completion = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "サポートチケットを分類してください。"},
        {"role": "user", "content": "ログイン時に500エラーが断続的に発生。SREチームから問い合わせ。"}
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "ticket_classification",
            "strict": True,
            "schema": schema
        }
    }
)

import json
result = json.loads(completion.choices[0].message.content)

2-3. strict mode の 制約事項(落とし穴)

OpenAI の Structured Outputs(strict mode)は強力だが、JSON Schema の全機能をサポートしているわけではない。公式ドキュメント(platform.openai.com/docs/guides/structured-outputs)に記載された主要な制約を整理する。

JSON Schema 機能 strict mode サポート 備考
type(string/number/integer/boolean/array/object/null) 基本型は全対応
enum 列挙値の制約
required ⚠️ 全プロパティを必須化必須 optionalにしたければ type: ["string","null"] でnull許容
additionalProperties ⚠️ 必ず false これを指定しないとエラー
anyOf / oneOf union 型に使える
$ref 再帰スキーマも可
minLength / maxLength / pattern ❌ → ✅(後に対応) 文字列制約は段階的にサポート拡大中
minimum / maximum 段階的サポート 数値制約は最新モデルで対応
ネスト深さ 最大 5 階層 それ以上は分割設計
プロパティ数 最大 100 大きすぎるスキーマは分割

最も引っかかりやすいのが「required に全プロパティを書け」と「additionalProperties は必ず false」の 2 点だ。Pydantic で Optional[str] を書くと自動的に "type": ["string","null"] + required から外れる挙動になるが、strict mode ではこれが拒否される。SDK の responses.parse() 経由なら自動的に required 化してくれるが、生 JSON Schema を組む場合は注意が必要だ。

3. Anthropic Tool Use と JSON Schema

3-1. 基本的な使い方

Anthropic Claude では、構造化データを得たい場合「実際には実行しないダミー関数」を Tool として定義し、その引数として欲しい構造を受け取る。Anthropic 公式ドキュメント(docs.anthropic.com/en/docs/build-with-claude/tool-use)の推奨パターンだ。

from anthropic import Anthropic

client = Anthropic()

extract_invoice_tool = {
    "name": "extract_invoice",
    "description": "請求書PDFから構造化データを抽出する",
    "input_schema": {
        "type": "object",
        "properties": {
            "invoice_number": {"type": "string"},
            "issue_date": {"type": "string", "format": "date"},
            "due_date": {"type": "string", "format": "date"},
            "issuer": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "registration_number": {"type": "string"}
                },
                "required": ["name"]
            },
            "line_items": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "description": {"type": "string"},
                        "quantity": {"type": "integer"},
                        "unit_price": {"type": "number"},
                        "amount": {"type": "number"}
                    },
                    "required": ["description", "amount"]
                }
            },
            "subtotal": {"type": "number"},
            "tax": {"type": "number"},
            "total": {"type": "number"}
        },
        "required": ["invoice_number", "issue_date", "total", "line_items"]
    }
}

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=4096,
    tools=[extract_invoice_tool],
    tool_choice={"type": "tool", "name": "extract_invoice"},
    messages=[
        {
            "role": "user",
            "content": "以下の請求書から情報を抽出してください:nn[請求書テキスト]"
        }
    ]
)

# tool_use ブロックから構造化データを取り出す
for block in response.content:
    if block.type == "tool_use" and block.name == "extract_invoice":
        invoice_data = block.input
        print(invoice_data["total"])

ポイントは tool_choice={"type": "tool", "name": "extract_invoice"}。これにより Claude は「必ずこの関数を呼ぶ」状態に強制される。普段は {"type": "auto"} でモデルが判断するが、構造化出力を取りたい場面では {"type": "tool", ...} で明示的に呼び出しを強制する。

3-2. tool_choice の 4 モード

tool_choice 挙動 使い所
{"type": "auto"}(デフォルト) Claude が必要と判断したら呼ぶ 会話エージェント・自由会話
{"type": "any"} 定義された tools の中から必ずどれか呼ぶ 「何か必ずアクションする」エージェント
{"type": "tool", "name": "X"} 指定した特定 tool を必ず呼ぶ 構造化出力・固定処理
{"type": "none"} tool を呼ばない(説明だけ要約) tools を context として渡したい時

3-3. disable_parallel_tool_use の活用

Claude のデフォルトは複数 tool を並列に呼ぶ「parallel tool use」を許可しているが、構造化出力用途では 1 つの tool を確実に 1 回だけ呼んでほしい。その時は tool_choice"disable_parallel_tool_use": True を加える。

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=4096,
    tools=[extract_invoice_tool],
    tool_choice={
        "type": "tool",
        "name": "extract_invoice",
        "disable_parallel_tool_use": True
    },
    messages=[...]
)

3-4. Anthropic の「strict」相当機能

2026年5月現在、Anthropic の Tool Use には OpenAI の strict: true フラグに相当する明示的な強制モードはないが、実用上は以下の組み合わせで 99% 以上のスキーマ準拠率が得られる。

  • tool_choice で特定 tool を強制呼出
  • input_schema明確な型・enum・description を書く(description は精度に効く)
  • System プロンプトで「required フィールドは必ず埋めること」と明記
  • 受信後にアプリケーション側で再 validation(後述)

Anthropic 公式の Tool use overview ドキュメントでも、構造化出力ユースケースに対しては「Tool Use を使ってください」と明確に案内されている。

4. Pydantic / Zod との連携 — 型ファーストの開発体験

4-1. Pydantic v2 と OpenAI

Python では Pydantic v2 がデファクトの型検証ライブラリだ。OpenAI SDK は Pydantic モデルをネイティブサポートしている。

from pydantic import BaseModel, Field, field_validator
from typing import Literal
from datetime import date

class Address(BaseModel):
    postal_code: str = Field(pattern=r"^d{3}-d{4}$")
    prefecture: str
    city: str
    street: str

class Customer(BaseModel):
    customer_id: str
    name: str
    email: str = Field(pattern=r".+@.+..+")
    age: int = Field(ge=0, le=150)
    membership_tier: Literal["bronze", "silver", "gold", "platinum"]
    address: Address
    registration_date: date

    @field_validator("name")
    @classmethod
    def name_not_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("name は空にできない")
        return v

# 1. JSON Schema を取り出す(必要なら)
schema = Customer.model_json_schema()

# 2. SDK に丸ごと渡す
response = client.responses.parse(
    model="gpt-4o-2024-08-06",
    input=[{"role": "user", "content": "..."}],
    text_format=Customer
)
customer: Customer = response.output_parsed  # 型安全

Pydantic の Field(pattern=...) / Field(ge=..., le=...) などの制約は JSON Schema に変換され、最新の GPT-4o では一部反映される。一方、複雑なカスタムバリデーション(@field_validator)は LLM 側には伝わらないが、受信後の Pydantic 検証で必ず走るため、二重防御になる。これが「LLM の strict を信用しきらない」という設計思想に直結する。

4-2. Anthropic + Pydantic

Anthropic SDK は Pydantic ネイティブ統合がない(2026年5月時点)が、簡単に橋渡しできる。

from anthropic import Anthropic
from pydantic import BaseModel

client = Anthropic()

class TaskBreakdown(BaseModel):
    project_name: str
    tasks: list[dict]  # 詳細は別モデル
    estimated_total_hours: int

# Pydantic → JSON Schema
schema = TaskBreakdown.model_json_schema()

tool = {
    "name": "submit_task_breakdown",
    "description": "プロジェクトのタスク分解結果を返す",
    "input_schema": schema
}

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=4096,
    tools=[tool],
    tool_choice={"type": "tool", "name": "submit_task_breakdown"},
    messages=[{"role": "user", "content": "ECサイト改修プロジェクトをタスク分解してください"}]
)

for block in response.content:
    if block.type == "tool_use":
        # 受信後に Pydantic で再検証
        result = TaskBreakdown.model_validate(block.input)

4-3. Zod(TypeScript)と Vercel AI SDK

TypeScript エコシステムでは Zod が事実上の標準だ。Vercel AI SDK の generateObject 関数は Zod スキーマを直接受け取れる。

import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const ReviewSchema = z.object({
  product_id: z.string(),
  rating: z.number().int().min(1).max(5),
  sentiment: z.enum(["positive", "neutral", "negative"]),
  aspects: z.array(z.object({
    aspect: z.string(),
    sentiment: z.enum(["positive", "neutral", "negative"]),
    excerpt: z.string()
  })).min(1).max(5),
  recommendation: z.boolean(),
  summary: z.string().max(200)
});

type Review = z.infer;

const { object } = await generateObject({
  model: openai("gpt-4o-2024-08-06"),
  schema: ReviewSchema,
  prompt: "以下のレビューを構造化してください: ..."
});

// object は Review 型として推論される
console.log(object.rating);  // number
console.log(object.aspects); // Array

Vercel AI SDK は内部で Zod を JSON Schema に変換し、OpenAI / Anthropic / Google Gemini のそれぞれ Structured Outputs / Tool Use 形式に橋渡ししてくれる。プロバイダ間の差を抽象化できる点で、エンタープライズの「マルチプロバイダ戦略」と相性が良い。

4-4. Pydantic vs Zod — 機能比較マトリクス

機能 Pydantic v2(Python) Zod(TypeScript) 備考
JSON Schema 出力 model_json_schema() z.toJSONSchema() / zod-to-json-schema 両者とも draft-2020-12 ベース
OpenAI SDK ネイティブ統合 responses.parse() responses.parse()(Node SDK) 2024年後半以降
Anthropic SDK ネイティブ統合 ❌(手動) ❌(手動) tool input_schema に変換
Vercel AI SDK 統合 —(TS用) generateObject({ schema }) TSではZodがデファクト
ランタイム検証 model_validate() schema.parse() / safeParse() 必須機能
制約表現力 非常に高い 非常に高い refine/transform両者あり
パフォーマンス Rust実装で高速 純TSだが十分速い 大量データなら Pydantic 有利
エラーメッセージ 詳細 + i18n対応 詳細 + カスタマイズ可 本番のロギングで重要

5. Schema 設計マトリクス — 「壊れにくい」型を作る 7 原則

LLM 用の JSON Schema は、人間がアプリの内部 API 用に書く Schema とは設計指針が違う。LLM が「迷わない」設計が必要だ。経験則として有効な 7 原則を提示する。

原則 1: 全フィールドを required にする(OpenAI strict は必須)

Optional を作らない。任意値は type: ["string", "null"] で表現し、required に入れる。これにより LLM は「埋めるか null を入れるか」の二択を迫られ、フィールド忘れがなくなる。

原則 2: enum を最大限活用する

カテゴリ値・ステータス・優先度などは必ず enum 化する。フリーテキスト type: string にすると、LLM の表記揺れ(”high” / “High” / “HIGH” / “高”)でアプリ側が壊れる。

原則 3: description を「LLM に向けた」言葉で書く

JSON Schema の description フィールドは LLM が読む。「summary: 記事の要約」ではなく「summary: 記事全体を 150 字以内の日本語で要約。第三者視点で書く。固有名詞は省略しない」のように、出力ガイドラインを混ぜる。

原則 4: 配列に minItems/maxItems を書く

LLM は配列を 0 件で返すこともあれば 30 件返すこともある。期待件数は明示する。OpenAI strict ではバージョンにより minItems/maxItems サポート状況が違うため、サポートされない場合は description で補完する。

原則 5: ネストは 3 階層までを推奨

strict mode の上限は 5 階層だが、深いネストは LLM の精度を下げ、デバッグも難しい。3 階層を超える場合は別オブジェクトに分割し、ID で参照する設計に変える。

原則 6: 自由テキストフィールドは最後に置く

JSON 出力は左から右へ順に生成される。category: enum → priority: enum → notes: string の順番だと、enum で意思決定が固まった上で notes を書ける。逆だと notes に引きずられて enum が揺れる。

原則 7: スキーマに $schema / バージョンを付け、Git で履歴管理

スキーマは「データ契約」だ。本番運用では破壊的変更が事故を呼ぶ。$schema ファイル化 + Git 管理 + CI で jsonschema ライブラリによる検証を回す。Pydantic の場合は Python ファイルそのものが履歴になる。

6. Streaming + Structured Outputs — 部分的に構造化を受信する

6-1. なぜ Streaming + Structured が難しいか

JSON は構造上、最後の } が来るまで「完成した」状態にならない。途中で受信したテキストは構文的に不完全な JSON 断片だ。これを部分パースしながら UI に表示するには、専用のロジックがいる。

6-2. OpenAI の Streaming Structured Outputs

Responses API では stream=True でイベントを購読できる。Python SDK のヘルパー client.responses.stream() が便利だ。

with client.responses.stream(
    model="gpt-4o-2024-08-06",
    input=[{"role": "user", "content": "5件の本のおすすめを返して"}],
    text_format=BookList
) as stream:
    for event in stream:
        if event.type == "response.refusal.delta":
            print(f"[拒否] {event.delta}", end="")
        elif event.type == "response.output_text.delta":
            print(event.delta, end="", flush=True)
        elif event.type == "response.completed":
            final: BookList = event.response.output_parsed
            print(f"n完了: {len(final.books)}件")

受信中の部分テキストは構文的に不完全な JSON 断片だ。それでも SDK は途中で partial オブジェクトとしてアクセスできる場合があり、進行表示に使える。ただし「途中の値はあてにならない(途切れた配列・未閉じの文字列を含む)」前提で扱う。

6-3. Anthropic の Streaming Tool Use

Anthropic は stream=Trueinput_json_delta イベントを送ってくる。これは Tool 引数の JSON 文字列の差分だ。

with client.messages.stream(
    model="claude-sonnet-4-5",
    max_tokens=4096,
    tools=[my_tool],
    tool_choice={"type": "tool", "name": "submit_report"},
    messages=[...]
) as stream:
    accumulated_json = ""
    for event in stream:
        if event.type == "content_block_delta":
            if event.delta.type == "input_json_delta":
                accumulated_json += event.delta.partial_json
                # 部分パース(外部ライブラリ partial-json-parser など)
        elif event.type == "message_stop":
            final_message = stream.get_final_message()

部分 JSON のパースは partial-json-parser(npm)や Python の jsonstreams 等を使う。本番では「最初に title が確定したら UI 上のローディングをタイトル表示に切り替える」「最初のアイテムが完成したらリスト表示を始める」など、UX 改善に有効だ。

6-4. Streaming を諦めるべきケース

すべての場面で Streaming が必要なわけではない。以下のケースは Streaming を諦めて 同期受信のほうが運用が楽だ。

  • 受信後に必ず Pydantic / Zod でフル検証する場合(部分受信に意味がない)
  • DB 書き込み・別 API 呼出など、完成データを前提とする後続処理がある場合
  • 応答が短い(200 トークン以下)場合(体感差がない)
  • バッチ処理・cron ジョブ

7. エラーハンドリングとフィールドバリデーション

7-1. 失敗モードの分類

Structured Outputs / Tool Use が「失敗」する経路は複数ある。それぞれ対処法が違う。

失敗モード 原因 対処
JSON 構文エラー strict mode 以外 / 古いモデル / Anthropicでまれに発生 retry + temperature=0
Schema 違反 required 漏れ / enum 外の値 / 型違い 受信後 Pydantic/Zod で validate → 失敗ならretry
意味的に不正 「在庫数が負」「期日が過去」など型は正しいが業務的にNG field_validator / refine で検出
Refusal(拒否) OpenAIが安全ポリシーで応答拒否 response.output[0].refusal を読む
length finish_reason max_tokens で途中切断 max_tokensを増やす / 出力分割
レート制限 / 障害 API側 exponential backoff + circuit breaker

7-2. 「受信後に必ず再検証する」パターン

from pydantic import ValidationError
import logging

logger = logging.getLogger(__name__)

def extract_with_retry(text: str, max_retries: int = 3) -> Customer:
    last_error = None
    for attempt in range(max_retries):
        try:
            response = client.responses.parse(
                model="gpt-4o-2024-08-06",
                input=[{"role": "user", "content": f"以下から顧客情報を抽出: {text}"}],
                text_format=Customer
            )

            # 1. Refusal チェック
            if hasattr(response, "refusal") and response.refusal:
                raise ValueError(f"Refused: {response.refusal}")

            # 2. Pydantic は SDK で既に通っているが、明示的に再検証
            customer = Customer.model_validate(
                response.output_parsed.model_dump()
            )

            # 3. 業務ルール検証
            if customer.age < 18 and customer.membership_tier == "platinum":
                raise ValueError("18歳未満のプラチナ会員はあり得ない")

            return customer

        except ValidationError as e:
            last_error = e
            logger.warning(f"検証失敗 attempt={attempt}: {e}")
        except Exception as e:
            last_error = e
            logger.error(f"その他エラー attempt={attempt}: {e}")

    raise RuntimeError(f"3回失敗: {last_error}")

7-3. Anthropic 側のエラーチェック

response = client.messages.create(...)

# stop_reason をまず確認
if response.stop_reason == "max_tokens":
    raise RuntimeError("出力が長すぎて切れた")
if response.stop_reason == "refusal":
    raise RuntimeError("Claude が拒否")

# tool_use ブロックを取り出す
tool_use_block = next(
    (b for b in response.content if b.type == "tool_use"),
    None
)
if tool_use_block is None:
    raise RuntimeError("tool_use ブロックなし — tool_choice 設定を見直す")

# Pydantic 検証
try:
    result = TaskBreakdown.model_validate(tool_use_block.input)
except ValidationError as e:
    # ここに来たら、LLM が input_schema を守らなかったということ
    logger.error(f"Schema violation by Claude: {e}")
    raise

8. エンタープライズ運用での型安全 — 5 つの実践

8-1. スキーマレジストリを持つ

本番運用では、複数チーム・複数サービスが同じスキーマを参照する。「schemas/v3/customer_extraction.py」のような形でスキーマを集中管理し、SemVer で版管理する。CI で破壊的変更を検出する仕組み(プロパティ削除・型変更・enum 値削除)を入れる。

8-2. LLM 入力スキーマと DB スキーマを分離する

LLM に渡すスキーマは「LLM が出しやすい」設計、DB スキーマは「正規化された」設計。両者を直結すると、片方の都合で両方が崩れる。アダプター層を挟み、LLM 出力 → アプリケーション内 DTO → DB エンティティ、と段階的に変換する。

8-3. 「黄金スキーマ」のリグレッションテスト

本番に出すスキーマには、過去の実データ 50〜200 件で「正解の出力」をスナップショット化しておく。スキーマ変更時はこのデータセットで LLM を叩き直し、出力差分が「意図した変更」か「リグレッション」かを人間がレビューする。これは Anthropic 自身も「Tool use accuracy evaluation」として推奨しているパターンだ。

8-4. 観測性(Observability)— 4 つの指標

指標 定義 目標値(参考)
Schema 準拠率 受信した出力がスキーマを満たした割合 strict: 99.9% / Tool Use: 99%以上
Field 充足率 required フィールドが「null や空文字以外」で埋まった割合 フィールド別に SLO 設定
業務ルール準拠率 field_validator / refine を通過した割合 95% 以上
Retry 率 1 回目で正解が取れた割合 90% 以上

これらは Datadog / OpenTelemetry / 自社ログに必ず仕込む。スキーマ違反が増えてきたら、モデル劣化・プロンプト drift・スキーマ自体の老朽化のいずれかが起きている。

8-5. プロバイダ抽象化レイヤー

2025-2026 年は OpenAI / Anthropic / Google Gemini / Mistral と、Structured Output を提供するプロバイダが増えた。マルチプロバイダ運用するなら、Vercel AI SDK や LangChain の with_structured_output() など、共通インターフェースに寄せる。これによりベンダーロックインを避けつつ、コスト最適化(タスクごとに安いモデルへ切替)が可能になる。

9. Function Calling から構造化データへの変換パターン

9-1. 「ダミー関数」パターン

Anthropic では既に説明した通り、「実行しない関数」を定義して引数として構造化データを受け取る。これは OpenAI でも有効なパターンだ。Function Calling のほうが Structured Outputs より「意図」が明確に伝わる場合がある。

9-2. 「複数候補から選ばせる」パターン

分類タスクは tools を複数定義し、tool_choice="auto" で「どれかを呼ばせる」と精度が出やすい。enum で 1 つのフィールドに収めるより、tool 自体を別にしたほうが LLM の意思決定境界が明確になる。

tools = [
    {
        "name": "categorize_as_bug",
        "description": "バグ報告として分類する場合",
        "input_schema": {
            "type": "object",
            "properties": {
                "severity": {"type": "string", "enum": ["S0","S1","S2","S3"]},
                "reproduction_steps": {"type": "array", "items": {"type": "string"}}
            },
            "required": ["severity", "reproduction_steps"]
        }
    },
    {
        "name": "categorize_as_feature_request",
        "description": "機能要望として分類する場合",
        "input_schema": {
            "type": "object",
            "properties": {
                "use_case": {"type": "string"},
                "estimated_users": {"type": "integer"}
            },
            "required": ["use_case"]
        }
    },
    {
        "name": "categorize_as_question",
        "description": "ユーザーからの質問として分類する場合",
        "input_schema": {
            "type": "object",
            "properties": {
                "topic": {"type": "string"}
            },
            "required": ["topic"]
        }
    }
]

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "any"},
    messages=[{"role": "user", "content": "サポートチケット本文..."}]
)

9-3. 「段階的に深掘り」パターン

複雑な構造化タスクは 1 回の API 呼出で完結させず、複数ステップに分ける。

  1. Step 1: ざっくり構造(カテゴリ・大枠の項目)だけ抽出
  2. Step 2: カテゴリに応じた詳細スキーマで再抽出
  3. Step 3: 業務ルール検証 + 不足分を人間にエスカレーション

1 回で全部やろうとすると、スキーマが肥大化し、LLM の注意が散る。分割すると、各ステップを安いモデル(Haiku / GPT-4o-mini)で回せる場合があり、コストも下がる。

10. ベンチマーク:プロンプト指示 vs Structured Outputs

「プロンプトに『JSON で返して』と書く」と「strict mode を使う」で、どれくらい差が出るのか。OpenAI 公式ブログ(前出 Introducing Structured Outputs)のデータが最も信頼できる。

手法 JSON Schema 準拠率
GPT-4(プロンプトのみ) 35.9%
GPT-4o(プロンプトのみ) 85% 前後(モデル進化で大幅改善)
GPT-4o-2024-08-06 + strict mode 100%

「100%」は OpenAI の評価セット上の数字であり、実運用での 100% を保証するものではない(ネットワーク切断やタイムアウトなどスキーマ以前の失敗はある)。それでも、文法的なスキーマ違反が ゼロにできる という性質は、エンタープライズの開発工数を劇的に減らす。

Anthropic はベンチマーク公開数値こそ控えめだが、Claude 3.5 Sonnet 以降の Tool Use 精度は実用上 99% 以上で安定しており、Anthropic Cookbook(公式)のサンプルでも同等の信頼性で利用されている。

11. 失敗パターン集 — 本番で踏んだ落とし穴

失敗 1: Optional だらけのスキーマで「ほぼ全部 null」が返る

Python で Optional[str] を多用すると、LLM は「迷ったら null」を選ぶ傾向が出る。本来抽出してほしい情報まで null になる。解決: 「不明な場合は "unknown" という文字列を入れる」のように、null 以外の選択肢を強制する。

失敗 2: enum 値に和英混在

enum: ["高", "中", "low"] のように混在させると LLM が混乱する。すべて同じ言語に統一する。多言語対応が必要なら、内部値は英語("high")にして、UI 表示の翻訳はアプリ側で行う。

失敗 3: description を書かない

name: string」だけのスキーマと、「name: string(人物の正式氏名。敬称・肩書きは含めない。複数いる場合は最も中心的な一人)」のスキーマでは、後者の精度が圧倒的に高い。スキーマは LLM への指示書だ。

失敗 4: required 違反を strict 任せにする

OpenAI strict は文法レベルで required を保証するが、Anthropic は強制力が弱い。両者を抽象化した共通レイヤーを書く際、「strict があるから受信側検証は不要」と思い込むとプロバイダ切替時に事故る。必ず受信側で再検証する

失敗 5: スキーマのバージョン管理を怠る

本番が読んでいるスキーマと、開発が書いているスキーマがずれる。マイクロサービスがそれぞれ違うバージョンを持って通信失敗する。SemVer + デプロイ時の互換性チェック を入れる。

失敗 6: Streaming で部分 JSON を信用する

Streaming 中に取得した「{"name": "山田"」を見て、後続処理を走らせると、後で {"name": "山田太郎"} に変わって不整合が起きる。Streaming はあくまで UX 改善用と割り切る。

失敗 7: 大きすぎるスキーマ

プロパティ 100 個・ネスト 5 段のスキーマを一発で渡すと、strict mode の上限に引っかかるか、引っかからなくても精度が下がる。Step 9-3 の段階的抽出に分割する。

12. 2026 年の展望 — Structured Outputs の進化

12-1. 出力スキーマの「動的化」

2025 年後半から、ユーザー入力に応じてスキーマ自体を動的生成するパターンが普及した。「アンケート設計エージェント」が回答スキーマを動的に生成し、それを次の LLM 呼出に渡す、という構成だ。スキーマ自体も型安全に管理する必要が出てくる(メタスキーマ)。

12-2. MCP(Model Context Protocol)との接続

Anthropic が提唱した MCP はツール定義の標準化を目指す。MCP サーバが提供する tools はそのまま Anthropic Tool Use にマッピングでき、構造化出力エコシステムが標準化に向かう。

12-3. マルチモーダル + Structured

画像入力 → 構造化 JSON 出力のユースケースが急増している(OCR 系・帳票処理・医療画像)。両プロバイダとも画像とテキストを同じスキーマ生成に統合できる。「請求書 PDF → 完全構造化 JSON」が 1 回の API 呼出で済む時代だ。

12-4. オープンソース側の追い上げ

Llama 3.x / Mistral 系も Outlines / lm-format-enforcer / Guidance などで Constrained Decoding が利用可能になっている。プロプライエタリ API に依存しない構造化出力もオプションになりつつある。

13. 関連記事

本記事と合わせて読むと理解が深まる Agent Lab の関連コンテンツ。

14. まとめ — 型安全な LLM アプリの設計指針

本稿のエッセンスを 10 行に圧縮する。

  1. OpenAI Structured Outputs(strict)は文法レベルで 100% スキーマ準拠を保証する。最強。
  2. Anthropic Tool Use は明示的な「strict」フラグはないが、tool_choice + 明確な input_schema で 99% 以上の準拠率。
  3. Pydantic(Python)/ Zod(TypeScript)から JSON Schema を自動生成する流れがデファクト。手書きスキーマはほぼ不要。
  4. strict mode は「全フィールド required」「additionalProperties false」が必須。Optional は type: [..., "null"] で表現。
  5. スキーマ設計 7 原則:全 required / enum 活用 / LLM向け description / 配列件数明示 / 3 階層まで / 自由テキストは後 / バージョン管理。
  6. Streaming は UX 改善用。部分 JSON で後続処理を走らせない。
  7. 受信後は必ず Pydantic / Zod で再検証。LLM 側の strict を 100% 信用しない。
  8. 失敗モードは 6 種類。それぞれに別の対処(retry / 再validate / refusal handling / backoff)。
  9. 本番運用は SLO 化:Schema 準拠率・Field 充足率・業務ルール準拠率・Retry 率の 4 指標。
  10. マルチプロバイダ抽象化(Vercel AI SDK 等)でベンダーロックインを避ける。

「JSON parsing roulette」の時代は終わった。これからのエージェント開発は 型ファーストで設計する。スキーマがエージェントの契約であり、契約が守られることがプロダクトの信頼性そのものになる。

15. 参考リンク(Tier 1 一次情報)

この記事を読んで導入イメージが固まってきた方へ

UravationではAIエージェント導入の研修・コンサルを行っています。Structured Outputs を活用した型安全なエージェント設計、Pydantic / Zod を組み合わせた本番運用基盤の構築、マルチプロバイダ環境でのスキーマ統一など、エンタープライズの「壊れない LLM アプリ」構築をお手伝いします。


執筆: 佐藤傑(さとう・すぐる)。株式会社Uravation代表取締役。X(@SuguruKun_ai)フォロワー約10万人。著書『AIエージェント仕事術』。AIエージェントの設計・導入支援を専門とし、Structured Outputs を活用した型安全な本番運用設計を多数手掛ける。

Need help moving from reading to rollout?

この記事を読んで導入イメージが固まってきた方へ

Uravationでは、AIエージェントの要件整理、PoC設計、社内導入、研修まで一気通貫で支援しています。

この記事をシェア

X Facebook LINE

※ 本記事の情報は2026年5月時点のものです。サービスの料金・仕様は変更される可能性があります。最新情報は各サービスの公式サイトをご確認ください。

関連記事