Skills 与自定义命令:什么该固化,什么不该

定制与自动化第 3 / 3 篇

本文的每个数字都是跑出来的。 环境:Claude Code 2.1.258 · Node v22.22.3 · 被试模型 claude-haiku-4-5-20251001。验证代码在仓库 experiments/claude-code-skills/, 整套可重跑(node e1-three-carriers.mjs 等),每个结论都对应一个脚本。

把反复用的工作流写成 skill,动机很直接:说过一次的话不想再说第二遍。

这一篇的结论偏负面。不是因为 skill 这个机制不好,而是因为 「我需要一个 skill」这个判断,本身很少被验证过 —— 而验证它只需要一个对照组:同样的话直接写进 prompt,结果差多少?

实测的答案是:没测出差别。差的是 token。

所以这篇的主要产出是一条判据,以及好几次「我量出了数字,而那个数字是假的」—— 后者比前者值钱,因为它们每一次都长得像结论: 一个 1/3 的遵循率、一整列会自己跳变的工具数、一个不是第一个参数的 $1, 还有一个被推翻之后换上来的解释同样站不住的归因。

一、先决定怎么算「skill 生效了」

这一节全是量具。跳过它的话,后面每张表都可以是假的。

不能看「有没有调用 Skill 工具」

最顺手的判据是从 --output-format stream-json 里数 Skill 工具调用。 它在自动触发那条路上确实好用,但在显式 /my-skill 那条路上会瞎:

显式 /repo-brief 的工具序列: Read → Write        ← 没有 Skill
自然语言提问的工具序列:      Bash → Skill → Read → Write

斜杠命令是把 SKILL.md 正文直接注入,不经过 Skill 工具。 拿工具调用当判据,斜杠那一组会整组报 0 命中 —— 一个瞎了却在报红的量具。

判据:一串猜不到的字符串

改用魔数。SKILL.md 的正文里写一串 MAGIC-7391, 而正文是渐进加载的 —— description 常驻系统提示,正文要等到被调用之后才进上下文。 没读到就写不出这串字符。

而且要分成两条,不能合成一个数:

判据 判什么 为什么不能只用另一条
回答里出现魔数 正文进没进上下文 不碰文件系统,绕开路径歧义与写权限
产物文件写对了 要求的动作做没做完 只看回答的话,「读到了但没做」也算过

为什么要分成两条:一个 1/3 的假数字

第一版只有文件那一条判据,跑出「命中 1/3」。 差一点就当成遵循率写进这篇文章。追下去发现三轮都是同一回事:

Write → /tmp/xxx/.claude/skills/repo-brief/out.txt
"Claude requested permissions to write ... but you haven't granted it yet"

SKILL.md 里写的是「写到 out.txt」,模型把它理解成相对于 skill 目录, 而 acceptEdits 会挡住 .claude/ 下的写入。

那个 1/3 量的是模型猜相对路径的能力,跟 skill 生没生效没有关系。 更要命的是另一半:out.txt 只要真的建起来了,首行 5/5 全对 —— 「没建成」和「格式没遵守」被我合并成了一个数,而它们的成因毫不相干。

修法是把路径在 SKILL.md 里写死到项目根,再加上那条不碰文件系统的判据。 改完重跑,9/9 两条全过。

二、这台机器上装着 89 个 skill

第二件必须先解决的事:实验环境不是空的。

在一个刚 mktemp -d 出来的空目录里起 headless 会话,init 事件报的是:

skills: 89   slash_commands: 121   tools: 49(含 Gmail、Google Drive 连接器)
plugins: 6   hooks: 4 个在会话开始时就跑了

「命中率」放在这种环境里量到的不是「我的 description 写得好不好」, 而是**「它在 89 个竞争者里排第几」**。

哪个参数能只留下项目级 skill,是测出来的(probe2.mjs,读 init 清单, 不花模型调用):

