Claude Code 省 Token 实操手册 · 按你这台机器改一遍

高效对话与省钱第 4 / 7 篇

这份手册只回答一个问题:我该怎么改我这台机器。不重复讲解原理,只给动作、验证和判据。 「视频讲了什么」在 视频整理版 里,两份分工不重叠。

素材: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-memory MEMORY.md。修正后「大头是 skills 元数据」这个结论在带项目配置的会话里不成立。 详见第 0 章的双表和附录 B 第 8 条。如果你读的是初版并照着优化了,请重跑第 0 章。


先说清我做不到的四件事

写在最前面,因为这决定了你该信这份手册到什么程度。

  1. 我不能替你跑 /context——它是交互式命令。脚本量的是配置侧静态开销(每开一个新会话就要付的那部分),/context 量的是运行时实际占用。两者互补,不能互相替代。凡是要下结论的数,最终以 /context 为准。
  2. token 数是估算。脚本以字符数为主指标(精确、可 diff),token 只给 ÷3.3 ~ ÷2.5 的区间。按常数折算不是真实 tokenizer,把估算值当主指标就是判据错位。
  3. 第三方网关的缓存折扣我无法验证。你本机有 OneHub / DSH / PiAgent 这些接第三方模型的路径,0.1× 缓存折扣在那些链路上是否生效,本文只给判断方法,不给结论(见 §8.3)。
  4. 我不判断哪个 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.md 32.7% > 项目 CLAUDE.md 22.6% > 项目 .claude/skills 10.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 交给模型的默认档

⚠️ 三个会让你「设了没生效」的前置条件

这三条都是本机二进制里的原始报错文案,不是我推测的:

  1. ultracode 需要先在 /config 里启用动态工作流——否则报 “Ultracode needs dynamic workflows enabled (see /config)”。

  2. ultracode 还要求模型支持 xhigh,且组织可以限制它—— “Ultracode runs at xhigh effort, which is restricted by your organization for …”。

  3. 🚨 环境变量会覆盖会话内的 /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 最近几轮走偏了想回退 只从尾部剪掉几轮,前面的缓存照常命中

三条容易搞错的

  1. /rewind 是被严重低估的那个。走偏了想回退,很多人第一反应是 /compact 甚至 /clear——但 /rewind 只剪尾部,前缀完好,比两者都便宜。视频里一个字没提。
  2. 离开键盘前先 /compact。订阅模式缓存约 1 小时过期;趁它还在时做摘要,比过期后全价重来便宜得多。
  3. 一个任务一个会话。官方给的对比是「同样三个任务,一口气做完 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 占了全部调用的七成以上——你实际重度依赖的是很小的一撮。

⚠️ 这个判据会误报,而它导向的动作是删除

这是本手册里最需要小心的一处。 四个坑,我在写脚本时全踩了一遍:

  1. 调用名带 plugin 前缀。superpowers:grilling 和 grilling 是两个字符串。不做归一化,会把用过的算成零调用。脚本已按 : 最后一段匹配——但如果你有重名 skill,仍会误判。

  2. 统计跨了所有项目,「装了什么」只数全局。analyze-then-fix、update-dev-log 这些高频 skill 在别的项目的 .claude/skills/ 里,不在全局清单——它们出现在「调用了但不在清单」那一栏,不代表全局多装了东西。

  3. 零调用 ≠ 没用。季节性工具(发版、迁移、出题、年度报告)可能三个月才用一次。30 天窗口对它们不公平。试 --usage-days 90 再看一遍。

  4. 别拿文件 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 天」,判据取的是「这个文件最近被写过」。

  5. 🚨 (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.md 11,237 + MEMORY.md 16,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 元数据」 初版结论 v2 已推翻,见第 0 章双表 + 附录 B #8

附录 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)是在写「判据要对准命题」这段文字的同一个小时里犯的。

这说明的不是「作者不细心」,而是:

  1. 知道一个认知陷阱的名字,不能免疫它。 我给这个病起了名、列了表、写了自查句式, 然后立刻又踩了两次。
  2. 修正的方向本身会形成新的偏见。 初版六个错全部偏大,我在「往小改」的那一轮里 完全没想到还有偏小的错(#8 偏小 65%)。
  3. 唯一真正拦住这五次错的动作,是把脚本实际跑一遍并逐项核对。 不是更仔细地读代码,不是更多的自我提醒——是执行,然后对着输出一项一项验。

如果这份手册只留一条,留这个:省 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-memory MEMORY.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