同一组测试跑两遍:直连 Claude 官方接口和通过中转站,到底差在哪
同一个脚本,对着 Claude 官方接口和一家中转站各跑两遍,三十多项逐条对照:官方会拒绝的写法里有八种中转站放行,几样功能中转站用不了或悄悄忽略,数 token、缓存、响应头和报错格式也不一样。附脚本,能对着你自己的那一家跑。
本站已实测,测试日期:2026-10-15。
本页目录(12 节)
这篇教程解决一个问题:我的代码通过中转站跑得好好的,换一家、或者换成直连官方,会不会出问题?反过来,官方文档里写的功能,在中转站上是不是都能用?
本站的 Claude API 系列,大部分是通过酷站AI 中转站实测的。《通过中转站用 Claude API》那篇已经汇总过出入,但当时没有官方的 key,“官方”那一栏只能写文档怎么说。这周用 Max 套餐每月赠送的 API 额度拿到了官方的 key,于是把同一个脚本对着两边各跑了两遍,逐条对照。
| 实测环境 | |
|---|---|
| 日期 | 2026-10-15 |
| 官方 | api.anthropic.com,一把 Console 里建的 API key,用的是 Max 套餐每月赠送的额度 |
| 中转站 | 酷站AI,一把有额度上限的 key |
| 模型 | Sonnet 5.5 |
| 方式 | 同一个只用 Python 标准库的脚本(文末附全文),两边各跑两遍,两遍的结论一致 |
先说结论
- 官方文档说会被拒绝的九种写法,官方确实全部返回 400,中转站放行了其中八种。 在中转站上能跑通的代码,换到官方可能直接报错。这是最该注意的一条,见第二节。
- 有几样功能中转站用不了,或者返回 200 却没有生效。 抓取网页、代码执行、按需压缩、停止序列、文件和批量接口、Agent Skills、托管智能体,官方都正常。
- 数字也不一样。 数 token 的接口在官方一个不差,在中转站差了一倍多;不带
metadata.user_id时中转站的缓存不命中,官方照常命中。 - 报错和响应头的格式不一样,依赖这些写的代码要留意。
- 日常的请求两边一致:发消息、流式、工具调用、结构化输出、base64 的图片、联网搜索。
一、总表
“官方”和“中转站”两栏都是这次实测看到的。
基本的请求和响应
| 项目 | 官方 | 中转站 |
|---|---|---|
| 发消息 | 200 | 200 |
| 流式 | 正常,以 message_stop 结束 |
一样 |
| 三次请求的耗时中位数 | 约 1.1 秒 | 约 2.8 到 3.2 秒 |
request-id 响应头 |
有 | 没有,有一个中转站自己的 x-request-id |
| 限流的响应头 | 12 个 | 一个也没有 |
| 模型列表 | 14 个,每条有 11 个字段:能力、上下文上限、下线时间等 | 16 个,每条只有 ID 等 4 个字段 |
| 查单个模型 | 200 | 404 |
耗时和测试机所在的网络位置关系很大,这里只说明在本站这台服务器上是这样,不能拿来比两家的快慢。
官方文档说会被拒绝的写法
| 写法 | 官方 | 中转站 |
|---|---|---|
不带 max_tokens |
400 | 400(10 月 13 日测时是 503,见第六节) |
| 预填:最后一条消息是 assistant | 400 | 200 |
Sonnet 5.5 把 thinking 设成 disabled |
400 | 200 |
temperature 设成 0.5 |
400 | 200 |
| 工具名带点号 | 400 | 200 |
| 工具名用中文 | 400 | 200 |
| 工具结果前面放一段文字 | 400 | 200 |
| 工具调用之后,下一条消息没有回结果 | 400 | 200,模型又调了一次工具 |
旧参数 output_format |
400 | 200,返回普通文字 |
常用功能
| 项目 | 官方 | 中转站 |
|---|---|---|
| 工具调用 | 200,tool_use |
一样 |
结构化输出(output_config.format) |
拿到合规的 JSON | 一样 |
| 图片,base64 | 200 | 200 |
| 图片,网址 | 200 | 400 |
| 停止序列 | 生效 | 不生效,照常输出完 |
| 一道要动脑的题,开自适应思考 | 有思考块,带签名 | 也有思考块,带签名 |
| 按需压缩 | 返回一个压缩块,stop_reason 是 compaction |
参数被忽略,照常回答 |
| 到阈值自动压缩的策略 | 被接受 | 400 |
数字
| 项目 | 官方 | 中转站 |
|---|---|---|
| 同一句话,数 token 的接口说 | 23 | 21 |
| 同一句话,真实请求计的输入 | 23 | 51 |
不标 cache_control 的普通请求 |
不写缓存,不读缓存 | 一样 |
同一段系统提示连发 4 次,不带 metadata.user_id |
第 1 次写,后 3 次都读到 | 4 次都在写,一次也没读到 |
同上,带固定的 user_id |
和不带一样 | 后 3 次读到,每次还多写 19 个 token 的缓存 |
在 Anthropic 那边执行的工具
| 工具 | 官方 | 中转站 |
|---|---|---|
| 联网搜索 | 200,有搜索的内容块 | 一样 |
| 抓取网页 | 200,有抓取的内容块 | 400 |
| 代码执行 | 200,有代码执行的结果块 | 200,但没有任何工具的内容块:被忽略了 |
消息接口以外
| 接口 | 官方 | 中转站 |
|---|---|---|
| 文件 | 200 | 404 |
| 批量 | 200 | 404 |
| skills | 200 | 404 |
| 托管智能体(列环境) | 200 | 404 |
这几样在官方上怎么用,这周都补测过,写进了各自的文章:文件和批量、Agent Skills、托管智能体的入门、环境和数据、多智能体。另外两处这周查到的差别也在别的文章里:浏览器工具集的结果里带一个 browser_state 块,中转站不认,见《用 SDK 的工具集类让 Claude 操作浏览器》;Claude Code 的 advisor 用令牌接中转站时打不开,用官方的 key 能用,见《advisor 工具》。
二、官方会拒绝的写法:在中转站上测不出来
上面第二张表是这次最有用的结果。这些写法大多在文档里有明确的出处:Sonnet 5.5 的迁移指南列了五种会返回 400 的设置(思考预算、采样参数、预填、强制选工具、关掉思考),工具名有一条正则(只能是字母、数字、下划线和连字符),工具结果必须紧跟在工具调用后面、并且放在消息的最前面。
官方确实一条条都拒绝了。中转站除了“不带 max_tokens”,其余八种都放行,返回 200,有的还给出了一段看起来正常的回答。
这意味着:
- 在中转站上测试全过,不等于代码写对了。 哪天换成直连官方,或者换一家校验严格的中转站,同一份代码会直接报 400。
- 升级模型的时候最容易踩。 迁移指南里“这一代开始拒绝”的那些写法,通过这家中转站一个也测不出来,见《升级到 5.5 的清单》。
- 按官方的规则写代码。 每个工具调用都回结果,结果放在消息最前面,工具名只用字母、数字、下划线和连字符,不用预填,Sonnet 5.5 和 Opus 5.5 不设采样参数。
三、中转站用不了或者悄悄忽略的
直接报错的(400、404)好办,第一次跑就知道。要小心的是返回 200、功能却没发生的那几样:
| 请求里带了什么 | 中转站上实际发生的 | 怎么发现 |
|---|---|---|
| 代码执行工具 | 没有任何工具的内容块 | 检查响应里有没有代码执行的结果块 |
| 按需压缩 | 照常回答,没有压缩块 | 检查 stop_reason 是不是 compaction |
| 停止序列 | 没在序列处停 | 检查 stop_reason 是不是 stop_sequence |
旧参数 output_format |
返回普通文字 | 解析 JSON 失败时查一下参数名 |
这几样在官方上都按文档工作。所以在中转站上写程序,不要只看状态码,要看响应里有没有你期望的那个东西。这条习惯在官方上也该有,换接口时更是必须。
四、数字对不上的
数 token 的接口。 官方文档说它给的是估算,可能差一点。实测官方一个不差:接口说 23,真实请求计了 23。中转站接口说 21,真实请求计了 51,多出来的这一截本站分不清来源。所以通过中转站,估算花费要看真实响应里的 usage,别看这个接口。
提示缓存。 官方不带 metadata.user_id 也照常命中:第一次写,之后每次都读。中转站不带的话一次都读不到,每次都按写缓存的价付钱;带上固定的 user_id 才命中,而且每次还多写 19 个 token 的缓存。通过这家中转站写程序,一场对话里的请求要带同一个 user_id,提示缓存那一篇里有更多测试。
响应头。 官方每个响应都有 request-id 和 12 个限流的响应头,可以读着它们主动放慢;中转站一个也没有,只能等 429 来了再退避。要找中转站查某一次请求,记下它的 x-request-id。
五、报错长得不一样
模型名写错时,两边都是 404,SDK 抛的异常类不会分错。细节不同:
| 官方 | 中转站 | |
|---|---|---|
error.type |
not_found_error |
中转站自己起的类型名 |
| 消息 | 英文 | 中文 |
响应体里的 request_id |
有 | 没有 |
| 路径写错时 | 404,JSON | 404,不是 JSON |
判断出了什么错,看 HTTP 状态码或者 SDK 的异常类,不要匹配 error.type 的字符串。 不然换个接口,判断就失效了。
六、中转站自己也在变
“不带 max_tokens”这一项,10 月 13 日测这家中转站时返回的是 503,这次是 400,和官方一致了。中转站的行为会随它的版本变,今天的这张表过一阵就不一定准。换中转站、升级模型、或者隔一段时间,把下面的脚本再跑一遍。
七、对照脚本
这个脚本在《通过中转站用 Claude API》那个自测脚本的基础上,加了官方会拒绝的那几种写法、报错的样子、缓存每次的读写数字和消息接口以外的几个接口。只用 Python 标准库,不打印 key 和请求头。不设 ANTHROPIC_BASE_URL 时请求发到官方;设了就发到那个地址。
"""官方和中转站对照:同一组请求对着一个接口地址跑一遍。只用标准库,不打印 key 和请求头。
不设 ANTHROPIC_BASE_URL 时发到官方。"""
import base64, json, os, statistics, struct, time, urllib.error, urllib.request, zlib
BASE = (os.environ.get("ANTHROPIC_BASE_URL") or "https://api.anthropic.com").rstrip("/")
KEY = os.environ.get("ANTHROPIC_API_KEY") or os.environ["ANTHROPIC_AUTH_TOKEN"]
MODEL = "claude-sonnet-5-5"
HDR = {"x-api-key": KEY, "anthropic-version": "2023-06-01", "content-type": "application/json", "user-agent": "compare/1"}
def call(path, body=None, beta=None, method=None):
h = dict(HDR)
if beta:
h["anthropic-beta"] = beta
req = urllib.request.Request(BASE + path, data=json.dumps(body).encode() if body is not None else None, headers=h, method=method)
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=180) as r:
code, text, hd = r.status, r.read().decode("utf-8", "replace"), dict(r.headers)
except urllib.error.HTTPError as e:
code, text, hd = e.code, e.read().decode("utf-8", "replace"), dict(e.headers)
try:
parsed = json.loads(text)
except ValueError:
parsed = {"_raw": text[:80]}
return code, parsed if isinstance(parsed, dict) else {}, {k.lower(): v for k, v in hd.items()}, time.time() - t0
def msg(content, beta=None, **f):
return call("/v1/messages", {"model": MODEL, "max_tokens": 200, "messages": [{"role": "user", "content": content}], **f}, beta)
def brief(code, body):
if code != 200:
e = body.get("error") if isinstance(body.get("error"), dict) else {}
return f"HTTP {code}({e.get('type') or '不是官方的报错格式'})"
return f"HTTP 200,stop_reason={body.get('stop_reason')}"
def text_of(body):
return "".join(b.get("text", "") for b in body.get("content", []) if b.get("type") == "text")
def row(name, result):
print(f"{name}:{result}", flush=True)
def png(size=64):
def chunk(tag, data):
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data))
px = b"".join(b"\x00" + b"\xff\x00\x00" * size for _ in range(size))
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(px)) + chunk(b"IEND", b"")
print("接口:", "官方" if "anthropic.com" in BASE else "中转站", flush=True)
# 一、基本
times = []
for _ in range(3):
code, body, hd, dt = msg("只回答两个字:你好", max_tokens=30)
times.append(dt)
row("基本请求", brief(code, body))
row("三次的耗时中位数", f"{statistics.median(times):.2f} 秒")
row("响应头", f"request-id {'有' if 'request-id' in hd else '没有'};限流头 {sum(1 for k in hd if k.startswith('anthropic-ratelimit-'))} 个;"
f"组织 ID {'有' if 'anthropic-organization-id' in hd else '没有'};x-request-id {'有' if 'x-request-id' in hd else '没有'}")
code, body, _, _ = call("/v1/models?limit=100")
data = body.get("data") or []
row("模型列表", f"HTTP {code},{len(data)} 个;每条的字段:{sorted(data[0].keys()) if data else '—'}")
code, body, _, _ = call(f"/v1/models/{MODEL}")
row("查单个模型", f"HTTP {code}" + (f",字段 {sorted(body.keys())}" if code == 200 else ""))
req = urllib.request.Request(BASE + "/v1/messages", data=json.dumps({"model": MODEL, "max_tokens": 30, "stream": True,
"messages": [{"role": "user", "content": "只回答两个字:你好"}]}).encode(), headers=HDR)
try:
with urllib.request.urlopen(req, timeout=180) as r:
events = [line.decode().split(":", 1)[1].strip() for line in r if line.startswith(b"event:")]
row("流式", "正常,以 message_stop 结束" if events[-1:] == ["message_stop"] else f"最后几个事件 {events[-3:]}")
except urllib.error.HTTPError as e:
row("流式", f"HTTP {e.code}")
# 二、报错的样子
code, body, hd, _ = msg("hi", model="claude-no-such-model", max_tokens=10)
e = body.get("error") if isinstance(body.get("error"), dict) else {}
row("模型名写错", f"HTTP {code},type={e.get('type')};顶层 type={body.get('type')};有 request_id={'request_id' in body};消息是中文={any(ord(c) > 0x4e00 for c in str(e.get('message', '')))}")
code, body, _, _ = call("/v1/no-such-path", {"x": 1})
row("路径写错", f"HTTP {code},{'JSON' if '_raw' not in body else '不是 JSON'}")
# 三、官方文档说会拒绝的写法
code, body, _, _ = call("/v1/messages", {"model": MODEL, "messages": [{"role": "user", "content": "你好"}]})
row("不带 max_tokens", brief(code, body))
code, body, _, _ = call("/v1/messages", {"model": MODEL, "max_tokens": 30, "messages": [{"role": "user", "content": "你好"}, {"role": "assistant", "content": "你好,"}]})
row("预填(最后一条是 assistant)", brief(code, body))
row("thinking 设成 disabled", brief(*msg("你好", max_tokens=30, thinking={"type": "disabled"})[:2]))
row("temperature=0.5", brief(*msg("你好", max_tokens=30, temperature=0.5)[:2]))
weather = {"name": "get_weather", "description": "查某个城市现在的天气",
"input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}
row("工具名带点号", brief(*msg("上海天气?", max_tokens=50, tools=[{**weather, "name": "get.weather"}])[:2]))
row("工具名用中文", brief(*msg("上海天气?", max_tokens=50, tools=[{**weather, "name": "查天气"}])[:2]))
hist = [{"role": "user", "content": "上海天气?"},
{"role": "assistant", "content": [{"type": "tool_use", "id": "toolu_compare01", "name": "get_weather", "input": {"city": "上海"}}]},
{"role": "user", "content": [{"type": "text", "text": "结果如下:"}, {"type": "tool_result", "tool_use_id": "toolu_compare01", "content": "晴,20 度"}]}]
code, body, _, _ = call("/v1/messages", {"model": MODEL, "max_tokens": 60, "tools": [weather], "messages": hist})
row("工具结果前面放一段文字", brief(code, body))
hist2 = [hist[0], hist[1], {"role": "user", "content": [{"type": "text", "text": "继续"}]}]
code, body, _, _ = call("/v1/messages", {"model": MODEL, "max_tokens": 60, "tools": [weather], "messages": hist2})
row("工具调用没有回结果", brief(code, body))
row("旧参数 output_format", brief(*msg("中国的首都是哪里?", output_format={"type": "json_schema", "schema": {"type": "object", "properties": {"city": {"type": "string"}}}})[:2]))
code, body, _, _ = msg("从 1 数到 5,用逗号隔开,只输出数字。", max_tokens=40, stop_sequences=["3"])
row("停止序列", "生效" if body.get("stop_reason") == "stop_sequence" else f"没有生效({brief(code, body)})")
# 四、常用功能
row("工具调用", brief(*msg("上海现在天气怎么样?", tools=[weather])[:2]))
schema = {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"], "additionalProperties": False}
code, body, _, _ = msg("中国的首都是哪里?", output_config={"format": {"type": "json_schema", "schema": schema}})
try:
ok = "city" in json.loads(text_of(body))
except ValueError:
ok = False
row("结构化输出", "拿到了合规的 JSON" if ok else brief(code, body))
row("图片,base64", brief(*msg([{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": base64.b64encode(png()).decode()}}, {"type": "text", "text": "什么颜色?"}])[:2]))
row("图片,网址", brief(*msg([{"type": "image", "source": {"type": "url", "url": "https://www.python.org/static/img/python-logo.png"}}, {"type": "text", "text": "什么颜色?"}])[:2]))
code, body, _, _ = msg("17 乘以 23 等于多少?只给数字。", max_tokens=2000, thinking={"type": "adaptive", "display": "summarized"}, output_config={"effort": "high"})
th = [b for b in body.get("content", []) if b.get("type") == "thinking"]
row("思考块和签名", f"{brief(code, body)};thinking 块 {len(th)} 个;签名长度 {[len(b.get('signature') or '') for b in th]}")
# 五、数字
q = "用一句话解释什么是提示缓存。"
c1, counted, _, _ = call("/v1/messages/count_tokens", {"model": MODEL, "messages": [{"role": "user", "content": q}]})
c2, real, _, _ = msg(q, max_tokens=60)
u = real.get("usage") or {}
billed = sum(u.get(k) or 0 for k in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"))
row("数 token 的接口", f"接口说 {counted.get('input_tokens')},真实请求计了 {billed}(input {u.get('input_tokens')},缓存写 {u.get('cache_creation_input_tokens')},缓存读 {u.get('cache_read_input_tokens')})")
code, body, _, _ = msg(q, max_tokens=60)
u = body.get("usage") or {}
row("不标 cache_control 的普通请求", f"缓存写 {u.get('cache_creation_input_tokens')},缓存读 {u.get('cache_read_input_tokens')}")
RULES = "".join(f"第 {i} 条:回答 {1000 + i} 号仓库的问题时,先确认仓库编号和日期,再给库存数字,数字后面写单位,不确定的要说明来源。\n" for i in range(1, 41))
def cache(extra):
tag = os.urandom(4).hex()
system = [{"type": "text", "text": f"会话 {tag}。你是仓库助手,遵守下面的守则:\n{RULES}", "cache_control": {"type": "ephemeral"}}]
out = []
for i in range(4):
_, b, _, _ = msg(f"{1001 + i} 号仓库有多少库存?一句话。", max_tokens=40, system=system, **extra)
uu = b.get("usage") or {}
out.append((uu.get("cache_creation_input_tokens") or 0, uu.get("cache_read_input_tokens") or 0))
time.sleep(2)
return f"后 3 次读到缓存 {sum(1 for w, r in out[1:] if r > 0)} 次;每次(写, 读)={out}"
row("提示缓存,不带 metadata", cache({}))
row("提示缓存,带固定的 user_id", cache({"metadata": {"user_id": "compare-" + os.urandom(4).hex()}}))
# 六、在 Anthropic 那边执行的工具
def server_tool(tool, question):
code, body, _, _ = msg(question, max_tokens=800, tools=[tool])
if code != 200:
return brief(code, body)
blocks = sorted({b["type"] for b in body.get("content", []) if b.get("type") not in ("text", "thinking")})
return f"HTTP 200,内容块 {blocks}" if blocks else "HTTP 200,但没有工具的内容块:被忽略了"
row("联网搜索", server_tool({"type": "web_search_20250305", "name": "web_search", "max_uses": 1}, "搜索 Python 最新的稳定版本号,一句话回答。"))
row("抓取网页", server_tool({"type": "web_fetch_20250910", "name": "web_fetch", "max_uses": 1}, "抓取 https://example.com 并用一句话说它是什么。"))
row("代码执行", server_tool({"type": "code_execution_20250825", "name": "code_execution"}, "用代码执行工具算 2 的 100 次方的各位数字之和。"))
# 七、消息接口以外
for name, path in (("文件接口", "/v1/files"), ("批量接口", "/v1/messages/batches"), ("skills 接口", "/v1/skills"), ("托管智能体(列环境)", "/v1/environments")):
beta = "managed-agents-2026-04-01" if "environments" in path else None
row(name, f"HTTP {call(path, beta=beta)[0]}")
用法:
# 对着官方跑:只设官方的 key
export ANTHROPIC_API_KEY="你在 Console 里建的 key"
python3 compare.py
# 对着中转站跑:再设上中转站的地址,key 换成中转站的
export ANTHROPIC_BASE_URL="中转站的接口地址"
export ANTHROPIC_API_KEY="中转站的 key"
python3 compare.py
一遍有四十个左右的请求,大多很短,大头是缓存那两组和三个服务器端工具。官方那边跑一遍,按 Sonnet 5.5 的标价是几美分。
没有官方的 key 怎么办? Max 和 Team 套餐每月送一笔 API 额度,可以在 Console 里建 key,见那篇快讯。拿来跑这样一个对照,看看你常用的那家中转站和官方差在哪,很值。
八、写程序时的几条习惯
- 地址和 key 从环境变量读。 换中转站、换成直连,代码都不用改。
- 按官方的规则写请求。 第二节那九种写法都不要用,哪怕在你的中转站上能过。
- 看结果,不只看状态码。 工具有没有被调用、有没有在该停的地方停、JSON 能不能解析,都检查。
- 一场对话带同一个
metadata.user_id。 官方上不需要,但不影响;中转站上缓存靠它。 - 判断报错看状态码或异常类。
- 估花费看
usage,实际扣费看账单。
和 Claude Code 的关系
用 Claude Code 时,上面这些大多碰不到:它自己按官方的规则组织请求、自己带 user_id、自己管上下文。Claude Code 通过中转站有它自己的一组注意事项,见《通过中转站用 Claude Code》。
花费
两边各跑两遍,加上补充的几项,官方那边按 Sonnet 5.5 的标价估计合计约 0.2 美元,用的是 Max 套餐赠送的额度。中转站那边花的是测试用的那把 key 的额度。
这次实测没有覆盖到的
- 只对照了一家中转站、一天。 别家的表现要自己跑;这一家以后也可能改,第六节就是例子。
- 只用了 Sonnet 5.5。 别的模型上,官方会拒绝的写法不完全一样,以迁移指南为准。
- 耗时只说明本站这台服务器上的情况,不能拿来比两家的快慢。
- 限流没有触发过,两边 429 时的样子没有对照。
- 扣费没有对照,中转站实际怎么计费,以它的账单为准。
- 快速模式、MCP 连接器、工具搜索、程序化调用工具没有放进这次的对照,中转站上的情况见《通过中转站用 Claude API》。
- 脚本只看“请求能不能过、功能有没有发生”,不看模型是不是它声称的那个。 那是另一件事,见《thinking signature 是什么》。