组合 skills slash 项目 skill 在?
默认 89 121 ✅
--setting-sources project 17 44 ✅
--setting-sources local 16 43 ❌
--setting-sources ""(空串) 16 43 ❌
--safe-mode 16 43 ❌
--disable-slash-commands 0 0 ❌

只有 --setting-sources project 既清了场又留住了项目 skill。 剩下的 16 个是 Claude Code 内置 skill(deep-research、dataviz、 code-review、simplify 等),跟着版本走 —— 别人重跑能对上, 而本机那 89 个对不上。

⚠️ 空串不等于「什么都不加载」,--disable-slash-commands 只关 skill、 MCP 工具照旧。两个名字都比它们的实际作用范围听起来更大。

顺带查出来的一个抖动量具

上面那张表的 tools 列整列作废。线索是 --disable-slash-commands 那行报了 84 个工具,比什么都不关的基线还多 —— 关掉 skill 不可能让工具变多。

同一组合连跑 5 次(probe3.mjs):

mcp__ 工具数:[21, 21, 21, 52, 21]
                       ↑ 这一轮 init 从 1–2 秒变成 8.5 秒

远程连接器的工具清单带缓存,每隔几轮会真去拉一次。撞上的那轮前缀里 多几十个工具定义,token 数直接偏掉。

📌 skills 和 slash_commands 两列 5/5 全稳,所以主指标可用 —— 但这是查过之后才知道的,不是默认成立的。 后面所有实验都记录每轮的 mcp 工具数,与同臂众数不符的判为不可用。 (阈值不能写死成「>0」:隔离臂的常态是 0,默认臂的常态是 21, 写死会把整个默认臂废掉,而症状是「这一臂一轮都没跑成」。)

三、同一份约定,装进三种载体

正题。三臂拿到的是同一段文字,只有存放位置不同:

  • A 裸提问 —— 约定全文写进 prompt
  • B 斜杠命令 —— 约定全文写进 .claude/commands/add-entry.md
  • C skill —— 约定全文写进 SKILL.md,提问用自然语言

任务是往 registry.json 加一条,并遵守三条项目土规矩: entries 按 weight 降序、id 一律小写、改完额外写一份 registry.lock。 选这三条是因为它们都不是通用最佳实践,只可能来自那段约定文本。

第四臂 D 不能省:裸提问,但不给约定。它回答的是 「这套规矩真的猜不到吗」—— D 要是也能过,A/B/C 全过就说明不了任何事。

36 轮(每臂 9 轮):

臂 三条全对 平均前缀 token 平均每轮成本
A 裸提问 9/9 77,068 $0.0300
B 斜杠命令 7/8 79,014 $0.0307
C skill 6/6 106,129 $0.0353
D 负例(不给约定) 0/6 85,971 $0.0298

D 组六轮的产物长得一模一样 —— 三条规矩一条没中:

ids = ["alpha", "beta", "Gamma"]     // 没排序,大写原样保留
registry.lock                         // 不存在

判据有区分力,而 A/B/C 的正确率没有可测出的差别。

差的是 token。skill 那条路比裸提问多约 38% 的前缀 token、贵约 18%, 原因在工具序列里看得很清楚:

A 裸提问 / B 斜杠命令: Read → Edit → Write
C skill:              Skill → Read → Edit → Write
                       ↑ 先花一轮决定用哪个 skill,然后才开始干活

多出来的是一整轮往返,而每一轮都要重发整个前缀。 让模型自己发现该用哪个 skill 是要花钱的,直接叫它的名字不用。

⚠️ 三处局限得说清楚:

  • 三个正确率之间没有可比的差距。 9/9、7/8、6/6 能说的只有 「在这个范围内没测出载体带来的正确率差异」,不是「三者等价」。
  • 前缀 token 本身在两个档位间跳(约 64k 和约 87k,同一臂内都出现), 上表是均值。所以那个 38% 该读成「三成到四成」,不是一个精确的数。
  • B 臂唯一那次失败见第七节 —— 它是参数切分错了,不是载体的问题。

