半夜那批任務回了空白,HTTP 卻是 200:stop_reason "refusal" 與 server-side fallback
講清楚這篇的來源:開頭那批空白是為了把問題講清楚而設的情境,不是我的事故報告。全文是讀 Anthropic 官方文件寫的,我沒有實際打過這個 API,fallbacks 沒設過、refusal 回應也沒收過。欄位名、header 值、分類清單、計費規則都出自文中連到的三頁官方文件,程式碼區塊逐字照抄。fallbacks 還在 beta,行為可能會變。要動生產環境,以你打的第一個請求的實際回應為準。
批次任務跑完,資料庫裡有十七筆的 summary 欄位是空的。
翻 log,那幾筆的 HTTP 狀態碼全部是 200。try/except 一條都沒進去,重試計數器停在 0,監控面板上的 error rate 平得像張紙。使用者是隔天早上自己發現報告缺頁才來問的。
你把原始回應撈出來,content 是 [],空陣列。再往下兩行,有一個你沒什麼印象的值:
1 | "stop_reason": "refusal" |
先排除掉最像的那兩個
第一個直覺是 max_tokens。輸出被截斷嘛,這種事天天有。
但截斷的長相不是這樣。你去看 usage,output_tokens 是 0,從頭到尾一個字都沒產出。而且截斷有自己的 stop_reason,它就叫 max_tokens,不會偽裝成別的東西。
第二個直覺是重試。既然 200 但沒東西,那就當它是一次抖動,退避幾秒再打一次。
這條路你可以試,但先想清楚你在重試什麼。抖動值得退避重打,是因為下一次碰到的伺服器狀況不一樣了;分類器這邊,每次看到的是同一段輸入,跟網路、負載都無關。同一個請求重打幾次會不會有一次過得去,官方文件沒有給說法,我也沒有查證到(未查證)。倒是官方端出來的解法本身就是線索:後面會講的 server-side fallback,動作是換一台模型重跑,不是對同一台做指數退避。
還有第三條路更值得講,因為它長得最像對的。
那個 fallback 不是這個 fallback
如果你用過 Claude Code 的 --fallback-model,你可能會想:不就是主模型不行就換一台嗎,這件事早就有解了。
沒有。那個機制處理的是 529 overload,是模型忙不過來的時候換一台繼續跑。官方文件在講 server-side fallback 的時候,把界線劃得很清楚:
Only a safety classifier decline triggers the fallback. A rate limit, overload, or server error on the requested model is returned to you as-is.
這句話兩邊都要讀。往左讀是:只有分類器拒絕會觸發自動換模型。往右讀是:rate limit、overload、server error 通通原樣丟回給你,fallbacks 參數一根手指都不會伸過去。
所以這是兩套獨立的保護。你把過載那套做好了,拒絕這套還是零防護。混為一談的代價,是你以為自己有備援,實際上在某一整類失敗上完全裸奔。
它為什麼被設計成 200
整篇文章的麻煩,都是從這個設計決定長出來的。
櫃檯辦事會遇到這種狀況:承辦人員看完你的表格,說這件我不能受理。他沒拉警報,也沒撕掉表格,就是把表格推回來,附一張紙條寫明原因。從大樓保全的角度看,這棟樓今天一切正常。
拒絕就是那張紙條。官方的說法是「you receive a normal response, not an error」:HTTP 200,stop_reason 是 "refusal",content 是空的。一次成功的 API 呼叫,內容為零。
於是所有架在錯誤上的東西全部失明。你的 except 抓的是例外,這裡沒有例外。你的告警看的是 5xx 比例,這裡是 200。你的 SDK 重試邏輯看狀態碼,判定這次很成功,直接回傳。整條防線沒有人喊過一聲。官方在 Common pitfalls 那段自己寫出來了:
A refusal is an HTTP 200, so monitoring built on error rates or 5xx responses never sees it. Emit one event per refusal and one per fallback-served response.
stop_reason 一共就這幾個值
既然唯一的線索在 stop_reason,那就得知道它會出現哪些東西。官方 Stop reasons and fallback 的 Quick reference 表照抄如下:
stop_reason |
什麼時候出現 |
|---|---|
end_turn |
模型自然把話講完了 |
max_tokens |
撞到你設的 max_tokens |
stop_sequence |
吐出了你指定的 stop_sequences 之一 |
tool_use |
模型要呼叫工具,等你把結果餵回去 |
pause_turn |
server tool 的迴圈跑到迭代上限 |
refusal |
模型拒絕回答 |
model_context_window_exceeded |
回應把模型的 context window 塞滿了 |
七個。最後那個 model_context_window_exceeded 跟 max_tokens 是兩件不同的事。
這張表是官方 Quick reference 的完整內容。文件本身沒有明講「這就是全集、不會再有第八個」,所以嚴格說起來,「只有這七個」是我根據官方清單頁做的推斷,不是官方的斷言。你的 switch 還是要留一個 default 分支。
被擋下來的是哪一類
stop_reason 只告訴你被擋了,stop_details 才告訴你為什麼。這個欄位在其他六種 stop reason 底下一律是 null,只有 refusal 時有內容(以下為官方範例照抄):
1 | { |
三個子欄位各有脾氣。category 是政策分類,官方給了完整的五個值。explanation 是給人看的說明,文件明講「The text is not stable, so display it rather than parse it」,印出來沒問題,寫成正則去比對就等著哪天字串改了整條壞掉。recommended_model 只有在你有設 fallbacks 的請求上才會出現,其他情況是 null,官方還自己加了註腳:「It’s a hint, not a guarantee.」
五個分類的說明重點:
category |
官方說明重點 |
|---|---|
cyber |
可能助長網路危害,例如惡意程式或漏洞開發。良性的資安工作也可能觸發 |
bio |
可能助長生物危害,例如危險的實驗方法。有益的生命科學工作也可能觸發 |
frontier_llm |
可能協助開發競爭模型,在 Anthropic 商業條款下受限。良性的機器學習工作也可能觸發 |
reasoning_extraction |
要求模型把內部推理原文吐進回應裡。想要結構化推理請改用 adaptive thinking |
general_harms |
落在前四類之外的使用政策範圍。良性工作也可能觸發 |
五格裡有四格掛著同一句免責:良性的工作也可能被擋。分類器是機率性的,把誤擋率壓到零的代價就是漏擋率上升。所以如果你的產品本來就在做滲透測試報告、疫苗文獻整理或模型評測,你會比別人更常撞到,而且那不算 bug,沒有工單可以開。
還有:拒絕對不上任何具名分類的時候,category 和 explanation 都會是 null。官方註明「That null is a normal, permanent value, not a placeholder」,別寫成「等一下重查就會有值」的邏輯。判斷要落在 stop_reason 或 stop_details.type 上,不要去看內層欄位。
讓 API 自己換一台重跑
官方的觀察很直白:被 Claude Fable 5.1 或 Fable 5 拒絕的請求,換一台模型通常就答得出來。最省事的做法是把重試搬到伺服器端,一個參數加一個 beta header 搞定:
1 | curl --fail-with-body -sS https://api.anthropic.com/v1/messages \ |
有幾個位置要看清楚。fallbacks 是頂層參數,跟 model、max_tokens 平起平坐,沒有藏在 message 或 config 物件底下。值有兩種形態:字串 "default",或一個最多三個模型的清單,像 [{"model": "claude-opus-4-8"}]。走 SDK 要用 client.beta.messages.create,並把 betas=["server-side-fallback-2026-07-01"] 傳進去。
beta header 的日期不能亂填:只接受 2026-07-01("default" 和清單兩種都支援)或 2026-06-01(只支援清單),其他任何 server-side-fallback-* 值都會讓 fallbacks 吃一個 400。
"default" 是把路由交給 Anthropic,它按 category 選一台建議的模型。代價是這份路由表不會發佈在 Models API 上,你看不到它下一步會選誰;有些 category 根本沒有建議的 fallback,那種情況拒絕就是拒絕。自己列清單則是另一種取捨:每個 entry 都得是原模型的合法目標(設了 beta header 之後 Models API 上會有 allowed_fallback_models 可查),而且整個請求對清單裡每一台都必須合法,有哪台不支援你用到的功能,API 會在最前面就把請求擋掉。
怎麼知道這次是誰回的
換了模型你得知道,不然帳單和輸出品質都變成謎。回應裡有三個地方在講。
頂層 model 欄位報的是實際產出這則訊息的模型。content 裡會多一個 fallback block 標記交棒點,形狀是 {"type": "fallback", "from": {"model": ...}, "to": {"model": ...}},產出前就被拒的話它會是第一個 content block。usage.iterations 則是逐次的帳單明細:拒絕的那次是一筆普通的 message entry,真正服務的那次是 fallback_message entry。官方範例判斷「這次是不是 fallback 回的」,用的就是這兩件事:usage.iterations 裡有 fallback_message,而且 stop_reason 不等於 refusal。
計費跟著這份明細走。產出前就被拒的嘗試不計費,token 會出現在它的 iterations entry 裡但不收錢。任何產出了 output 的嘗試都要付錢,包括吐到一半才被拒的那次,各自按跑它的模型費率算。不同模型的 token 不會被加總進同一個欄位,頂層 usage 只描述產出最終訊息的那一次。
不收費不代表沒代價:每一次跑過的嘗試都計入它自己那台模型的 rate limit,連被拒的那次也算。拒絕率高的流量,配額消耗會比帳單上看到的多。
串流跟非串流,這裡分岔
這是整個機制裡最容易讓人踩空的地方:同一個參數在兩種 code path 底下的語意不一樣。
先看拒絕發生在產出任何東西之前。這種情況兩邊一致:message_start 直接報 fallback 模型,fallback block 是第一個 content block。串流這邊有個副作用,message_start 得等 fallback 嘗試開始才發得出來,所以你量到的 time to first byte 包含了那次被拒絕的嘗試,使用者體感上就是特別久。
真正分岔的是拒絕發生在吐了一半的時候。
非串流的處理是丟掉重來。 回應會省略被拒模型的部分輸出,fallback 模型從頭回答一次,結果就跟「產出前被拒」一模一樣,fallback block 排在最前面。被丟掉的那次嘗試和它的 output token 仍留在 usage.iterations 裡,你還是要付那筆錢,只是看不到那段文字。
串流的處理是接著寫。 開著的 content block 會被關掉,fallback block 以一組沒有任何 delta 的 content_block_start / content_block_stop 出現,標出交界。然後 fallback 模型從那段部分輸出接下去。這裡有個細節值得記:只有部分輸出裡的 text block 會當成 context 傳給 fallback 模型,其他型別的 block 留在 content 裡但不進去。
還有一個坑是讀模型名的位置。串流的 mid-output 情境下,message_start 早就發出去了,上面寫的是你原本請求的那台。真正服務的模型得去看 fallback block 的 to.model,或最後那個 message_delta 裡 usage.iterations 的 fallback_message entry。在這裡讀了 message_start.model 就當定案,儀表板上就會出現一堆「原模型回應成功」的假訊號。
把兩種行為擺在一起看,結論有點違反直覺:同一段 prompt、同一組參數,走串流拿到的是「前半段由 A 寫、後半段由 B 接」的混合體,走非串流拿到的是「純 B 從頭寫」。如果你的前台走串流、批次任務走非串流,兩條路的輸出特性本來就不一樣,做 A/B 比較時要把這件事算進去。
開了也解不掉的那些
fallbacks 不是開了就不用管的開關。官方明載的限制裡,有幾條直接影響架構決策。
批次 API 用不了。 Message Batches 不支援 fallbacks,帶了這個參數的 batch item 會變成一筆 errored result。batch 裡的拒絕長相是 result.type: "succeeded" 配 stop_reason: "refusal",你得自己把它們撈出來、剝掉 thinking block、重新提交到 fallback 模型。開頭那十七筆如果是走 batch 跑的,這套自動重試從一開始就不在選項裡。
只有 Claude API 有。 Amazon Bedrock、Google Cloud、Microsoft Foundry 都沒有 server-side fallback,那些平台得改用 SDK middleware。順帶一提,middleware 跟 fallbacks 做的是同一件事,官方明講不可以在同一個請求上同時開。
負載一大它會自己退化。 這條最陰險。如果 fallback 模型自己被 rate limit 或 overload 了,那次嘗試根本不會發生,API 直接把前面那個 refusal 回給你,並在 stop_details.recommended_model 裡放一台建議你直接重打的模型。文件的結論句值得抄在牆上:「Size the fallback model’s rate limits for the refusal volume you expect, or fallbacks degrade to refusals under load.」流量尖峰的時候,正好是備援最可能失效的時候。
它不會自己傳下去。 fallbacks 不會傳進你在 tool execution 裡面發起的模型呼叫,agent 底下的 sub-agent 每次呼叫都要自己設。官方另外提醒重試預算要按請求算,因為一個 turn 裡可能有 agent 加上 sub-agent 產生好幾次拒絕。
最後是成本。自己寫重試的話,fallback 模型的 prompt cache 要從零寫一遍,而 cache write 比 cache read 貴。官方為此做了 fallback credit:refusal 會帶一個 fallback_credit_token,重試時原封不動帶回去,就按「這段對話本來就在新模型上」計價。token 五分鐘後過期,重試的 body 必須跟被拒請求逐欄相符(system、messages、tools、thinking、cache_control 一個都不能動)。用 server-side fallback 或 middleware 會自動處理。
先量,再開
給一個明確的立場:這套東西我會先加監控,再開 fallbacks,中間隔幾天。
開了 fallback 之後,拒絕就被吸收掉了。使用者拿到答案,儀表板一片祥和,而「哪些請求會被擋、擋在哪一類、量有多大」這個訊號就此消失。它告訴你產品有多少流量落在政策灰帶,也決定你該不該為 fallback 模型多買配額。官方在 pitfalls 那段給了做法:拒絕發一個事件、fallback 成功回應發另一個事件,對差額告警,那就是「擋下來而且救不回來」的量。先開再補監控,你永遠不會知道那個差額原本有多大。
什麼情況我會顛倒過來做?即時對著使用者回話的聊天視窗,先保住回應,空白氣泡的成本遠高於晚三天拿到統計。還有一種情況是整套都可以不用管:流量全部來自內部工具、prompt 由你自己組、沒有終端使用者的自由輸入。那就在 stop_reason 上加一條判斷、記個 log,等真的撞到再說。
回到那十七筆
現在你重新看那十七筆空白。
第一件事是把 stop_reason 撈出來寫進 log,先別急著加重試。這十七筆會分成兩堆:refusal 的那堆有救,其他值的那堆是另一個問題,混在一起查會查到天亮。
第二件事是看 stop_details.category。如果十七筆有十四筆是 cyber,那你的產品大概在做資安相關的分析,這已經是常態,該做的是把它算進容量規劃。如果分類散得很開、每類一兩筆,那更像是使用者輸入的長尾,處理方式完全不同。
第三件事才是決定要不要開 fallbacks。開了之後這十七筆大部分會自己補回來,代價是你以後看不到它們。所以在按下去之前,先讓那個 refusal 計數器跑幾天。
至於監控,改一行就好:別再只盯著 error rate。那條線從頭到尾都是平的,它平的時候,使用者已經在寫信給你了。





























































































































































































