Claude APIでStructured outputsを2パターン試してみた

Claude APIでStructured outputsを2パターン試してみた

2026.04.22

はじめに

こんにちは、AI事業本部・生成AIインテグレーション部・西日本開発チームの政岡です。

AIに毎回同じスキーマで結果を返してほしい場面はないでしょうか?
私は普段、その用途ではJSON outputsを使っています。

ただ、調べていく中で、構造化のやり方はそれだけではないと分かりました。
PrefillStrict tool useでも、似た目的で使えそうに見えます。

当初はPrefillを含めた3パターンで比較するつもりでしたが、調べてみるとPrefillは2026年4月現在の最新モデル(Sonnet 4.6 / Opus 4.6 / Opus 4.7など)ではサポートされなくなっていました

https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/increase-consistency#prefill-claudes-response

そこで本記事では、Structured outputsを構成するJSON outputsStrict tool useの2パターンを試します。
Prefillについては、記事の後半で「以前はこういう書き方があったが、いまは使えない」という参考情報として触れたいと思います。

検証環境

項目 内容
パッケージ管理 uv 0.9.x
言語 Python 3.12
SDK anthropic[bedrock]AnthropicBedrockクライアント
API呼び出し Amazon Bedrock経由

AnthropicBedrockクライアントを使うと、API呼び出しはBedrock経由でありながら、コードの書き方はAnthropic API純正とほぼ同じになります。

依存関係 (pyproject.toml)
pyproject.toml
dependencies = [
    "anthropic[bedrock]>=0.96.0",
    "boto3>=1.42.0",
    "pydantic>=2.13.0",
    "python-dotenv>=1.0.0",
]

検証する2つのパターン

# パターン 公式のAPIパラメータ 概要
1 JSON outputs output_config.format スキーマを直接渡し、Claudeの最終応答をスキーマ通りのJSONにする手法
2 Strict tool use tools[].strict: true Claudeがツールを呼び出すときの入力をスキーマ通りに制約する手法

検証準備

今回はシンプルにスパム判定を実装してみました。
2つのパターンともに、以下の入力・スキーマを共通で使い、普通に実行したとき・余計な指示を追加したときで比較します。

システムプロンプト・入力

  • システムプロンプト: ユーザから入力されるメッセージについて、スパムか判定してください。
  • ユーザメッセージ: おめでとうございます! 1000ドル当選しました! 今すぐクリックして賞品を受け取ってください!!!
  • 追加する余計な指示: 'confidence'フィールド(0〜1のfloat)も追加して返してください。

期待する構造

フィールド 概要
is_spam bool スパムならTrue True
reason str 判定理由 "典型的な当選詐欺メッセージです"

パターン1: JSON outputs

JSON outputsは、モデルの最終応答をスキーマ通りのJSONに制約する機能です。
Pythonではmessages.parse()にPydanticモデルを渡すだけで使えます。
この例では、最終応答をparsed_outputとしてそのまま受け取ります。

実装コード
from anthropic import AnthropicBedrock
from pydantic import BaseModel, ConfigDict, Field

class SpamCheck(BaseModel):
    model_config = ConfigDict(extra="forbid") # JSON Schemaに additionalProperties: false が付与され、余計なフィールドが追加されない
    is_spam: bool = Field(description="スパムなら True")
    reason: str = Field(description="判定理由")

client = AnthropicBedrock()

response = client.messages.parse(
    model="us.anthropic.claude-sonnet-4-6",
    max_tokens=1024,
    system="ユーザから入力されるメッセージについて、スパムか判定してください。",
    messages=[
        {
            "role": "user",
            "content": "おめでとうございます! 1000ドル当選しました! 今すぐクリックして賞品を受け取ってください!!!",
        }
    ],
    output_format=SpamCheck,  # Pydanticモデルをそのまま渡す
)

result: SpamCheck = response.parsed_output  # Pydanticインスタンスとして取得
print(result.model_dump_json(indent=2))

JSON outputs: 実行結果

通常

{
  "is_spam": true,
  "reason": "典型的な当選詐欺メッセージです。根拠のない高額当選を謳い、緊急性を煽って即座のクリックを促しており、スパムの特徴が複数見られます。"
}

