Bedrock の構造化出力が Claude Sonnet 5 で使えるようになった — 強制 tool use をやめたら thinking が動き出した

Amazon Bedrock が Claude Sonnet 5 で outputConfig.textFormat を受け付けるようになりました。強制 tool use から置き換えると JSON の形は直りましたが、thinking を切る必要がありました。

白いカードに、六角形の輪郭と小さな円で終わる回路の線からなる白い線描きの脳を配したティール色の角丸正方形のAmazon Bedrockのアイコンと、濃紺のAmazon Bedrockの文字
目次

はじめに

今月の初めまで、Amazon Bedrock は Claude Sonnet 5 で構造化出力を受け付けませんでした。そのため、私が関わっている翻訳パイプラインでは、プロンプトのルールとエラーを返す出し直しで JSON の形を守っていました。2026-10-20 に AWS が Bedrock のすべての Claude モデルで構造化出力に対応したと発表したので、この回避策を外せるかどうかを確かめました。

外せました。プロンプトのルールを外すと、実際のバッチで強制 tool use は 20 回中 5 回崩れ、outputConfig.textFormat は 1 回も崩れませんでした。ただし、切り替えて元の速さを保てたのは thinking を切ったときだけでした。強制 tool use が Sonnet 5 に thinking をまったくさせていなかったためで、それがなくなると、143 セグメントのバッチ 1 つが 47 秒から 731 秒になりました。

今は Bedrock がモデルの生成中にスキーマを強制するので、リクエストは、Sonnet 5.5 と Opus 5.5 が拒否する強制 tool use に頼らずに済みます。

Bedrock が Claude Sonnet 5 で受け付けるようになったもの

Bedrock で呼べた Claude のモデルは、どれも回答に JSON Schema を指定できるようになっていました。Claude Code が ap-northeast-1 の global.anthropic.* 推論プロファイルに、モデルごとに小さなリクエストを 1 つずつ送りました。ランタイム API は 2 つとも試しています。Bedrock のモデルを問わない会話 API である Converse と、Anthropic 独自のリクエストボディをそのまま渡す InvokeModel です。

モデルConverse outputConfig.textFormatInvokeModel output_config.format
Sonnet 4.5、4.6、5、5.5通る通る
Opus 4.5、4.6、4.7、4.8、5、5.5通る通る
Haiku 4.5、5.5通る通る
Fable 5、5.1試せず試せず

プロンプトでは、JSON ではない普通の文章での回答、スキーマにない enum の値、余分なキーをわざと求めました。それでも、通ったレスポンスはすべてスキーマに合っていました。Fable はアカウントの設定の問題で使えなかったため、構造化出力に対応しているかどうかは確かめていません。Sonnet 5 のモデルカードでも、Structured outputs が Supported に載るようになっています。

わざと壊したスキーマ {"type": "bogus"} は拒否されました。つまり Bedrock はこのフィールドを読んでおり、黙って捨ててはいません。

ValidationException: The json schema definition at outputConfig.textFormat.structure.jsonSchema is invalid.

Converse はスキーマを json.dumps() で JSON 文字列にしてから渡す必要があり、InvokeModel は Python の dict をそのまま受け取ります。そのため、2 つのリクエストの形はそのまま入れ替えられません。

Converse のリクエスト
client.converse(
modelId="global.anthropic.claude-sonnet-5",
messages=messages,
outputConfig={"textFormat": {
"type": "json_schema",
"structure": {"jsonSchema": {
"schema": json.dumps(schema), # 文字列。InvokeModel は dict を受け取る
"name": "translations",
}},
}},
)
InvokeModel のリクエスト
client.invoke_model(
modelId="global.anthropic.claude-sonnet-5",
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": max_tokens,
"messages": anthropic_messages,
"output_config": {"format": {
"type": "json_schema",
"schema": schema, # dict。Converse は JSON 文字列を受け取る
}},
}),
)

toolChoice、strict、textFormat はそれぞれ別のものを保証する

