给 Claude 中转站验明正身:签名里的模型身份
用中转站的 Claude,价格是真便宜,渠道也多得眼花缭乱,但有个问题一直绕不开:我花的钱,买到的到底是不是真货?
说实话,光看响应体感上很难分辨。thinking 块也有,signature 也有,service_tier 也对,id 也是 msg_01 开头——看着挺像那么回事。但”像”不等于”是”。这些东西说白了都能伪造,signature 也就是一串 base64,谁都能随便拼一个。
所以我花了一些时间,折腾出了一套验证方法,从解码签名明文开始,到交叉验证签名的真假,再到写个定时任务持续巡检。这里完整记录一下,代码全用 Go 写的,跑在服务器上挂着就行。
签名到底是个啥
Anthropic 的响应里,如果启用了 thinking(就是 extended thinking),返回的 thinking 块会带一个 signature 字段。这个东西本质上是用 Anthropic 的私钥对完整思考内容做的数字签名。
关键的是:这个私钥全世界只有 Anthropic 自己拿着,第三方手里没有,也推导不出来。所以如果一个签名的确是官方私钥签的,那就是真的;如果签不出来,那只能是假的。这就是整套验证方案能成立的底层逻辑。
打个不太严谨但好理解的比方:签名就像官方的防伪钢印。你可以仿制包装盒(伪造响应格式),但你弄不出那个钢印的凹凸纹理(私钥签名),拿去官方一验就露馅。
第一步:解码签名明文,先把最假的筛掉
签名虽然是加密的,但你把它 base64 decode 之后,里面其实藏着一小段明文——官方签名的时候会写死一个模型标识进去,类似这样:
1 | claude-fable-5 |
这段明文不是加密的,就是编码在签名结构里的元数据。也就是说,你不需要任何密钥,光靠 base64 解码就能看到它里面写的是哪个模型。
写个简单的 Go 程序就能提取出来,你获得的是 claude-fable-58,这是正常的,如果你要检测 Opus,记得修改:if strings.Contains(mid, "fable") :
1 | package main |
这个阶段能筛掉什么?筛掉那种连装都懒得装像的:你请求的是 claude-fable-5,结果签名明文里写的是 claude-opus-4-8——都不用往下验了,直接拉黑。这种大概率是下游套了个别的便宜模型在冒充,连签名的模型标识都没改。
当然,如果你请求的模型本身就是小版本号很多的(比如 haiku、sonnet 的不同迭代),明文里显示的可能是更底层的模型代号,不一定跟你请求的模型名严格一致。但至少型号得对得上——你请求的是 fable 系列,签名里却写着 opus,怎么都说不过去。
第二步:交叉验证——让官方来认
第一步能筛掉最粗制滥造的假货,但有个问题:明文本身也能伪造。我拼一个包含 claude-fable-58 字符串的 base64 很难吗?几行代码的事。
所以真正的硬验证是:把这个签名发给官方,看它认不认。前面说了,签名的私钥只有 Anthropic 有。你用私钥签出来的东西,官方验证的时候能过;你伪造的字符串,就算明文写对了,到官方那里一验就报 Invalid signature。
问题来了:怎么发回给官方验证?如果你手上本来就有官方的 Claude API key,那直接用官方 API 验就行。但说实话,大部分人用中转站的原因就是因为没有官方的 key,毕竟 A/ 的德行,都懂的…..
这时候 OpenRouter 就派上用场了。OpenRouter 的 Anthropic 端点后端直接就是官方,而且它支持 provider 参数来强制指定路由到哪个供应商。只要把 provider 设成 {"order": ["anthropic"]},你的请求就一定会走到 Anthropic 官方后端,不会被负载均衡到其他云上的供应商。
关键依据在这里,官方文档白纸黑字写的:
signature values are compatible across platforms (Claude APIs, Amazon Bedrock, and Google Cloud). Values generated on one platform work on another.
—— Thinking encryption 官方文档
翻译一下:签名跨平台兼容,一个平台生成的签名在另一个平台也有效。所以不管你的中转站上游是 Azure、Bedrock 还是 Google Cloud,它的响应签名都能拿到 OpenRouter 去验证。
这个兼容性或许不是巧合,是 Anthropic 故意设计的——签名用的是同一套私钥,验证自然也是同一套逻辑。如果签名有问题,你就会收获:
1 | {"error":{"message":"Provider returned error","code":400,"metadata":{"raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_011CdaeZ8yEJYQopRE6Nrucu\"}","provider_name":"Azure","is_byok":false,"previous_errors":[{"code":400,"message":"Provider returned error","provider_name":"Anthropic","raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_011CdaeYtExbLzgxyzirpHAV\"}"},{"code":400,"message":"Provider returned error","provider_name":"Amazon Bedrock","raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_011CdaeYwFHWbEZbg6Y13nbz\"}"},{"code":400,"message":"Provider returned error","provider_name":"Google","raw":"{\"type\":\"error\",\"error\":{\"type\":\"invalid_request_error\",\"message\":\"messages.1.content.0: Invalid `signature` in `thinking` block\"},\"request_id\":\"req_vrtx_011CdaeYyzjKCiFJ8jmJEjVT\"}"}]}},"user_id":"user_3AJa6bua6Af5ze9Gfl8w46LSeHK"} |
验证的示例代码:
1 | package main |
判断逻辑非常直白:200 = 签名是真的,官方的私钥签的;400 且 body 里是 Invalid signature = 伪造的。其他错误码(比如 401/429)那是你自己的 key 或者额度问题,不是签名的问题。
第三步:A 模型的签名丢给 B 模型,会怎样?
有人可能会动歪脑筋:你不是要我用 fable-5 的签名来证明我调的是 fable-5 吗?那我拿一个 opus-4-8 的真签名来冒充行不行?反正都是真签名,都是官方签的,你的交叉验证能过就行。
好问题。实测结果:OpenRouter 不会报错,直接 200 通过。
这就是官方文档里写的另一个行为:
Thinking blocks are tied to the model that produced them. Other models silently ignore them rather than rejecting the request.
思考块(Thinking blocks)与生成它们的模型绑定。其他模型会默默忽略它们,而不会拒绝请求。
签名验证其实分了两个层面:
- 完整性验证:签名本身是不是官方私钥签的、有没有被篡改过?如果签名的内容跟 thinking 原文对不上,或者根本就是个假签名,必报 400 Invalid signature。
- 模型绑定:签名是哪个模型签的?如果你把 opus-4-8 的签名塞进 fable-5 的请求,官方不会拒绝你——它只是静默忽略这个旧签名,然后用 fable-5 重新思考一遍,返回一个新的 fable-5 签名。
所以单靠交叉验证(只看 200 还是 400)确实有被绕过的风险:只要能搞到一个任何模型生成的真签名,交叉验证这一步就能过。
但别急,这就是为什么第一步的明文解析不能省。交叉验证过了只能说明”这个签名是官方签的”,而明文解析告诉你”这个签名是哪个模型签的”。
opus-4-8 的签名明文里是 claude-opus-4-8,fable-5 的签名明文里是 claude-fable-58。想冒充 fable-5?那你得有一个 fable-5 生成的签名——但你要是有 fable-5 的真签名,说明你确实调了 fable-5,还有什么好冒充的呢?
这就是”明文筛选 + 交叉验证”组合在一起才完整的原因:
- 交叉验证回答”签名是不是官方签的”(真伪问题)
- 明文解析回答”官方签的是哪个模型”(身份问题)
两个都过了,你才能确定:这个响应是官方 Claude 生成的,而且生成它的模型就是你请求的那个。
你今天测了,明天呢?
说实话,这套方法有一个天然的软肋:它只验证了你手动测试的那一条请求。
中转站完全可以第一天给你真的(甚至头一个星期都给你真的),等你信任了、充值了、开始大量用了,某一天偷偷把后端切成便宜的假模型。你手动测一次根本发现不了——你要是在它换成假模型的第二天刚好没测,那你就一直在用假货而不自知。
这不是理论上的担忧。中转站的商业模式决定了它天然有动机降低成本——上游 API 的价格是刚性的,但卖给用户的价格是可以自由定价的。如果中间差价不够吃了,切个便宜的”平替”是最直接的省钱方式。
所以对策也很直接:别手动测,写个程序让它跑着。
1 | package main |
把环境变量配好:TARGET_BASE(待测渠道地址)、TARGET_KEY(渠道的 key)、TARGET_MODEL(模型名)、OPENROUTER_KEY(你的 OpenRouter key)。程序跑起来之后,扔在服务器后台或者挂在 systemd / cron 里就行,异常会在日志里直接暴露。
巡检频率的话,每 1-2 小时一次足够了——太频繁了浪费钱(OpenRouter 也要走 token 计费),太稀疏了发现问题的窗口期太长。如果你特别谨慎,可以再配一个告警(比如飞书 Webhook 或者钉钉机器人),出异常第一时间通知你。
总结
整套方法说穿了就三步:
- 解码签名明文——先筛一遍,模型标识对不上的直接淘汰
- 交叉验证——把签名发给 OpenRouter 官方验证,200 是真,400 是假
- 定时巡检——写个程序让它自己跑,每 1-2 小时一次,连续几天没异常就稳了
背后的原理其实就一个:签名用的是 Anthropic 的私钥,跨平台兼容,只有官方能生成,而且生成时就把模型身份写死在里面了。这是目前能想到的,验证中转站模型真伪最硬核的依据。
踩过的坑
一些实操中容易翻车的地方,列在这里供参考:
- adaptive thinking 的问题:如果你用的是 adaptive thinking 模式(而不是手动设 thinking budget),简单问题(比如”2+2 等于几”)模型可能直接跳过思考、不返回 thinking 块。测试的 prompt 需要有足够的复杂度(比如辗转相除法求最大公约数、多步逻辑推理这类),才能稳定触发模型输出思考内容。
- thinking 被省略但签名还在:有些渠道为了省 token,会设置
display: "omitted",这时候返回的 thinking 文本是空的,但signature字段依然存在,不影响提取和验证。 - OpenRouter 记得强制路由:调 OpenRouter 的 Anthropic 端点时,务必带上
provider: {"order": ["anthropic"]},不然请求可能会被分发到其他供应商(比如通过 Bedrock 或 GCP 托管的其他实例),虽然不影响验证结果,但为了严谨还是强制走 Anthropic 直连最好。 - OpenRouter 的费用:交叉验证每次会消耗少量 token(主要是 prompt 的输入 token),按 OpenRouter 的计费标准,单次验证大概几分钱人民币。每 2 小时跑一次,一个月下来也就几块钱,比用着假模型亏的钱少得多。
- 签名明文和模型名的对应关系:签名里的模型标识是 Anthropic 内部代号,不一定跟 API 的模型名完全一致。比如 claude-fable-5 的内部代号可能是
claude-fable-58(注意数字部分),这个需要你自己先拿一个确认是真的签名来看一下具体长什么样。 - 网络超时和重试:中转站有时候不太稳定,fetch 签名的时候偶尔会超时。生产环境的代码最好加上重试逻辑(比如失败后间隔 30 秒重试一次,最多 3 次),别因为一次网络抖动就误报。
许可协议
本文由 ROYWANG 原创,采用 CC BY-NC-SA 4.0 协议。转载请注明出处。
PERMALINK
https://roy.wang/claude-channel-verification/