Bedrock 的結構化輸出只支援到 Claude 4.6 — 用一行提示詞與錯誤回傳守住 Sonnet 5 的 JSON 輸出

Amazon Bedrock 上 Claude Sonnet 5 以後的模型不支援 strict tool use。少了它之後,我靠著一行提示詞規則,並且以 tool_result 回傳錯誤後重試,讓模型的 JSON 輸出維持了正確的格式。

白色卡片上的 Amazon Bedrock 圖示,藍綠色圓角方塊中有一個白色線條繪製的大腦,由六邊形輪廓與末端帶小圓點的電路線構成,旁邊是深藍色的 Amazon Bedrock 字樣
本頁目錄

引言

我負責的一條文件翻譯工作流,有一次 batch 在執行時因為 ValueError: 応答要素の形式が不正: ['id', 'text'] 而失敗,這代表回應的 JSON 結構不正確。這條工作流會強制 Claude Sonnet 5 使用一個工具來回答,工具要求模型回傳含有 id 和 translated_text 這兩個 key 的 JSON。但在某幾次執行裡,它把 translated_text 寫成了 text,回傳了 {id, text} 的 JSON。

strict tool use 正是為了不讓這種問題發生而存在的。可是 Amazon Bedrock 上 Sonnet 5 以及 Opus 4.6 之後的所有 Claude 模型都不支援 strict tool use。最後為了維持住 JSON 的輸出格式,我在提示詞的最後加上一行規則提醒 JSON 的輸出格式,並且把 JSON 格式驗證失敗的訊息包裝成工具結果,連同格式錯誤的回應一起送回給模型、讓它重新生成一次。在 Sonnet 5 上,這兩種方法都發揮了作用。

Bedrock 上的結構化輸出支援到第幾世代?

結構化輸出這項功能會把 JSON Schema 編譯成文法,讓模型在生成時只能選出文法允許的 token,這樣一來,輸出就不可能偏離要求的 JSON Schema。Bedrock 提供了兩種用法,可以分開也可以一起使用:

  1. strict: true 加在工具定義上,讓模型依照該工具 input_schema 裡的 schema 填寫參數
  2. output_config.format 自帶一份 JSON Schema,約束模型直接回答的 JSON,不需要經過工具

這兩種方法都能保證模型的 JSON 輸出格式,只是約束的對象不同。這兩種我都試過了,但在 Bedrock 上,Sonnet 5 兩種都不支援。

我把同一個最小請求送到 ap-northeast-1 的每一個 Claude 推論設定檔之後,是否支援 strict tool use,在 4.6 世代之後出現了一道明顯的分界:

strict_probe.py
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.5ValidationException
Sonnet 5、Sonnet 5.5ValidationException

每一次拒絕回傳的都是同一段錯誤訊息。output_config.format 在 Sonnet 5 上也以同樣的方式失敗了,而 Haiku 4.5 則回傳了符合要求 schema 的 JSON:

ValidationException: tools.0.custom.strict: Extra inputs are not permitted
ValidationException: output_config.format: Extra inputs are not permitted

Sonnet 5 的模型卡也證實了這一點:在 Capabilities and Features 表格裡,不論是 bedrock-runtime 還是 bedrock-mantle 的 endpoint,Structured outputs 這一項都顯示 Not Supported。Fable 5 和 5.1 則完全無法測試,因為兩者都以 data retention mode 'default' is not available for this model 拒絕了請求。

