ストアのレビュー文を「不具合」「要望」「称賛」に分類して、JSON で受け取りたい——個人開発でアプリを運用していると、この手の処理はいつの間にか一つ二つ増えていきます。そして動いているコードほど、しばらく触らないままになりがちです。
私の手元のコードにも structured-outputs-2025-11-13 という beta ヘッダーが残っていないか、先日ふと気になりました。公式ページを読み直すと、構造化出力のパラメーターは output_format から output_config.format へ移り、beta ヘッダーも不要になった、と書かれています。
最初にお伝えしたいのは、この移行は「パラメーター名を一つ変えるだけ」では済まないという点です。名前の差し替えに加えて、SDK の挙動、プロンプトキャッシュ、スキーマの制約、そして「スキーマに合わない出力が返る場面」まで、確かめる箇所がいくつかあります。移す前に公式ページから洗い出した順番を、書き残します(2026 年 10 月 20 日に確認)。
まず、何が変わったのか
公式の「Migrating from the beta」の節は短く、要点は次の3つです。
| 項目 | 書かれていること |
|---|---|
| パラメーター | output_format は output_config.format へ移った。output_format は非推奨で、将来削除される |
| beta ヘッダー | もう不要。古い書き方を使い続けるなら structured-outputs-2025-11-13 が必要で、付けないと API は 400 を返す |
| Python SDK | v1.0 以降は、client.beta.messages.create() と count_tokens() に output_format={...} を渡すと TypeError になる。output_config を使う |
つまり、古い書き方は「いつか止まる」だけでなく、SDK を上げた時点で先に止まる可能性があります。API は動いているのに、手元のコードが TypeError で落ちる——この順番で壊れると、原因が見えにくいのです。
書き換えの Before / After
何を解決するか: beta ヘッダー付きの呼び出しを、現行の形へ置き換えます。
# Before: output_format と beta ヘッダーに頼っていた書き方(非推奨)
response = client.beta.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
betas=["structured-outputs-2025-11-13"],
messages=[{"role": "user", "content": review_text}],
output_format={"type": "json_schema", "schema": REVIEW_SCHEMA},
)# After: output_config.format に schema を渡す。beta は使わない
import json
response = client.messages.create(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": review_text}],
output_config={
"format": {"type": "json_schema", "schema": REVIEW_SCHEMA}
},
)
# 結果は text ブロックの中に JSON 文字列として入っている
text = next(b.text for b in response.content if b.type == "text")
result = json.loads(text)なぜこう書くか: client.beta.messages.create から client.messages.create に変わる点が、見落としやすい差分だからです。ヘッダーだけ消して beta のクライアントを使い続けると、SDK 側の挙動が変わった箇所でつまずきます。
もう一つ、少し紛らわしい点があります。Pydantic のモデルを渡す SDK ヘルパー client.messages.parse() では、引数名が今も output_format です。公式はこちらを推奨の方法としていて、SDK がスキーマを変換し、output_config.format として送り、応答を検証して parsed_output に詰めてくれます。
from pydantic import BaseModel
class Review(BaseModel):
category: str
confidence: float
summary: str
parsed = client.messages.parse(
model="claude-sonnet-5-5",
max_tokens=1024,
messages=[{"role": "user", "content": review_text}],
output_format=Review, # ヘルパーの引数名は output_format のまま
)
print(parsed.parsed_output)「output_format を探して全部消す」という置換は、このヘルパーまで壊してしまいます。検索するときは、messages.create 系の生の呼び出しと、parse() を分けて見るのが安全だと感じました。
移したあとに変わること
名前を直しても、挙動の側にはまだ確かめる点が残ります。
| 確かめる点 | 公式の記述 | 手元で見るもの |
|---|---|---|
| 初回のレイテンシ | 新しいスキーマの最初のリクエストは、文法のコンパイルで遅くなる。コンパイル済みの文法は最後の使用から 24 時間キャッシュされる | デプロイ直後の最初の呼び出しだけ遅くないか |
| キャッシュの無効化 | スキーマの構造を変えると無効になる。name や description だけの変更は無効にならない | スキーマを頻繁に差し替えていないか |
| プロンプトキャッシュ | output_config.format を変えると、その会話スレッドのプロンプトキャッシュが無効になる | キャッシュヒット率が移行の前後で落ちていないか |
| 入力トークン | 出力形式を説明する追加のシステムプロンプトが自動で入り、わずかに増える | 請求の入力トークンの変化 |
特に、プロンプトキャッシュは見落としやすいと思います。ユーザーごとにスキーマを動的に組み立てている場合、リクエストのたびにキャッシュが切れてしまうかもしれません。スキーマはできるだけ固定し、変えたい部分は description ではなくメッセージ側に寄せる、という線引きが現実的です。
スキーマに書けないもの
移行のついでに、スキーマの制約も読み直しておきます。公式が挙げている「サポートされない」ものは次のとおりです。
- 再帰的なスキーマ、enum の中の複雑な型、外部の
$ref minimum/maximum/multipleOfといった数値の制約minLength/maxLengthといった文字列の制約minItemsが 0 か 1 以外の配列の制約additionalPropertiesをfalse以外にすること
使うと 400 エラーが返ります。Pydantic で Field(ge=0, le=1) のように範囲を書いている場合、SDK ヘルパーは制約を取り除いて説明文に書き足し、応答は元のスキーマで検証し直してくれます。生のスキーマを渡す場合は、anthropic パッケージの transform_schema() で同じ変換ができます。
さらに、複雑さの上限があります。
| 上限 | 値 |
|---|---|
strict: true のツール数(1 リクエストあたり) | 20 |
| 省略可能なパラメーターの合計 | 24 |
ユニオン型(anyOf や型の配列)を使うパラメーターの合計 | 16 |
超えると 400 で「Schema is too complex for compilation」と返ります。省略可能な項目が多いスキーマは、「必須にして、無いときは明示的な値を返してもらう」形へ寄せると収まりやすい、と公式も勧めています。
「スキーマどおり」にならない 4 つの場面
構造化出力は、ほとんどの場合スキーマに沿った JSON を返します。ただし公式は、例外もはっきり書いています。移行後のテストには、次の4つを入れておくと安心です。
- 拒否(
stop_reason: "refusal"): ステータスは 200 で、課金もされますが、出力はスキーマに合わないことがあります。拒否のメッセージが優先されるためです。 - 途中切れ(
stop_reason: "max_tokens"): 出力が不完全な JSON になります。max_tokensを上げて再試行します。 - enum の大文字小文字: 空白の後ろの単語の頭文字などが、スキーマとは大文字小文字だけ違う値で返ることがあります。エラーにはなりません。比較は大文字小文字を区別せずに行います。
- 併用できない機能: 引用(citations)と一緒に使うと 400 になります。プリフィル(prefill)とも併用できません。
# 何を解決するか: スキーマ外の出力を、パースする前に弾く
def read_structured(response):
if response.stop_reason in ("refusal", "max_tokens"):
raise RuntimeError(f"schema を満たさない可能性: {response.stop_reason}")
text = next(b.text for b in response.content if b.type == "text")
return json.loads(text)なぜこう書くか: json.loads が失敗してから原因を探すより、stop_reason を先に見るほうが、はるかに早く手がかりに辿り着けるからです。分類の結果を category == "bug" のように比較しているなら、enum の値は .lower() してから突き合わせる一行も足しておきます。
移行の最小手順
私が今回まとめた順番は、次のとおりです。個人開発の小さなリポジトリでも、この5段で足ります。
- コード全体から
output_formatとstructured-outputs-2025-11-13を検索し、生の呼び出しとparse()を分けて数えます。 - 生の呼び出しだけ
messages.createとoutput_config.formatへ書き換え、beta ヘッダーを外します。 - スキーマを
transform_schema()か SDK ヘルパーに通し、400 が出る制約を洗い出します。 stop_reasonの確認と、enum の比較を大文字小文字無視にする一行を足します。- 移行の前後で、初回レイテンシ・キャッシュヒット率・入力トークンを一度だけ見比べます。
名前を直す前に、壊れ方を先に知っておきます。 移行では、この順番だけは崩さないようにしています。
まずは、お手元のリポジトリで structured-outputs-2025-11-13 を検索するところから始めていただければと思います。一件も出なければ、この移行は済んでいます。出てきたら、その行がいちばん先に止まる場所です。