Claude API 400错误完整解决方案

400 到底是谁的锅

400 是请求体的问题,跟你账号没关系

接口返回 400,意味着请求已经打到服务端,在 JSON 解析和 schema 校验这层被拦下。Key 有效、额度够、模型服务也活着,问题就在你发出去的 body 上。不少人一看到 400 就去充值、换 Key、重装客户端,折腾半天全是白费力气。

判断方法很简单:拿同一个 Key 发一个最小合法请求,能通就说明账号和额度没问题,剩下的全是报文格式的锅。

最小合法请求长这样:model 填一个确定存在的模型,messages 只放一条 user,max_tokens 给 1024,其余参数一个都不带。它能通,就说明 Key、额度、网络、模型可用性这四件事全部正常。接下来把业务报文一层层往这个骨架上加,加到哪一步开始报错,问题就锁在那一步,不用再怀疑基础设施。

把 400 和 401、403、429、5xx 分开看

状态码实际含义该去查什么
400请求体不合法,schema 校验没过messages 结构、role 顺序、必填字段、参数冲突
401鉴权失败Key 写错或被禁、鉴权头格式不对
403没有权限模型未开通、地区限制、账号被限制
404路径打错了base_url 少写或多写 /v1、请求打到了错误路径
429限流或额度耗尽并发过高、余额不足
500/502/529服务端自己出问题与你的代码无关,重试或换渠道

400 永远不会因为你充值而消失,只会因为你改对字段而消失。

400 还分两类:结构错和语义错

结构错是 JSON 本身就坏了,比如 messages 不是数组、括号没闭合,服务端连解析都过不去。语义错是 JSON 合法但不符合 schema,比如 role 顺序乱了、max_tokens 设成 0、thinking 和 temperature 同时出现。后者的报错一般会精确点名哪个字段不合法,照着字段名改就行。

先把完整的错误体挖出来

只盯着 "400 Bad Request" 等于没看

这是我踩得最深的一个坑。某次客户端抛异常,我只打印了 str(e),屏幕上一行 400 Bad Request,我以为是渠道侧抽风,翻了两小时文档也没结论。后来把 response body 打出来,一行字直接写着 messages 里的 role 必须交替出现。答案从来都写在响应体里,问题在于大部分人没拿到它。

curl 加 -i,一次看到响应头和响应体

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,等于把唯一有用的报错信息扔进垃圾桶。

requests 里的 raise_for_status 会吞掉响应体

错误写法:

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()

OpenAI SDK 要抓 APIStatusError 拿 e.response.text

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 的排查,本质是在猜。

messages 数组格式错误

messages 传成了字符串或者字典

错误写法:

