Claude Code隐藏归因头与第三方缓存失效问题解析
Claude Code隐藏归因头与第三方缓存失效问题解析
这篇文章整理一次围绕 Claude Code 的问题排查过程:
- 一个看起来像“兼容 Anthropic API”的第三方网关,为什么会出现 token 消耗暴涨、推理变慢、缓存命中率极低 的问题
- Claude Code 到底在请求里偷偷塞了什么
- 为什么官方服务端基本不受影响,而第三方兼容服务经常会被打爆缓存
- 我在本地恢复源码仓库里是如何修复这个问题的
说明:下文统一使用 Claude Code 这个名称。部分社区讨论里会口误成 “Cloud Code”,本文都按 Claude Code 处理。
一、问题是怎么暴露出来的
问题最初来自一个 B 站视频。视频里提到:
- 使用 Claude Code 接第三方 Anthropic 兼容 API
- 最近发现同样的会话,前缀缓存几乎不命中
- 结果表现为:
- token 开销变大
- 响应变慢
- 同一段 system prompt 和历史上下文反复重新计算
这个现象有一个很明显的特征:
不是模型答错了,而是每一轮都像“第一次见到这段上下文”一样。
当我去检查本地恢复源码仓库 /Users/util6/fork-code/claude-code-rev 时,确认这个问题不是空穴来风,代码里确实存在对应实现。
二、源码里真正干了什么
在本地恢复仓库里,原始逻辑的关键位置主要有三处:
src/constants/system.tssrc/services/api/claude.tssrc/utils/sideQuery.ts
核心逻辑是:
- 先构造一段
x-anthropic-billing-header: ... - 再把这段内容作为 system prompt 的第一块文本 注入请求
- 请求发送前,如果启用了 native attestation,会把里面的
cch=00000占位符替换成真实值
它不是普通 HTTP header,而是伪装成 system 文本进入模型请求体。
可以把原始结构粗略理解成这样:
system block 1: x-anthropic-billing-header: cc_version=...; cc_entrypoint=...; cch=xxxxx;
system block 2: You are Claude Code, Anthropic's official CLI for Claude.
system block 3: 其他系统提示词
user block 1: 用户消息这就是问题的起点。
三、这个隐藏头里到底有什么
这段 billing header 里,最关键的字段通常有这些:
cc_version
表示 Claude Code 的版本,还会拼接一个指纹cc_entrypoint
表示从哪个入口进入,比如 CLIcc_workload
用于路由或 QoS 提示,可选cch
一个非常关键的字段。它不是固定值,而是 每个请求都可能不同的 attestation/token
在恢复源码里,cch 的生成并不是在普通 JS 逻辑里直接完成的,而是采用了“占位符 + 原生层替换”的做法:
cch=00000发送前再由底层 native HTTP stack 改写成真实值。
这说明这不是普通提示词,而是某种 客户端归因 / 客户端真实性证明(client attestation) 机制。
四、为什么它会把第三方缓存打爆
关键不在于它是不是 header,而在于:
它被拼进了 system prompt 的最前面,而且还是 每轮都会变化 的内容。
1. Prompt Cache 的命中方式,本质是“前缀内容完全一致”
很多大模型服务端的 prompt cache / prefix cache,不是模糊匹配,而是类似:
- 把
system + messages按顺序拼起来 - 在若干个断点上计算“从开头到这里”的前缀 key
- 如果 key 完全相同,就复用 prefill / KV cache
所以只要最前面那段变了,哪怕只改了一个字符,后面所有依赖这个前缀的缓存断点都会一起变。
2. 为什么放前缀尤其致命
假设原始请求是:
A = 你是 Claude Code
B = 规则说明
C = 用户输入缓存断点可能是:
k1 = hash(A)
k2 = hash(A + B)
k3 = hash(A + B + C)现在前面多出一个每轮都变的 header:
H1 = x-anthropic-billing-header ... cch=97bd6
H2 = x-anthropic-billing-header ... cch=24c2d第一轮:
k1 = hash(H1 + A)
k2 = hash(H1 + A + B)
k3 = hash(H1 + A + B + C)第二轮:
k1 = hash(H2 + A)
k2 = hash(H2 + A + B)
k3 = hash(H2 + A + B + C)因为 H1 != H2,所以 k1/k2/k3 全部不同。
缓存自然全 miss。
3. 如果它拼到后缀,会不会就没事
不一定,但通常会好很多。
真正决定缓存是否失效的,不是“前缀 / 后缀”这四个字本身,而是:
- 它是不是位于 缓存断点之前
- 它是不是被纳入 缓存 key 计算
如果一段每轮都变的内容只出现在所有缓存断点之后,它仍然会消耗上下文,但不会污染前面可复用的缓存前缀。
而这个 billing header 恰好被放在 system 第一块,基本等价于“污染所有前缀”。
五、它是不是故意限制第三方 API
如果只看“客户端发没发这段东西”,答案反而是:
- 原始客户端逻辑对 provider 并不区分
- 它默认会发送这段 attribution/billing header
所以从“请求形态是否统一”这个角度说,客户端原始行为反而是“一视同仁”的。
但从最终效果上看,它又明显不是一视同仁的:
- 官方 Anthropic 服务端知道怎么处理这段数据
- 第三方 Anthropic-compatible 网关通常不知道
于是结果变成:
- 官方:缓存照常命中
- 第三方:缓存被污染、token 变贵、速度变慢
所以更准确的说法是:
这首先是一套 官方客户端归因 / attestation 机制,但它天然带来了对第三方兼容链路极不友好的副作用。
它未必从动机上就是“专门为了打第三方缓存”,但从工程效果上,确实构成了限制。
六、官方为什么能不受影响
这部分是整个问题最关键的理解点。
很多人直觉上会以为:
“我发给官方 API 的请求,不就是直接喂给模型吗?”
实际上,商业大模型 API 基本都不是这样工作的。
更合理的链路是:
也就是说,在“真正进入模型”之前,通常还会经过一层甚至多层请求处理。
1. 恢复源码里其实已经泄露了这个事实
本地恢复仓库里有两处注释非常关键:
src/utils/sideQuery.ts明确写了:attribution header 要单独放一个 block,以确保 server-side parsing 能正确提取
cc_entrypointsrc/constants/system.ts还提到了:Server _parse_cc_header tolerates unknown extra fields
这说明后端几乎肯定存在一个专门的解析层,会把这个伪 header 从 system block 里抽出来。
2. 最可能的后端处理方式
我认为最合理、也最符合工程常识的方案是:
- API 网关收到请求
- 先检查 system 第一块是不是
x-anthropic-billing-header - 解析出:
cc_versioncc_entrypointcchcc_workload
- 把它们转成内部 metadata
- 然后:
- 要么从真正给模型看的 prompt 里剥离
- 要么至少在缓存 key 计算时忽略
所以,官方服务端实际上可能看到的是:
prompt:
你是 Claude Code ...
其他系统提示 ...
用户消息 ...
metadata:
cc_version=...
cc_entrypoint=...
cch=...
cc_workload=...而不是让模型真的去“阅读”这段 billing header。
3. 第三方为什么做不到
第三方兼容服务没有这个私有约定,它只能看到:
- 这是 system 第一块
- 这块文本长得像普通字符串
- 每一轮还不一样
于是它只能保守处理:
- 当成普通 system prompt
- 算进 prompt cache key
- 最终导致缓存全 miss
这不是第三方实现“太差”,而是它不知道这段私有字段原本不该参与缓存语义。
七、这个问题和 AI Infra 的关系
这个例子其实很好地说明了一件事:
LLM API 服务的本质,和传统电商、视频网站、支付系统一样,都是一套高并发、多租户、可观测、可计费的在线服务。
只不过 AI Infra 比普通 Web 服务又多了几层特殊能力:
- tokenizer
- batching
- prefix cache / prompt cache / KV cache
- prefill / decode 调度
- 模型路由
- 供应商路由
- 结构化输出处理
- tool / function calling 编排
所以“在请求真正到达模型之前,还有一层处理层”不但正常,而且几乎是必然的。
八、我在本地仓库里是怎么修复的
这次目标不是“只让第三方不受影响”,而是:
对所有模型一视同仁,并尽量减少额外上下文消耗。
在这个目标下,最干净的方案不是把 header 往后挪,也不是只在某些 provider 上禁用,而是:
直接不发送这段 attribution/billing header
1. 为什么我没有选择“挪到后缀”
因为后移只能“减轻缓存污染”,不能彻底解决问题:
- 它仍然会额外占用 system / prompt token
- 只要落在某个缓存断点之前,还是会污染缓存 key
而用户真正关心的是:
- 请求形态对所有 provider 一致
- 不要隐形限制
- 不要额外浪费上下文
那直接移除,比后移更彻底。
2. 实际修复方式
我在本地恢复仓库里采用的是一个非常直接的做法:
- 让
isAttributionHeaderEnabled()永远返回false - 因而
getAttributionHeader()永远返回空字符串 - 主请求和 side query 的 system block 里都不会再注入这段内容
这样一来:
- 不管接 Anthropic、Bedrock、代理还是其他 provider
- 请求形态完全一致
- 不再多出
x-anthropic-billing-header - 不再多出
cch - 不再污染前缀缓存
- 也不再额外占用 system prompt 上下文
九、最终结论
这个问题的本质,可以压缩成一句话:
Claude Code 把一段带有 动态 attestation 字段 的伪 header 塞进了 system prompt 最前面。官方后端知道怎么把它解析并剥离,第三方兼容服务通常不知道,于是这段每轮变化的内容污染了前缀缓存 key,导致缓存命中率大幅下降。
从工程角度看,这背后至少包含了三层机制:
- 客户端归因 / attestation
- 服务端请求解析层
- 前缀缓存对动态前缀极其敏感
而从本地修复角度看,最稳妥的方案也很明确:
不让这段 attribution header 再进入 prompt。
这不仅能让所有 provider 一视同仁,也能顺手减少额外上下文消耗。
十、给自己的工程提醒
如果以后自己做 LLM 网关、提示词缓存、OpenAI-compatible 兼容层,至少要记住下面几点:
- 不能假设所有 system block 都是纯语义提示词
- 需要区分“真正给模型看的 prompt”和“仅给基础设施使用的 metadata”
- 任何会变化的前缀字段,只要进入缓存 key 计算,就会天然破坏命中率
- 做兼容层时,必须考虑上游客户端有没有私有协议、伪 header、特殊 block、隐藏 beta 字段
在传统 Web 服务里,大家已经习惯了“控制面数据和业务数据分离”。
到了 AI Infra,这个原则一样成立,只是更容易因为 prompt 形式而被忽略。