Structured outputsのJSON Schema制限をSDKが自動変換する仕組み
structured outputsはminimum・maximumなどのJSON Schema制約に対応しません。SDKがどの制約を取り除き、descriptionへ言い換え、レスポンスをどう検証しているかを5ステップで追います。
structured outputsのJSON Schemaは、標準仕様のごく一部しか受け付けません。minimumやmaximumのような数値制約、minLengthやmaxLengthのような文字列制約を含めると、そのままでは400エラーになります。Python・TypeScript・Ruby・PHPの各SDKは、モデル経由でスキーマを作る限りこれを黙って回避します。制約をワイヤー上のスキーマから取り除き、代わりにdescriptionへ言い換えて送ります。返ってきたレスポンスは、Python・TypeScript・PHPのヘルパーが元のスキーマに照らして検証します。
この記事は、その変換の中身をPython SDKの実際の出力で示し、変換が働かない場面と、スキーマに従わない応答が返る3つの例外までを追います。
400エラーになるJSON Schemaの制約
Claudeの応答をJSON Schemaに強制一致させるstructured outputsは、output_config.format(JSON outputs)とstrict: true(Strict tool use)という2つの仕組みの総称です。どちらも同じサブセットのJSON Schemaしか読みません。
| 分類 | 制約の例 |
|---|---|
| 対応 | 制約の例type・enum(文字列・数値・真偽値・nullのみ)・const・required・additionalProperties: false・anyOf / allOf(allOfと$refの組み合わせは不可)・内部の$ref / $def / definitions・default・String formats(date-time・time・date・duration・email・hostname・uri・ipv4・ipv6・uuid)・配列minItems(0か1のみ)・単純なpattern |
| 非対応 | 制約の例minimum / maximum / multipleOf・minLength / maxLength・再帰スキーマ・外部$ref・minItemsの2以上・additionalPropertiesのfalse以外の値・enum内の複合型 |
非対応の制約を含むスキーマをそのままAPIへ送ると、詳細付きの400エラーが返ります。
pattern(正規表現)にも線引きがあります。^...$の完全一致・部分一致、* + ?と単純な{n,m}、文字クラス、グループは通ります。後方参照(\1)、先読み・後読み((?=...) (?!...))、単語境界(\b・\B)、範囲の大きい{n,m}は非対応で、複雑なパターンは400エラーの原因になることがあります。
SDKが自動変換する5つのステップ
Python・TypeScript・Ruby・PHPの各SDKは、Pydantic・Zodなどのモデルからスキーマを生成する経路で、送信前に次の5ステップを踏みます。C#・GoのSDKも、スキーマをネイティブ型(C#のCreate<T>()、Goの構造体リフレクション)から導出する場合は同じ変換を適用します。
SDKがスキーマを送るまでの5ステップ
- 1
非対応の制約を取り除く
minimum・maximum・minLength・maxLengthなど、APIが400を返す制約をワイヤー上のスキーマから消します。 - 2
制約をdescriptionへ書き足す
消した制約を、そのフィールドの
descriptionに文字列として残します。 - 3
additionalProperties: falseを付ける
すべてのオブジェクトに付与します。
- 4
String formatを絞り込む
対応リストにあるformatだけを残します。
- 5
レスポンスを元のスキーマで検証する
取り除く前の完全な制約に照らして、返ってきたJSONを検証します。ヘルパーが検証する場合に限ります。
Python SDKで変換結果を出力してみる
公式の例は「minimum: 100が{minimum: 100}としてdescriptionに足される」という1つだけです。実際に何が送られるかは、PythonのSDKが公開しているtransform_schema()で手元に出力できます(API呼び出しは発生しません)。次はPython SDK v1.11.0・Pydantic v2.13.5での実行結果です。
from pydantic import BaseModel, Field
from anthropic import transform_schema
class Person(BaseModel):
name: str = Field(min_length=1, max_length=40)
age: int = Field(ge=0, le=150)
tags: list[str] = Field(min_length=3)
print(transform_schema(Person))出力は次のとおりです(整形済み、titleは省略)。
{
"type": "object",
"properties": {
"name": {"type": "string", "description": "{maxLength: 40, minLength: 1}"},
"age": {"type": "integer", "description": "{maximum: 150, minimum: 0}"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "{minItems: 3}"}
},
"additionalProperties": false,
"required": ["name", "age", "tags"]
}descriptionに入るのは「Must be at least 0」のような自然文ではなく、{maximum: 150, minimum: 0}という制約そのままの記法です。キーはアルファベット順に並び、minItems: 3のように配列の下限(2以上)もここへ移されます。Claudeが受け取るのはこの簡略化されたスキーマだけで、minimumやmaximumを制約としては見ていません。descriptionを手がかりに、妥当な範囲の値を返そうとしているだけです。
同じ要領で、formatとadditionalPropertiesの扱いも確かめられます。
transform_schema({
"type": "object",
"properties": {
"c": {"type": "string", "format": "color"},
"d": {"type": "string", "format": "date"},
"n": {"type": "number", "multipleOf": 0.5},
},
"additionalProperties": True,
})結果では、対応リストにないformat: colorとmultipleOfがdescriptionへ移り、format: dateは残り、additionalPropertiesはTrueからfalseに書き換わりました。入力でTrueを指定していても、変換後は常にfalseになります。
patternは公式の対応表と食い違います。v1.11.0のtransform_schema()は、APIが対応する単純な正規表現(^\d{3}$)も{pattern: ^\d{3}$}としてdescriptionへ移します。SDKのバージョンによって変わりうる挙動なので、パターンを制約として効かせたい場面では、送信前のスキーマを自分で出力して残っているか見るのが確実です。
検証はローカルのコードが担う
ここが誤解しやすい点です。ステップ5の検証は手元で動くコードで、Claudeの推論とは無関係に走ります。SDKは元のPydanticモデル・Zodスキーマ(制約をすべて保持したもの)に対してレスポンスを検証し直します。
同じPydanticモデルに{"name":"a","age":200,"tags":["x","y","z"]}を渡すと、次のエラーでパースが失敗します(出力末尾の参照先案内1行は省略しています)。
1 validation error for Person
age
Input should be less than or equal to 150 [type=less_than_equal, input_value=200, input_type=int]生のJSON Schemaを渡すと変換と検証はどうなるか
変換の有無は、SDKの種類より「どの入口から渡すか」で決まります。公式のSDK別の記述を並べると、次のようになります。
| SDK / 経路 | 変換 | レスポンス検証 |
|---|---|---|
Python client.messages.parse() + Pydanticモデル | 変換あり | レスポンス検証あり |
Python transform_schema() | 変換あり(変換結果を返すだけで送信しない) | レスポンス検証なし |
TypeScript zodOutputFormat() | 変換あり | レスポンス検証あり |
TypeScript jsonSchemaOutputFormat() | 変換既定であり({ transform: false }で無効化) | レスポンス検証なし |
| PHPのモデルクラス | 変換あり | レスポンス検証あり(minimum・maximum・multipleOf・minLength・maxLength) |
| Java | 変換なし | レスポンス検証非対応制約をローカル検証で拒否 |
output_config.formatへ生のJSONを直接渡す | 変換なし | レスポンス検証なし |
特に差が出るのが、TypeScriptのjsonSchemaOutputFormat()です。Zodを入れずに素のスキーマを渡せる便利な入口ですが、制約を取り除くところまではzodOutputFormat()と同じで、レスポンスの検証だけが無く、minimum違反のような値はそのまま通ります。Javaは変換せず、非対応の制約を含むクラスをローカルの検証で弾く設計です。Zod以外の検証ライブラリには、betaStandardSchemaOutputFormat()があります。ベータ名前空間(@anthropic-ai/sdk/helpers/beta/standard-schemaとclient.beta.messages.parse())の機能です。Standard Schemaの~standard.validateでレスポンスを検証し、送信するスキーマはjsonSchemaオプションで指定できます。
モデル経由と生スキーマの違い
モデル経由(Pydantic・Zod)
制約はdescriptionに移って送られ、返ってきた値は元のモデルで再検証されます。Claudeが範囲外の値を返しても、アプリに渡る前にSDKが例外にします。
生のJSON Schema
非対応の制約が残っていれば、送信時に400エラーです。通るように手で書き換えた場合も、範囲外の値を弾くのはアプリ側のコードになります。
生のスキーマで起きやすい事故は次の3つです。
- 数値制約を残したまま送る:
{"type": "integer", "minimum": 0}をoutput_config.formatへ渡すと400エラーになります。descriptionへ言い換えるか、SDKのモデル経由に切り替えます minItemsに2以上を指定する:通るのは0か1だけです。「3件以上」を強制したい場合は、descriptionで指示するか、応答後にアプリ側で件数を検証しますadditionalPropertiesをfalse以外にする:オブジェクトではadditionalPropertiesをfalseにする必要があり、false以外の値は非対応です
これらはいずれも仕様上の制限で、SDKのバグではありません。制約を諦めるのではなく、「Claudeへの指示(description)」と「アプリ側の検証」に分けて持つ、というのがこの仕組みの前提です。
スキーマ通りにならない3つの例外
制約の変換とは別に、structured outputsそのものにもスキーマ一致を保証しない例外が3つあります。SDKの変換を完璧に実装しても避けられません。これらとは別に、contentが空になる異常応答がOpus 4.6で報告されたこともあります(詳細はOpus 4.6のjson_schemaで空コンテンツを返す不具合と回避策)。
スキーマに従わない応答の3パターン
拒否(refusal)
安全上の理由でClaudeがリクエストを拒否すると、
stop_reasonはrefusalになります。ステータスコードは200で、生成されたトークン分の課金も発生します。拒否メッセージがスキーマ制約より優先されるため、本文はスキーマに従いません。スキーマのプロパティにモデルの思考過程や段階的な推論を求めると、
reasoning_extractionの拒否を招くことがあります。理由が必要なら、短い説明を求めます。max_tokens超過
出力が上限で打ち切られると
stop_reasonはmax_tokensになり、途中で終わった出力はスキーマに一致しません。max_tokensを引き上げて再試行します。enumの大文字小文字ゆれ
文字列の
enum・constは、大文字小文字までは保証されません。スペースの後に続く単語の先頭文字で起きやすく、"Conversation topic 3"というenum値に対して"Conversation Topic 3"が返ることがあります。エラーにも特別なstop_reasonにもならず、応答は正常に完了します。
いずれも400エラーにはなりません。拒否とmax_tokensはstop_reasonで判別できるので、パース処理ではstop_reasonを確認してから本文をスキーマとして扱う一段階を挟みます。enumのゆれは正常完了の応答に紛れるため、stop_reasonでは検出できません。enum値は大文字小文字を無視して比較し、大文字小文字だけが違う値は定義しない運用が公式の案内です。
プロパティの順序にも癖があります。出力では必須プロパティが先に並び、任意プロパティがその後に続きます。スキーマでnotesを先頭に書いても、requiredに入れていなければname・emailの後ろに出ます。出力の並び順に依存するパーサーを書くときは、全プロパティをrequiredにするか、並べ替えを前提にします。
複雑さの上限に当たったときの切り分け
同じ制限はStrict tool use(strict: true)にも及びます。JSON outputsとStrict tool useは目的が違う2つの機能ですが、対応するJSON Schemaのサブセットは共通です。ツール引数のminimum・maximumも同様に非対応です。両者を同一リクエストで併用すると、複雑さの上限は合算で数えられます。
| 上限 | 値 | 数え方 |
|---|---|---|
strict: trueのツール | 値1リクエスト20個 | 数え方strictでないツールは数えない |
| 任意パラメータ | 値24個 | 数え方requiredに無いプロパティを、全strictツールとJSON出力のスキーマで合計 |
| 共用体型のパラメータ | 値16個 | 数え方anyOfや["string", "null"]のような型配列を使う項目の合計 |
この3つを満たしていても、コンパイル後のグラマーが大きすぎれば「Schema is too complex for compilation」という400エラーになります。上限は合算なので、任意パラメータ6個のstrictツールが4本あるだけで24個に達します。最後の防壁として、コンパイルには180秒のタイムアウトがあります。
エラーが出たら、優先度の低いツールからstrictを外す、任意パラメータをrequiredに寄せる、ネストを浅くする、リクエストを分割する、の順に試すのが公式の案内です。任意パラメータが重いのは、1個増えるごとにグラマーの状態空間の一部がおよそ倍になるためです。この上限に達する具体的な原因と、文書化前のエラーメッセージの変遷は「compiled grammar is too large」エラーの原因とスキーマ分割で対処する方法にまとめています。Strict tool useの組み合わせ方はAnthropic Advanced Tool Useを参照してください。
スキーマを変えるとキャッシュはどう壊れるか
structured outputsはスキーマをグラマーにコンパイルして出力を制約します。初回のリクエストはコンパイル分だけレイテンシが増え、コンパイル済みのグラマーは最後の使用から24時間キャッシュされます。
キャッシュが無効になる変更と、ならない変更は次のとおりです。
- 無効になる:JSONスキーマの構造を変える、strictツールとJSON outputsを併用しているときにツールの組を変える
- 無効にならない:
nameやdescriptionだけの変更
output_config.formatを変えると、その会話スレッドのプロンプトキャッシュも無効になります。structured outputsを使うと、出力形式を説明するシステムプロンプトが自動で追加されて入力トークンがわずかに増える点も、同じ文脈の話です。頻繁にスキーマを変えるアプリでは、この再コンパイルのコストも設計に含める必要があります。
併用できない機能もあります。Citationsを有効にした状態でoutput_config.formatを使うと400エラーで、メッセージのプリフィルも使えません。バッチ処理には対応し、Claude Batch APIの使い方にあるとおり50%割引が効きます。
スキーマ自体は最大24時間キャッシュされるため、PHI(個人の健康情報)をプロパティ名・enum値・const値・patternに含めないようにします。PHIはプロンプトと応答の中にだけ置くのが公式の扱いです。
まとめ
範囲制約を本当に効かせたいなら、送る前にtransform_schema()などでワイヤー上のスキーマを出力し、残った制約とdescriptionへ移った制約を見分けておくのが確実です。descriptionへ移った分は指示にすぎないので、守らせる処理はアプリ側かヘルパーの検証に置きます。structured outputs全体の実装手順はClaude JSONモードの使い方にあります。