{
  "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 的对象,没有例外。

数组元素少了 role

错误写法:

[{"content": "你好"}]

正确写法:

[{"role": "user", "content": "你好"}]

循环拼 messages 时漏一个字段名,多轮对话里夹进一条没有 role 的消息,整段对话就全挂。

content 是 null

从数据库或前端状态拼 messages 时特别容易出这个,字段没填上就成了空,序列化成 JSON 就是 null。发之前统一过滤一遍空值,比事后猜省事。

role 顺序与 system 的位置

第一条必须是 user

以 assistant 开头、或者一上来就是 system,都会被拒。历史对话做裁剪时尤其容易翻车,切掉前面的 user 却留下 assistant,报文立刻就废。

user 和 assistant 必须严格交替

错误写法:

{
  "messages": [
    {"role": "user", "content": "第一问"},
    {"role": "user", "content": "第二问"}
  ]
}

正确写法:

{
  "messages": [
    {"role": "user", "content": "第一问"},
    {"role": "assistant", "content": "第一答"},
    {"role": "user", "content": "第二问"}
  ]
}

确实要连着发两句用户输入,先自己拼成一条再发。

system 不放在 messages 里,结尾也不能是 assistant

原生 Anthropic 协议里 system 是顶层独立字段,塞进 messages 会被判非法;结尾那条也不能是 assistant。各家聚合服务宽松程度不一样,按最严格的标准写,到哪都不会错。

必填字段与 content 类型

model 和 max_tokens 都是必填

Anthropic 原生协议里 max_tokens 没有默认值,漏了就 400,这跟 OpenAI 协议不一样,从那边迁过来的人几乎必踩。

走 OpenAI 兼容协议时 max_tokens 会变成可选,省略它意味着把输出长度交给服务端决定,长回答容易被悄悄截断,排查时反而更难判断是模型能力问题还是长度问题。手工构造请求时,显式写一个合理值更稳。

content 用字符串还是 content block 数组

单个内容直接给字符串;要传图片就换成 content block 数组,两种写法不能混用。

一个很常见的诡异现象:多轮对话第一轮纯文本能通,第二轮带上图片就 400。原因往往是同一个 messages 数组里 content 类型来回切换,有的元素是字符串,有的元素是数组,拼接逻辑自己都没统一。要么全用字符串,要么全部走 content block 数组,纯文本也写成 {"type": "text", "text": "你好"},整个请求保持一致才安全。

多模态图片 block 的正确结构

{
  "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。

模型 ID 与 max_tokens 的坑

用 OpenAI 风格的名字调 Anthropic 协议

把 gpt-4o 或者 claude-3-5-sonnet-latest 这类名字塞给原生 Anthropic 接口,字段对不上就是 400。名字要从服务商的模型列表里原样复制,别凭记忆手敲。

大小写和日期后缀都不是随便写的

模型 ID 大小写敏感。聚合类服务通常有自己的命名规律,比如带 thinking 后缀表示开了推理的版本,像 claude-sonnet-4-5-thinking、claude-opus-4-5-thinking。少个横杠、多个日期后缀都可能直接报错。

max_tokens 有三种死法

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 和地址,这段就能直接跑起来验证链路。

推理模型专属:thinking 参数限制

开了 thinking 就不能传 temperature 和 top_p

这是推理模型最容易吃 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,你手动开了思考就撞车了。排查时先把这个参数删掉再试。

budget_tokens 必须小于 max_tokens

思考预算是从输出上限里切的,等于或者大于都会被拒。max_tokens 给 4096,budget_tokens 就只能给 4096 以下。

budget_tokens 不能低于 1024

设成 512 或者 100 会直接报错,最小值就是 1024。既要小于 max_tokens,又要不小于 1024,两头的空间都得留出来。

排查顺序清单

从上到下逐条过

  1. 打印 status 和 response body,先拿到真实报错文本
  2. 看 messages 是不是数组、每个元素有没有 role 和 content
  3. 看 role 是否以 user 开头、是否严格交替、system 有没有混进去
  4. 看 model 拼写和 max_tokens 是否为正整数
  5. 看 content 类型是否统一,图片 block 的字段名对不对
  6. 看 temperature 和 thinking 有没有同时出现
  7. 看 budget_tokens 是否卡在 1024 到 max_tokens 之间
  8. 用最小合法请求复测,确认账号和额度正常

按这个顺序走,绝大多数 400 在前三步就暴露了。

三种肉眼看不见的脏数据

报文看着干净,400 却反复出现,往这三处找:一是从网页或 Word 里复制文本带进来的零宽空格、全角引号和中文字符,混在 content 里完全看不出来;二是自己拼 JSON 字符串时忘了转义引号,字符串提前闭合;三是某些客户端会往 body 里塞自定义 extra 字段,服务端 schema 严格就直接拒收。把 body 用 json.dumps 重新序列化一遍再发,前两类问题基本消失,第三类靠对比最小请求和业务请求的字段差异定位。

每次只改一个变量

一次改三四个参数再复测,成功了也不知道是哪个改对的,下次还会踩同一个坑。改一个、跑一次、看一次 body。

客户端不给你看 body 的时候怎么办

图形客户端经常只弹一句"请求失败",把 body 吞掉了,这时候别在里面反复点重试。把同样的参数搬到命令行用 curl 发一次,报错原文立刻就出来。实在要在客户端里查,就打开它的调试日志,或者挂一个抓包代理,看它到底往 body 里塞了什么——不少 400 的根因就藏在客户端自作主张补的默认参数里,比如悄悄加上一个 temperature,或者把 max_tokens 设成模型不支持的值。

预防性写法:校验函数与脱敏日志

一个能复用的 messages 校验函数

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 再回头查快得多。

用 Pydantic 兜住类型问题

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 只留长度就行。

脱敏要脱在序列化之前,先复制一份请求体再改字段,别去改那个即将发出去的对象,否则日志里的报文和真实发出的请求对不上,越查越乱。

中转站与官方接口的协议差异

base_url、鉴权头和路径三处都不一样

项目原生 Anthropic 协议OpenAI 兼容协议
base_urlhttps://api.anthropic.comhttps://xyuapi.top/v1
鉴权头x-api-keyAuthorization: Bearer
路径/v1/messages/v1/chat/completions
system顶层独立字段放进 messages 的第一条
max_tokens必填可选

国内直连的中转服务一般走 OpenAI 兼容格式,接入地址统一成 https://xyuapi.top/v1,客户端里填好地址和 Key 就能跑通。这类服务多按次计费,推理模型单价大致如下:

模型单价
gemini-2.5-pro0.031元/次
gemini-3-pro-preview0.05元/次
gemini-3.1-pro-preview0.09元/次
claude-sonnet-4-5-thinking0.09元/次
claude-opus-4-5-thinking0.12元/次
claude-opus-4-6-thinking0.25元/次
claude-opus-4-7-thinking0.25元/次
grok-4.10.05元/次
grok-4.50.09元/次
deepseek-v4-pro-thinking0.09元/次
gpt-5.50.2元/次
kimi-k2.60.09元/次

混着写协议就是 400 的重灾区

拿 Anthropic 的鉴权头去打 OpenAI 兼容的路径,或者两个头一起塞进去,服务端认不出身份,可能返回 401,也可能因为字段对不上直接给 400。改配置前先确认用的是哪套协议,路径、鉴权头、base_url 必须成套出现。

排查这类问题的成本其实不高,把请求体打印出来看一眼基本就够了。不想自己维护一堆渠道和 Key 的话,小鱼API(xyuai.cc)支持一个 Key 调全系列模型,按次计费、国内直连,最低 7 元起充,把精力留给业务本身更划算。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信