Troubleshooting · 請求格式

跑了二十輪好好的,突然開始 400 Bad Request — 你要找的是觸發條件,不是拼寫錯誤

30 秒版
  1. 配置沒錯——它在兩輪之間沒變,變的是請求本身。要找的是觸發條件,不是配置。
  2. 最小復現:把請求剝到最小,再逐個把元素加回去,第一個讓它復現的就是觸發條件。
  3. 常見觸發條件三類:tool_usetool_result 沒配對 · 上下文累積越界 · 端點對流式/工具/多模態的支援不完整。

跑著跑著開始 400,和第一次請求就 400,是兩個不同的問題。第 20 輪到第 21 輪之間,你的配置沒有變,變的是你的請求。再把配置讀一遍不會有任何發現,因為配置本來就沒錯。真正要做的是找出那一個元素——它把一個能透過的請求變成了被拒絕的請求。這件事有一套機械的做法,大約五分鐘。

你看到的報錯

以下任意一種
API Error: 400 {"type":"error","error":{"type":"invalid_request_error","message":"messages: roles must alternate between \"user\" and \"assistant\", but found multiple \"user\" roles in a row"}}

400 Bad Request

{"type":"error","error":{"type":"invalid_request_error","message":"messages.3: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_xxxxxxxx. Each `tool_use` block must have a corresponding `tool_result` block in the next message."}}

{"type":"error","error":{"type":"invalid_request_error","message":"messages.4: `tool_result` block(s) provided when previous message does not contain any `tool_use` blocks"}}

四種都是 invalid_request_error。意思是請求送達了伺服器、被解析了、然後因為格式不對被拒絕——所以網路、憑證、模型名這三樣都是好的,否則你看到的會是另一個狀態碼。問題在你發出去的那個 body 裡面。

先把問題一分為二

第一次請求就失敗先能跑,然後開始失敗
變的是什麼你的配置你請求裡的內容
該看哪裡模型名、端點路徑、必填欄位剛進入這輪對話的是什麼元素
有用的動作拿你的 payload 逐欄位對照文件最小復現(見下)
常見原因model 寫錯、缺 max_tokens、API 形狀不對一次工具呼叫、一個 thinking 塊、一張圖、純粹是變大了

本頁講的是右邊那一欄。如果你第一次請求就 400,那是普通的配置問題,最快的修法是拿請求體逐欄位對照端點文件裡的形狀。

觸發多輪 400 的三類條件

觸發類別實際發生了什麼它的表現誰能修
1 — 訊息序列變得不合法tool_use 卻沒有配對的 tool_result、有 tool_result 卻沒有對應的呼叫、連續兩條 user、空的 content 塊確定性的:同一段對話、同一輪、每次都錯。拿存下來的 body 重放,一模一樣地復現你,或你的客戶端
2 — 上下文累積到了邊界歷史越過了某個限制——上下文視窗、請求體大小、附件總位元組數呈閾值形狀:第 N 輪之前都好,從第 N 輪起全壞;刪掉舊訊息就好了你——請看上下文超長
3 — 端點對某個欄位支援不完整端點接受基本形狀,但不接受某個特定組合:流式加工具、thinking 塊、多模態內容塊用到那個功能的那一刻才出現,且只在那時出現;同一段對話不帶它就正常端點運營方

第 1 類和第 3 類在控制台里長得一模一樣——都只是 400。只有最小復現能把它們分開。而且,做出復現也是唯一能讓運營方接得住的報告方式。

按這個順序排查

1 · 把失敗的那個請求體存下來#

不是控制台那行——控制台那行是截斷過的,而且經常被重新格式化。你要的是真正發出去的那份 JSON。

兩條路,按可靠性排:

終端
# a) 不管你的客戶端把它的 verbose / debug 日誌叫什麼,開啟它,找到發出去的 body
#    存成 request.json

# b) 客戶端不肯給,就在前面掛一個記錄用的代理,然後把失敗那一輪重跑一次
export ANTHROPIC_BASE_URL=http://127.0.0.1:8080

拿到 request.json,你就有了一個可重放的實物。下面所有步驟都是對它做的,不是對正在跑的客戶端做的。

終端
jq '.messages | length' request.json      # 實際發出去多少條訊息
wc -c request.json                        # 這個 body 到底有多大
把這個檔案給別人看之前,先刪掉憑證。存下來的請求體裡帶著你的 Key。

2 · 先看結構,再看內容#

多輪 400 大多數都能在訊息陣列的結構上直接看出來,而整個結構一條命令就能鋪在一屏裡:

終端
jq -r '.messages[] | "\(.role)\t\([.content[]?.type] | join(" "))"' request.json | tail -20

你會得到這樣的東西:

輸出
user      text
assistant text tool_use
user      tool_result
assistant text

