跑了二十轮好好的,突然开始 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 — 上游容量,以及重试到底在做什么
- 全部报错速查 — 排查手册总表