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 模型都支援結構化輸出,於是我想確認能不能換掉這套權宜做法。

經過實測後結論是可行的。拿掉提示詞規則之後,在一個實際的 batch 上測試時,強制 tool use 的 20 次呼叫中有 5 次出錯,outputConfig.textFormat 則一次都沒有。不過,切換之後必須關閉 thinking,才能維持原本的速度。原本強制 tool use 會讓 Sonnet 5 完全不做 thinking,不再強制之後因為做了 thinking,143 個段落的那個 batch,執行時間從 47 秒被拉長到了 731 秒。

現在 schema 改由 Bedrock 在模型生成時強制套用,請求也不必再依賴強制 tool use,而且 Claude 的 Sonnet 5.5 和 Opus 5.5 本來就不接受強制 tool use。

Bedrock 上的 Claude Sonnet 5 現在接受哪些設定?

我在 Bedrock 上能呼叫的每一個 Claude 模型,現在都能指定一份 JSON Schema 來約束模型給出的回答。我對 ap-northeast-1 的 global.anthropic.* 推論設定檔裡的每個模型各送出了一個小請求,並測試了兩種 API:Converse(Bedrock 不限模型的對話 API),以及 InvokeModel(直接使用 Anthropic 原生的請求主體格式)。

模型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無法測試無法測試

我還在提示詞裡故意要求模型用一般文字回答、使用 schema 以外的 enum 值,並多加一個 schema 裡沒有的 key。即使如此,只要請求被接受,模型給出的回應就都符合 schema。Fable 因為帳戶設定的問題無法使用,因此我沒有確認 Fable 是否支援結構化輸出。Sonnet 5 的模型卡現在也把 Structured outputs 列在 Supported 底下了。

我刻意送出一份格式錯誤的 schema {"type": "bogus"},結果請求被拒絕了,可見 Bedrock 確實會讀取這個欄位,而不是選擇直接忽略:

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

Converse 要求先用 json.dumps() 把 schema 轉成一段 JSON 字串再傳入,InvokeModel 則直接接受 Python 的 dict 物件,所以這兩種請求的寫法不能直接互換:

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)。加在工具定義上的 strict: true,則是讓模型呼叫這個工具時,傳入的參數一定符合工具的 schema。outputConfig.textFormat 不經過任何工具,直接讓模型回覆的 JSON 符合指定的 schema。

toolChoice、strict 與 textFormat 各自控制什麼這張表列出 3 個設定與 4 個問題。toolChoice 控制模型要不要呼叫工具,需要工具,設為 any 或 tool 時會強制呼叫,不屬於結構化輸出。strict 控制工具 input 的 JSON 結構,需要工具,不會強制呼叫,屬於結構化輸出。textFormat 控制文字回應的 JSON 結構,不需要工具,沒有可強制的呼叫,屬於結構化輸出。toolChoicestrict: trueoutputConfig.textFormat控制的對象是否需要工具是否強制呼叫是否屬於結構化輸出模型要不要呼叫工具工具 input 的JSON 結構文字回應的JSON 結構是是否any 或 tool時會強制不會沒有工具可呼叫否是是
只有 toolChoice 能強制模型呼叫工具。strict 和 textFormat 則是要求 JSON 符合 schema:strict 針對工具的參數,textFormat 則針對模型的回應。

strict 和 textFormat 是結構化輸出的兩種用法。兩者都採用受限解碼:Bedrock 會把 schema 編譯成文法,模型只能取樣文法允許的 token。toolChoice 是更早之前就有的設定,不屬於結構化輸出。