四、描述写模糊,命中率掉到多少

skill 和另外两种载体的真正区别,不在能力,在触发方式: 另外两种是你点名叫它,skill 是模型自己决定要不要用。 那么「什么时候该用我」这句话写得好不好,就是它唯一的命门。

三档描述,SKILL.md 正文完全相同,10 种问法,每种跑 2 遍:

description 三条全对 其中「id 小写」单项
P 精确(列出用户会用的词) 12/12 12/12
V 模糊(Registry helper.) 5/15 14/15
M 误导(Internal tooling for repository metadata processing.) 4/15 13/15

精确档 12/12 同时充当扫描面自检:连它都低于 8 成的话, 说明这 10 句提问算不上「相关提问」,模糊档的低分就归不到 description 头上 —— 那时候该换提问集,而不是写结论。

📌 「id 小写」那一列值得单看:它在模糊档仍有 14/15,而另外两条只有 5/15。 因为「id 用小写」恰好撞上模型的自然倾向,而「降序」和「写一份 lock 文件」不是。 如果三条规矩全挑成这种「本来就会做」的,三档都会是满分, 整个实验测不出任何东西。判据得挑猜不到的那种。

还有一条自检:「做对了」与「调用过 Skill 工具」打架 0 次。 两条独立判据完全一致,说明这一组的结论不依赖某一个量具没瞎。

五、你的 skill 在跟另外 88 个抢

这一节原本不在计划里,是探针阶段撞出来的 —— 而它撞出来的第一个结论是错的。

探针跑在默认环境(89 个 skill)里,Skill 工具一次都没被调用。 当时手边最顺的解释是:headless 模式下项目级 skill 不会自动触发。 换成 --setting-sources project 之后,同一个 skill、同一句提问 12/12 全部被调用。

所以那句差点写下的话是错的。但接下来我给的第二个解释也不全对。

正式量一遍(两臂只差一个 CLI 参数,任务和判据与第三、四节完全相同):

臂 init 里的 skill 数 三条全对 平均前缀 token
隔离(--setting-sources project) 17 18/18 116,876
默认(本机全部都在) 89 14/16 117,553

拥挤度确实有影响,方向也对,但幅度远小于探针阶段看上去的那样 —— 不是「0% 对 100%」,是 88% 对 100%。

差别在于那两次跑的是不同的任务。探针用的是「总结一个文件」, 而那件事模型自己就能做(见下一节)。那 0/2 里同时装着两个因素: 竞争者太多,以及这活儿本来就用不着 skill。我当时把它们全记在了前者头上。

📌 教训是这一篇里最贵的一条: 第一个解释被推翻之后,第二个解释同样需要一个对照组。 「找到了真正的原因」这种感觉,和「找到了第一个说得通的原因」在当时毫无区别。

⚠️ 另外那两列前缀 token 几乎一样(差 0.6%)。 88 个 skill 的 description 加起来,在十万量级的前缀里并不显著 —— 拥挤度花的不是 token,是模型的注意力。

六、模型自己就能做的事,它不会去调 skill

这一节是实验二第一版的残骸,而它测出的东西比原来那版更有用。

第一版的任务是「总结一个文件」。跑完三档 description, 精确档只有 5/9 —— 自检当场判了不可用:连精确档都不到 8 成, 模糊档的 0/10 就说明不了 description 的作用。

病根在任务:总结文件这件事,模型不调 skill 也做得成。 于是「没调用 skill」里混着「根本不需要调用」,两者被合成了一个数。

描述 Skill 被调用
P 精确 5/9
V 模糊 0/10
M 误导 0/10

换个角度读,这张表就有意义了:即使描述写得精确,只要那件事模型自己就能做, 它有一半的时候压根不会去看你的 skill。 而模糊和误导那两档,20 轮里一次都没被选中。

📌 这一处是自检救回来的,不是我自己看出来的。 如果没有「精确档必须够高」那条前置检查,我会拿 5/9 → 0/10 写一段 关于 description 的漂亮结论,而那个对比根本不成立。