toolChoice が制御するのは、モデルが tools の一覧からツールを選ぶときの振る舞いです。モデルに任せる(auto)、どれかのツールを必ず呼ばせる(any)、指定したツールを必ず呼ばせる(tool)の 3 つがあります。ツール定義に付ける strict: true は、そのツールが呼ばれたときに、引数が必ずツールのスキーマに合うようにします。outputConfig.textFormat はツールを介さずに、モデルが返す JSON そのものを指定したスキーマに合わせます。

toolChoice・strict・textFormat がそれぞれ制御するもの3 つの設定と 4 つの問いの表。toolChoice はツールを呼ぶかどうかを制御し、ツールが必要で、any か tool なら呼び出しを強制し、構造化出力には含まれない。strict はツールの input の構造を制御し、ツールが必要で、呼び出しは強制せず、構造化出力の一部である。textFormat はテキスト応答の構造を制御し、ツールは不要で、強制する呼び出しがなく、構造化出力の一部である。toolChoicestrict: trueoutputConfig.textFormat制御するものツールが必要か呼び出しを強制するか構造化出力の一部かツールを呼ぶかどうかツールの inputの構造テキスト応答の構造はいはいいいえany か toolならするしない呼ぶツールがないいいえはいはい
呼び出しを強制できるのは toolChoice だけです。strict と textFormat は JSON をスキーマに合わせるもので、strict はツールの引数、textFormat はモデルの応答が対象です。

strict と textFormat は、構造化出力を構成する 2 つの機能です。どちらも制約付きデコーディングを使います。Bedrock がスキーマを文法にコンパイルし、モデルはその文法が許すトークンしかサンプリングできません。toolChoice はそれより古い機能で、構造化出力には含まれません。

