跑了二十輪好好的,突然開始 400 Bad Request — 你要找的是觸發條件,不是拼寫錯誤
- 配置沒錯——它在兩輪之間沒變,變的是請求本身。要找的是觸發條件,不是配置。
- 最小復現:把請求剝到最小,再逐個把元素加回去,第一個讓它復現的就是觸發條件。
- 常見觸發條件三類:
tool_use與tool_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 到底有多大
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; user和assistant交替出現;- 沒有哪一行的塊列表是空的。
你找到的第一處違反,極可能就是這個 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,觸發條件是 |
|---|---|---|
| 1 | system 提示詞 | 系統提示詞本身——長度,或者端點不接受的某種塊型別 |
| 2 | tools 陣列,只宣告、不呼叫 | 工具的 schema,不是工具呼叫 |
| 3 | 從失敗對話裡取一整輪 tool_use + tool_result | 工具呼叫的往返——回到第 2 步那四條不變數 |
| 4 | stream: true | 流式,與 payload 裡已有的東西的組合 |
| 5 | thinking,如果你開了的話 | thinking 塊的處理 |
| 6 | 一個圖片或文件塊 | 多模態支援 |
| 7 | 完整的訊息歷史 | 不是形狀,是大小。去看下面第 6 步 |
每次嘗試單獨存一個檔案(try-1.json、try-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 本身不是一個合法的請求:訊息陣列裡有東西違反了規則,而刪多少歷史都不會讓那條規則被滿足。裝不下則正好相反——請求形狀完全沒問題,只是它太大了,所以裁剪就是全部的修法。分辨的抓手就一個:刪掉無關的舊訊息會不會有變化。會,你就走錯頁了,請看上下文超長。
相關報錯
- context length exceeded — 當它是大小問題,不是形狀問題
- 401 Unauthorized — 兩層認證,一條錯誤資訊
- 連線錯誤 — 分辨網路、代理與端點
- 429 Too Many Requests — 上游容量,以及重試到底在做什麼
- 全部報錯速查 — 排查手冊總表