各 Claude 模型在不同平台上的結構化輸出與強制 tool use這張表列出了 6 組 Claude 模型與 3 個欄位。在 Bedrock 上,只有 Haiku 4.5 以及 4.5、4.6 世代的 Sonnet 與 Opus 支援 strict tool use,Opus 4.7 以後的模型與 Sonnet 5、5.5 都會回傳錯誤。在 Anthropic API 上,每個世代都支援結構化輸出。強制 tool_choice 除了 Sonnet 5.5 與 Opus 5.5 以外都能用。本文使用的 Sonnet 5 在兩個平台都能強制 tool use,但結構化輸出只有 Anthropic API 才支援。模型Bedrockstrict / output_configAnthropic API結構化輸出Anthropic API強制 tool_choiceHaiku 4.5✓ 支援✓ 支援✓ 支援Sonnet 4.5 · Opus 4.5✓ 支援✓ 支援✓ 支援Sonnet 4.6 · Opus 4.6✓ 支援✓ 支援✓ 支援Opus 4.7 · 4.8 · 5✗ 不支援✓ 支援✓ 支援Sonnet 5本文使用✗ 不支援✓ 支援✓ 支援Sonnet 5.5 · Opus 5.5✗ 不支援✓ 支援✗ 不支援
同一批模型在 Bedrock 與 Anthropic 自家 API 上的比較。保證輸出符合 schema 的功能與強制 tool use,分別從不同的世代開始便不再支援。

這個限制來自 Bedrock 還是 Anthropic?

Anthropic 自家的 API 在 Sonnet 5 上是能夠使用結構化輸出的。Anthropic 文件中的結構化輸出頁面把 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 自己維護的,所以支援與否取決於部署方式,而不是模型本身:同一個模型換了平台或推論設定檔,結果就可能不同。AWS 還警告,透過印度的推論設定檔 (in.anthropic) 呼叫 Claude Haiku 4.5 時不支援結構化輸出,而同一個模型透過其他推論設定檔都支援結構化輸出。

至於為什麼每一代新的 Claude 在 Bedrock 上推出時都沒有這項功能,以下只是我的猜測,官方文件裡並沒有註明,但最合理的解讀是:每個模型都得在 Bedrock 自己的文法引擎裡個別開啟,而 AWS 還沒有替 4.6 世代之後的模型開啟這項功能。AWS 沒有說明原因,也沒有給出時程,但我推測之後會逐漸開放支援。

在 Bedrock 上改用 Anthropic SDK

anthropic[bedrock] 提供了一個 AnthropicBedrock 用戶端,使用的是 Anthropic 原生的 Messages API 格式,看起來好像能規避 Bedrock 自家通用 API Converse 的限制。但它其實只是替請求簽章,然後送到同一個 bedrock-runtime 的 InvokeModel endpoint。我之前本來就是透過 invoke_model 向 Sonnet 5 送出 strict tool use 的請求,所以就算換了 anthropic[bedrock] 也行不通。

為什麼強制 tool use 還是回傳了錯誤格式的 JSON?

tool_choice: {"type": "tool"} 保證的只是模型會呼叫這個工具,但並不會檢查呼叫的內容。Anthropic 的結構化輸出頁面也明白寫著,少了結構化輸出,「Claude can generate malformed JSON responses or invalid tool inputs」,其中也包括缺少必填欄位 (「Missing required fields」) 的情況。

我設計的工作流會把每個 batch 整理成由 {"id", "text"} 物件組成的 JSON 陣列,放進 user 訊息送出,並要求模型回傳符合 {"id", "translated_text"} 格式的 JSON。以其中一份 Word 文件為例,一個 batch 就有 63 個這種形式的物件。

只要模型在第一個物件把 key 寫成了 text,63 個物件的 key 就全都會寫成 text。這些 text 欄位裡的值都是正確的英文譯文,所以模型其實已經正確翻完了,只是把 key 的名稱標錯了而已。

出錯的比例會隨提示詞改變,而且翻譯那份 Word 文件時,隨時都有可能出現同樣的錯誤:

提示詞所有 key 都變成 text 的呼叫次數
不加「譯文長度控制在原文 2 倍左右」的規則30 次中 2 次
加上這條規則 (與 production 環境相同)40 次中 8 次

在 production 環境的設定下,每五次就有一次出錯,而用同一個提示詞直接重試,出錯的比例也一樣。

明確禁止 text 這個 key 的提示詞規則