制約付きデコーディングの仕組みBedrock が JSON Schema を文法にコンパイルする。生成の 1 ステップで、出力が {" まで進んだとき、文法が許すキーは translations だけで、text、Sure、Here などの候補は除外される。トークンは説明用の例。Bedrock がスキーマを文法にコンパイルし、文法がトークンを絞り込むJSON Schema文法生成の 1 ステップ{"translations": [{"id", "translated_text"}]}{" の次に書けるキーは"translations" だけここまでの出力{"次のトークンの候補translationstextSureHere選べる除外される
Bedrock はスキーマを文法にコンパイルし、文法が許さないトークンをすべて除外します。図中のトークンは説明用の例です。

強制 tool use と構造化出力は両立しないのか?

両立します。私は、構造化出力はツールとして実装されていて、強制 tool use と組み合わせないと効かないと思い込んでいました。実際には、構造化出力そのものはツールではなく、モデルが選べるトークンを制限する仕組みで、使い方は 2 つあります。strict はツール定義に付ける属性で、モデルがそのツールを呼んだときだけ引数を制限します。textFormat はリクエストに直接書き、応答全体を制限します。

前回の記事の回避策は、まさにこの思い込みの上に成り立っていました。回答のスキーマをそのまま入力スキーマにしたツールを定義し、toolChoice: {"tool": {"name": ...}} で強制し、その引数を読むというものです。強制で保証されるのは呼び出しだけで、その中身は一切確認されません。前回、キーが text で返ってきたのはそのためです。

strict は、従来の強制 tool use に欠けていた部分をちょうど補います。強制 tool use はモデルがこのダミーのツールを必ず呼ぶことを保証し、strict はモデルがダミーのツールに入れる input が、ツールに定義したスキーマに必ず合うことを保証します。つまり、ダミーのツールを実行して結果を得るのではなく、モデルがダミーのツールに入れた input から、スキーマに合った JSON をそのまま受け取ります。

ふつうのツールとダミーのツールの比較4 ステップを 2 段で比べた図。ふつうのツール:get_weather を渡し、モデルが location に "Tokyo" を埋め、コードが get_weather("Tokyo") を実行し、天気が最終結果になる。ダミーのツール:submit_translations を渡し、モデルが translations を埋めるが、ツールは実行されず、モデルが埋めた input そのものが最終結果になる。ふつうのツール:引数はツールへの入力ツールを渡すget_weather( location)モデルが引数を埋めるlocation: "Tokyo"コードが実行するget_weather("Tokyo")最終結果18°C、くもりダミーのツール:引数そのものが答えツールを渡すsubmit_translations( translations)モデルが引数を埋めるtranslations: [ … ]実行しないinput をそのまま読む最終結果{"translations": […]}
どちらもツールを渡し、モデルに引数を埋めさせます。ふつうのツールはそのあと実行されますが、ダミーのツールは実行されず、引数そのものが答えになります。

Sonnet 5 は strict と強制 tool use を両方含むリクエストを受け付け、毎回スキーマに合った結果を返しました。一方、textFormat はツールとは独立した機能で、ツールの一部ではありません。そのため、ツールがまったく要らず、呼び出しを強制する必要もありません。

toolChoice の auto・any・tool の意味

toolChoice の値は、モデルが tools の一覧をどう使うかを決めます。

  1. {"auto": {}}:どのツールを呼ぶかをモデルが決めます。ツールを選ばず、テキストで直接答えることもできます。toolChoice を省いたときは、この値が既定になります。

    tool_config = {
    "tools": [get_weather, get_time],
    "toolChoice": {"auto": {}}, # どちらのツールを呼んでも、テキストだけで答えてもよい
    }
  2. {"any": {}}:一覧のツールを少なくとも 1 つ必ず呼びます。テキストは生成しません。

    tool_config = {
    "tools": [get_weather, get_time],
    "toolChoice": {"any": {}}, # ツールは必ず呼ぶが、どちらを呼ぶかはモデルが決める
    }
  3. {"tool": {"name": ...}}:指定したツールを必ず呼びます。

    tool_config = {
    "tools": [get_weather, get_time],
    "toolChoice": {"tool": {"name": "get_weather"}}, # 必ず get_weather を呼ぶ
    }

後の 2 つが強制 tool use で、モデルは必ずツールを使い、テキストで直接答えることはできません。

小さな検証では、Opus 4.7、Opus 4.8、Haiku 5.5 が stopReason: end_turn でテキストで答え、ツールを呼びませんでした。strict と auto の組み合わせでは、スキーマが効くのは呼び出しが起きた場合だけです。

strict と textFormat は同じものか?

仕組みは同じで、対象が違います。AWS は 2 つを、「can be used independently or together in the same request」(単独でも、同じリクエストで両方を一緒にも使える) 仕組みだと説明しています。

strict と textFormat がスキーマをどこに書き、答えがどこに返るか2 段の図。strict: true では、スキーマは実行されない submit_translations というツールに書かれ、モデルは tool_use ブロックで答え、文法はその input に効く。コードはその input を答えとして読む。outputConfig.textFormat では、スキーマはリクエストに書かれ、文法は応答テキストそのものに効く。リクエストレスポンスstrict: true実行しないダミーのツールname: "submit_translations"strict: trueinputSchema: {…}tool_use ブロック(呼び出し)name: "submit_translations"input: {"translations": [ … ]}ツールは実行されず、コードは input をそのまま答えとして読む。textFormatリクエストのフィールドoutputConfig.textFormat: {schema: {…}}テキストブロック(応答){"translations": [ … ]}文法が効く範囲
strict はスキーマを実行されないダミーのツールに載せ、答えは tool_use の input として返ってきます。textFormat はスキーマをリクエストに載せ、答えは応答そのものです。
strict: trueoutputConfig.textFormat
書く場所ツール定義の中リクエストのトップレベル
制約する対象モデルがダミーのツールの input に入れる JSONモデルがテキストで答えるときの出力
効くタイミングモデルがそのツールを呼ぶと決めたときモデルがツールを呼ばず、テキストで直接答えるとき(ツールを渡していなければ毎回)

2 つを同じリクエストに入れても動き、それぞれが自分の担当する出力だけを制約します。Sonnet 5 に、strict 付きのツール、toolChoice: auto、textFormat をすべて指定したリクエストを 30 回送りました。返ってきたのは毎回ツール呼び出しかテキストのどちらか一方で、両方が同時に返ることはありませんでした。ただし auto では、モデルはほとんどの場合ツールを呼び、ツールを使わないよう明示した 10 回でも 4 回はツールを呼びました。つまり、モデルがツールを呼ばないと決めたときにだけ textFormat が効きます。

strict と textFormat は別々の経路を通ります。モデルがツールを呼ぶと決めたときは strict が、ツールを呼ばずテキストで直接答えるときは textFormat が効きます。どちらの経路でも、結果はスキーマに合います。ツール呼び出し 24 回もテキストの応答 6 回も、すべてそれぞれのスキーマに合っていました。

同じ JSON を求める 3 つのリクエストを実際のバッチで比べる

強制 tool use + strict、strict + auto、textFormat の 3 つの組み合わせを試しました。Sonnet 5 と Sonnet 5.5 の両方で崩れず、しかもツールを呼ぶかどうかの判断をモデルに残さなかったのは textFormat だけでした。

テストのバッチは 63 セグメントです。崩れる余地を残すため、text を禁止キーとして名指しするプロンプトのルールは外しました。各リクエストを Sonnet 5 で 20 回、Sonnet 5.5 で 10 回実行しました。

同じ JSON を求める 3 つのリクエストと、それぞれの結果3 枚のカード。S1 はツールを強制し strict を付ける。Sonnet 5 では 20 回中 20 回正しいが、Sonnet 5.5 では強制 tool use を拒否されて 400 になる。A1 はツールに strict を付け toolChoice を auto にする。Sonnet 5 では 20 回中 19 回で、1 回は空の配列を提出した。Sonnet 5.5 では 10 回中 10 回。T1 はツールなしで outputConfig.textFormat を送る。Sonnet 5 で 20 回中 20 回、Sonnet 5.5 で 10 回中 10 回。S1強制 tool use + strictリクエストtoolChoice: {tool: …}tools: [{strict: true}]Sonnet 520 / 20Sonnet 5.5400 で拒否失敗の原因Sonnet 5.5 と Opus 5.5 は強制 tool use を拒否するA1strict + autoリクエストtoolChoice: {auto: {}}tools: [{strict: true}]Sonnet 519 / 20Sonnet 5.510 / 10失敗の原因1 回、空の配列を提出した(スキーマ上は正しい)T1textFormat(ツールなし)リクエストoutputConfig.textFormat(no tools)Sonnet 520 / 20Sonnet 5.510 / 10一度も失敗しなかったツールがないので呼び忘れが起きない
S1 はツールを強制し、A1 は呼ぶかどうかをモデルに任せ、T1 はツールをまったく使いません。一度も失敗しなかったのは T1 だけです。
リクエストモデル正しい形所要時間の中央値
強制 tool use、プロンプトのルールありSonnet 520 回中 20 回25 秒
強制 tool use、プロンプトのルールなしSonnet 520 回中 15 回25 秒
S1: 強制 tool use + strictSonnet 520 回中 20 回24 秒
A1: strict + autoSonnet 520 回中 19 回24 秒
T1: textFormatSonnet 520 回中 20 回24 秒
S1: 強制 tool use + strictSonnet 5.5400 で拒否—
A1: strict + autoSonnet 5.510 回中 10 回36 秒
T1: textFormatSonnet 5.510 回中 10 回36 秒

ルールなしで崩れた 5 回は、どれもすべてのキーが text になっていました。前回の記事で追いかけたのと同じ崩れ方です。S1 は最小の変更でこれを直せます。しかし、Sonnet 5.5 と Opus 5.5 は強制 tool use そのものを拒否します。

tool_choice: type "tool" and "any" are not supported for this model.

A1 が唯一失敗した 1 回では、モデルは 2 秒で出力 75 トークンだけを生成し、空の translations 配列を渡してツールを呼びました。空の配列もスキーマには合うので、strict はこれを通してしまいました。最終的に私は T1 を選びました。A1 は auto なので、モデルがツールを呼ばないこともありえますが、T1 にはそもそもツールがないので、その心配がありません。

構造化出力でもコード側に残る確認

スキーマの保証には範囲があり、パイプラインの Bedrock アダプターは、その外側を 1 つずつ確認し続けています。

  1. max_tokens で途切れたレスポンスや拒否のレスポンスは、スキーマに合わないことがあります。Anthropic の構造化出力のページに、両方についてそう書かれています。Converse には refusal の停止理由がないので、アダプターは stopReason が end_turn のときだけ応答を読みます。
  2. 要素の数と id は制約されません。minItems(配列に最低いくつの要素が必要か)は 0 か 1 しか受け付けず、63 個を必須にはできません。A1 の空の配列が通ったのもこのためで、入力の id との照合は残しています。
  3. すべてのオブジェクトに "additionalProperties": false が必要です。ほかの値を指定すると、Bedrock は 400 を返します。

強制 tool use をやめたら adaptive thinking が動き出した

強制 tool use が Sonnet 5 に thinking をさせておらず、textFormat にしたことで再び thinking するようになりました。adaptive thinking は Sonnet 5 での Claude の既定の動作です。回答の前に推論するかどうか、どれだけ推論するかをモデルがリクエストごとに決め、推論のトークンは maxTokens に含まれます。

textFormat で 1 つの文書をまるごと翻訳したところ、ほとんどのバッチは以前と同じ時間で終わりましたが、3 つは明らかに遅くなりました。最も遅いバッチは合計 6 回試行されました。そのうち 5 回が 120 秒の read_timeout を超えたためです。boto3 の下で動く AWS SDK である botocore は、そのたびに例外を出さずに再送しました。そのため、タイムアウトした試行はどれも課金され、どれも失敗としてログに残りませんでした。

adaptive thinking の有無によるバッチごとの所要時間2 つのバッチの横棒グラフで、方式ごとに 1 行。143 セグメントのバッチは、強制 tool use で 47 秒、thinking ありで 6 回の試行に 731 秒、thinking なしで 45 秒。80 セグメントのバッチは 25 秒、74 秒、25 秒。thinking ありでは、最後以外の試行がすべて 120 秒の read_timeout を超えて再送された。タイムアウトを超えて再送された試行read_timeout 120 秒143 セグメントのバッチ強制 tool usetextFormat(thinking あり)textFormat(thinking なし)47 秒731 秒・6 回45 秒80 セグメントのバッチ強制 tool usetextFormat(thinking あり)textFormat(thinking なし)25 秒74 秒25 秒0120240360480600720秒
143 セグメントと 80 セグメントの 2 つのバッチを、強制 tool use、thinking ありの textFormat、thinking なしの textFormat で比べたものです。thinking ありでは、143 セグメントのバッチが 120 秒のタイムアウトを 5 回超え、6 回目の試行でようやく終わりました。

遅いバッチを、タイムアウト 300 秒、再送なしの設定で、それぞれさらに 3 回ずつ流すと、原因を切り分けられました。

リクエスト所要時間停止理由出力トークン推論が入った実行
強制 tool use42〜47 秒6 回中 6 回 tool_use6,767〜7,3049 回中 0 回
textFormat、thinking あり45〜138 秒6 回中 2 回 max_tokens6,854〜16,3849 回中 5 回
textFormat、thinking なし41〜46 秒6 回中 6 回 end_turn6,598〜6,8719 回中 0 回

所要時間、停止理由、出力トークンは最も遅い 2 バッチ(計 6 回)だけを集計し、最後の列は 3 バッチの全 9 回を集計しています。

JSON そのものの長さはどのモードでも同じで、1 バッチでおよそ 13,000 文字でした。つまり、増えたトークンは推論の分です。143 セグメントのバッチは、文法がコンパイルされてキャッシュされたずっと後の 2 回目の通し実行でも同じように遅かったので、コンパイルが遅れの原因ではありません。

原因は、Anthropic の thinking のトラブルシューティングのページに書かれています。「A forced tool_choice ({"type": "any"} or a named tool) also returns no thinking block: the response starts with the tool call.」(強制の tool_choice では thinking ブロックも返らず、レスポンスはツール呼び出しから始まる) とあります。強制 tool use を使っていた間、このパイプラインはずっと thinking なしで翻訳していました。そのため、textFormat に切り替えたあとは、タイムアウトや出力トークンの増加を避けるために thinking を切る必要があります。

thinking を切る設定と、それを拒否するモデル

thinking: {"type": "disabled"} を送ると、textFormat は強制 tool use のときの数字に戻りました。Converse では、Anthropic 固有のフィールドを additionalModelRequestFields で渡します。

Converse のリクエスト
client.converse(
# …
additionalModelRequestFields={"thinking": {"type": "disabled"}},
outputConfig={"textFormat": {"type": "json_schema", "structure": {"jsonSchema": {
"schema": json.dumps(schema), "name": "translations",
}}}},
)

23 バッチで試したところ、すべてのレスポンスがスキーマに合い、すべての id が返ってきました。最も長い呼び出しは 45 秒で、再送は 1 回もありませんでした。パイプラインの評価スイートでの翻訳品質も、強制 tool use 版の実行ごとのばらつきの範囲に収まりました。

この設定は、すべてのモデルが受け付けるわけではありません。Anthropic のモデル別の表は次のとおりです。

モデルthinking: {"type": "disabled"}
Sonnet 5通る
Opus 5、Haiku 5.5effort が high 以下なら通る
Sonnet 5.5400。代わりに {"type": "between_tools"} を送る
Opus 5.5、Fable 5、Fable 5.1400。thinking は常に有効

disabled は thinking を完全に切ります。between_tools を受け付けるのは Sonnet 5.5 だけで、切るのは応答前の thinking です。ただし、ツール呼び出しの合間にモデルが書く短い進捗メモは、thinking ブロックとして返ってきます。ドキュメントの説明どおりなら、このパイプラインのようにツールを使わず 1 回だけ呼ぶリクエストでは、両者に違いはないはずです。

パイプラインはモデル ID を実行時に SSM Parameter Store から読むので、再デプロイなしで Sonnet 5.5 に切り替えられます。そして切り替えると、その後のバッチはすべて 400 で失敗します。そこで私は、thinking を切った状態で確かめていないモデル ID を、Bedrock を呼ぶ前にアダプターが拒否するようにしました。

bedrock adapter
THINKING_DISABLED_MODELS = frozenset({"claude-sonnet-5"})
def __init__(self, *, model_id: str) -> None: # ほかの引数は省略
if model_id.rsplit("anthropic.", 1)[-1] not in THINKING_DISABLED_MODELS:
raise ConfigurationError(
f"thinking を切って呼べるか確かめていないモデル: {model_id}"
)

このエラーは、Bedrock の 400 が延々と続く形ではなく、モデル ID を含んだ 1 つのメッセージとして表に出ます。モデルを追加するには、先に測定が必要になります。

まとめ

問題対処
強制 tool use が保証するのは呼び出しだけで、中身ではないツールを使わない outputConfig.textFormat。プロンプトのルールなしで 20 回中 20 回
Sonnet 5.5 と Opus 5.5 が強制 tool use を拒否する同じ textFormat のリクエスト。Sonnet 5.5 で 10 回中 10 回
強制 tool use をやめると adaptive thinking が再び動くthinking: {"type": "disabled"}。強制 tool use のときの所要時間とトークン数に戻る
max_tokens での途切れ、拒否、要素の数、idend_turn のときだけ読み、id の照合を残す
disabled を拒否するモデルへの切り替えBedrock を呼ぶ前にモデル ID 付きで失敗させる許可リスト

Bedrock の Claude Sonnet 5 は、応答に JSON Schema を指定できるようになりました。ただ、強制 tool use から離れると、モデルが thinking するかどうかも変わります。そのため、切り替える前に、JSON の形だけでなく、いちばん重いバッチで所要時間と出力トークンを測ってください。

参考リンク

この記事をシェア