Bedrock の構造化出力は Claude 4.6 で止まる — Sonnet 5 の JSON をプロンプト 1 行とエラーの差し戻しで守る
Amazon Bedrock は Claude Sonnet 5 で strict tool use を拒否します。プロンプトの 1 行と tool_result によるエラーの差し戻しで、翻訳の JSON を正しい形に保ちました。

目次
はじめに
私が関わっている文書翻訳のパイプラインで、バッチが ValueError: 応答要素の形式が不正: ['id', 'text'] で失敗するようになりました。応答の要素のキーが違う、というエラーです。Claude Sonnet 5 には、{id, translated_text} を返すスキーマのツールで答えるよう強制していました。ところが一部の呼び出しで、入力を写したように、すべてのキーを text にして返していました。
strict tool use はこれを起こさないための機能です。しかし、Amazon Bedrock は Sonnet 5 と、Opus 4.6 より後のすべての Claude モデルで strict tool use を拒否します。JSON の形を守ったのは、プロンプトに足した 1 行のルールと、検証のエラーをツールの結果としてモデルに返す出し直しでした。Sonnet 5 では、どちらも効果がありました。
Bedrock で構造化出力はどこまで使えるか
構造化出力は、JSON Schema を文法にコンパイルし、その文法が許すトークンだけをモデルにサンプリングさせる機能です。そのため、出力がスキーマを外れることがありません。Bedrock では 2 つの形で使え、どちらか一方だけでも、同じリクエストで両方を指定しても構いません。strict: true はツール定義に付け、呼び出しの引数をそのツールの input_schema に従わせます。output_config.format は自前の JSON Schema を持ち、ツールを介さずにモデルが本文として返す JSON の回答を制約します。どちらも保証は同じで、制約する対象が違うだけです。Sonnet 5 では両方を試しました。
ap-northeast-1 のすべての Claude の推論プロファイルに同じ最小限のリクエストを送ると、4.6 世代を境にモデルがきれいに分かれました。
body = { "anthropic_version": "bedrock-2023-05-31", "max_tokens": 200, "messages": [{"role": "user", "content": "a に hello を入れて"}], "tools": [{"name": "t", "description": "x", "input_schema": schema, "strict": True}], "tool_choice": {"type": "tool", "name": "t"},}client.invoke_model(modelId=f"global.anthropic.claude-{m}", body=json.dumps(body))| モデル | Bedrock での strict: true |
|---|---|
| Haiku 4.5、Sonnet 4.5、Opus 4.5 | 通る |
| Sonnet 4.6、Opus 4.6 | 通る |
| Opus 4.7、Opus 4.8、Opus 5、Opus 5.5 | ValidationException |
| Sonnet 5、Sonnet 5.5 | ValidationException |
拒否はどれも同じメッセージでした。output_config.format も Sonnet 5 では同じように失敗し、Haiku 4.5 はスキーマどおりの JSON を返しました。
ValidationException: tools.0.custom.strict: Extra inputs are not permittedValidationException: output_config.format: Extra inputs are not permittedSonnet 5 のモデルカードも同じことを書いています。Capabilities and Features の表では、Structured outputs が bedrock-runtime と bedrock-mantle の両方のエンドポイントで Not Supported になっています。Fable 5 と 5.1 は試すこともできませんでした。どちらも data retention mode 'default' is not available for this model でリクエストを拒否しました。
制約は Bedrock 側か、Anthropic 側か
Anthropic 自身の API では、Sonnet 5 でも構造化出力が使えます。構造化出力のページは、対応モデルとして claude-sonnet-5、claude-opus-5-5、claude-sonnet-5-5 を挙げています。そのうえで、Amazon Bedrock で使えるのはもっと狭い範囲 (Opus 4.6、Sonnet 4.6、Sonnet 4.5、Opus 4.5、Haiku 4.5) だと注記しています。
文法をコンパイルしているのは Bedrock 自身で、リクエストごとの JSON Schema から作っています。Bedrock の構造化出力のページは「Request workflow」で「Amazon Bedrock compiles the grammar, which may take up to a few minutes」と書いています。
文法のパイプラインは Bedrock 独自の基盤です。そのため、対応はモデルごとではなく、提供先 (プラットフォームや推論プロファイル) ごとに決まります。同じページには、構造化出力が「aren’t supported for Claude Haiku 4.5 through the India (in.anthropic) inference profile」という注意もあります。Haiku 4.5 は、ほかの推論プロファイルではどこでも使えるモデルです。
新しい Claude の世代が構造化出力なしで Bedrock に来る理由は、文書には書かれておらず、ここからは推測です。2 つの引用から最も素直に読めるのは、モデルごとに Bedrock 独自の文法エンジンで有効にする必要がある、ということです。そう読むと、4.6 世代より新しいモデルではそれがまだ済んでいないことになります。AWS は理由も時期も示していません。ただ、今後は順次対応されていくと推測しています。
Bedrock で Anthropic の SDK を使う場合
anthropic[bedrock] には、Messages API の形式でリクエストを送る AnthropicBedrock クライアントがあります。Bedrock 独自の、モデルを問わない会話 API である Converse を迂回する手段に見えますが、このクライアントはリクエストに署名して、同じ bedrock-runtime の InvokeModel エンドポイントに送ります。上の検証も invoke_model で同じ形のリクエストを送っており、Bedrock が拒否したのはまさにそのリクエストです。
強制 tool use でもキーが違った理由
tool_choice: {"type": "tool"} が保証するのは、モデルがツールを呼ぶことだけです。呼び出しの中身までは見ません。Anthropic の構造化出力のページも、構造化出力を使わない場合は「Claude can generate malformed JSON responses or invalid tool inputs」と明記しており、その例に「Missing required fields」を挙げています。
このパイプラインは、各バッチを {"id", "text"} のオブジェクトの JSON 配列として user メッセージに入れて送ります。Word の文書 1 つで 63 個です。返してほしいのは {"id", "translated_text"} です。
モデルが最初の要素で text と書くと、63 個すべてが text になりました。値はきちんとした英訳でした。つまりモデルは翻訳を済ませていて、ラベルだけを間違えていました。
崩れる率はプロンプトによって変わり、その Word の文書 1 つでいつでも再現できました。
| プロンプト | すべてのキーが text になった呼び出し |
|---|---|
| 「訳文の長さは原文の 2.0 倍程度」のルールなし | 30 回中 2 回 |
| このルールあり (本番と同じ) | 40 回中 8 回 |
本番の設定では 5 回に 1 回崩れました。同じプロンプトでそのまま出し直しても、崩れる率は同じです。
禁止するキーを名指しするプロンプトのルール
1 つ目の対策は、システムプロンプトの末尾、ほかのすべての指示の後ろに足す 1 行です。結果は submit_translations で返すこと、キーは id と translated_text にすること、入力のキー text は使わないことを書いています。
- 結果は `submit_translations` ツールで返すこと。`translations` の各要素のキーは `id` と `translated_text` にする。入力のキー `text` を出力に使ってはいけない (訳文は `translated_text` に入れる)正しいキーだけを書き、text に触れない版では、まだ崩れました。
| ルール | すべてのキーが text になった呼び出し |
|---|---|
| なし | 40 回中 8 回 |
| 正しいキーだけ | 40 回中 4 回 |
正しいキーと、text の禁止 | 40 回中 0 回 |
このルールのコストは 1 回あたり入力 82 トークンで、元のプロンプトは 4,885 トークンです。ただ、40 回中 0 回だからといって、崩れる率が 0 とは言えません。40 回の試行から言えるのは、本当の率がおよそ 7.5% 未満だということまでです。そのため、すり抜ける呼び出しに備えて 2 段目の守りが要ります。
エラーを tool_result として返す
同じバッチを送り直すのではなく、崩れた答えをエラーと一緒に返します。私は text-to-SQL のプロジェクトで、無効な SQL をこう扱っていました。クエリを実行し、データベースのエラーを返し、それを見たうえでモデルにもう一度書かせる、というやり方です。
Anthropic もツール呼び出しについて同じ手を説明しており、「continue the conversation forward with a tool_result that indicates the error, and Claude will try to use the tool again」と書いています。
Bedrock の Converse API では、崩れた assistant のターンをそのまま会話に足し、その toolUse にエラーで答えます。
bad_use = next(b["toolUse"] for b in bad_content if "toolUse" in b)messages = [ {"role": "user", "content": [{"text": user}]}, {"role": "assistant", "content": bad_content}, # 返ってきた応答を加工せずに入れる {"role": "user", "content": [{"toolResult": { "toolUseId": bad_use["toolUseId"], "content": [{"text": feedback}], "status": "error", }}]},]client.converse(modelId=model_id, system=[{"text": system}], messages=messages, inferenceConfig=inference_config, toolConfig=tool_config)feedback の文には、何が違っていて、どうすればよいかを書きます。受け取ったキーは ['id', 'text'] だったこと、各要素には id と translated_text が必要なこと、すべての要素をもう一度提出することです。assistant の content は 1 バイトも変えずに返してください。adaptive thinking が有効だと reasoningContent のブロックが含まれることがあり、それも一緒に返す必要があるからです。
上の測定で実際に崩れた応答は、8 件すべてが 1 回目の出し直しで直りました。出し直しの呼び出しは、元の呼び出しより入力トークンが約 72% 多くかかり、所要時間はどちらも 24 秒でした。
応答キャッシュに書き込む前に出し直しを終える
このパイプラインは、完成したプロンプトのハッシュをキーにして、モデルの応答を DynamoDB にキャッシュします。そのため、再配信されたジョブは成功済みのバッチをキャッシュから読み直せるので、料金をもう一度払わずに済みます。上の 2 つの対策は、どちらもキャッシュに書き込む前に終わらせる必要があります。そうしないと、キャッシュが対策を打ち消してしまいます。
形を確かめる前に応答を保存するキャッシュは、崩れたバッチも保存してしまい、再配信のたびにそれを返します。2 回目と 3 回目の SQS の配信は、どちらも同じキーでのキャッシュヒットをログに出し、同じ ValueError で失敗しました。保存の前と読み出しのときに形を確かめれば、これは防げます。
2 つ目の落とし穴は、プロンプトを変えて出し直すことです。エラーをモデルに返すと、崩れた応答とエラーメッセージがリクエストに加わります。さらに Sonnet 5 は既定の 1.0 以外の temperature を受け付けないので、もう一度呼ぶたびに別の訳が返ります。
しかも、各バッチのプロンプトには、直前のバッチの訳文の末尾がコンテキストとして入っています。別のプロンプトで直したバッチは、次の配信では二度と組み立てられないキーで保存されるので、キャッシュに当たりません。そして新しく作り直した訳文が後ろのバッチのプロンプトを変え、キャッシュの外れが次々と後ろへ広がっていきます。
5 バッチの模擬文書で、バッチ 1 を 1 回だけ崩し、1 回目の配信がバッチ 3 で落ちる場合を再現しました。すると 2 回目の配信は、バッチ 3 と 4 だけでなく、バッチ 1 から 4 までモデルを呼びました。
出し直しを Bedrock のアダプターの中に移すと、2 つの落とし穴がまとめて解消します。キャッシュのラッパーは、元のプロンプトでアダプターを 1 回呼びます。アダプターは形を確かめ、必要ならエラーを返して出し直し、正しい応答か崩れた応答のどちらかを返します。キャッシュが保存するのは正しい応答だけで、キーは元のキーです。
まとめ
| 問題 | 対処 |
|---|---|
Bedrock が Claude Sonnet 5 と Opus 4.6 より後のすべてのモデルで strict: true と output_config.format を拒否する | Bedrock 側ではまだ手がないので、自分のコードで検証する |
強制 tool use で translated_text が text になる (40 回中 8 回) | text の禁止を名指しするシステムプロンプトのルール: 40 回中 0 回 |
| それでも崩れる呼び出し | 使うべきキーを添えて toolResult のエラーとして返す: 8 件中 8 件が直った |
| プロンプトをキーにした応答キャッシュ | 保存の前に検証し、出し直しはモデルのアダプターの中で行ってキーを元のままにする |
Bedrock の構造化出力が使えるかどうかは提供先ごとに決まるので、モデルを選ぶ前にモデルカードで確かめるのが確実です。使えない場合でも、エラーをモデルに返すバリデーターを置けば、出し直しが要るバッチで 1 回呼び出しが増えるだけで、保証の大部分を取り戻せます。
参考リンク
- Amazon Bedrock: Get validated JSON results from models (structured outputs)
- Amazon Bedrock model card: Claude Sonnet 5
- Claude API: Structured outputs (対応モデルの一覧と Bedrock での提供状況)
- Claude API: Strict tool use
- Claude API: Define tools (強制 tool use の制限を含む)
- Claude API: Handle tool calls (is_error によるエラーの扱いを含む)