第一個對策是在系統提示詞的最後,加上一行規則:用 submit_translations 回傳結果,每個物件的 key 必須是 id 和 translated_text,並且絕對不能在輸出裡使用輸入的 key text。

系統提示詞的最後一行
- 結果は `submit_translations` ツールで返すこと。`translations` の各要素のキーは `id` と `translated_text` にする。入力のキー `text` を出力に使ってはいけない (訳文は `translated_text` に入れる)
中文翻譯 (未經測試)
- 結果請用 `submit_translations` 工具回傳。`translations` 的每個元素,key 必須是 `id` 和 `translated_text`。不能在輸出裡使用輸入的 key `text` (譯文要放進 `translated_text`)

只寫出正確的 key、沒有提到 text 的版本,還是出錯了:

規則所有 key 都變成 text 的呼叫次數
不加規則40 次中 8 次
只要求寫正確的 key40 次中 4 次
要求寫出正確的 key,並明確禁止使用 text 這個 key40 次中 0 次

原本的提示詞有 4,885 個 token,加上這條規則之後,每次呼叫多了 82 個輸入 token。不過 40 次都沒出錯,並不代表出錯率就是 0:樣本只有 40 次,統計上只能推論真正的出錯率大約在 7.5% 以下。所以還需要第二道防線。

把錯誤包裝成 tool_result 回傳給模型

不要重送同一個 batch,而是把出錯的回答連同錯誤訊息一起送回給模型。我以前在一個 text-to-SQL 專案裡,就是這樣處理無效的 SQL:執行查詢、把資料庫的錯誤回傳給模型,讓模型參考錯誤訊息再寫一次。

