发现Claude Code的文档Skill在单次简单问答后会消耗大量token(30%-40%上下文窗口),原因是Skill将整个文档内容注入上下文,可通过添加文件到特定目录部分缓解。
向 Claude Code 输入了一个一行问题——does fable use api billing?——然后习惯性地跑了 /context。
100 万 token 上下文窗口的 30% 没了。一问一答,用了 29.5 万 token。
然后我在第二台笔记本上跑了同样的提示词:40%。同一个问题,同一个答案,多用了约 10 万 token。
两台机器 CLI 版本相同。暂且叫它们笔记本 1(30%)和笔记本 2(40%)。这就是找出那 10 万 token 去向的故事。剧透:我的每个理论都是错的,根因最后证明是单个捆绑 skill 的设计缺陷——你可以通过往一个文件夹里加文件来部分解决这个问题。
这是笔记本 1 上跑完那一轮对话后 /context 显示的内容:
两件事很显眼。
第一,所有人都担心的固定开销——MCP 服务器、skills、memory——几乎不占 token。59 个 MCP 工具显示 0 token,因为 Claude Code 会延迟加载它们的 schema 直到实际使用。16 个 skill 描述加起来才 2.2k token。
第二,一行问题之后 Messages 分类占用了 264.6k token。对话本身可能才 2k token。剩下的来自我的问题提到了一个 Claude 模型名称,触发了内置的 claude-api skill——而 skill 触发不只是加载指令。这个 skill 把整个文档负载作为单条消息注入到了对话中。
值得在这里停一下:我的问题其实是一个账单查询,Google 搜索五秒就能答。而在你得出"agent 会话就是这样贵"的结论之前——其实不是。作为对照,我在全新会话里问了另外两个查询问题:"最新 Node LTS 版本是什么?"和"MIT vs Apache 2.0 哪个好?"。两个加起来才 7.5k token。普通问题很便宜。
这个 26.5 万的消耗有一个特定触发条件:提到 Claude 模型名称。这会唤起内置的 claude-api skill,而它没有任何"问题权重"的概念——一个随意的价格问题收到的多语言文档负载和"实现一个流式 tool-use 循环"完全一样。我为这个网络搜索五秒就能告诉我的东西花了 25 万 token。
在笔记本 2 上,同一分类显示 354k token。9 万 token 的差异完全在一条消息里。
错误的理论 #1:本地 CLAUDE.md 文件
我的第一个猜测:笔记本 2 有更多项目指令文件——CLAUDE.md、memory、rules——被悄悄注入上下文。
一想就不对。Memory 文件在笔记本 1 上只占 318 token,而差距约 90k token ≈ 360 KB 文本。CLAUDE.md 得是一本小书才说得过去。更重要的是,当我从笔记本 2 的空文件夹运行提示词时差距依然存在——没有任何项目文件,仍然是约 355k。
错误的理论 #2:skill 版本
捆绑的 skills 存在于内容寻址缓存中:
%LOCALAPPDATA%\Temp\claude\bundled-skills\<cli-version>\<hash>\claude-api\
两台机器都运行 CLI 2.1.226,skill 版本也是 2.1.226。同一个版本……但哈希不同。有戏!但当我比较实际文件时,每个 doc 文件夹都完全一致——两台机器上都是 847 KB 的资源。哈希差异是真实的,但后来的事实证明那只是症状,不是原因。
错误的理论 #3:tokenizer
这个差点骗到我,因为计算很漂亮。我之前试过 Sonnet 5,它带了一个新的 tokenizer,对同一段文本产生的 token 数大约多 30%。
264.6k × 1.3 ≈ 344k。和笔记本 2 的数字几乎一致。同样的字节,不同的尺子!
然后我做了对照实验:相同模型、相同努力、两台机器。差距依然存在。Tokenizer 排除。(不过记住这个失败模式——token 预算的直觉在不同模型家族之间确实不能迁移。)
相同 CLI、相同 skill 资源、相同模型、相同提示词——但 token 计数不同。这时唯一诚实的做法是停止理论推导,直接 diff 实际字节。
Claude Code 会把每个会话写入一个 JSONL 转录文件。找到那条重的消息只需一个循环:
$proj = Get-ChildItem "$env:USERPROFILE\.claude\projects" -Directory |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
$t = Get-ChildItem $proj.FullName -Filter *.jsonl |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
$i = 0
Get-Content $t.FullName | ForEach-Object { $i++
if ($_.Length -gt 50KB) { "Line $i : $([math]::Round($_.Length/1KB)) KB" } }
笔记本 1 转录文件:一条 719 KB 的消息。笔记本 2 转录文件:一条 957 KB 的消息。差距就在这里——238 KB 文本,按每 token 约 2.6 个字符算,约 9 万 token。
负载是 skill 的文档,以 <doc path="..."> 块的形式嵌入。提取文档列表:
[regex]::Matches($line, '<doc path=\\"([^\\"]+)\\"') |
ForEach-Object { $_.Groups[1].Value }
笔记本 1:32 份文档——共享 API 文档 + python/ 文件夹。
笔记本 2:65 份文档——共享 API 文档 + 全部八种语言文件夹:Python、TypeScript、Go、Java、C#、PHP、Ruby 和 cURL。
两份负载共有的全部 32 份文档完全一致。笔记本 2 的负载只是多了 33 份语言文档,总计 237 KB。
Diff 两份负载顶部的指令文本(各 60 KB,除此之外完全一致)只发现一个区别:
→ Refer to python/claude-api/README.md
→ Refer to unknown/claude-api/README.md
未检测到项目语言。询问用户正在使用哪种语言,然后参考下方匹配的文档。
这就是全部机制。当 claude-api skill 触发时,Claude Code 从工作目录检测你项目的语言,然后只注入该语言的文档。当它什么都检测不到——空文件夹、只有文档的仓库——后备方案是注入所有支持语言的文档,因为模型可能需要其中任何一种。
为什么笔记本 1 检测到了 Python 呢?我所在的文件夹有个子目录,里面有一个带 .venv 的 Python 项目——成千上万个 .py 文件。一个意外的 virtualenv 替我省了 9 万 token。
不同的缓存哈希也说得通了:skill 捆绑包似乎是按渲染变体缓存的——资源文件相同,但指令文本因检测结果不同而不同,所以每个结果有自己的哈希目录。
讽刺之处,以及值得知道的副作用
这是我最喜欢的部分。当我认真做起控制变量的工作时,我跑了"干净"实验:空文件夹、全新会话、相同提示词。这种方法论上纯粹的设置恰恰最大化了负载。受控实验创造了它所测量的条件。
另一面是一个确实有用、虽然奇怪副作用:工作目录里有语言上下文会减少 token 使用。任何能让 Claude Code 检测到语言的文件——一个 .py 文件、package.json、pyproject.toml,甚至一个遗留的 .venv——都会把 skill 负载固定到一种语言的文档上,每次 claude-api skill 触发节省约 9 万 token(约为负载的 25%)。最终得分,两台机器均可复现:
所以如果你准备在某个临时目录里向 Claude Code 问 API 问题:别这样。从一个真实项目里运行——或者先把一个 pyproject.toml(或者你所用语言的对等物)丢进那个临时文件夹。这听起来像个笑话,但每次会话可测量地节省 9 万 token,在更小的上下文窗口上一点都不好笑:全语言负载本身在 20 万 token 的上下文窗口里都放不下。这个整个问题只有在 100 万 window 的模型上才能回答。
为什么这是缺陷,不是特性
Claude Code 中的 skills 是围绕渐进式披露设计的:一行描述坐在上下文里(16 个捆绑 skill 加起来才 2.2k token),完整指令只在被触发时加载。claude-api skill 遵循了这个触发模式——然后在内容上完全抛弃了它:不是让模型按需读取需要的文档,而是急切地把整套文档作为单条消息注入。是唯一一个大到需要磁盘缓存的捆绑 skill(847 KB;其他 15 个都小到可以忽略)。
无语言检测的后备方案让情况更糟,简直像喜剧。被注入的指令文本明明白白写着:
未检测到项目语言。询问用户正在使用哪种语言,然后参考下方匹配的文档。
……但到了模型能问的时候,八种语言的文档已经被买单了。问题出现在购买之后。任何一种显而易见的设计——先问清楚再注入一种语言;只注入共享文档并让模型按需读取语言文件;按问题缩放负载——都能把开销上限限制在检测到语言的水平或以下。
公平地说一下范围:这是一个 skill,在一个 CLI 版本(2.1.226)里,而且负载在你用检测到的语言项目对 Claude API 写代码时确实有用。缺陷是那个急切的全语言后备方案以及触发器对问题权重的迟钝——两者都可以在上游修复,而不会失去 skill 的本来用途。
Skill 负载主导上下文成本。大家都怪的那些东西——MCP 服务器、memory 文件、系统提示——加起来才约 31k token。一个 skill 触发器增加了 265–360k。相应地去审计吧。
缺陷是具体的,不是通用的 agent 开销。全新技术问题("最新 Node LTS?"、"MIT vs Apache 2.0?")在全新会话里每个才花 7.5k token。贵的触发器是提到 Claude 模型或 API 名称。想要快速查 Claude 价格/文档,用网络搜索——在 Claude Code 内部,skill 会自动触发。
延迟加载是有效的。59 个 MCP 工具在用到之前都是 0 token。如果你的设置在急切加载 MCP schema,那值得修,但不是我的问题。
/context 告诉你分类;转录文件告诉你元凶。上面那个 JSONL 行长度技巧只需 30 秒就能指向确切的消息。
相同 CLI 版本 ≠ 相同上下文成本。成本取决于运行时条件——在这个例子里,取决于你工作目录里放着什么。
文件夹里的语言标记是一个 token 优化。反直觉,但可复现:给语言检测器一些东西去发现,skill 就会只注入一种语言的文档而不是八种。
Token 直觉在不同模型家族之间不能迁移。我的 tokenizer 理论这次错了,但 Sonnet 5 约 30% 的差异是真实的——切换模型时要重新校准。
以上全部是在 Claude Code 2.1.226(捆绑 claude-api skill 2.1.226)上、用 Fable 5 和 Sonnet 5、在两台 Windows 机器上测量的。行为在未来版本中很可能会有变化——可以说那个后备方案应该在注入 237 KB 多语言文档之前先问一下——但审计方法总会继续有效。
自己复现:从空文件夹里向 Claude Code 问任何 Claude-API 问题,跑 /context,然后在 Python 或 TypeScript 仓库里做同样的事,比较 Messages 分类。