Claude Code 省 Token 实操手册 · 按你这台机器改一遍
这份手册只回答一个问题:我该怎么改我这台机器。不重复讲解原理,只给动作、验证和判据。 「视频讲了什么」在 视频整理版 里,两份分工不重叠。
素材:Anthropic 官方博客 Maximizing the value of your Claude Code sessions(2026-08-14, Lydia Hallie)+ 一个 19 分钟的中文讲解视频 + 本机实测(2026-08-24)。
配套脚本:
context-audit.sh(全文见文末附录 C,可直接复制使用)。手册里所有关于「你这台机器」的数字都来自它, 不是我估的。验证基准:本机 Claude Code 2.1.220(
claude --version)。手册用到的每个斜杠命令 都在这个版本的二进制里确认过注册(name:"rename"/name:"rewind"/name:"autocompact"这类注册块,与/compact同形态)。换版本请重新确认——命令名和前置条件都会变。🔔 「重新确认」不用手工做,跑这一段就行(零 API 成本,几秒):
BIN=$(python3 -c "import os,sys;print(os.path.realpath(sys.argv[1]))" "$(command -v claude)") miss=0 for c in rename rewind autocompact effort context compact clear model mcp config agents; do strings -a "$BIN" | grep -q "name:\"$c\"" || { echo "❌ /$c 不再注册"; miss=1; } done [ $miss -eq 0 ] && echo "✅ 11 条都还在($(claude --version))"⭐ 它检查的是结论本身,不是版本号——所以 Claude Code 每发一个补丁版它都不会瞎叫, 只在真的有命令被改名/删掉时才响。⚠️ 用版本号当判据的话,
2.1.220三天内就变成了2.1.247(27 个补丁版), 而这 11 条命令一条都没动 —— 那样的判据会天天误报,最后被无视。📌 校准过:把一个不存在的命令名混进那个列表,脚本确实会报
❌。 ✅ 2026-08-27 复核:本机仍是 2.1.220,11 条全部命中。⚠️ v2 修订(2026-08-24 晚,在 Unity 项目
<项目名>_dev里复核):初版所有基线数只扫了~/.claude一个目录,因此漏掉了两个更大的常驻项——项目级CLAUDE.md和 auto-memoryMEMORY.md。修正后「大头是 skills 元数据」这个结论在带项目配置的会话里不成立。 详见第 0 章的双表和附录 B 第 8 条。如果你读的是初版并照着优化了,请重跑第 0 章。
先说清我做不到的四件事
写在最前面,因为这决定了你该信这份手册到什么程度。
- 我不能替你跑
/context——它是交互式命令。脚本量的是配置侧静态开销(每开一个新会话就要付的那部分),/context量的是运行时实际占用。两者互补,不能互相替代。凡是要下结论的数,最终以/context为准。 - token 数是估算。脚本以字符数为主指标(精确、可 diff),token 只给
÷3.3 ~ ÷2.5的区间。按常数折算不是真实 tokenizer,把估算值当主指标就是判据错位。 - 第三方网关的缓存折扣我无法验证。你本机有 OneHub / DSH / PiAgent 这些接第三方模型的路径,0.1× 缓存折扣在那些链路上是否生效,本文只给判断方法,不给结论(见 §8.3)。
- 我不判断哪个 skill「你不用」。脚本给你调用统计,结论你自己下。原因见 §9——那个判据会误报,而它导向的动作是删除。
一条贯穿全篇的口径(v2 新增)
「配置文件里没有」≠「会话里没有」。 这句话在初版只写在 §7 的 MCP 一节,但它对 hooks(§8.2)同样成立,而初版在那里没说——于是脚本在某些配置目录下会报「没有配置 hooks」, 而实际有 7.7k 注入。已确认的反例:
~/.claude/settings.json hooks 字段有 11 类
~/.claude-onehub/settings.json hooks 字段 0 类
~/.claude/plugins/cache/thedotmack/claude-mem/13.8.1/hooks/hooks.json
→ Setup / SessionStart / UserPromptSubmit / PostToolUse / PreToolUse / Stop
↑ 这里的 SessionStart + UserPromptSubmit 同样注入上下文,
但它不在任何 settings.json 的 hooks 字段里
凡本手册出现「有 N 个 X」,都请读成「至少 N 个」。 脚本数的是它能看到的那个字段, 不是运行时真实加载的全集。
第 0 章 先跑体检,存下基线
改任何东西之前先量一次,否则你不会知道改有没有用。
cd ~/你放脚本的目录 # 脚本全文见文末「附录 C」
./context-audit.sh --save # 体检 + 存基线
./context-audit.sh --usage # 额外统计 skill 真实调用(扫会话历史,约 12 秒)
全部参数:
| 参数 | 作用 |
|---|---|
| (无) | 体检 + 与上次基线对比 |
--save |
体检并把本次存为新基线(存到脚本同目录 .context-audit/) |
--usage |
加上 skill 真实调用统计(扫 ~/.claude/projects/**/*.jsonl) |
--usage-days N |
调用统计回溯天数,默认 30。季节性工具要用 90 再看一遍 |
--top N |
description 排行榜长度,默认 10 |
--help |
用法 + 「它量什么/不量什么」的说明 |
换机器或换配置目录:设 CLAUDE_CONFIG_DIR 环境变量后再跑。
你这台机器 2026-08-24 的基线(脚本原样输出,全局侧):
项目 数量 字符数 ≈tokens 占比
------------------------------------------------------------------
skills 元数据 68个 17,213 5,216~6,885 97.7%
agents 元数据 0个 0 0~0 0.0%
CLAUDE.md 11行 404 122~162 2.3%
------------------------------------------------------------------
合计(配置侧静态常驻) 17,617 5,338~7,047 100%
幽灵开销(磁盘上有但 plugin 未启用,不进上下文):55 个 skill、31 个 agent。别去优化它们。
对照:所有 SKILL.md 正文合计 1,196,571 字节 —— 懒加载,不常驻。常驻的只是其中约 1.4%。
(脚本这一行拿「字符」比「字节」,口径不一致;同按字节算是 1.49%。见附录 B 第 6 条)
⚠️ 上面这张表只有一半(v2 修订)
初版到这里就下结论了,而这是全篇最大的判据错位。上表的「CLAUDE.md 404 字符」指的是
全局 ~/.claude/CLAUDE.md。但 Claude Code 还会自动发现项目级 CLAUDE.md,
auto-memory 还会每会话注入 MEMORY.md——两者都没被脚本扫到。
在 Unity 项目 <项目名>_dev 里实测(脚本 v2 原样输出,非手工估算):
项目 数量 字符数 ≈tokens 占比
------------------------------------------------------------------
[全局]skills 元数据 68个 17,213 5,216~6,885 34.6%
[全局]agents 元数据 0个 0 0~0 0.0%
[全局]CLAUDE.md 0行 0 0~0 0.0%
[项目]CLAUDE.md 223行 11,237 3,405~4,495 22.6%
[项目]MEMORY.md 135行 16,279 4,933~6,512 32.7%
[项目].claude/skills 21个 5,055 1,532~2,022 10.2%
------------------------------------------------------------------
合计(配置侧静态常驻) 49,784 15,086~19,914 100%
全局侧 17,213 字符 (35%) · 项目侧 32,571 字符 (65%)
| 常驻项 | 字符 | ≈tokens | 初版脚本扫到了吗 |
|---|---|---|---|
全局 ~/.claude/CLAUDE.md |
404 | 122~162 | ✅(onehub 后端下为 0,见下方注) |
项目 <repo>/CLAUDE.md |
11,237 | 3,405~4,495 | ❌ 漏 |
auto-memory MEMORY.md |
16,279 | 4,933~6,512 | ❌ 漏 |
项目 .claude/skills/(21 个) |
5,055 | 1,532~2,022 | ❌ 漏 |
| 全局 skills 元数据(68 个) | 17,213 | 5,216~6,885 | ✅ |
| 合计 | 49,784 | 15,086~19,914 | 初版只报了 17,617 |
注:上表实跑于
CLAUDE_CONFIG_DIR=~/.claude-onehub,那个目录没有全局CLAUDE.md(~/.claude下才有那 404 字符)。这本身就是「多配置目录」那一节要说的事—— 换后端,连全局 CLAUDE.md 有没有都会变。
结论直接反转:
- 初版说「大头是 skills 元数据(97.7%),CLAUDE.md 只占 2.3%」——这只在没有项目配置的裸目录里成立。
- 在一个真实项目会话里,项目侧合计 32,571 字符(65%),全局 skills 元数据降到 35%。
单项最大的是
MEMORY.md(32.7%),第二是项目CLAUDE.md(22.6%)。 - 初版第 7 章据此写「CLAUDE.md 已达标,没有优化空间」,在项目会话里是错的—— 真正的优化面恰恰是这两份文件。
- 初版报的 17,617 字符是真实值(49,784)的 35%,也就是低估了近三分之二。
这个错的病因和附录 B 那七次完全一样:我要证明的是「一个新会话要付多少常驻开销」,
判据取的是「$HOME/.claude 这一个目录里有多少」。见附录 B 第 8 条。
修正后的量法(脚本 v2 加了 --project):
./context-audit.sh --project /path/to/your/repo
它会并排列出全局侧和项目侧,并把 auto-memory 目录一起算进去。没有项目参数时, 脚本会在表尾打一行警告,提示这张表不完整。
三个结论(v2 修正版)
- 带项目配置时,大头是项目侧(65%),不是全局 skills 元数据(35%)。
单项排名:
MEMORY.md32.7% > 项目CLAUDE.md22.6% > 项目.claude/skills10.2%。 - 全局
CLAUDE.md确实已极简(404 字符)——但别把这个结论套到项目CLAUDE.md(11,237 字符)上。 - 磁盘上还有 55 个 skill + 31 个 agent 属于未启用的 plugin,它们不进上下文。别把它们当成自己的负担。
(另见 §8.5:
plugins/cache/下还有 109 个temp_git_*残留目录占 628M,同属「占盘不占上下文」, 但那个可以放心清。)
脚本 ↔ /context 对照表
手册反复说「以 /context 为准」,所以得说清两边怎么对上。跑一次 /context,按这张表读:
/context 的行 |
脚本里对应 | 谁更准 / 备注 |
|---|---|---|
Skills: |
skills 元数据 | /context 更准(真实 tokenizer)。脚本的价值是可 diff + 给出排行榜 |
Custom agents: |
agents 元数据 | 同上。你这里两边都是 0 |
System prompt: / System tools: |
无 | 客户端内置,改不了,看看就好 |
MCP tools: |
脚本完全量不到 | plugin 和 claude.ai 连接器带的 MCP 都算这里(见 §7) |
Memory files:(含项目 CLAUDE.md) |
v1 漏了,v2 用 --project 才算 |
项目 CLAUDE.md + auto-memory MEMORY.md 都落这里。这是初版最大的盲区 |
Messages: |
脚本完全量不到 | 运行时对话 + hook 注入都在这里。会话刚开始时的读数 ≈ hook 注入量(见 §8.2) |
Free space: |
无 | — |
一句话分工:脚本告诉你「哪一项在涨、涨了多少字符、谁是大户」;/context 告诉你「此刻真实占了多少 token」。要下结论用 /context,要追踪变化用脚本。
⚠️ 多配置目录:单目录假设不成立(v2 新增)
CLAUDE_CONFIG_DIR 让脚本只能一次看一个目录,但你本机同时存在三份并行配置,
各带 37 个顶层 skill、各自独立的 settings.json:
~/.claude hooks 11 类(SessionStart + UserPromptSubmit 注入)· enabledPlugins 6 个
~/.claude-onehub hooks 0 类 · enabledPlugins 4 个(少 code-review / csharp-lsp)
~/.claude-kimi 37 skill
三个目录下都有 projects/-Users-leo-…-<项目名>-dev/memory/,也就是三份独立的
auto-memory。你今天用哪个后端,付的就是那一份的开销。
所以:换后端 = 换一整套常驻开销。在 ~/.claude 下测出来的数,搬到 onehub 后端不成立
(那边 settings.json 的 hooks 是 0,但 claude-mem 的 plugin hooks 照常注入 7.7k)。
量之前先确认你这个会话跑在哪个 CLAUDE_CONFIG_DIR 上。
顺便记一个 CLI 参数,后面 §8.2 会用到:
claude --bare起会话会跳过 hooks、auto-memory 和 CLAUDE.md 自动发现——一次性小任务最干净的省法,不用改任何配置文件。 注意它跳过的正是 v2 新发现的那两个大头(auto-memory + CLAUDE.md 自动发现), 所以--bare的实际收益比初版估计的大得多:在本项目里约省 15~20k tokens (项目侧 32,571 字符 + hook 注入 7.7k)。
第 1 章 钱花在哪:只讲够用的两件事
不懂这两件事,第 2、4 章的动作你会觉得莫名其妙。所以只讲这两件,其余原理去看整理版。
1.1 成本 = 单价 × 数量
一次会话的成本 = 单价(每个 token 多贵)× 数量(一共发了多少 token)
│ │
模型 / 输入还是输出 / 缓存 上下文里有什么 × 重发了多少轮
两个可操作的量级(来自官方原文,已核对):
- 输出 ≈ 输入的 5 倍价。而「思考过程」(thinking tokens)算输出 —— 所以 effort 档位直接换算成钱。
- 缓存命中按 0.1 倍计价,写入缓存最多收 2 倍且只付一次。这就是长对话没有想象中贵的原因。
1.2 缓存是「前缀匹配」,不是「记住了」
这是全篇最重要的一句话。
请求 2:[系统提示][工具定义][CLAUDE.md][你的话 1][新增]
└────────── 与请求 1 的开头逐字节相同 ──────────┘
↓ 这一段按 0.1× 计价
一旦开头动了一个字节:
请求 3:[系统提示][工具定义][CLAUDE.md*][你的话 1][新增]
↑ 变了
└── 从这里往后,全部按全价重新处理 ──────────────┘
推论(第 2、4 章的全部依据):任何会改变「请求开头」的动作,都会让它之后的整段对话按全价重来。对话越长,这一下越贵。
会改变开头的动作有 6 个:/model 换模型、/effort 调档、开 Fast mode、/compact、缓存超时、恢复旧 Session。
第 2 章 开工前的 30 秒 【对你:高】
这是官方 TL;DR 里唯一被单独强调「before you start」的一条,也是最容易漏的一条。
做什么
# 在会话的第一句话之前:
/model # 选定模型,Enter 设为默认(s 只对本次会话生效)
/effort # 选定档位
effort 的档位(视频画面 + 本机 2.1.220 二进制字符串双重确认):
Faster ←────────────────────────────────→ Smarter
low medium high xhigh max │ ultracode + auto
│ ← 不是第 6 档 effort,
│ 是「xhigh + 动态工作流编排」
auto 是视频画面上没有、但实际存在的一档:“auto: Use the default effort level for your model”。查当前值用 /effort current(或 /effort status)。
怎么选
| 任务 | 档位 | 理由 |
|---|---|---|
| 格式调整、写简单测试、日常小修小补 | low / medium |
思考 token 算输出,5 倍价 |
| 陌生代码库排查、跨文件重构 | high / xhigh |
值得花 |
| 要多智能体编排的大活 | ultracode |
它 = xhigh + 动态工作流编排,成本量级不同 |
| 懒得判断 | auto |
交给模型的默认档 |
⚠️ 三个会让你「设了没生效」的前置条件
这三条都是本机二进制里的原始报错文案,不是我推测的:
-
ultracode需要先在/config里启用动态工作流——否则报 “Ultracode needs dynamic workflows enabled (see /config)”。 -
ultracode还要求模型支持 xhigh,且组织可以限制它—— “Ultracode runs at xhigh effort, which is restricted by your organization for …”。 -
🚨 环境变量会覆盖会话内的
/effort——二进制里的原始文案是 “CLAUDE_CODE_EFFORT_LEVEL=… overrides this session — clear it and … takes over”。 如果你在 shell 或 settings.json 的 env 里设过它,会话里怎么调/effort都不算。 先查这个。⚠️ v2 修订:本机实际设的变量名不是那个。 实测:
$ env | grep -i EFFORT CLAUDE_EFFORT=high初版给的自查命令
env | grep EFFORT碰巧能查到(因为 grep 的是子串), 但如果你按全名env | grep CLAUDE_CODE_EFFORT_LEVEL去查,会得出「我没设过」的假否定。 判据侥幸命中不等于判据正确——这又是附录 B 那个病,见第 9 条。 查的时候一律用宽匹配:env | grep -i -E 'EFFORT|THINKING'。顺带一句实话:本机默认挂在
high。按 §1.1「思考 token 算输出、5 倍价」, 日常小活挂 high 是实打实的钱。
另外两条口径:/effort <档> 按 Enter 是存为新会话的默认,按 s 是只对本次会话生效;
max 需要模型支持(“Maximum capability with deepest reasoning” 只对部分模型开放)。
想彻底关掉思考:MAX_THINKING_TOKENS=0(写进 settings.json 的 env,或 shell 里 export)。
为什么必须在开头
effort 和 model 都是缓存 key 的一部分。长对话进行到一半才去改,等于把前面所有轮次的缓存全部作废,按全价重新 prefill 一遍。
便宜的时刻:会话刚开头、刚 /clear 之后——反正没什么可失去。
贵的时刻:长对话做到一半才想起来换模型。
自查
□ 开一个新会话时,我是先设 model/effort 再说话,还是说到一半才想起来?
□ 我的默认 effort 是什么?(跑 /effort current)
□ 我有没有在哪里设过 effort 环境变量?用宽匹配查:env | grep -i -E 'EFFORT|THINKING'
(别按全名 CLAUDE_CODE_EFFORT_LEVEL 查——本机实际叫 CLAUDE_EFFORT,全名查会假否定)
□ 日常小活我是不是一直挂在 max / high 上?
第 3 章 问法:@ 文件,不要描述文件 【对你:高,但有反面】
三种问法的实际代价
同一个任务——修复 utils.test.ts 里失败的测试:
| 问法 | Claude 会做什么 | 代价 |
|---|---|---|
| 「测试挂了,帮我看看」 | grep 搜代码 → 打开 a.test.ts(不是)→ 打开 b.test.ts(不是)→ 读 utils.ts(找到了) | ≈4 次探索全部留在上下文里,之后每一轮重发 |
| 「请修复 utils.test.ts」 | Read utils.test.ts → 顺带读 utils.ts → 修 | 1~2 次读取直达 |
「@utils.test.ts 修一下这个」 |
文件全文已在第一条消息里 → 直接修 | 0 次额外读取 |
「@CfgGenerated.cs 看下这个表」 |
1.45 MB 全文进上下文 | ≈416,000 tokens,1M 窗口的 40% |
第一种贵的不是「多读了几个文件」,而是那几次白跑的痕迹会被之后每一轮重发。
⚠️ v2 修订:「一律 @」在大型仓库是危险建议
初版写「你已经知道是哪个文件 → 一律 @」。在本 Unity 项目实测,这条会直接打爆窗口:
1,452,926 字节 ≈416,202 tok CfgGenerated.cs(配置表自动生成)
19,731 字节 ≈ 5,831 tok effect_list.tsv(一张普通配置表)
14,287 字节 ≈ 4,329 tok UiMainAlarmNew.prefab(一个中等 prefab)
@CfgGenerated.cs 单次操作就比本手册讨论的所有常驻开销加起来还大一个量级
(49,784 字符 vs 1,452,926 字节)。而 prefab / tsv / .meta 这些恰好是日常最常要看的东西。
修正后的规则:
wc -c <file> # @ 之前先量。这一步一秒钟,省下的可能是四十万 token
| 文件字节数 | 怎么办 |
|---|---|
| < 20 KB | 放心 @ |
| 20~50 KB | @ 之前想一下:我要的是全文还是某一段? |
| > 50 KB | 别 @。用 grep 定位行号 → Read 带 offset/limit,或整个丢给子代理(见第 6 章) |
自动生成的文件(*Gen*.cs / *.Designer.cs) |
一律不 @,它们按定义就是巨大且低信息密度的 |
做什么
- 你已经知道是哪个文件、且文件不大 →
@,不要用自然语言描述。 - 同一个会话里
@一次就够,之后 Claude 记得住,不必每次都带。 - 大文件 → 给路径 + 让它自己 grep,比
@全文便宜得多。这时候「描述文件」反而是对的。
自查
□ 翻一下最近的会话:有多少次我让它「找一下哪里有问题」,而我其实知道文件名?
□ 我有没有 @ 过自动生成的大文件?(本项目里最大的一个 = 1.45 MB ≈ 416k tokens)
第 4 章 会话边界:四个命令的分工 【对你:高】
这四个命令解决的是同一个问题的不同阶段,用错了就白花钱。
| 命令 | 什么时候用 | 它对缓存做了什么 |
|---|---|---|
/clear |
换任务了 | 清空重开,从第一句开始 —— 反而是最便宜的起点 |
/rename |
/clear 之前,如果这个会话你可能还要回来 |
不影响缓存,纯粹是给会话起名 |
/compact |
同一个任务做太久、或离开键盘前 | 会打断缓存,但「趁缓存还热时压缩」比过期后全价重来便宜 |
/rewind |
最近几轮走偏了想回退 | 只从尾部剪掉几轮,前面的缓存照常命中 |
三条容易搞错的
/rewind是被严重低估的那个。走偏了想回退,很多人第一反应是/compact甚至/clear——但/rewind只剪尾部,前缀完好,比两者都便宜。视频里一个字没提。- 离开键盘前先
/compact。订阅模式缓存约 1 小时过期;趁它还在时做摘要,比过期后全价重来便宜得多。 - 一个任务一个会话。官方给的对比是「同样三个任务,一口气做完 vs 每个一个短会话」——注意这里有个数字要打折,见 §8.4。
长会话的代价从哪来
❌ 一个长会话连着做:
[任务1] [任务2 = 自己 + 重发任务1的历史] [任务3 = 自己 + 重发 1+2 的全部历史]
↑ 任务1 变成了甩不掉的行李
✅ 三个短会话:
[任务1] /clear [任务2] /clear [任务3] ← 每个只重发自己
长会话不是「错」——深入调试同一个问题时值得。但要清楚它每一轮都在为更长的历史买单。
自查
□ 我上一个会话里做了几件不相关的事?
□ 我用过 /rewind 吗?还是走偏了就 /clear 重来(把有用的上下文也扔了)?
□ 昨天离开电脑前,那个会话我 compact 了吗?
第 5 章 让命令闭嘴 【对你:中】
危险区不是「输出太大」,是「刚好不够大」
Claude Code 有个保护机制:输出超过约 30,000 字符会被写进文件、只给开头预览(BASH_MAX_OUTPUT_LENGTH 可调)。
但它管不到真正的危险区:30k 以下的那些。 比如 400 行「测试全绿」——每一行都在稀释关键信息,而且每轮重发。
本机实测:git log 有多浪费
视频里作者演示了 git log,我放大数过那一屏的 5 条 commit:
commit 1fa2be0310c49608db7db1ca9f7760ad4fcaa755 ← 样板
Author: xiaowei <xiaowei@qq.com> ← 样板
Date: Thu Jul 23 14:43:12 2026 +0800 ← 样板
feat: add AI cover generation dual-track workflow ← 这才是信息
Co-authored-by: Cursor <cursoragent@cursor.com> ← 样板(5 条里 4 条有)
5 条 commit 里,真正是信息的标题行只占约四分之一,剩下四分之三是 hash、邮箱、时区和署名。
做什么
npm run -s test # -s 静默
git log --oneline # 一行一条
pytest -q # quiet
npx vitest run <file> --reporter=dot # dot 进度
更省心的做法:把「带安静 flag 的完整命令」写进 CLAUDE.md,例如:
- 跑单个测试文件:npx vitest run <file> --reporter=dot
- 看提交历史:git log --oneline -20
这样 Claude 不用再摸索命令,还能省下几百行输出。也可以用 hook 自动加安静参数——但注意你已经有 11 类 hooks 了,见 §8.2。
判据
这条输出里,对任务有用的信息占比有多高? 「全绿的 400 行」有用的只有最后一行。
第 6 章 脏活外包给子代理 【对你:中,但注意反面】
什么活该外包
输出巨大、答案很小的活:日志分析、全量测试、翻 git 历史。
🏠 主会话 🚀 子代理(独立上下文)
你:「分析构建日志,为什么挂了」 ──派出──▶ 自带:系统提示 + 工具 + CLAUDE.md
Claude:派个子代理去处理 干脏活:读完整日志(输出量巨大)
(上下文保持干净) 中间过程:grep、翻文件、报错堆栈
✅ 只收到最终答案 ◀──只带回答案── 🗑 任务结束,除答案外全部丢弃
重复出现的脏活,用 /agents 定义一个专用子代理,模型可以指定 haiku / sonnet 等便宜档。
⚠️ 反面:小任务外包是净亏
视频只讲了省的那一半。 页面上写了但口播跳过的是:
每个子代理都要带上自己的系统提示 + 工具 + CLAUDE.md,这是「同时跑几个上下文」的固定开销。
三个子代理 = 三份启动成本。v2 补上量级——初版只说了「有固定开销」,没给数, 于是那张「划算/净亏」表的阈值是拍的。在本项目实测:
每个子代理的固定开销 ≈ 项目 CLAUDE.md 3,405~4,495 tok
(+ 全局 CLAUDE.md 122~162 tok)
(+ 系统提示 / 工具定义,客户端内置,量不到)
─────────────────
下限 ≈ 3.5~4.7k tok / 个
所以净亏线不是「50 行的文件」,而是:省下的读取量 < 3.5~4.7k tok 就别外包。 按 §3 那把尺子,约等于「小于 12~15 KB 的文件,自己读比派子代理便宜」。
| 场景 | 外包? | 按 v2 阈值重算 |
|---|---|---|
| 读 2000 行日志找一个报错 | ✅ 划算 | 日志远超 15 KB |
| 跑全量测试只要「过/不过」 | ✅ 划算 | 输出巨大、答案一行 |
| grep 一个 1.45 MB 的生成文件 | ✅ 强烈划算 | 这正是子代理存在的理由(见 §3) |
| 读一个 50 行的文件 | ❌ 净亏 | 50 行 ≈ 2 KB,远小于 15 KB 的固定成本 |
| 需要来回追问的探索性任务 | ❌ 净亏 | 子代理看不到你的对话历史 |
⚠️ 批量 fan-out 要先乘一遍:如果你像本项目「多 agent 分批审查 133 模块」那样一批开 5 个 agent,光 CLAUDE.md 就是 17~22k tok,而这部分是每批重付的。批量外包之前, 先把「固定成本 × 并发数」和「省下的读取量」放一起比。
判据
你要的是「结果」还是「过程」? 只要结果 → 外包。过程也要看 → 留在主会话。 再加一道:省下的量 > 3.5~4.7k tok × 子代理个数?不满足就自己读。
第 7 章 上下文保持精简 【v2:从「基本空转」改为「最大优化面」】
初版这一章的标题是「对你:大部分空转」,开头写着「这一章对你基本没有可做的事,我直说,不灌水」。 那个判断建立在「CLAUDE.md 只有 404 字符」上,而那是漏掉项目级文件的结果。 修正后,这一章是全篇优化收益最大的一章。
| 官方建议 | 初版结论 | v2 修正 |
|---|---|---|
| CLAUDE.md 只留必需的 | ✅ 已达标(404 字符) | ⚠️ 全局达标,项目没达标:项目 CLAUDE.md = 11,237 字符,是全局的 27.8 倍 |
用 /mcp 查谁在占上下文 |
⚠️ 配置里 0 个 MCP server | 不变,但见下面的口径提醒 |
| 工作流交给 Skills 按需加载 | ✅ 已经在做,且做过头了 | 不变(68 个 skill,见 §8.1) |
| (官方没提)auto-memory | 初版完全没提 | 🚨 MEMORY.md = 16,279 字符,是单项最大的常驻开销 |
真正的优化面,按收益排序
1. auto-memory MEMORY.md 16,279 字符 ≈4,933~6,512 tok ← 最大单项(32.7%)
2. 项目 CLAUDE.md 11,237 字符 ≈3,405~4,495 tok (22.6%)
3. 项目 .claude/skills(21个) 5,055 字符 ≈1,532~2,022 tok (10.2%)
4. 全局自建 skill description 3,804 字符(前 5 名合计) ← 初版认定的重点,实际排第四
5. 全局 CLAUDE.md 404 字符 ← 已极简,别动
前两项合计 27,516 字符,是第四项的 7.2 倍。初版把力气全指向第四项(§8.1 那份「收税大户榜」), 方向没错但收益最小。
⚠️ 但这两项都是「有价值的交换」,不是纯浪费——和 §8.2 对 hook 注入的判断同理:
MEMORY.md 换来的是跨会话记忆,项目 CLAUDE.md 换来的是不用每次重讲项目约定。
要判断的不是「能不能删」,而是「这一条在多少比例的会话里真的用得上」。
一条只在发版时才需要的约定,常驻在 CLAUDE.md 里,就是 365 天付费、用 12 次。
可操作的做法:把低频内容从常驻文件里搬进按需加载的位置——
CLAUDE.md 里长段落搬进 .claude/context/<module>.md 并只留一行指针(本项目已经在做,
「模块规范」那一节就是这个模式);MEMORY.md 里的长条目同理,正文进独立 memory 文件,
索引只留一行钩子。这不是删记忆,是把它从「每会话付」改成「用到才付」。
一个必须说清的口径
我查的是 ~/.claude.json 和 settings.json 里的 mcpServers 字段,那里确实是 0。但这个会话里明明有 mcp__claude_ai_*、mcp__plugin_claude-mem_* 这些工具在。
它们来自 plugin 和 claude.ai 连接器,不走那个字段。
所以「配置文件里没有」≠「会话里没有 MCP 开销」。 真实占用只有 /context 的 MCP tools 那一行算得准。这是本手册里判据与结论最容易脱节的一处,我自己也差点写错。
v2 补充:初版把这个口径只写在这里,是个错误——同一条对 hooks 完全成立 (见 §8.2 的 v2 修订:claude-mem 的 SessionStart hook 定义在 plugin 自己的
hooks/hooks.json里,不在任何settings.json的hooks字段里)。 已提升为全篇通用口径,写在开头「先说清我做不到的四件事」之后。
第 8 章 视频没讲、但对你更重要的四条
前七章是官方 6 条的骨架。这一章是在你这台机器上实测出来的,视频和官方原文都没有。
8.1 元数据税:68 个 skill 的常驻开销
⚠️ v2 前置说明:这一节的分析本身没错,但它的重要性被初版夸大了。 skills 元数据(17,213 字符)确实是全局侧的大头,但在项目会话里只占 34.6%, 少于项目侧合计(32,571 字符 / 65%)。 想按收益排序做优化,先看第 7 章的 v2 修正版。
Claude Code 对 skill 是懒加载——只把 name + description 常驻在 system prompt 里,正文只在调用时才读。所以:
所有 SKILL.md 正文合计 1,196,571 字节 ← 懒加载,不常驻
name + description 合计 17,213 字符
17,839 字节 ← 每次会话都付
≈ 常驻的只占全文的 1.49%
这里必须同口径比:17,839 字节 / 1,196,571 字节 = 1.49%。脚本输出里那个 1.4% 是 拿字符除字节算的(分子分母口径不一致)——这正是附录 B 第 5 条我批评自己的错误, 自审时又抓到一次,见附录 B 第 6 条。
这意味着:装 skill 本身很便宜,贵的是 description 写太长。
你的收税大户(脚本 --top 输出):
| description 字符数 | skill | 来源 |
|---|---|---|
| 938 | agent-browser |
自建 |
| 829 | orchestration |
自建 |
| 793 | orca-cli |
自建 |
| 698 | pptx |
自建 |
| 546 | computer-use |
自建 |
| 482 | oh-my-issues |
plugin |
| 479 | obsidian-cli |
自建 |
| 477 | baoyu-youtube-transcript |
自建 |
最长的一条(agent-browser,938 字符)= 你整个 CLAUDE.md(404 字符)的 2.3 倍。
前 5 条加起来 3,804 字符,占全部 skills 元数据的 22%——而它们全是自建的。
这里有个真实的权衡,不是「写短就对」:description 是模型判断「该不该用这个 skill」的唯一依据。写长是为了让它在该触发的时候能触发。写短了省 token,但可能该用的时候不用。
我的建议:别动 plugin 的(下次更新会覆盖),只看自建的那几条,问自己「这段话里有多少是给模型判断用的,有多少是我写给自己看的文档」。后者搬进 SKILL.md 正文——那部分是懒加载,不要钱。
8.2 hooks:有几类的 stdout 直接进上下文
SessionStart 1 个 ← stdout 进上下文,每次都付
UserPromptSubmit 1 个 ← stdout 进上下文,每次都付
PermissionRequest / PostToolUse / PostToolUseFailure / PreToolUse /
Stop / StopFailure / SubagentStart / SubagentStop / TeammateIdle (不注入)
活证据:本次会话开头那段 [_AI] recent context, 2026-08-24 9:51am 的 observations 列表,就是 SessionStart hook 注入的,它自己标着 8,172t read。
也就是说,你每开一个新会话,在 5.3–7.0k 的配置侧开销之外,还要再付一次 hook 注入。这一项可能比 skills 元数据还大,而视频和官方原文都没提 hook 这个维度。
⚠️ v2 修订:上面那个枚举是下限,不是全量
初版这一节的标题写「11 类里有 2 类」,正文却在脚本里定义了三类
(INJECT = {'SessionStart', 'UserPromptSubmit', 'PreCompact'})——同一份材料内部口径不一致,
标题的「2 类」是错的。更要紧的是那个「11 类」本身只是 ~/.claude/settings.json 的 hooks 字段,
而plugin 自带的 hooks 不在那里。已确认:
~/.claude/settings.json hooks 字段 11 类
~/.claude-onehub/settings.json hooks 字段 0 类 ← 但注入照常发生
~/.claude/plugins/cache/thedotmack/claude-mem/13.8.1/hooks/hooks.json
Setup / SessionStart / UserPromptSubmit / PostToolUse / PreToolUse / Stop
↑ SessionStart + UserPromptSubmit 同样注入,settings.json 里查不到
后果:在 onehub 后端下跑脚本会打印「没有配置 hooks」,而那个会话开头明明有 7,727t 的 observations 注入。这是「配置文件里没有 ≠ 会话里没有」的第二个实例(第一个是 §7 的 MCP), 病因和附录 B 那一串完全相同——要证明「有哪些 hook 会注入」,判据取的是「某一个 JSON 字段里写了几条」。
正确的量法:跑 /context,看会话刚开始时 Messages 那一行的读数。那才是注入的真实总量,
不管它定义在哪个文件里。
能做什么:
这个注入是 claude-mem 的记忆检索,换来的是跨会话记忆——这是有价值的交换,不是浪费。要判断的是「8.2k 换来的记忆,我这个会话用得上吗」。
-
一次性小任务 → 用
claude --bare起会话。这是本机claude --help里的参数,一次性跳过:--bare Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1.hooks 和 CLAUDE.md 一起跳过,不用改任何配置文件,退出即恢复。 问一句话就走的场景,这是最干净的解法。
💡 v2 补充:注意
--bare跳过的清单里有auto-memory和CLAUDE.md auto-discovery—— 正是 v2 发现的那两个最大常驻项。所以它的收益比初版估计的大得多:本项目里 约 15~20k tokens(项目侧 32,571 字符 + hook 注入 7.7k)。 初版把--bare当成「省掉 hook 注入」的小技巧,实际它是全篇单个动作收益最大的一条。 -
长会话、需要历史上下文 → 正常起,让它注入。
-
想永久关掉某个 hook → 从
settings.json的hooks里删掉那一段,别想着「注释掉」——JSON 不支持注释,加//会让整个配置文件解析失败(而且 Claude Code 对损坏的 JSON 是静默忽略,你不会收到报错,只会发现 hook 全都不生效了)。要留着以后用就先cp settings.json settings.json.bak再删。 ⚠️ 但 plugin 自带的 hook 删不掉——它在plugins/cache/<...>/hooks/hooks.json里, 下次更新会覆盖。要关只能在enabledPlugins里把整个 plugin 关掉。
脚本量不到注入的字数——它只能告诉你「有哪几个 hook 会注入」,而且只是 settings.json 里那些。要看实际大小,跑 /context 看 Messages 那一行在会话刚开始时是多少。
8.3 换第三方模型/agent 工具时,哪几条还成立(未实测)
这一节是全篇唯一没有实测支撑的,我明确标出来。
你本机有 OneHub 网关、DSH、PiAgent、Orca 这些接第三方模型的路径。视频作者本人就是跑在 GLM-5.3 上的(他 /model 菜单里的「Custom Opus / Fable / Sonnet」槽位全部指向同一个 glm-5.3)。
| 技巧 | 换到第三方模型后 | 为什么 |
|---|---|---|
@ 文件、命令加安静 flag、拆会话、子代理 |
照常成立 | 这些是减少发送量,与谁提供模型无关 |
| effort 档位 | 看客户端 | 是 Claude Code 客户端的功能,但下游模型是否支持 thinking 另说 |
/context、/compact、/clear、/rewind |
照常成立 | 纯客户端行为 |
| 0.1× 缓存折扣 / 2× 写入 | 不确定 | 这是 Anthropic 官方模型的价目。第三方网关是否实现 prompt cache、折扣多少,得看网关自己 |
怎么自己验证那个不确定项:连续两轮发几乎相同的长上下文,看网关侧的账单/用量是否出现「缓存命中」类目。如果网关不报这个类目,就当没有折扣来规划——别假设它有。
💡 v2 补充:你手上已经有验证工具了。 初版写这一节时把它当成「需要另外搭环境才能测」的事, 但本机有 ccusage 定时体检系统(launchd 每日/周/月出账单到
~/_Data/_AI/ccusage-report/, 脚本~/.local/bin/ccusage-checkup.sh)。直接看报告里有没有 cache read / cache write 类目、 数值是否非零,就能回答这个问题,不用手工构造对照实验。顺带修正一个更根本的表述问题:初版通篇说「真值只有一个来源——自己跑
/context」, 这句话只对「此刻占用多少」成立。要回答「实际花了多少钱」,/context无能为力, ccusage 才是真值。两个问题、两个真值来源,初版把它们混成一个了。⚠️ 用 ccusage 判金额前先
--update-pricing,否则第三方模型的单价可能是旧的。
8.5 占盘但不占上下文:plugins/cache/ 的 temp_git 残留(v2 新增)
体检时顺手发现的,和 token 无关,但值得清:
~/.claude/plugins/cache/
claude-code-warp/ claude-hud/ claude-plugins-official/ thedotmack/ ← 真正的加载源
temp_git_1786413187105_tdo8kf/ … 共 109 个 ← 残留
└─ 含 SKILL.md 的:0 个
整个 cache 目录占盘:628 MB
109 个 temp_git_* 是 plugin 安装/更新时的临时 clone 没被清掉,零个含 SKILL.md,
所以完全不进上下文——它和 §0 说的「55 个幽灵 skill」性质相同(占盘不占 token),
但区别在于:幽灵 skill 别动(关掉的 plugin 还可能重新启用),temp_git 可以直接删。
# 先看一眼确认都是空壳(应该输出 0)
find ~/.claude/plugins/cache/temp_git_* -name SKILL.md 2>/dev/null | wc -l
# 确认后再删
rm -rf ~/.claude/plugins/cache/temp_git_*
为什么写进这份手册:因为它是**「省 token」和「省磁盘」被混为一谈**的典型。 628 MB 听起来很值得优化,但它对 token 成本的贡献是 0。 初版的「幽灵开销」一节已经建立了这个区分(「别去优化它们」),这里是同一条原则的另一个应用—— 只是这一个可以放心清,因为它连「将来可能被启用」的价值都没有。
8.4 「1.9 倍」不是官方数字
视频和它讲解的那个图解页都说「同样三个任务,一个长会话 ≈ 1.9 倍 token」,口播还说「看官方给出的对比」。
我抓了官方原文全文(31,355 字符纯文本)搜索,1.9 出现 0 次。 原文这一节的原话是:
“One long session costs more than the same work spread over a few short ones, and by more than you’d think, because turn 40 is also re-reading the 39 turns before it.”
只有「比你想的更多」这个定性判断,没有任何量化。那个图解页自己在图注里也标了「柱宽为示意,非实测数据」。
所以:方向是官方的,数字是二次创作的。 引用时别说「官方测出 1.9 倍」。
第 9 章 该关什么:用调用记录,不靠名字猜
/context 告诉你「skills 占了多少」,但不告诉你「哪些该关」。这一章解决后者。
数据在哪
~/.claude/projects/**/*.jsonl 你机器上:17,516 个会话文件(跑的时候还在涨)
解析每行的 tool_use 块就能统计每个 skill 的真实调用次数。脚本已经实现:
./context-audit.sh --usage --usage-days 30
你的实测结果(近 30 天,按记录内 timestamp 筛)
116 grilling 13 proceed-if-ok 3 xlsx
85 analyze-then-fix 11 session-archive 2 superpowers:brainstorming
48 update-dev-log 9 defuddle 1 domain-modeling
31 confirm-before-edit 7 sync-ai-config 1 systematic-debugging
14 agent-browser 6 diagnosing-bugs 1 superpowers:grilling
全局装了 68 个;近 30 天的调用记录涉及 23 个名字。
· 匹配上全局清单:10 个
· 全局装了但零调用:58 个(85%)
· 调用了但不在全局清单:11 个 ← 别的项目的 .claude/skills/
grilling 是你用得最多的 skill(30 天 116 次,7 天 38 次)。前四名 grilling / analyze-then-fix / update-dev-log / confirm-before-edit 占了全部调用的七成以上——你实际重度依赖的是很小的一撮。
⚠️ 这个判据会误报,而它导向的动作是删除
这是本手册里最需要小心的一处。 四个坑,我在写脚本时全踩了一遍:
-
调用名带 plugin 前缀。
superpowers:grilling和grilling是两个字符串。不做归一化,会把用过的算成零调用。脚本已按:最后一段匹配——但如果你有重名 skill,仍会误判。 -
统计跨了所有项目,「装了什么」只数全局。
analyze-then-fix、update-dev-log这些高频 skill 在别的项目的.claude/skills/里,不在全局清单——它们出现在「调用了但不在清单」那一栏,不代表全局多装了东西。 -
零调用 ≠ 没用。季节性工具(发版、迁移、出题、年度报告)可能三个月才用一次。30 天窗口对它们不公平。试
--usage-days 90再看一遍。 -
别拿文件 mtime 当「会话发生时间」——这个坑是我在这台机器上实测撞出来的:
按文件 mtime 分桶:{'<7d': 5660, '7-30d': 11852, '30-90d': 3, '>90d': 0}17,516 个会话文件里,mtime 落在 30 天外的只有 3 个。显然是某次批量操作(备份 / 同步 / 索引)把全部文件的 mtime 刷新了。我第一版脚本用 mtime 筛「最近 N 天」,结果
--usage-days 30和--usage-days 90输出一模一样——参数形同虚设,而它看起来在正常工作。正解:用每行记录里的
timestamp字段筛。改完之后窗口才真的起作用:grilling在 7 / 30 / 90 天分别是 38 / 116 / 116 —— 7→30 有增长说明筛选生效了,30→90 持平说明这个 skill 的调用确实全在近 30 天内(不是参数失效)。这是「判据取的东西 ≠ 要证明的事」的又一个实例:我要证明的是「这个会话发生在最近 30 天」,判据取的是「这个文件最近被写过」。
-
🚨 (v2 新增)那个目录可能是另一个 CLI 的唯一入口。 本项目实测:
<repo>/.claude/skills = 21 个 ← 进 Claude Code 上下文 <repo>/.cursor/skills = 26 个 ← Cursor 的唯一入口,不进 Claude Code 上下文 <repo>/.agents/skills = 27 个 ← Kimi Code CLI 的唯一入口,同样不进按初版「幽灵开销」的定义,后两份对 Claude Code 是幽灵(不进上下文、不该优化)。 但它们不是可删的冗余——Kimi Code CLI 只扫
.agents/skills/,Cursor 只认.cursor/skills/。 照 §9 那句「自建 skill 直接移出」操作,会静默切断另外两个 CLI 的全部 skill。后果为什么特别坏:Claude Code 侧一切正常,你不会收到任何信号; 要等到某天在 Cursor 里打
/xxx没反应才发现,而那时你已经不记得动过什么。 这是本节四个坑之外最不可见的一个,删之前先问:这个目录是不是别人的唯一入口?
判据错 + 动作不可逆 = 最贵的组合。 先把清单看一遍,别照着批量删。
真要关的话
# 1. 先备份
cp ~/.claude/settings.json ~/.claude/settings.json.bak
# 2. 关 plugin 用 settings.json 的 enabledPlugins(改成 false)
# 不要手删 plugins/cache/ 下的目录 —— 下次更新会重装
# 你已启用的 6 个:claude-hud / claude-mem / superpowers / warp / code-review / csharp-lsp
# ⚠️ v2:onehub 后端只启用了 4 个(少 code-review / csharp-lsp)——三份配置各自独立,
# 要按你实际在用的那个 CLAUDE_CONFIG_DIR 改
# 3. 自建 skill 直接移出 ~/.claude/skills/(先移到别处,别删)
# ⚠️ v2:项目侧的 .cursor/skills/ 和 .agents/skills/ 不要动 —— 见上面第 5 条坑
# 4. 改完重跑体检,看 diff
./context-audit.sh --project /path/to/your/repo
一个更划算的方向
初版看完调用数据的判断是:关 skill 的收益不如缩 description。
理由:58 个零调用的 skill 里绝大多数是 plugin 带的(改了会被更新覆盖),而 8.1 节那份收税大户榜前 5 名全是你自建的,3,804 字符 = 全部 skills 元数据的 22%。改自建的 description 不会被覆盖,收益立即可见,而且不影响任何 plugin 的完整性。
⚠️ v2 修订:这个方向对,但优先级排错了。 3,804 字符的收税大户榜, 和第 7 章那两项(项目
CLAUDE.md11,237 +MEMORY.md16,279 = 27,516 字符)比, 只有 14%。修正后的优先级:
1. 精简 auto-memory MEMORY.md(长条目移出,索引只留钩子) 16,279 字符 32.7% 2. 精简项目 CLAUDE.md(长段落移进 .claude/context/) 11,237 字符 22.6% 3. 精简项目 .claude/skills 的 description(21 个) 5,055 字符 10.2% 4. 缩全局自建 skill description 3,804 字符 5. 关 skill ← 收益最小、风险最大,排最后也就是说:初版整个第 9 章讨论的事情,在正确的优先级里排第 5。 它不是没用,是被排在了不该有的位置上——因为初版的基线漏掉了排在它前面的三项。
附录 A 与视频/官方原文的对照表
每一条的出处,避免把二次创作当成官方结论。
| 内容 | 出处 |
|---|---|
输出 ≈ 输入 5 倍、缓存 0.1×、写入 2×、30,000 字符阈值、订阅 1 小时 / API key 5 分钟、/autocompact 200k |
官方原文(已逐项核对) |
ENABLE_PROMPT_CACHING_1H / MAX_THINKING_TOKENS / BASH_MAX_OUTPUT_LENGTH |
官方原文 |
/clear /model /effort /compact /context /rewind /mcp /rename /autocompact /loop |
官方原文(10 个命令全覆盖)+ 本机 2.1.220 逐个验证存在(见下) |
effort 的 auto 档、/effort current、ultracode 的三个前置条件、CLAUDE_CODE_EFFORT_LEVEL 覆盖行为、--bare |
本机 2.1.220 实测(二进制字符串 + claude --help),官方博客和视频都没有 |
| 「400 行测试全绿」的例子 | 官方原文 |
| 缓存失效的 6 个操作、便宜/贵的时刻 | 图解页整理,与官方原文一致 |
| 「1.9 倍」 | 图解页的示意值,官方原文没有这个数(见 §8.4) |
| 「6 个技巧」这个分组 | 图解页重新归并的,与官方 6 条 TL;DR 不是同一组(映射错位见整理版第八节) |
| effort 五档 + ultracode | 视频画面实测 |
| 68 skills / 17,213 字符 / 0 agents / 404 字符 CLAUDE.md / 55+31 幽灵 / 11 类 hooks / 17,516 会话 / 85% 零调用 | 本机实测(context-audit.sh,2026-08-24) |
| 「SessionStart 注入 8,172t」 | 本次会话的 hook 自报值,非我测量 |
| 第三方网关的缓存折扣 | 未实测,只给判断方法(v2:ccusage 报告可验,见 §8.3) |
v2 新增的全部数字:项目 CLAUDE.md 11,237 字符 / MEMORY.md 16,279 字符 / CfgGenerated.cs 1,452,926 字节 / 三份并行配置目录各 37 skill / onehub hooks 0 类 / claude-mem plugin 自带 hooks / 项目侧 21+26+27 三侧 skill / 109 个 temp_git 残留 628M / CLAUDE_EFFORT=high |
本机实测(2026-08-24 晚,在 <项目名>_dev 项目里复核) |
| 「大头是 skills 元数据」 |
附录 B 我在算这些数时错了十二次(初版说六次)
保留这一节,因为**「算清自己的开销」比想象的难得多,而且错的方向是系统性偏大的**。如果你自己改脚本或换机器重算,这些坑会原样等着你。
⚠️ v2:标题里的「六次」已经不准了,现在是十二次。 新增的五次(#8~#12) 比前七次严重——它们不是让数字虚高,而是让结论反转。 其中 #11、#12 是修 #8 的过程中当场新犯的,靠实跑脚本才抓到。 保留旧标题和旧序号是为了让 diff 可读。
| # | 错法 | 根因 | 独立影响 |
|---|---|---|---|
| 1 | 不按版本收敛 | 同一个 plugin 多个版本目录共存(superpowers 有 6.0.3 / 6.2.0 / 6.3.0 三份),只有最新版被加载 | 与 #2 耦合,未单独隔离测量 |
| 2 | 把 marketplaces/ 当加载源 |
那是插件市场的 git clone(含 .git/dist/tests),是源仓库。真正的加载源是 cache/ —— 我最初的假设正好反了 |
与 #1 耦合,未单独隔离测量 |
| 3 | 不按 enabledPlugins 过滤 |
磁盘上有 ≠ 已加载 | 见下面那条「幽灵」 |
| 4 | description 正则写成 (?:\n\s+.*)* |
\s 包含换行,跨空行一路吞掉整个 frontmatter |
实测:code-simplifier 444 → 2,267 字符(frontmatter 总长 2,382,几乎全被吞) |
| 5 | 用 wc -c 的字节数当字符数 |
中文 UTF-8 三字节一个字 | 实测:404 → 688,虚高 70% |
| 6 | 同一个错在文档里又犯一次:算「常驻占正文的百分比」时,分子用字符、分母用字节 | 知道坑 ≠ 不会踩。写第 5 条的时候,第 0 章和 §8.1 里正躺着同一个错 | 实测:1.4%(字符÷字节)→ 1.49%(同按字节)。数值影响小,但它是同一个病 |
| 8 | 🚨 只扫 $HOME/.claude,漏掉项目级 CLAUDE.md、auto-memory MEMORY.md、项目 .claude/skills/ |
要证明「一个新会话付多少常驻开销」,判据取的是「这一个目录里有多少」。Claude Code 明确会自动发现项目 CLAUDE.md(--bare 的帮助文本自己写着 CLAUDE.md auto-discovery),auto-memory 每会话注入 —— 都在这个目录之外 |
实测:17,617 → 49,784 字符,初版报的是真值的 35%。结论反转:「大头是 skills 元数据(97.7%)」变成「大头是项目侧(65%)」,第 7 章从「基本空转」变成「最大优化面」 |
| 9 | 🚨 只数 settings.json 的 hooks 字段,漏掉 plugin 自带 hooks |
同一个病。已确认:claude-mem 的 SessionStart + UserPromptSubmit 定义在 plugins/cache/thedotmack/claude-mem/13.8.1/hooks/hooks.json 里 |
实测:onehub 后端 settings.json 的 hooks 是 0 类,脚本报「没有配置 hooks」,而那个会话开头有 7,727t 注入。补扫 plugin 后查出 SessionStart 3 个、UserPromptSubmit 2 个。这个错我在 §7 已经为 MCP 承认过一次,却没想到它对 hooks 同样成立 |
| 10 | 变量名写全名 CLAUDE_CODE_EFFORT_LEVEL,本机实际叫 CLAUDE_EFFORT |
抄了二进制里的报错文案,没在本机 env 里核对 |
实测:初版给的自查命令 env | grep EFFORT 碰巧命中(grep 子串),但按全名查会得出「我没设过」的假否定。判据侥幸命中 ≠ 判据正确 —— 这条最阴,因为它看起来是验证过的 |
🚨 v2 写脚本时,同一个病当场又发作两次(#11、#12)
这两条不是回顾旧错,是修 #8 的过程中新犯的——我一边在文档里写「判据要对准命题」, 一边在代码里连犯两次。如果不是坚持把脚本跑一遍,两条都会带着错数字发布。
| # | 错法 | 我要证明的 vs 我实际取的 | 实测影响 |
|---|---|---|---|
| 11 | auto-memory 路径用 endswith(basename(P)) 模糊匹配 |
要证明「这个项目的 MEMORY.md 有多大」,判据取的是「slug 目录名以项目文件夹名结尾」。而转义规则会把 _ 也变成 -(_Data → --Data、<项目名>_dev → <项目名>-dev),所以 endswith('<项目名>_dev') 对 8 个候选目录全部返回 False |
实测:MEMORY.md 报 0 字符,而真值是 16,279。更糟的是它静默计 0——表里就是一行 0行 0 字符 0.0%,完全看不出是「没找到」。而这恰好是本手册批评初版的头号毛病(漏了却不声明)。修法:精确拼 slug(/ 和 _ 都换 -),且找不到时必须打警告 |
| 12 | 项目 skills 用 glob('**/SKILL.md', recursive=True) |
要证明「Claude Code 加载了几个 skill」,判据取的是「这棵目录树里有几个 SKILL.md 文件」。而 skill 内部的 references/ 子目录里还有嵌套 SKILL.md,Claude Code 只加载 skills/<name>/SKILL.md 这一层 |
实测:递归数 91 个 / 27,005 字符,真实顶层 21 个 / 5,055 字符 —— 虚高 4.3 倍 / 5.3 倍。这是 #2、#3 的原样重演(「目录里有文件」≠「被加载了」),而我在同一份文档里刚写完那两条 |
两条的方向相反(#11 偏小、#12 偏大),凑巧部分抵消 —— 第一版实跑合计报 55,455, 真值 49,784,只差 11%。如果我只看合计数「看起来差不多」就收工,两个错都会留下。 按项拆开看才发现一项是 0、另一项是 4 倍。
可复用的教训:合计数会掩盖方向相反的错误。逐项核对,别只核总数。 以及:
0是最危险的输出值——它同时是「真的没有」和「我没找到」的表示。 见 [[feedback-negative-findings-need-control]] 那条:否定结论必须带对照组。
#1–#3 的影响是交织的(同一批文件同时被三种错法重复计入),我没有逐个隔离测量它们的独立贡献,所以这里不给单项虚高倍数。只有 #4、#5 是我实测过独立值的。 能给的确定结论是端到端的:skills 从最初报的 151 个 / 38,317 字符收敛到 68 个 / 17,213 字符。
再加一个更根本的:我一度报「31 个 agents、11,043 字符、占 27%」,实际是 0 —— 那 31 个 agents 全在未启用的 plugin 目录里(code-simplifier、hookify、feature-dev…),一个都没加载。纯幽灵开销。
六个错误,方向完全一致,全都让开销看起来更大(那 31 个「幽灵 agents」是第 3 条的直接后果)。我当时正在论证「你的大头不是 CLAUDE.md 而是 skills」——每一个错误都在替我强化那个结论。最终数字(5.3–7.0k tokens)比我最初报的(24.7–29.9k)小了 4–5 倍。
结论方向没塌:skills 元数据确实是大头(97.7%),CLAUDE.md 确实只占 2.3%。但如果你只想验证一个结论,你的取数口径会自动偏向它。这是这份手册最该带走的方法,比任何一条省 token 技巧都值钱。
⚠️ v2:上面这段自我总结,本身就是同一个病的第十次发作
读一遍上一段的最后两句:
「结论方向没塌:skills 元数据确实是大头(97.7%),CLAUDE.md 确实只占 2.3%。」 「如果你只想验证一个结论,你的取数口径会自动偏向它。」
我在同一段里,先说出了那句正确的方法论,然后立刻用一个偏向自己结论的口径给它盖了章。 「结论方向没塌」这句话是在纠正六个错误之后写的——但它依据的仍然是那个只扫一个目录的口径。 换成完整口径(#8),这个结论就塌了:skills 元数据不是大头,CLAUDE.md 也不是只占 2.3%。
初版的方向是「六个错误都让开销偏大」,所以我以为纠正它们就是终点。 真实情况是第七、八个错误让开销偏小,方向相反,所以在「往小改」的那一轮里 它们完全不会引起怀疑——修正的方向本身也会形成偏见。
顺带一提,同一类错误在调用统计那边还犯了一次(拿文件 mtime 当「会话发生时间」,导致
--usage-days 参数形同虚设却看起来在正常工作),细节见 §9 的第 4 条坑。
十二次错误里有十次同属一种病——「判据取的东西 ≠ 要证明的事」:
| 我要证明的 | 我实际取的判据 | 偏向 |
|---|---|---|
| 这个 skill 被加载了 | 这个目录里有 SKILL.md 文件(#2、#3) | 偏大 |
| 这段文字有多少字符 | wc -c 的字节数(#5) |
偏大 |
| 常驻占正文的比例 | 字符数 ÷ 字节数(#6) | 偏小 |
| 这个会话发生在最近 30 天 | 这个文件最近被写过(mtime) | 参数失效 |
| 一个新会话付多少常驻开销 | $HOME/.claude 这一个目录里有多少(#8) |
偏小 65% |
| 有哪些 hook 会注入上下文 | settings.json 的 hooks 字段里写了几条(#9) |
偏小 |
| 这个环境变量有没有被设过 | 按我以为的名字 grep(#10) | 假否定 |
| 这个项目的 MEMORY.md 有多大 | slug 目录名以项目文件夹名结尾(#11) | 偏小 100% |
| Claude Code 加载了几个项目 skill | 这棵目录树里有几个 SKILL.md(#12) | 偏大 430% |
| 这个 skill 目录能不能删 | Claude Code 会不会加载它(漏了 Cursor / Kimi,见 §9 第 5 条坑) | 后果不可见 |
剩下两次(#1 不按版本收敛、#4 正则贪婪)是纯实现 bug。不是粗心——判据错位那十次,每一次我都 少问了同一句话:「我这个判据,真的在测我要测的东西吗」。
给这个病一个可复用的自查句式(v2 提炼,比初版的散点描述好用):
我要证明的命题是:____________
我实际测量的是: ____________
这两句话是同一件事吗?如果不是,差在哪?那个差会往哪个方向偏?
最后一问是 v2 才补上的,也是最关键的一问:初版六个错误全部偏大、#8~#9 全部偏小、 #11 与 #12 方向相反且相互抵消。知道偏差方向,才知道该往哪里再查一遍—— 而方向相反那两条证明了:只核对合计数,会让两个大错互相掩护。
再加一条 v2 才明确的:「0」是最危险的输出值。 它同时表示「真的没有」和「我没找到」,
而后者在表格里长得和前者一模一样。#11 就是这么躲过第一轮自审的
(MEMORY.md 0行 0 字符 0.0% 看起来完全正常)。
凡是输出 0 的地方,都要能回答「这是测出来的 0,还是没测到」。
所以第 0 章那句话不是客套:脚本给的是配置侧估算,真值只有一个来源——自己跑 /context。
(v2 补正:这句话也需要限定。/context 是「此刻占用多少」的真值;
「实际花了多少钱」的真值是 ccusage,见 §8.3。一个问题一个真值来源,别混。)
v2 的元教训:一份讲「怎么算准」的文档,自己算错了五次
初版七个错、v2 又发现并新犯五个(#8~#12),合计十二次。而 v2 这五次里有两次 (#11、#12)是在写「判据要对准命题」这段文字的同一个小时里犯的。
这说明的不是「作者不细心」,而是:
- 知道一个认知陷阱的名字,不能免疫它。 我给这个病起了名、列了表、写了自查句式, 然后立刻又踩了两次。
- 修正的方向本身会形成新的偏见。 初版六个错全部偏大,我在「往小改」的那一轮里 完全没想到还有偏小的错(#8 偏小 65%)。
- 唯一真正拦住这五次错的动作,是把脚本实际跑一遍并逐项核对。 不是更仔细地读代码,不是更多的自我提醒——是执行,然后对着输出一项一项验。
如果这份手册只留一条,留这个:省 token 的技巧会随版本失效, 「先问我的判据测的是不是我要证明的事、然后跑一遍逐项核对」不会。
附录 C context-audit.sh 全文
手册里每一个「你这台机器」的数字都来自这个脚本,所以把它整篇附在这里 —— 读者不必去别处找,我自己也多一份副本。
用法:存成 context-audit.sh,chmod +x,然后 ./context-audit.sh。
只依赖 bash 和 python3,不装任何东西。
⚠️ 它量的是配置侧的静态常驻开销(每开一个新会话都要付的那部分),
不是运行时实际占用 —— 真值只有一个来源:在 Claude Code 里跑 /context。
这条区别是整份手册的前提,脚本头部的注释里也写着。
🚨 v2 变更(必读):初版脚本只扫
$HOME/.claude,因此漏掉了两个更大的常驻项。 v2 加了--project <path>:./context-audit.sh --project /path/to/your/repo它会额外统计项目
CLAUDE.md、项目.claude/skills/、以及该项目的 auto-memoryMEMORY.md,并在表尾对照全局侧。不带--project时, 脚本会打印一行警告,明确告诉你这张表不完整——初版最大的问题不是漏了数据, 是漏了却不声明,让人以为看到了全貌。另加
--all-config-dirs:扫描~/.claude*全部并行配置目录(本机有三份), 因为「换后端 = 换一整套常驻开销」。
#!/usr/bin/env bash
# context-audit.sh — Claude Code 常驻上下文开销体检 [v2 2026-08-24]
#
# 它量什么:配置侧的「静态常驻开销」——每开一个新会话就要付一次的那部分。
# · skills / agents 的 name + description(Claude Code 懒加载,只有这两个字段常驻)
# · 全局 CLAUDE.md 体积
# · 【v2】项目 CLAUDE.md + 项目 .claude/skills/ + auto-memory MEMORY.md
# ← 初版漏了这三项,而前两项合计比 skills 元数据还大。带 --project 才会算。
# · hooks 里哪些类型的 stdout 会被注入上下文(**只是 settings.json 里那些,见下**)
#
# 它不量什么(必须自己跑 /context):
# · 运行时实际占用(messages、已读文件、命令输出)
# · plugin 自带 MCP / claude.ai 连接器带来的工具定义
# · 【v2 重要】plugin 自带的 hooks —— 它们在 plugins/cache/<...>/hooks/hooks.json 里,
# 不在任何 settings.json 的 hooks 字段。已确认 claude-mem 的 SessionStart +
# UserPromptSubmit 就在那里。所以本脚本报的 hooks 是**下限**,不是全量。
# · hook 注入内容的真实大小(脚本只能告诉你「有哪几个 hook 会注入」)
#
# 主指标是「字符数」——它精确、可 diff。token 只给参考区间(字符 ÷ 3.3 ~ ÷ 2.5),
# 因为按常数折算不是真实 tokenizer,把估算值当主指标就是判据错位。
#
# 用法:
# ./context-audit.sh # 全局侧体检 + 与上次基线对比
# ./context-audit.sh --project ~/repo # 【v2】加上项目侧(强烈建议一直带)
# ./context-audit.sh --all-config-dirs # 【v2】扫 ~/.claude* 全部并行配置目录
# ./context-audit.sh --save # 体检并把本次存为新基线
# ./context-audit.sh --usage # 额外统计 skill 真实调用次数(扫会话历史,慢)
# ./context-audit.sh --usage-days 90 # 调用统计的回溯天数(默认 30)
# ./context-audit.sh --top 20 # description 排行榜长度(默认 10)
set -uo pipefail
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.context-audit"
SAVE=0; USAGE=0; USAGE_DAYS=30; TOPN=10; PROJECT=""; ALLDIRS=0
while [ $# -gt 0 ]; do
case "$1" in
--save) SAVE=1 ;;
--usage) USAGE=1 ;;
--usage-days) USAGE_DAYS="${2:-30}"; shift ;;
--top) TOPN="${2:-10}"; shift ;;
--project) PROJECT="${2:-}"; shift ;;
--all-config-dirs) ALLDIRS=1 ;;
-h|--help) awk 'NR>1 && /^#/ {sub(/^# ?/,""); print; next} NR>1 {exit}' \
"${BASH_SOURCE[0]}"; exit 0 ;;
*) echo "未知参数:$1(--help 看用法)" >&2; exit 2 ;;
esac
shift
done
if [ ! -d "$CLAUDE_DIR" ]; then
echo "找不到 $CLAUDE_DIR。若你的配置在别处,设 CLAUDE_CONFIG_DIR 后重跑。" >&2
exit 1
fi
mkdir -p "$BASE_DIR"
CLAUDE_DIR="$CLAUDE_DIR" BASE_DIR="$BASE_DIR" SAVE="$SAVE" \
USAGE="$USAGE" USAGE_DAYS="$USAGE_DAYS" TOPN="$TOPN" \
PROJECT="$PROJECT" ALLDIRS="$ALLDIRS" \
python3 - <<'PYEOF'
import os, re, json, glob, time, collections
CD = os.environ['CLAUDE_DIR']
BD = os.environ['BASE_DIR']
SAVE = os.environ['SAVE'] == '1'
USAGE = os.environ['USAGE'] == '1'
DAYS = int(os.environ['USAGE_DAYS'])
TOPN = int(os.environ['TOPN'])
PROJECT = os.environ.get('PROJECT') or ''
ALLDIRS = os.environ.get('ALLDIRS') == '1'
FM = re.compile(r'^---\s*\n(.*?)\n---', re.S)
def frontmatter(path):
"""返回 (name, description);没有 frontmatter 或没有 description 则返回 None。"""
try:
t = open(path, encoding='utf-8', errors='replace').read(20000)
except OSError:
return None
m = FM.match(t) or FM.search(t[:4000])
if not m:
return None
fm = m.group(1)
d = re.search(r'^description:\s*(.*(?:\n[ \t]+.*)*)', fm, re.M)
if not d:
return None
n = re.search(r'^name:\s*(.*)$', fm, re.M)
name = n.group(1).strip().strip('"\'') if n else os.path.basename(os.path.dirname(path))
desc = re.sub(r'\s+', ' ', d.group(1)).strip().strip('"\'')
return name, desc
def chars_of(path):
"""字符数(不是 wc -c 的字节数 —— 中文 UTF-8 三字节一个字,混用虚高约 70%)。"""
if not os.path.exists(path):
return 0, 0
t = open(path, encoding='utf-8', errors='replace').read()
return len(t), t.count('\n') + 1
# ── 搞清「哪些目录才是真正的加载源」,这一步最容易错 ──────────────
#
# plugins/cache/<marketplace>/<plugin>/<版本>/… ← 真正的加载源
# 同一个 plugin 会有多个版本目录共存,只有最新版被加载。
# 不按版本收敛,一个 plugin 就被算好几遍。
# plugins/marketplaces/<marketplace>/… ← 市场的 git clone(含 .git/dist/tests)
# 是源仓库,不是加载源。算进来会双倍计数。
# 还要只算 settings.json 里 enabledPlugins 为 true 的那些。
#
# 【v2】plugins/cache/ 下还可能有一堆 temp_git_* 残留(本机 109 个 / 628 MB,
# 零个含 SKILL.md)—— 占盘不占上下文,可以直接删,见 §8.5。
# 下面的 glob 只匹配 <marketplace>/<plugin>/<版本> 三级结构,天然不会误收它们。
#
VER = re.compile(r'^\d+(\.\d+)*$')
def vkey(s):
try:
return tuple(int(x) for x in s.split('.'))
except ValueError:
return (0,)
enabled = set()
for sf in ('settings.json', 'settings.local.json'):
p = os.path.join(CD, sf)
if os.path.exists(p):
try:
for k, v in (json.load(open(p, encoding='utf-8')).get('enabledPlugins') or {}).items():
if v:
enabled.add(k) # 形如 plugin@marketplace
except Exception:
pass
def plugin_roots():
"""返回每个已启用 plugin 的最新版本目录。"""
roots = {}
base = os.path.join(CD, 'plugins', 'cache')
for mk in sorted(glob.glob(os.path.join(base, '*'))):
if os.path.basename(mk).startswith('temp_git_'): # v2:跳过安装残留
continue
for pl in sorted(glob.glob(os.path.join(mk, '*'))):
key = f"{os.path.basename(pl)}@{os.path.basename(mk)}"
if enabled and key not in enabled:
continue
subs = [d for d in glob.glob(os.path.join(pl, '*')) if os.path.isdir(d)]
vers = [d for d in subs if VER.match(os.path.basename(d))]
if vers: # 正常情况:版本号目录
roots[key] = max(vers, key=lambda d: vkey(os.path.basename(d)))
elif subs: # 有的 plugin 用 commit hash 命名
roots[key] = max(subs, key=os.path.getmtime)
else:
roots[key] = pl
return roots
PLUGIN_ROOTS = plugin_roots()
def collect(sub):
"""sub 为 'skills' 或 'agents'。返回 [(chars, name, desc_len, source)]。"""
files = []
if sub == 'skills':
files += glob.glob(os.path.join(CD, 'skills', '*.md'))
files += glob.glob(os.path.join(CD, 'skills', '**', 'SKILL.md'), recursive=True)
for r in PLUGIN_ROOTS.values():
files += glob.glob(os.path.join(r, 'skills', '**', 'SKILL.md'), recursive=True)
else:
files += glob.glob(os.path.join(CD, 'agents', '**', '*.md'), recursive=True)
for r in PLUGIN_ROOTS.values():
files += glob.glob(os.path.join(r, 'agents', '*.md'))
seen, rows = {}, []
for f in files:
r = frontmatter(f)
if not r:
continue
name, desc = r
if name in seen: # 同名只算一次(加载时也只会生效一个)
continue
seen[name] = 1
src = 'plugin' if '/plugins/' in f else 'self'
rows.append((len(name) + len(desc), name, len(desc), src))
rows.sort(reverse=True)
return rows
skills = collect('skills')
agents = collect('agents')
# 全局 CLAUDE.md
cmd_chars, cmd_lines = chars_of(os.path.join(CD, 'CLAUDE.md'))
# ── 【v2】项目侧:初版完全漏掉的部分 ────────────────────────────────
# 病因见附录 B #8:要证明「一个新会话付多少常驻开销」,判据却只取了 $HOME/.claude。
# Claude Code 会自动发现项目 CLAUDE.md(--bare 的帮助文本写着 CLAUDE.md auto-discovery),
# auto-memory 每会话注入 MEMORY.md —— 两者都在那个目录之外。
proj = None
if PROJECT:
P = os.path.abspath(os.path.expanduser(PROJECT))
pj_md_chars, pj_md_lines = chars_of(os.path.join(P, 'CLAUDE.md'))
# auto-memory 路径:<config>/projects/<路径转义>/memory/MEMORY.md
# 转义规则:'/' 和 '_' 都变成 '-'。所以 /Users/leo/_Data/… → -Users-leo--Data-…
# ⚠️ 必须**精确**匹配,不能用 endswith:本机同时存在 …-<项目名>-dev /
# -dev2 / -dev3 / -release 四个 slug,endswith 要么全不中('_' 没转),
# 要么把 dev 和 dev2 混起来。这一条我第一版就写错了,见附录 B #11。
slug = P.replace('/', '-').replace('_', '-')
mem_chars = mem_lines = 0
mem_path = os.path.join(CD, 'projects', slug, 'memory', 'MEMORY.md')
if os.path.exists(mem_path):
mem_chars, mem_lines = chars_of(mem_path)
else:
mem_path = ''
# ⚠️ 只数**顶层** skill:Claude Code 加载的是 skills/<name>/SKILL.md,
# 而 recursive glob 会把 skill 内部 references/ 里的嵌套 SKILL.md 也算进来。
# 实测本项目:递归 91 个,真实顶层 21 个 —— 虚高 4.3 倍。见附录 B #12。
pj_skill_files = (glob.glob(os.path.join(P, '.claude', 'skills', '*.md')) +
glob.glob(os.path.join(P, '.claude', 'skills', '*', 'SKILL.md')))
pj_skills = [r for r in (frontmatter(f) for f in pj_skill_files) if r]
pj_sk_chars = sum(len(n) + len(d) for n, d in pj_skills)
# 另两侧镜像:对 Claude Code 不进上下文,但**是 Cursor / Kimi 的唯一入口,不可删**
other = {}
for d, label in (('.cursor/skills', 'Cursor'), ('.agents/skills', 'Kimi Code CLI')):
n = len([x for x in glob.glob(os.path.join(P, d, '*')) if os.path.isdir(x)])
if n:
other[d] = (n, label)
proj = dict(path=P, md_chars=pj_md_chars, md_lines=pj_md_lines,
mem_chars=mem_chars, mem_lines=mem_lines, mem_path=mem_path,
sk_n=len(pj_skills), sk_chars=pj_sk_chars, other=other)
# SKILL.md 正文总量(对照用:证明懒加载省了多少)
body = sum(os.path.getsize(f) for f in
glob.glob(os.path.join(CD, '**/SKILL.md'), recursive=True))
# hooks:这三类的 stdout 会进上下文
# 【v2】注意:这里只看 settings.json。plugin 自带 hooks 在
# plugins/cache/<...>/hooks/hooks.json 里,本段查不到 —— 见文件头注释。
INJECT = {'SessionStart', 'UserPromptSubmit', 'PreCompact'}
hooks = {}
for sf in ('settings.json', 'settings.local.json'):
p = os.path.join(CD, sf)
if not os.path.exists(p):
continue
try:
d = json.load(open(p, encoding='utf-8'))
except Exception:
continue
for ev, arr in (d.get('hooks') or {}).items():
n = sum(len(x.get('hooks', [])) for x in arr) if isinstance(arr, list) else 1
hooks[ev] = hooks.get(ev, 0) + n
# 【v2】plugin 自带 hooks —— 单独列,证明上面那个枚举是下限
plugin_hooks = collections.Counter()
for r in PLUGIN_ROOTS.values():
for hf in glob.glob(os.path.join(r, 'hooks', 'hooks.json')):
try:
hj = json.load(open(hf, encoding='utf-8'))
except Exception:
continue
for ev in (hj.get('hooks') or hj):
plugin_hooks[ev] += 1
def tok(chars):
return f"{chars/3.3:,.0f}~{chars/2.5:,.0f}"
sk_chars = sum(r[0] for r in skills)
ag_chars = sum(r[0] for r in agents)
global_total = sk_chars + ag_chars + cmd_chars
proj_total = (proj['md_chars'] + proj['mem_chars'] + proj['sk_chars']) if proj else 0
total = global_total + proj_total
print("═" * 66)
print(f" Claude Code 常驻开销体检 v2 {time.strftime('%Y-%m-%d %H:%M')}")
print(f" 配置目录:{CD}")
if proj:
print(f" 项目目录:{proj['path']}")
print("═" * 66)
print()
print(f"{'项目':<26}{'数量':>6}{'字符数':>12}{'≈tokens':>16}{'占比':>8}")
print("-" * 66)
rows = [
('[全局]skills 元数据', len(skills), sk_chars),
('[全局]agents 元数据', len(agents), ag_chars),
('[全局]CLAUDE.md', cmd_lines, cmd_chars),
]
if proj:
rows += [
('[项目]CLAUDE.md', proj['md_lines'], proj['md_chars']),
('[项目]MEMORY.md', proj['mem_lines'], proj['mem_chars']),
('[项目].claude/skills', proj['sk_n'], proj['sk_chars']),
]
for label, n, c in rows:
pct = f"{c/total*100:.1f}%" if total else "-"
unit = '行' if 'CLAUDE.md' in label or 'MEMORY.md' in label else '个'
print(f"{label:<26}{str(n)+unit:>7}{c:>12,}{tok(c):>16}{pct:>8}")
print("-" * 66)
print(f"{'合计(配置侧静态常驻)':<24}{'':>7}{total:>12,}{tok(total):>16}{'100%':>8}")
print()
if not proj:
# 初版最大的问题不是漏了数据,是漏了却不声明。
print(" 🚨 这张表不完整:没带 --project,因此**没有算**项目级 CLAUDE.md 和")
print(" auto-memory MEMORY.md。在真实项目会话里这两项可能比上面所有项加起来还大")
print(" (实测某 Unity 项目:项目侧 32,571 字符 = 65%,全局侧 17,213 = 35%)。")
print(" 请改用:./context-audit.sh --project /path/to/your/repo")
print()
else:
g, p = global_total, proj_total
print(f" 全局侧 {g:,} 字符 ({g/total*100:.0f}%) · 项目侧 {p:,} 字符 ({p/total*100:.0f}%)")
if p > g:
print(f" ⚠ 项目侧比全局侧大 —— 优化应该先从项目 CLAUDE.md / MEMORY.md 下手,")
print(f" 而不是从 skills 元数据。(初版结论正好相反,因为它没扫项目侧)")
# 0 值必须区分「真的是 0」和「没找到」—— 静默计 0 正是初版最大的毛病
if not proj['mem_path']:
print(f" ⚠ 没找到这个项目的 auto-memory MEMORY.md,上表按 0 计。")
print(f" 期待路径:{CD}/projects/{proj['path'].replace('/','-').replace('_','-')}"
f"/memory/MEMORY.md")
print(f" 换后端时 slug 或目录都可能不同 —— 别把「没找到」当成「没有开销」。")
if proj['other']:
print()
print(" 项目侧另两个 skill 镜像(对 Claude Code 不进上下文,但**不是可删的冗余**):")
for d, (n, label) in proj['other'].items():
print(f" · {d:<20}{n:>3} 个 ← {label} 的唯一入口,删了那边就全没了")
print()
# 未启用 plugin 里的 skills/agents 是「幽灵开销」——磁盘上有、但没被加载。
# 不把这一栏单独列出来,很容易把它们当成自己的负担去优化。
ghost_ag = ghost_sk = 0
for f in glob.glob(os.path.join(CD, 'plugins', 'marketplaces', '**', 'agents', '*.md'), recursive=True):
if frontmatter(f):
ghost_ag += 1
for f in glob.glob(os.path.join(CD, 'plugins', 'marketplaces', '**', 'skills', '**', 'SKILL.md'), recursive=True):
if frontmatter(f):
ghost_sk += 1
if ghost_ag or ghost_sk:
print(f" 幽灵开销(磁盘上有但 plugin 未启用,不进上下文):"
f"{ghost_sk} 个 skill、{ghost_ag} 个 agent。别去优化它们。")
# 【v2】安装残留:占盘不占上下文,这个可以放心删
tmp = [d for d in glob.glob(os.path.join(CD, 'plugins', 'cache', 'temp_git_*'))
if os.path.isdir(d)]
if tmp:
n_skill = len(glob.glob(os.path.join(CD, 'plugins', 'cache', 'temp_git_*',
'**', 'SKILL.md'), recursive=True))
print(f" 安装残留:{len(tmp)} 个 temp_git_* 目录(含 SKILL.md {n_skill} 个)"
f"—— 占盘不占上下文。")
if n_skill == 0:
print(f" 确认为空壳,可删:rm -rf {CD}/plugins/cache/temp_git_*")
print()
print(f" 统计口径:{len(PLUGIN_ROOTS)} 个已启用 plugin,各取最新版本目录;同名 skill 只算一次。")
for k in sorted(PLUGIN_ROOTS):
print(f" · {k:<40} {os.path.basename(PLUGIN_ROOTS[k])}")
print(f" 这个数仍是配置侧估算。真值只有一个来源——自己跑 /context 看 Skills /"
f" Custom agents / Memory files 几行。")
print()
print(f" 对照:所有 SKILL.md 正文合计 {body:,} 字节 —— 懒加载,不常驻。")
if body:
# v2:同口径比(字符÷字节是附录 B #6 那个错)
print(f" 常驻的只是其中约 {sk_chars/body*100:.2f}%(按字符÷字节,口径不一致,"
f"仅作量级参考)。")
print(f" 提醒:上面的字符数都是**字符**,不是 wc -c 的字节数。"
f"中文 UTF-8 三字节一个字,混用会让它虚高约 70%。")
print()
# 【v2】并行配置目录 —— 换后端 = 换一整套常驻开销
if ALLDIRS:
print("── 并行配置目录(换后端 = 换一整套开销)" + "─" * 24)
for d in sorted(glob.glob(os.path.expanduser('~/.claude*'))):
if not os.path.isdir(d) or not os.path.isdir(os.path.join(d, 'skills')):
continue
n = len([x for x in glob.glob(os.path.join(d, 'skills', '*'))
if os.path.isdir(x)])
c, _ = chars_of(os.path.join(d, 'CLAUDE.md'))
hk = 0
sp = os.path.join(d, 'settings.json')
if os.path.exists(sp):
try:
hk = len(json.load(open(sp, encoding='utf-8')).get('hooks') or {})
except Exception:
pass
mark = ' ← 当前' if os.path.abspath(d) == os.path.abspath(CD) else ''
print(f" {os.path.basename(d):<22}skills {n:>3} · CLAUDE.md {c:>6} 字符 · "
f"settings hooks {hk:>2} 类{mark}")
print(" 注:settings hooks 为 0 不代表没有注入 —— plugin 自带 hooks 不在那个字段里。")
print()
print("── hooks:哪些会把 stdout 注入上下文 " + "─" * 26)
if not hooks:
print(" settings.json 里没有配置 hooks。")
for ev in sorted(hooks, key=lambda e: (e not in INJECT, e)):
mark = ' ← stdout 进上下文,每次都付' if ev in INJECT else ''
print(f" {ev:<22}{hooks[ev]} 个{mark}")
if plugin_hooks:
print()
print(" 【v2】plugin 自带 hooks(不在任何 settings.json 里,初版完全漏掉):")
for ev in sorted(plugin_hooks, key=lambda e: (e not in INJECT, e)):
mark = ' ← 同样注入上下文' if ev in INJECT else ''
print(f" {ev:<20}{plugin_hooks[ev]} 个{mark}")
print("\n 注:上面是**下限**。脚本只能告诉你「有哪几个会注入」,注不进来多少字得看")
print(" /context 的 Messages 那一行在会话刚开始时的读数。")
print()
print(f"── description 最长的 {TOPN} 个(常驻收税大户)" + "─" * 12)
merged = sorted([(r[0], r[1], 'skill', r[3]) for r in skills] +
[(r[0], r[1], 'agent', r[3]) for r in agents], reverse=True)
for c, name, kind, src in merged[:TOPN]:
bar = '█' * max(1, round(c / 120))
print(f" {c:5d} {kind:<6}{src:<7}{name:<26}{bar}")
if merged and cmd_chars:
c0, n0 = merged[0][0], merged[0][1]
print(f"\n 最长的一条({n0},{c0} 字符)= 你整个 CLAUDE.md({cmd_chars} 字符)的 {c0/cmd_chars:.1f} 倍。")
elif merged and proj and proj['md_chars']:
# v2:全局 CLAUDE.md 可能为 0(换后端时常见),改跟项目 CLAUDE.md 比
c0, n0 = merged[0][0], merged[0][1]
print(f"\n 最长的一条({n0},{c0} 字符)= 项目 CLAUDE.md({proj['md_chars']} 字符)"
f"的 {c0/proj['md_chars']*100:.1f}%。")
print()
# ── skill 真实调用统计(--usage)
usage = None
if USAGE:
# 按「记录里的 timestamp」筛,不能按文件 mtime。
# 实测过:这台机器 17,512 个会话文件里有 17,512 个 mtime 都在 30 天内
# (某次批量操作刷新了全部 mtime),拿 mtime 当「会话发生时间」等于没筛。
cutoff = time.time() - DAYS * 86400
files = glob.glob(os.path.join(CD, 'projects', '**', '*.jsonl'), recursive=True)
cnt, lines, dated, undated = collections.Counter(), 0, 0, 0
for f in files:
try:
fh = open(f, encoding='utf-8', errors='replace')
except OSError:
continue
with fh:
for line in fh:
lines += 1
if '"Skill"' not in line: # 预筛,避开解析每一行的开销
continue
try:
d = json.loads(line)
except Exception:
continue
ts = d.get('timestamp')
if ts:
try:
t = time.mktime(time.strptime(ts[:19], '%Y-%m-%dT%H:%M:%S'))
if t < cutoff:
continue
dated += 1
except ValueError:
undated += 1
else:
undated += 1 # 没时间戳的照计,但单独报出来
m = d.get('message') or {}
c = m.get('content')
if not isinstance(c, list):
continue
for blk in c:
if isinstance(blk, dict) and blk.get('type') == 'tool_use' and blk.get('name') == 'Skill':
s = (blk.get('input') or {}).get('skill')
if s:
cnt[s] += 1
usage = dict(cnt)
print(f"── skill 真实调用(近 {DAYS} 天,扫 {len(files)} 个会话 / {lines:,} 行)" + "─" * 3)
if undated:
print(f" (其中 {undated} 条 Skill 记录没有可解析的 timestamp,已计入;"
f"{dated} 条按记录时间落在窗口内)")
if cnt:
for s, v in cnt.most_common():
print(f" {v:5d} {s}")
else:
print(" 这段时间没有 Skill 调用记录。")
# 调用名可能是 plugin:skill 形式,而 frontmatter 里的 name 是裸名。
# 不做这一步归一化,就会把「用过的」误判成「零调用」——而这个判据导向的动作
# 是删除,误报的代价不对称。这是本脚本最容易出错的一处。
def norm(s):
return s.split(':')[-1]
called_raw = set(cnt)
called_norm = {norm(s) for s in called_raw}
installed = {r[1] for r in skills}
used = sorted(installed & called_norm)
cold = sorted(installed - called_norm)
alien = sorted(s for s in called_raw if norm(s) not in installed)
print(f"\n 全局装了 {len(installed)} 个;近 {DAYS} 天的调用记录涉及 {len(called_raw)} 个名字。")
print(f" · 匹配上全局清单:{len(used)} 个")
print(f" · 全局装了但零调用:{len(cold)} 个"
f"({len(cold)/len(installed)*100:.0f}%)" if installed else "")
if alien:
print(f" · 调用了但不在全局清单:{len(alien)} 个 —— 基本是**别的项目**的"
f" .claude/skills/,不代表全局多装了什么:")
print(f" {', '.join(alien)}")
print()
print(" ⚠ 三条都要看清再动手:")
print(" 1) 零调用 ≠ 没用。季节性工具(发版、迁移、出题)可能三个月才用一次。")
print(" 2) 这份统计跨了所有项目,而「装了什么」只数了全局 —— 两边口径不同。")
print(" 3) 调用名带 plugin 前缀,脚本已按最后一段归一化匹配;若你的 skill 重名,"
"仍可能误判。")
print(" 判据错 + 动作不可逆 = 最贵的组合。先把清单看一遍,别照着批量删。")
print()
# ── 与上次基线对比
snap = {
'ts': time.strftime('%Y-%m-%d %H:%M'),
'skills_n': len(skills), 'skills_chars': sk_chars,
'agents_n': len(agents), 'agents_chars': ag_chars,
'claude_md_chars': cmd_chars,
'global_total': global_total,
'total_chars': total,
'hooks': hooks, 'plugin_hooks': dict(plugin_hooks),
}
# 【v2】项目侧也要进快照,否则 diff 追踪不到最大的那两项在涨
if proj:
snap.update({
'proj_path': proj['path'],
'proj_md_chars': proj['md_chars'],
'proj_mem_chars': proj['mem_chars'],
'proj_sk_chars': proj['sk_chars'],
'proj_total': proj_total,
})
if usage is not None:
snap['usage'] = usage
prev_files = sorted(glob.glob(os.path.join(BD, 'baseline-*.json')))
if prev_files:
prev = json.load(open(prev_files[-1], encoding='utf-8'))
print(f"── 与上次基线对比({prev.get('ts','?')})" + "─" * 30)
fields = [('skills_n', 'skills 个数'), ('skills_chars', 'skills 字符'),
('agents_n', 'agents 个数'), ('agents_chars', 'agents 字符'),
('claude_md_chars', '全局 CLAUDE.md'),
('proj_md_chars', '项目 CLAUDE.md'),
('proj_mem_chars', '项目 MEMORY.md'),
('proj_sk_chars', '项目 skills'),
('total_chars', '合计字符')]
for k, label in fields:
if k not in snap and k not in prev:
continue # 两边都没有(比如没带 --project)
a, b = prev.get(k, 0), snap.get(k, 0)
if a == b:
print(f" {label:<18}{b:>10,} (不变)")
else:
d = b - a
pct = f"{d/a*100:+.1f}%" if a else "n/a"
arrow = '↓' if d < 0 else '↑'
print(f" {label:<18}{a:>10,} → {b:,} {arrow}{abs(d):,} ({pct})")
if prev.get('proj_path') and proj and prev['proj_path'] != proj['path']:
print(f" ⚠ 项目路径变了({prev['proj_path']} → {proj['path']}),"
f"项目侧的 diff 不可比。")
if ('proj_total' in prev) != ('proj_total' in snap):
print(f" ⚠ 一次带了 --project 一次没带 —— 合计字符的 diff 不可比。")
print()
else:
print("── 还没有基线。加 --save 存一份,下次就能 diff。" + "─" * 18)
print()
if SAVE:
out = os.path.join(BD, f"baseline-{time.strftime('%Y%m%d-%H%M')}.json")
json.dump(snap, open(out, 'w', encoding='utf-8'), ensure_ascii=False, indent=2)
print(f"已存基线:{out}")
PYEOF