接口返回 400,意味着请求已经打到服务端,在 JSON 解析和 schema 校验这层被拦下。Key 有效、额度够、模型服务也活着,问题就在你发出去的 body 上。不少人一看到 400 就去充值、换 Key、重装客户端,折腾半天全是白费力气。
判断方法很简单:拿同一个 Key 发一个最小合法请求,能通就说明账号和额度没问题,剩下的全是报文格式的锅。
最小合法请求长这样:model 填一个确定存在的模型,messages 只放一条 user,max_tokens 给 1024,其余参数一个都不带。它能通,就说明 Key、额度、网络、模型可用性这四件事全部正常。接下来把业务报文一层层往这个骨架上加,加到哪一步开始报错,问题就锁在那一步,不用再怀疑基础设施。
| 状态码 | 实际含义 | 该去查什么 |
|---|---|---|
| 400 | 请求体不合法,schema 校验没过 | messages 结构、role 顺序、必填字段、参数冲突 |
| 401 | 鉴权失败 | Key 写错或被禁、鉴权头格式不对 |
| 403 | 没有权限 | 模型未开通、地区限制、账号被限制 |
| 404 | 路径打错了 | base_url 少写或多写 /v1、请求打到了错误路径 |
| 429 | 限流或额度耗尽 | 并发过高、余额不足 |
| 500/502/529 | 服务端自己出问题 | 与你的代码无关,重试或换渠道 |
400 永远不会因为你充值而消失,只会因为你改对字段而消失。
结构错是 JSON 本身就坏了,比如 messages 不是数组、括号没闭合,服务端连解析都过不去。语义错是 JSON 合法但不符合 schema,比如 role 顺序乱了、max_tokens 设成 0、thinking 和 temperature 同时出现。后者的报错一般会精确点名哪个字段不合法,照着字段名改就行。
这是我踩得最深的一个坑。某次客户端抛异常,我只打印了 str(e),屏幕上一行 400 Bad Request,我以为是渠道侧抽风,翻了两小时文档也没结论。后来把 response body 打出来,一行字直接写着 messages 里的 role 必须交替出现。答案从来都写在响应体里,问题在于大部分人没拿到它。
curl -i https://xyuapi.top/v1/chat/completions \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-sonnet-4-5-thinking",
"max_tokens": 1024,
"messages": [{"role": "user", "content": "你好"}]
}'
-i 会把状态行、响应头和 body 全打出来。写 -s 再加 -o /dev/null,等于把唯一有用的报错信息扔进垃圾桶。
错误写法:
import requests
resp = requests.post(url, headers=headers, json=payload, timeout=60)
resp.raise_for_status() # 400 直接抛异常,body 被丢掉了
data = resp.json()
正确写法:
import requests
resp = requests.post(url, headers=headers, json=payload, timeout=60)
if resp.status_code != 200:
print("status:", resp.status_code)
print("body:", resp.text) # 原始响应体才是排查依据
resp.raise_for_status()
data = resp.json()
from openai import OpenAI, APIStatusError
client = OpenAI(api_key="sk-xxx", base_url="https://xyuapi.top/v1")
try:
resp = client.chat.completions.create(
model="claude-sonnet-4-5-thinking",
max_tokens=1024,
messages=[{"role": "user", "content": "你好"}],
)
print(resp.choices[0].message.content)
except APIStatusError as e:
print("status:", e.status_code)
print("body:", e.response.text) # 真正的错误原因在这里
这三种方式拿到的 body 才是排查的起点。没有 body 的排查,本质是在猜。
错误写法:
{
"model": "claude-sonnet-4-5-thinking",
"max_tokens": 1024,
"messages": "你好,帮我写个函数"
}
正确写法:
{
"model": "claude-sonnet-4-5-thinking",
"max_tokens": 1024,
"messages": [
{"role": "user", "content": "你好,帮我写个函数"}
]
}
messages 必须是数组,每个元素都是带 role 和 content 的对象,没有例外。
错误写法:
[{"content": "你好"}]
正确写法:
[{"role": "user", "content": "你好"}]
循环拼 messages 时漏一个字段名,多轮对话里夹进一条没有 role 的消息,整段对话就全挂。
从数据库或前端状态拼 messages 时特别容易出这个,字段没填上就成了空,序列化成 JSON 就是 null。发之前统一过滤一遍空值,比事后猜省事。
以 assistant 开头、或者一上来就是 system,都会被拒。历史对话做裁剪时尤其容易翻车,切掉前面的 user 却留下 assistant,报文立刻就废。
错误写法:
{
"messages": [
{"role": "user", "content": "第一问"},
{"role": "user", "content": "第二问"}
]
}
正确写法:
{
"messages": [
{"role": "user", "content": "第一问"},
{"role": "assistant", "content": "第一答"},
{"role": "user", "content": "第二问"}
]
}
确实要连着发两句用户输入,先自己拼成一条再发。
原生 Anthropic 协议里 system 是顶层独立字段,塞进 messages 会被判非法;结尾那条也不能是 assistant。各家聚合服务宽松程度不一样,按最严格的标准写,到哪都不会错。
Anthropic 原生协议里 max_tokens 没有默认值,漏了就 400,这跟 OpenAI 协议不一样,从那边迁过来的人几乎必踩。
走 OpenAI 兼容协议时 max_tokens 会变成可选,省略它意味着把输出长度交给服务端决定,长回答容易被悄悄截断,排查时反而更难判断是模型能力问题还是长度问题。手工构造请求时,显式写一个合理值更稳。
单个内容直接给字符串;要传图片就换成 content block 数组,两种写法不能混用。
一个很常见的诡异现象:多轮对话第一轮纯文本能通,第二轮带上图片就 400。原因往往是同一个 messages 数组里 content 类型来回切换,有的元素是字符串,有的元素是数组,拼接逻辑自己都没统一。要么全用字符串,要么全部走 content block 数组,纯文本也写成 {"type": "text", "text": "你好"},整个请求保持一致才安全。
{
"model": "claude-sonnet-4-5-thinking",
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "这张图里有什么?"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": "<base64字符串>"
}
}
]
}
]
}
最常见的错是把 media_type 写成 mime_type,或者把 base64 前面那段 data:image/png;base64, 前缀一起传进去,两种都会换来 400。
把 gpt-4o 或者 claude-3-5-sonnet-latest 这类名字塞给原生 Anthropic 接口,字段对不上就是 400。名字要从服务商的模型列表里原样复制,别凭记忆手敲。
模型 ID 大小写敏感。聚合类服务通常有自己的命名规律,比如带 thinking 后缀表示开了推理的版本,像 claude-sonnet-4-5-thinking、claude-opus-4-5-thinking。少个横杠、多个日期后缀都可能直接报错。
import requests
resp = requests.post(url, headers=headers, json={
"model": "claude-sonnet-4-5-thinking",
"max_tokens": 1024, # 必须是正整数
"temperature": 0.7,
"messages": [{"role": "user", "content": "写个冒泡排序"}],
}, timeout=60)
print(resp.status_code)
print(resp.text)
换成自己的 Key 和地址,这段就能直接跑起来验证链路。
这是推理模型最容易吃 400 的地方。开了扩展思考之后,采样参数会被服务端拒收,temperature、top_p、top_k 一个都不能带。
错误写法:
{
"model": "claude-sonnet-4-5-thinking",
"max_tokens": 4096,
"temperature": 0.7,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "你好"}]
}
正确写法:
{
"model": "claude-sonnet-4-5-thinking",
"max_tokens": 4096,
"thinking": {"type": "enabled", "budget_tokens": 2048},
"messages": [{"role": "user", "content": "你好"}]
}
很多客户端模板里默认塞了一个 temperature,你手动开了思考就撞车了。排查时先把这个参数删掉再试。
思考预算是从输出上限里切的,等于或者大于都会被拒。max_tokens 给 4096,budget_tokens 就只能给 4096 以下。
设成 512 或者 100 会直接报错,最小值就是 1024。既要小于 max_tokens,又要不小于 1024,两头的空间都得留出来。
按这个顺序走,绝大多数 400 在前三步就暴露了。
报文看着干净,400 却反复出现,往这三处找:一是从网页或 Word 里复制文本带进来的零宽空格、全角引号和中文字符,混在 content 里完全看不出来;二是自己拼 JSON 字符串时忘了转义引号,字符串提前闭合;三是某些客户端会往 body 里塞自定义 extra 字段,服务端 schema 严格就直接拒收。把 body 用 json.dumps 重新序列化一遍再发,前两类问题基本消失,第三类靠对比最小请求和业务请求的字段差异定位。
一次改三四个参数再复测,成功了也不知道是哪个改对的,下次还会踩同一个坑。改一个、跑一次、看一次 body。
图形客户端经常只弹一句"请求失败",把 body 吞掉了,这时候别在里面反复点重试。把同样的参数搬到命令行用 curl 发一次,报错原文立刻就出来。实在要在客户端里查,就打开它的调试日志,或者挂一个抓包代理,看它到底往 body 里塞了什么——不少 400 的根因就藏在客户端自作主张补的默认参数里,比如悄悄加上一个 temperature,或者把 max_tokens 设成模型不支持的值。
def validate_messages(messages):
if not isinstance(messages, list) or not messages:
raise ValueError("messages 必须是非空数组")
cleaned = []
for i, m in enumerate(messages):
if not isinstance(m, dict):
raise ValueError(f"第 {i} 条消息不是对象")
if m.get("role") not in ("user", "assistant"):
raise ValueError(f"第 {i} 条消息 role 非法: {m.get('role')}")
if m.get("content") in (None, ""):
raise ValueError(f"第 {i} 条消息 content 为空")
cleaned.append({"role": m["role"], "content": m["content"]})
if cleaned[0]["role"] != "user":
raise ValueError("第一条消息必须是 user")
for a, b in zip(cleaned, cleaned[1:]):
if a["role"] == b["role"]:
raise ValueError("user 和 assistant 必须交替出现")
if cleaned[-1]["role"] == "assistant":
raise ValueError("最后一条消息不能是 assistant")
return cleaned
发请求前跑一遍,报错发生在本地,比等接口返回 400 再回头查快得多。
from typing import Literal
from pydantic import BaseModel, Field
class Message(BaseModel):
role: Literal["user", "assistant"]
content: str
class ChatPayload(BaseModel):
model: str
max_tokens: int = Field(gt=0)
messages: list[Message]
字段类型和取值在这一层就被卡住,非整数、负数、空内容走不到网络请求那一步。
排查期把 body 原样打进日志,定位完再关掉。API Key 截断成前六位加星号,图片 base64 只留长度就行。
脱敏要脱在序列化之前,先复制一份请求体再改字段,别去改那个即将发出去的对象,否则日志里的报文和真实发出的请求对不上,越查越乱。
| 项目 | 原生 Anthropic 协议 | OpenAI 兼容协议 |
|---|---|---|
| base_url | https://api.anthropic.com | https://xyuapi.top/v1 |
| 鉴权头 | x-api-key | Authorization: Bearer |
| 路径 | /v1/messages | /v1/chat/completions |
| system | 顶层独立字段 | 放进 messages 的第一条 |
| max_tokens | 必填 | 可选 |
国内直连的中转服务一般走 OpenAI 兼容格式,接入地址统一成 https://xyuapi.top/v1,客户端里填好地址和 Key 就能跑通。这类服务多按次计费,推理模型单价大致如下:
| 模型 | 单价 |
|---|---|
| gemini-2.5-pro | 0.031元/次 |
| gemini-3-pro-preview | 0.05元/次 |
| gemini-3.1-pro-preview | 0.09元/次 |
| claude-sonnet-4-5-thinking | 0.09元/次 |
| claude-opus-4-5-thinking | 0.12元/次 |
| claude-opus-4-6-thinking | 0.25元/次 |
| claude-opus-4-7-thinking | 0.25元/次 |
| grok-4.1 | 0.05元/次 |
| grok-4.5 | 0.09元/次 |
| deepseek-v4-pro-thinking | 0.09元/次 |
| gpt-5.5 | 0.2元/次 |
| kimi-k2.6 | 0.09元/次 |
拿 Anthropic 的鉴权头去打 OpenAI 兼容的路径,或者两个头一起塞进去,服务端认不出身份,可能返回 401,也可能因为字段对不上直接给 400。改配置前先确认用的是哪套协议,路径、鉴权头、base_url 必须成套出现。
排查这类问题的成本其实不高,把请求体打印出来看一眼基本就够了。不想自己维护一堆渠道和 Key 的话,小鱼API(xyuai.cc)支持一个 Key 调全系列模型,按次计费、国内直连,最低 7 元起充,把精力留给业务本身更划算。