受限解碼的運作方式Bedrock 把 JSON Schema 編譯成文法。在生成的其中一步,輸出已經寫到 {" 時,文法只允許 translations 這個 key,text、Sure、Here 等候選都被遮罩。圖中的 token 都只是示意。Bedrock 把 schema 編譯成文法,再由文法過濾 tokenJSON Schema文法生成中的一步{"translations": [{"id", "translated_text"}]}在 {" 之後唯一允許的 key是 "translations"目前的輸出{"下一個 token 的候選translationstextSureHere允許被遮罩
Bedrock 把 schema 編譯成文法,文法不允許的 token 全部會被遮罩。圖中的 token 只是示意。

強制 tool use 和結構化輸出只能二選一嗎?

不是。我原本以為結構化輸出是以工具的形式實作的,必須搭配強制 tool use 才能發揮作用。但其實結構化輸出本身不是工具,而是限制模型能選哪些 token 的一種方法,有兩種用法:strict 是工具定義上的一個屬性,只在模型呼叫這個工具時限制它的參數;textFormat 則直接寫在請求上,限制整個回應。

前一篇文章的權宜做法,正是典型的強制 tool use 做法。首先定義一個工具,工具裡定義了模型回應時應該採用的 schema,再用 toolChoice: {"tool": {"name": ...}} 強制呼叫,相當於讓模型答題前再看一次小抄。但強制 tool use 只保證一定會呼叫該工具,而從不檢查輸出的內容,前一篇裡每個 key 都被寫成 text,就是因為這個原因。

strict 正好彌補了傳統強制 tool use 的不足。強制 tool use 保證模型一定會呼叫這個假工具,而 strict 保證模型填進這個假工具的 input 一定會符合工具裡定義的 schema。也就是說,這個做法並不是透過執行假工具來取得結果,而是直接從模型放入假工具裡的 input 取得符合 schema 的 JSON。

一般工具與假工具的比較這張圖用兩列比較 4 個步驟。一般工具:提供 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 的請求,並且每次輸出的結果都符合 schema。至於 textFormat,則是獨立於工具之外的功能,並不屬於工具的一部分,所以完全不需要工具,也不必強制呼叫。

toolChoice 的 auto、any 與 tool 是什麼意思?

toolChoice 的值會決定模型怎麼使用 tools 清單:

  1. {"auto": {}}:模型自己決定要呼叫哪個工具,甚至可以不選工具直接用文字回答。省略 toolChoice 時,API 預設就是這個值。

    tool_config = {
    "tools": [get_weather, get_time],
    "toolChoice": {"auto": {}}, # 可以呼叫任一個工具,也可以只用文字回答
    }
  2. {"any": {}}:模型至少要呼叫清單裡的一個工具,不會產生文字。

    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
    }

後兩種屬於強制 tool use,會要求模型必須使用工具,不能直接用文字回答。

在前面我對每個模型送出的小請求裡,Opus 4.7、Opus 4.8 和 Haiku 5.5 都以 stopReason: end_turn 直接用文字回答,沒有呼叫工具。所以用 strict 搭配 auto 時,只有在模型真的呼叫工具時,schema 才會生效。

strict 和 textFormat 是同一件事嗎?

底層機制相同,但約束的對象不同。AWS 的文件也寫到,strict 和 textFormat 可以單獨使用,也可以在同一個請求裡一起使用。

strict 與 textFormat 把 schema 放在哪裡,答案又從哪裡回來這張圖有兩列。使用 strict: true 時,schema 寫在一個名為 submit_translations、不會被執行的工具上,模型以 tool_use 區塊回答,文法套用在它的 input,程式再把 input 當成答案讀取。使用 outputConfig.textFormat 時,schema 寫在請求上,文法直接套用在回應文字本身。請求回應strict: true程式不會執行的假工具name: "submit_translations"strict: trueinputSchema: {…}tool_use 區塊(「呼叫」)name: "submit_translations"input: {"translations": [ … ]}沒有任何東西會執行這個工具,程式直接把 input 當成答案。textFormat請求上的欄位outputConfig.textFormat: {schema: {…}}文字區塊(回應本身){"translations": [ … ]}套用文法的範圍
strict 把 schema 放在一個不會被執行的假工具上,答案從 tool_use 的 input 回來;textFormat 則把 schema 放在請求上,答案就是回應本身。
strict: trueoutputConfig.textFormat
寫在哪裡工具定義裡請求的最上層
約束什麼模型填進假工具 input 的 JSON模型用文字回應時的輸出
何時生效模型選擇呼叫這個工具時模型選擇不呼叫工具、直接以文字回應時
如果沒有提供工具則是每一次都會被套用

兩者放在同一個請求裡也能運作,而且各自只約束自己那一部分的輸出。我對 Sonnet 5 送出了 30 次請求,同時設定了 strict 工具、toolChoice: auto 和 textFormat。每次回來的不是工具呼叫就是文字,從來沒有兩者同時出現過。不過在 auto 之下,模型多半會選擇呼叫工具,連明確要求不要使用任何工具的 10 次裡也有 4 次呼叫了工具,所以只有模型決定不呼叫工具時,textFormat 才會生效。

strict 和 textFormat 走的是兩條不同的路徑。模型選擇呼叫工具時,套用的是 strict;模型決定不呼叫工具、直接用文字回答時,套用的則是 textFormat。不論走哪一條,結果都符合 schema。24 次工具呼叫和 6 次文字回應,全都符合各自的 schema。

在實際的 batch 上,用三種請求取得符合同一份 schema 的 JSON

我嘗試了強制 tool use + strict、strict + auto、textFormat 三種組合。只有 textFormat 在 Sonnet 5 和 Sonnet 5.5 上都穩定成功,而且不會把要不要呼叫工具的決定權留給模型。

測試用的 batch 有 63 個段落。為了讓格式錯誤有機會出現,我拿掉了提示詞裡禁止使用 text 這個 key 的規則。每種請求在 Sonnet 5 上執行 20 次,在 Sonnet 5.5 上執行 10 次。

要求同一份 JSON 的 3 種請求,以及各自的結果這張圖有 3 張卡片。S1 強制呼叫工具並加上 strict:在 Sonnet 5 上 20 次都正確,在 Sonnet 5.5 上因為拒絕強制 tool use 而回傳 400。A1 在工具上加 strict,toolChoice 維持 auto:在 Sonnet 5 上 20 次中有 19 次正確,有 1 次提交了空陣列;在 Sonnet 5.5 上 10 次都正確。T1 不用工具,改送 outputConfig.textFormat:在 Sonnet 5 上 20 次、在 Sonnet 5.5 上 10 次都正確。S1強制 tool use + strict請求toolChoice: {tool: …}tools: [{strict: true}]Sonnet 520 / 20Sonnet 5.5400 拒絕失敗的原因Sonnet 5.5 與 Opus 5.5會拒絕強制 tool useA1strict + auto請求toolChoice: {auto: {}}tools: [{strict: true}]Sonnet 519 / 20Sonnet 5.510 / 10失敗的原因有 1 次提交了空陣列(符合 schema)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 次,每個 key 都被寫成了 text,和前一篇文章描述的是同一個錯誤。S1 能夠用最小的改動解決這個問題,但 Sonnet 5.5 和 Opus 5.5 會直接拒絕強制 tool use:

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

在 A1 唯一失敗的那一次,模型花了兩秒,只輸出了 75 個 token,用一個空的 translations 陣列呼叫了工具。空陣列也符合 schema,所以 strict 批准了這個結果。我最後選擇用 T1,理由是 A1 在 auto 模式下可能選擇不去呼叫工具。而 T1 本來就沒有工具,也就沒有這個風險。

結構化輸出保證不了什麼?

schema 只保證 JSON 的結構。結構以外的事情,仍由工作流裡負責呼叫 Bedrock 的那段程式(轉接器)逐一檢查:

  1. 因為 max_tokens 被截斷的回應,或是模型拒絕回答的回應,可能不符合 schema。Anthropic 的結構化輸出頁面對這兩種情況都有說明。Converse 沒有 refusal 這個停止原因,沒辦法直接分辨模型是不是拒絕回答,所以轉接器只在 stopReason 為 end_turn 時才讀取回應。
  2. 項目數量和 id 不受約束。schema 的 minItems(陣列至少要有幾個項目)只接受 0 或 1,沒辦法要求一定要有 63 個,A1 那個空陣列也因此通過了。所以轉接器仍然保留和輸入 id 的比對。
  3. 每個物件都必須加上 "additionalProperties": false(不允許出現 schema 沒定義的 key)。設成其他值的話,Bedrock 會回傳 400。

不再強制 tool use 之後,adaptive thinking 就自動開啟了

強制 tool use 會一直讓 Sonnet 5 沒辦法做 thinking,改用 textFormat 之後,Sonnet 5 又會開始做 thinking 了。adaptive thinking 是 Claude 在 Sonnet 5 上的預設模式。模型會針對每個請求,決定回答前要不要推理、推理多久,而且推理用掉的 token 也會算進 maxTokens。

我用 textFormat 翻譯一整份文件時,大部分 batch 的耗時都和之前一樣,但其中 3 個明顯變慢。最慢的一個 batch 總共嘗試了 6 次,這是因為其中 5 次都超過了 120 秒的 read_timeout。botocore 是 boto3 底下的 AWS SDK,botocore 每次都在不拋出例外的情況下自動重試,所以每一次逾時的嘗試都被計費了,卻沒有一次被記錄成失敗。

開啟與關閉 adaptive thinking 時每個 batch 的耗時這張橫條圖比較了 2 個 batch,每種做法各一列。143 個段落的 batch 在強制 tool use 下花了 47 秒,開啟 thinking 時分 6 次嘗試共花了 731 秒,關閉 thinking 時花了 45 秒。80 個段落的 batch 分別是 25 秒、74 秒、25 秒。開啟 thinking 時,除了最後一次以外的每次嘗試都超過了 120 秒的 read_timeout 而被重送。超過逾時而被重送的嘗試read_timeout 120 秒143 個段落的 batch強制 tool usetextFormat(開啟 thinking)textFormat(關閉 thinking)47 秒731 秒・6 次45 秒80 個段落的 batch強制 tool usetextFormat(開啟 thinking)textFormat(關閉 thinking)25 秒74 秒25 秒0120240360480600720秒
143 個和 80 個段落的兩個 batch,在強制 tool use、開啟 thinking 的 textFormat、關閉 thinking 的 textFormat 下的耗時。開啟 thinking 時,143 個段落的 batch 超過了 120 秒的逾時 5 次,第 6 次嘗試才終於完成。

為了找出原因,我把變慢的 3 個 batch 各自再執行了 3 次,並把逾時改成 300 秒、同時關閉了重試:

請求耗時停止原因輸出 token出現推理的執行
強制 tool use42–47 秒6 次中 6 次 tool_use6,767–7,3049 次中 0 次
textFormat,開啟 thinking45–138 秒6 次中 2 次 max_tokens6,854–16,3849 次中 5 次
textFormat,關閉 thinking41–46 秒6 次中 6 次 end_turn6,598–6,8719 次中 0 次

耗時、停止原因和輸出 token 只統計最慢的 2 個 batch(共 6 次執行),最後一欄則統計全部 9 次。

不論哪一種模式,JSON 本身的長度都一樣,一個 batch 大約 13,000 個字元,所以多出來的 token 都是推理。整份文件第二次完整執行時,143 個段落的 batch 一樣很慢,而那時文法早就編譯好也快取了,所以延遲並不是編譯造成的。

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 才能避免逾時以及 token 消耗變多等問題。

關閉 thinking,以及不接受關閉 thinking 的模型

我送出 thinking: {"type": "disabled"} 之後,textFormat 的耗時和輸出 token 都回到了強制 tool use 時的水準。Converse 透過 additionalModelRequestFields 傳遞 Anthropic 專屬的欄位:

Converse 請求
client.converse(
# …
additionalModelRequestFields={"thinking": {"type": "disabled"}},
outputConfig={"textFormat": {"type": "json_schema", "structure": {"jsonSchema": {
"schema": json.dumps(schema), "name": "translations",
}}}},
)

我在 23 個 batch 上測試,每一個回應都符合 schema,輸入的 id 也全都對得上,最長的一次呼叫也只花了 45 秒,沒有任何一次重試。在工作流的評估套件上,翻譯品質也落在強制 tool use 版本每次執行之間的波動範圍內。

但並不是每個模型都接受 thinking: {"type": "disabled"}。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 區塊回傳。依照文件的描述,像這條工作流這樣不用工具、只呼叫一次的請求,兩者應該沒有差別。

這條工作流會在執行時從 SSM Parameter Store(AWS 存放設定值的服務)讀取模型 ID,所以不必重新部署就能切換到 Sonnet 5.5。但 Sonnet 5.5 不接受 disabled,一旦切換過去,之後的每個 batch 都會因為 400 而失敗。因此我讓轉接器在呼叫 Bedrock 之前,就先拒絕還沒驗證過能關閉 thinking 的模型 ID:

Bedrock 轉接器
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}"
)

這個錯誤會附上模型 ID 一次報出來,而不是變成一連串 Bedrock 的 400 錯誤;想要嘗試新模型,就得先測試過。

總結

問題處理方式
強制 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"},耗時與 token 數回到強制 tool use 時的水準
max_tokens 截斷、拒絕回答、項目數量和 id只讀取 end_turn 的回應,並保留 id 比對
切換到不接受 disabled 的模型用允許清單在呼叫 Bedrock 之前就擋下,並在錯誤訊息中附上模型 ID

在 Bedrock 上,Claude Sonnet 5 現在可以指定 JSON Schema 來約束回應。不過,不再強制 tool use 也會讓模型開始做 thinking,所以切換之前,除了確認 JSON 的結構,也需要用量大點的 batch 量測耗時和輸出 token。

參考連結

分享這篇文章