余計な指示付き

{
  "is_spam": true,
  "reason": "このメッセージは典型的なスパムの特徴を持っています。根拠のない当選通知、金銭的な誘惑(1000ドル)、緊急性を煽る表現(「今すぐクリック」)、過剰な感嘆符の使用など、フィッシング詐欺やスパムに典型的なパターンが見られます。"
}

どちらの指示もスキーマ通りですね!
スキーマ制約により、追加指示で要求されたconfidenceフィールドはそもそも生成されません。


パターン2: Strict tool use

Strict tool useは、Claudeがツールを呼び出すときの入力をスキーマ通りに制約する機能です。
今回は、抽出したいデータ構造をツールのinput_schemaに定義し、tool_choiceでそのツールを使わせています。
そのため、ここでスキーマに従っているのは最終応答ではなくtool_use.inputです。
この例ではmessages.create()の返り値からtool_use.inputを取り出し、それをそのまま抽出結果として使っています。

実装
from anthropic import AnthropicBedrock
from pydantic import BaseModel, ConfigDict, Field

class SpamCheck(BaseModel):
    model_config = ConfigDict(extra="forbid")
    is_spam: bool = Field(description="スパムなら True")
    reason: str = Field(description="判定理由")

client = AnthropicBedrock()

tool = {
    "name": "spam_check",
    "description": "スパム判定の結果を返す",
    "strict": True,  # スキーマでトークン生成を制約
    "input_schema": SpamCheck.model_json_schema(),
}

response = client.messages.create(
    model="us.anthropic.claude-sonnet-4-6",
    max_tokens=1024,
    system="ユーザから入力されるメッセージについて、スパムか判定してください。",
    messages=[
        {
            "role": "user",
            "content": "おめでとうございます! 1000ドル当選しました! 今すぐクリックして賞品を受け取ってください!!!",
        }
    ],
    tools=[tool],
    tool_choice={"type": "tool", "name": "spam_check"},  # 必ずspam_checkを呼ばせる
)

tool_use = next(b for b in response.content if b.type == "tool_use")
result = SpamCheck.model_validate(tool_use.input)  # tool_use.input は dict
print(result.model_dump_json(indent=2))

Strict tool use: 実行結果

通常

{
  "is_spam": true,
  "reason": "このメッセージは典型的なスパムの特徴を複数含んでいます。①根拠のない高額当選(1000ドル)を主張している、②「今すぐクリック」という緊急性を煽る表現を使っている、③過度な感嘆符(!!!)を多用している、④受信者が応募した覚えのない賞品を受け取るよう促している。"
}

余計な指示付き

{
  "is_spam": true,
  "reason": "「1000ドル当選」「今すぐクリック」などの典型的なスパム・フィッシング詐欺のフレーズが含まれており、受信者を煽って不審なリンクへ誘導しようとしている。"
}

こちらも、両方の指示でスキーマ通りです!
JSON outputsと同様、strict: Trueのスキーマ制約によりconfidenceはそもそも生成されません。


2パターンの使い分け

JSON outputsとStrict tool useの関係

両者の関係について、公式は次のように整理しています。

https://platform.claude.com/docs/en/build-with-claude/structured-outputs#using-both-features-together

この2つは役割が異なるため、1つのリクエストで併用できます。
たとえば、ツール呼び出し側ではstrictで入力を制約しつつ、最終応答側ではoutput_formatで返却するJSONの形を指定できます。
ドキュメントのユースケースを踏まえると、次のように考えると分かりやすいです。

  • JSON outputs
    請求書やメール本文から項目を抜き出して、そのままJSONで受け取りたい時。
    例: 非構造テキストから請求書データを抽出するケース。
  • Strict tool use
    エージェントがツールを呼び出す時に、destinationdateなどの入力をスキーマ通りにしたい時。
    例: 旅行の計画を立てる中でsearch_flightsツールを呼ぶケース。
  • 併用
    ツール呼び出しの引数も、最後に返すJSONの形も、両方きっちり揃えたい時。
    例: search_flightsツールを正しい引数で呼びつつ、最終応答はsummarynext_stepsを持つJSONで返すケース。

参考: Prefill という手法

