不是额度,也不是指纹:这次真凶藏在 system 首块
上一篇《不是额度,是指纹》的结尾我说:再见到 RESOURCE_EXHAUSTED,先问这条错误是谁发给你的、它有没有理由说谎。两周后它真的又来了,而且这次连我自己上篇的结论也一起被推翻了——词指纹不是唯一凶手,甚至不是这次的凶手。这次排查里最有价值的一课:旧结论和错误码一样,都是待验证的假设,不是免死金牌。
一、链路换了,老朋友换了个地方赴约
先把结构变化说清楚。上篇的链路是 Claude Code → 自建网关 → CPA → mihomo → Google,排查结束后我把中间两层合并了:CPA 退役,换成 magpie——一个本地多模型网关,顺手接管了模型路由和插件管理。链路从五层变四层:
Claude Code(主会话 / 子代理) → magpie(协议转换 + 路由分流 + 中间件) → mihomo(代理,海外节点出去) → Google(Antigravity 订阅池)换完当晚主会话一切正常。第二天开 haiku 档的子代理——分流规则把这档送去 gemini-flash——429 回来了,文案还是那熟悉的配额耗尽。
我的第一反应非常自然:这病上个月刚治过,药方还在。上篇的结论是身份词指纹,修法是敏感词零宽混淆。magpie 有网关中间件机制,能在请求出网关前改写请求体,我把等价的混淆逻辑写成中间件挂上,测:还是被拒。
药方无效只有两种解释:要么药方本身不对,要么这是另一种病。上篇那套验证过三层问题,词指纹是实打实测出来的——所以更像后者。但”更像”不是结论,是下一个待验证的假设。
二、先审问网关:这条错误到底是谁说的
上篇最硬的教训是错误码不可当证据用。这次我把顺序提前:不看错误文案,先看网关的路由日志。magpie 对每个请求都会记一条路由决策,谁进的、送到哪个上游、上游回了什么,这是链路里最原始的证词。
一看就发现症状是转述过的:Antigravity 实际回的是 400 配额文案,根本不是 429;而且失败之后路由组立刻兜底重试,最后落到我眼前的错误已经是第三手转述——原始 400、网关兜底失败、错误聚合器改写文案。我之前对着最终文案推理了半天”配额为什么耗尽”,等于在审一份传闻证据。
所以第一件事不是改代码,是抓原话。
三、抓原话:tee 代理截获 87KB
要比对”成功的请求”和”失败的请求”,得先有两个真实样本。成功样本好办,curl 手搓一个就过;失败样本必须是真的 Claude Code 发出去的原始字节。办法是老一套:本机起一个 tee 代理(监听 3426,转发到网关 3425,把原始字节落盘),再把 Claude Code 指过去。
这里踩了两个坑,都值得记:
一是环境变量压不过配置文件。网关地址之前写进了 Claude Code 的 settings.json,单纯 export 环境变量根本不生效,客户端压根没走我的 tee。要用 claude --settings 显式覆盖。
二是删文件不等于断流。tee 进程还握着旧文件的 inode,我删掉抓包文件重建,它还在往那个已经 unlink 的旧 inode 里写,新文件永远是空的。重启 tee 才算换手。
拿到 87KB 真实请求后,和 curl 成功样本做结构对比,列出所有 curl 没有的特征:一套客户端请求头、请求体里的用户标识、工具定义数组、system 开头多出来的一个块、流式开关。六个候选,凶手在其中一个里。
四、消融实验:一次只放回一块
六个候选逐个”推理”是没有尽头的,每个都像、每个都不像。这种时候不该继续想,该做实验:拿 curl 干净样本当基线(它稳定 200),每次只加回一块真实特征,谁引爆谁负责。
| 加回的特征 | 结果 |
|---|---|
| Claude Code 全套请求头 | 200 |
| metadata.user_id(设备指纹) | 200 |
| 完整 tools 数组 | 200 |
| 流式请求 | 200 |
| 模型名上的 [1m] 窗口标记 | 200 |
| system 首块:x-anthropic-billing-header | 被拒,每次都是 |
真凶是 Claude Code 放在 system 数组第一位的计费元数据块,内容是一行 cc_version=…; cc_entrypoint=sdk-cli; …。这个块是写给 Anthropic 自己计费系统看的记账便签,对谷歌毫无用处,却被上游拿来当了识别特征。把这一块去掉,87KB 的完整真实请求体原样重放——200。
回头看词混淆为什么”没用了又没完全没用”:身份词还散在 system 其余部分和 messages 里,混淆它们确实消掉了词指纹这条触发线,但计费块是另一条独立的触发线,一条词一条块,切掉一条另一条还在。上篇的病里两条线可能都存在,只是当时消融没细分到 system 块级别,药方恰好把两条都盖住了大半。
回头看本质就一句:错误码会说谎,上游的拦截特征也不止一条,每次复发都得重新归因。
五、药方:两层中间件,热重载免重启
修法落在 magpie 的网关中间件上。这是个单文件 JS 插件,onRequest(body, ctx) 里改写请求体再放行,热重载,一秒生效,网关不用重启,生产链路中途不用动。
两层逻辑,都很短:
// 层一:丢掉含计费标记的 system 块const billing = o.billing ?? ["x-anthropic-billing-header"]if (Array.isArray(body.system) && billing.length) { const kept = body.system.filter(b => !(b && typeof b.text === "string" && billing.some(k => b.text.includes(k)))) if (kept.length !== body.system.length) { body.system = kept; changed = true }}
// 层二:零宽混淆身份词(上篇药方,降级为保险层)const re = new RegExp("(" + words.map(escapeRe).join("|") + ")", "gi")const hide = (t) => t.replace(re, (w) => w.split("").join(ZW))词混淆从主角降成配角,但我没删它:哪天上游改规则,它是第一道闸。所有阈值(词表、计费标记)都做成 options 可换,[] 可单独关掉一层。
中间件机制有个容易忽略的行为:钩子抛异常等于静默放行,请求原样过去,症状是”药没生效”而不是报错。我第一版就踩了——注入的 changed = true 写在了 let changed 声明之前,TDZ 崩溃,中间件当场变哑巴,我还以为逻辑错了查了半天。所以这个插件带了个探针:options 里开 probe 后,发一条内容恰为 PROBE_CLAUDE_SCRUB 的用户消息,网关回 418,一键判活。
六、验证与开源
三重验证,全部真实链路:
- 87KB 真实请求体去掉计费块后原样重放——200;
- 真 Claude Code 开 haiku 档子代理,让它只回一个单词——返回 ok,退出码 0;
- 探针消息——418,中间件在岗。
整套东西整理成了 magpie 插件开源:仓库在 heaven-1314/magpie-plugin-claude-scrub,一条命令安装:
magpie plugin add github:heaven-1314/magpie-plugin-claude-scrub十一个测试用例按官方契约写,也提了 PR 进社区插件仓库。适用范围写在 README 里:自己的订阅账号、自己的网关,一人一户。
七、复盘清单
| 层 | 症状 | 真凶 | 修法 |
|---|---|---|---|
| 一 | 429 复发,旧药方无效 | 症状是网关层层转述过的 400 | 查路由日志拿原始证词 |
| 二 | 六个候选特征不知谁负责 | system 首块计费元数据 | 消融:基线 200,逐块放回 |
| 三 | 中间件挂了没反应 | 钩子抛异常被静默吞掉 | 探针判活,改完先 418 再收工 |
几条我认为可迁移的:
旧结论也是待验证的假设。复发性问题最诱人的捷径是直接搬上次的药方,但上游的拦截规则是多条的、会变的。药方无效时先别怀疑药方,重新归因。
多候选归因不做推理做消融。候选超过三个就不该继续在脑子里排列组合,找一个干净基线,一次只放回一个变量,让每次实验只回答一个是非题。
长链路里的错误文案都是转述。离你最近的那个错误,往往经过聚合、兜底、改写三道手。归因前先找到最原始的那份日志,网关的路由决策日志是首选。
中间件类组件必须自带心跳。凡是”抛异常即放行”的 fail-open 设计,都要配一个探针端点,否则它坏了的样子和没装一模一样。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!