Anthropic 的文件對工具呼叫也介紹了同樣的做法:用一個標示為錯誤的 tool_result 把對話接下去,Claude 就會再試著使用一次工具 (「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 附上一個錯誤訊息說明為什麼出錯:

Bedrock 轉接器
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 這段文字寫出了哪裡錯、該怎麼改:收到的 key 是 ['id', 'text']、每個物件都需要 id 和 translated_text、所有物件都要重新提交一次。assistant 訊息的 content 必須原封不動地送回去,因為開啟 adaptive thinking 時,裡面可能帶著 reasoningContent 區塊,這些區塊也必須一起送回。

之前測試中 AI 給出的錯誤回應,8 件全都在第一次重試後就修好了。一次重試的輸入 token 數比原本的呼叫多了大約 72%,但花的時間基本一樣,都是 24 秒。

在寫入回應快取之前先完成重試

這條工作流會把每個模型回應存進 DynamoDB,key 是完整提示詞的雜湊值。這樣一來,SQS 重新傳遞同一個工作時,已經成功的 batch 就能直接從快取取回,不必再讓 AI 模型跑一遍。上面兩個對策都必須在寫入快取之前完成,否則快取可能會回傳之前儲存的錯誤回應,或是讓後面的 batch 都對不上快取。

如果快取在檢查格式之前就先儲存回應,就會連出錯的 batch 也存起來,之後每次重新傳遞都會從快取取回這個錯誤的回應。第 2 次和第 3 次 SQS 重新傳遞的日誌裡,都顯示同一個 key 命中了快取,接著又因為同一個 ValueError 失敗。只要在儲存之前、以及從快取讀出的時候都檢查格式,就能防止這個問題發生。

第二個陷阱是會改變提示詞的重試:把錯誤回傳給模型時,請求裡就多了出錯的回應和錯誤訊息。而且 Sonnet 5 只接受預設的 temperature 1.0,所以每重新呼叫一次,得到的譯文都會不一樣。

另外,每個 batch 的提示詞都會帶著前一個 batch 譯文的結尾當作脈絡。用改過的提示詞修好的 batch,會以改過的提示詞當 key 存進快取;但下一次重新傳遞時,工作流只會用原本的提示詞組出 key,所以永遠找不到之前對應的快取。這個 batch 只好重新翻譯一次,新的譯文又改變了下一個 batch 的脈絡,於是快取未命中就一路往後擴散。

用改過的提示詞重試,會讓後面每個 batch 都無法命中快取五個 batch 排成兩列。第 1 次傳遞時,batch 0 已儲存,batch 1 格式錯誤,改用新的提示詞重新請求並存在重試的 key 下,batch 2 已儲存,batch 3 因 Bedrock 錯誤失敗,batch 4 沒有執行。第 2 次傳遞時,batch 0 命中快取,batch 1 組出原本的 key 而未命中,產生了新的譯文,batch 2 到 4 因脈絡改變而未命中。batch 0batch 1batch 2batch 3batch 4第 1 次傳遞已儲存格式錯誤,改用新的提示詞重新請求存在重試的 key 下已儲存Bedrock 錯誤這次傳遞結束未執行第 2 次傳遞命中快取原本的 key未命中,產生新譯文脈絡改變未命中脈絡改變未命中脈絡改變未命中SQS 重新傳遞工作,每個 batch 重新組出提示詞並查詢自己的 key每個提示詞都帶著前一個 batch 的譯文,所以只要一份譯文改變,後面所有的 key 都會跟著改變
重試時改了提示詞,修好的 batch 就會存到另一個 key 底下,下一次重新傳遞時查不到,只好重新翻譯。這時重新翻譯出的譯文又和之前不同,導致後面每個 batch 的脈絡也跟著改變了。

我在一份分成五個 batch 的模擬文件裡,讓 batch 1 出錯一次,並讓第 1 次傳遞在 batch 3 中斷。結果第 2 次傳遞時,模型並沒有只處理 batch 3 和 4,而是重新處理了 batch 1 到 4。

把重試移到 Bedrock 轉接器裡面之後,兩個陷阱就一次解決了。負責快取的那一層只會用原本的提示詞呼叫轉接器一次。轉接器負責驗證格式,必要時把錯誤回傳給模型重試,最後回傳格式正確的回應,或是仍然出錯的回應。快取只會把格式正確的回應存在原本的 key 底下。

Bedrock 轉接器先驗證格式,必要時把錯誤回傳給模型重試,回應快取只存下格式正確的結果Translator 呼叫回應快取。快取的 key 是原始提示詞的雜湊值,只會儲存格式正確的回應。快取之內是 Bedrock 轉接器。轉接器呼叫 Converse 並檢查格式,正確的話就回傳給快取。格式錯誤的話,就以 tool_result 錯誤回傳給模型並重新呼叫,最多一次。Translator回應快取key:原始提示詞的雜湊值只儲存格式正確的回應Bedrock 轉接器呼叫 Converse檢查格式以 tool_result 錯誤回傳給模型格式正確的回應正確錯誤最多一次回傳給快取
快取只會用原本的提示詞呼叫轉接器一次,驗證格式和把錯誤回傳給模型重試,都在轉接器裡完成。

總結

問題處理方式
Bedrock 上的 Claude Sonnet 5 以及 Opus 4.6 之後的所有模型,都不支援 strict: true 和 output_config.formatBedrock 這邊目前沒有辦法,要在自己的程式裡驗證
強制 tool use 把 translated_text 寫成 text,40 次中 8 次明確禁止 text 的系統提示詞規則:40 次中 0 次
仍然出錯的呼叫附上該用的 key,以 toolResult 錯誤回傳:8 件中 8 件修好
以提示詞為 key 的回應快取儲存前先驗證,並在 Bedrock 轉接器裡重試,讓 key 維持原本的值

Bedrock 上能不能用結構化輸出,取決於部署方式,而不是模型本身,所以挑選模型之前,最好先到模型卡上確認。就算沒有這項功能,只要自己驗證輸出,並在出錯時把錯誤回傳給模型重試,也能得到相對穩定的輸出,代價只是出錯的時候 batch 需要多呼叫一次。

參考連結

分享這篇文章