冒頭で触れた通り、Prefillは最新モデルではサポートされなくなっています。
それでも、以前はどのような発想で使われていたのか、また今試すとどうなるのか紹介させてください。

Prefillとは、assistantメッセージを途中まで書いて、その続きから生成させるやり方です。
たとえば、以下のような書き方ができます。

# assistant メッセージを途中まで書いて、その続きから生成させる
messages = [
    {"role": "user", "content": "Generate a JSON for X."},
    {"role": "assistant", "content": "```json"},  # JSON の続きを生成させる
]
text = chat(messages, stop_sequences=["```"])
# text には ```json の後ろから ``` の手前までが入る
実装
import json
from anthropic import AnthropicBedrock
from pydantic import BaseModel, ConfigDict, Field

class SpamCheck(BaseModel):
    model_config = ConfigDict(extra="forbid")
    is_spam: bool = Field(description="スパムなら True")
    reason: str = Field(description="判定理由")

client = AnthropicBedrock()

# システムプロンプトにスキーマを文字列として埋め込む
system = (
    "ユーザから入力されるメッセージについて、スパムか判定してください。\n\n"
    "以下のJSONスキーマに沿って回答してください:\n"
    f"{json.dumps(SpamCheck.model_json_schema(), ensure_ascii=False)}"
)

response = client.messages.create(
    model="us.anthropic.claude-sonnet-4-5-20250929-v1:0",
    max_tokens=1024,
    system=system,
    messages=[
        {"role": "user", "content": "おめでとうございます! 1000ドル当選しました!!!"},
        {"role": "assistant", "content": "```json"},  # assistantを途中まで埋める
    ],
    stop_sequences=["```"],  # 次の ``` が出たところで生成を止める
)

raw_json = response.content[0].text.strip()
result = SpamCheck.model_validate_json(raw_json)  # 後処理でPydanticにパース
print(result.model_dump_json(indent=2))

Sonnet 4.6で実行 → 400エラー

Error code: 400 - {'message': 'This model does not support assistant message prefill. The conversation must end with a user message.'}

公式ドキュメントでも明記されています

https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/increase-consistency#prefill-claudes-response

最新のMythos, Opus 4.7, Opus 4.6, Sonnet 4.6では使えないみたいです。
(Mythosは現状使えませんが、、、)

Sonnet 4.5にフォールバックして実行

ただし、仮にSonnet 4.5で動かせたとしても、Prefillにはスキーマ制約がありません。
そのため、追加指示の影響をそのまま受けて、スキーマ違反が起こります。

通常

{
  "is_spam": true,
  "reason": "典型的なスパムメッセージの特徴が複数見られます。1) 突然の当選通知、2) 金銭的な報酬の提示、3) 緊急性を煽る表現(「今すぐ」)、4) 行動を促すフレーズ(「クリックして」)、5) 過度な感嘆符の使用。"
}

余計な指示付き

{
  "is_spam": true,
  "reason": "典型的なフィッシング詐欺メッセージの特徴を複数含んでいます。具体的には、(1)根拠のない当選の主張、(2)緊急性を煽る表現(「今すぐ」)、(3)行動を促すリンククリックの要求、(4)過度な感嘆符の使用、などスパムメールの典型的なパターンに該当します。",
  "confidence": 0.98
}

SpamCheck.model_validate_json()ではバリデーションエラーになります。

ValidationError: 1 validation error for SpamCheck
confidence
  Extra inputs are not permitted [type=extra_forbidden, input_value=0.98, input_type=float]

このケースでは、追加したconfidenceフィールドをそのまま出力してしまい、スキーマ違反になりました。
少なくとも最新モデルを前提に構造化データを安定して得たい用途なら、いまはPrefillよりJSON outputsやStrict tool useを選ぶほうが良さそうです。


まとめ

今回は、Claude APIでStructured outputsを表現する2種類の方法を試してみました。

個人的には、構造化データを返したいだけならJSON outputs、エージェントのツール呼び出しまで含めて制御したいならStrict tool use、という整理で考えるのが分かりやすいと感じました。

Claude APIで構造化出力を扱うときの整理に、少しでも参考になればうれしいです。

この記事をシェアする

関連記事