拿這份輸出對四條不變數:

  • 每一行帶 tool_use 的,緊接著下一行必須帶 tool_result
  • 任何一行有 tool_result,它上面那行必須有 tool_use
  • userassistant 交替出現;
  • 沒有哪一行的塊列表是空的。

你找到的第一處違反,極可能就是這個 400。這是本頁收益最高的一條檢查,一條命令的事。

3 · 把它剝到最小的能跑通的樣子#

先對同一個端點、同一個模型建立基線:

終端
# <模型 id>:從 https://api.9coding.com/v1/models 裡複製一個
curl -sS -w '\n%{http_code}\n' https://api.9coding.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
  -H "content-type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"<模型 id>","max_tokens":16,"messages":[{"role":"user","content":"hi"}]}'

回 200,基線就是好的。回不了 200 就停——那你不是多輪問題,是配置問題,下面的內容都不適用。

重放存下來的 body 用同一條命令,換成讀檔案:

終端
curl -sS -w '\n%{http_code}\n' https://api.9coding.com/v1/messages \
  -H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
  -H "content-type: application/json" \
  -H "anthropic-version: 2023-06-01" \
  --data-binary @request.json

4 · 一次只加回一個元素#

從基線出發,每次只放回一樣東西。第一個讓 400 重新出現的元素,就是觸發條件——到這裡就停。你不需要看懂整段對話,只需要那一個元素。

步驟加回什麼如果這一步 400,觸發條件是
1system 提示詞系統提示詞本身——長度,或者端點不接受的某種塊型別
2tools 陣列,只宣告、不呼叫工具的 schema,不是工具呼叫
3從失敗對話裡取一整輪 tool_use + tool_result工具呼叫的往返——回到第 2 步那四條不變數
4stream: true流式,與 payload 裡已有的東西的組合
5thinking,如果你開了的話thinking 塊的處理
6一個圖片或文件塊多模態支援
7完整的訊息歷史不是形狀,是大小。去看下面第 6 步

每次嘗試單獨存一個檔案(try-1.jsontry-2.json),這樣失敗的那份可以直接交給需要的人。

5 · 二分歷史 — 但要切在合法的位置上#

如果上面都復現不了,只有完整歷史能復現,那就把陣列砍一半再重放:

終端
jq '.messages |= .[-6:]' request.json > try-history.json

這裡有個能讓人搭進去一個下午的坑:隨便切會造出一個全新的 400。切在工具往返中間,你就孤立了一個 tool_result;切成陣列以 assistant 開頭,你就破壞了交替。然後你調的就是自己的裁剪邏輯,不是原來那個 bug。

只切在這樣的位置:剩下的第一條是 user 訊息,且不含 tool_result 塊。每份裁過的檔案,都要先跑一遍第 2 步那條命令,再從它身上得出任何結論。

6 · 判斷這是形狀問題還是大小問題#

兩者要看不同的頁,而且很好分:

  • 形狀 — 刪掉一些無關的舊訊息之後,它還是在同一輪失敗;報錯裡點了某個欄位、某個下標(messages.3)或某種塊型別。
  • 大小 — 隨便刪點舊訊息就好了;每段長對話都在差不多的位置崩;文案裡提到上下文上限或者 prompt 太長。那是另一個問題,另一種修法,請看上下文超長

什麼時候這不是你的問題

如果第 4 步得到的最小復現已經很小了——一條普通訊息加宣告的工具,或者一輪工具呼叫,或者一張圖——它還是 400,那問題就不是形狀。是那個欄位的支援程度。

四分鐘的 curl,能換來一張任何文件都不會給你的支援矩陣。每一格都對你的端點實測一次,把結果寫下來:

非流式流式
普通訊息
+宣告 tools
+一輪 tool_use / tool_result
+thinking
+圖片塊

一個端點可以接受基本的訊息形狀,同時在隔壁那一欄就不完整了——支援不是一個是非題,它是有顆粒度的。某一格掛了,你的排查就結束了:把最小的那份失敗 body 和狀態碼發給運營方。那是他們接得住的報告,也正是這份復現值得做出來的原因。

在認定它一直就是壞的之前,先看一眼運營方的狀態頁與變更日誌——昨天還能用、今天不能用的欄位,那是事故,不是限制。我們的在狀態頁

提問題時請帶上 request id——9Coding 的報錯響應裡都有一個,形如 (request id: 2026...)。這個 id 加上時間戳、模型名和完整報錯文字,就能把那一次呼叫查出來。只說「用不了」的反饋沒法排查。

請求不合法,和對話漲過了視窗,不是同一個問題

400 invalid_request_error 表示你發出去的 body 本身不是一個合法的請求:訊息陣列裡有東西違反了規則,而刪多少歷史都不會讓那條規則被滿足。裝不下則正好相反——請求形狀完全沒問題,只是它太大了,所以裁剪就是全部的修法。分辨的抓手就一個:刪掉無關的舊訊息會不會有變化。會,你就走錯頁了,請看上下文超長

相關報錯