七、斜杠命令的参数,$1 不是第一个

这个坑是实验一的 B 臂踩出来的:第一版命令文件里写「id 为 $1、weight 为 $2」, 跑出来 id="2"。看着像「斜杠命令这个载体不可靠」。

让命令做一件只需复制、不需理解的事(probe6.mjs),两轮结果一致:

传入: /probe-args ALPHA BRAVO CHARLIE
产物: ALL=[ALPHA BRAVO CHARLIE] P0=[ALPHA] P1=[BRAVO] P2=[CHARLIE] P3=[$3]

位置参数是 0-based:$0 是第一个、$1 是第二个。 而越界的 $3 原样留在 prompt 里 —— 不报错、不置空、不提示。 模型收到一句带 $ 符号的怪话,然后自己猜, 猜出来的东西长得就像被测对象的行为。

那 $ARGUMENTS 呢?它拿到的是完整参数串,替换本身两轮都对。 但它把切分推给了模型 —— 第三节 B 臂唯一那次失败正是这个:

命令里写的: 参数为「$ARGUMENTS」,格式是 <id> <weight>
实际传入:   /add-entry Gamma 2
产物:       ids = ["alpha", "gamma-2", "beta"]
                          ↑ 整串被当成了一个 id

8 轮错 1 轮。所以两条路各有各的坑: 位置参数的索引反直觉,$ARGUMENTS 的切分不可靠。 参数多于一个时,与其指望哪一种,不如在命令正文里把每个参数的含义写清楚。

八、这一篇的判据

回到开头那个问题:什么该固化成 skill。

先做那个对照组。 把同样的话直接写进 prompt,跑 5 轮。 如果 5 轮结果就已经稳定 —— 像本文第三节那样 —— 这个 skill 不该存在。 它不会让结果更对,只会让每次调用多出一整轮往返, 并且多一份需要维护、会过期、而过期不报错的文件。

真正值得固化的,是同时满足下面几条的:

  1. 那段话本身很长,且每次都一模一样。 省下的是你重复粘贴的功夫, 不是模型的正确率 —— 别指望后者,实测没有可测出的差别。
  2. 那件事模型不会自己做。 第六节:能自己做的事,它有一半时候不看你的 skill。 反过来说,需要固化的是项目土规矩,不是通用最佳实践 —— 第四节那张表里「id 用小写」几乎不受 description 影响,正是因为它撞上了常识。
  3. 你愿意为「模型自己决定要不要用它」这件事付钱。 不愿意的话,写成斜杠命令 —— 没测出正确率差异,更少的 token, 而且触发与否由你说了算,不由 description 写得好不好决定。
  4. description 写得出「什么时候该用我」,而不只是「我是什么」。 第四节:从 12/12 掉到 5/15,差别只在这一句话。

第 3 条是这篇里最实用的一条:大多数人想要的其实是斜杠命令。 「让 agent 自己发现」听起来更聪明,但你为此付了三到四成的前缀 token, 换来的是一个由 description 措辞决定的、并不稳定的触发 —— 而且在装了几十个 skill 的机器上,它还要跟另外那些抢(第五节)。


本文没有回答的三个问题:

一是换个更强的模型会怎样。全部实验的被试都是 haiku。 换模型重跑一遍就知道(LAB_MODEL=sonnet node e1-three-carriers.mjs), 但那是另一个成本量级,本篇不做 —— 这里也不给「大模型应该更好」这种没跑过的推断。

二是多个 skill 之间怎么互相干扰。本文每次只装一个自己的 skill, 「拥挤度」那一节改变的是背景 skill 的数量,不是它们与被测 skill 的语义距离。 两个功能相近的 skill 摆在一起会怎样,需要另一组实验。

三是 SKILL.md 正文写多长开始失效。本文的正文都在十几行的量级, 官方文档建议的渐进加载(正文再引用别的文件)没有测。