🐎 第3章 授人以渔:Skills 工程实践
📄 一句话概括

技能可以被传参:调用行里的词按位置填进 $1 $2,或者整串填进 $ARGUMENTS。 还有一种更主动的做法——正文里写一行反引号包起来的命令,触发时先执行它,把输出拼进正文。 这让技能能拿到「此刻的事实」,代价是它真的会跑。

📝 本章和前面几章的关系

3.1 到 3.6 讲的是「技能怎么被写好、怎么被选中、能干什么」,这一节开始加功能。 参数解决的是「同一个人对不同的目录、不同的重点要说不同的话」; 预取解决的是「有些事实模型不知道,但命令知道」。

一、参数从哪来

技能的调用行是这样的:一个斜杠命令加后面几个词。斜杠后面那段文字,就是参数的来源。 系统把它按空格切开,第一块叫 $1,第二块 $2,依此类推。整串原文放在 $ARGUMENTS 里。 技能正文里写 $1 的地方,触发时被替换成对应的词;没写的地方不动。

所以参数不是「传给一个函数」,是在正文里做字符串替换。这个区别决定了后面所有的坑。 替换是纯文本的:它不检查 $2 有没有值,也不管替换之后句子通不通。

二、位置参数:精确,但脆

演示里一份审查技能的正文写着「审查 $1 这个目录下的改动,重点看 $2」。 调用「/review src/api 边界条件」,正文变成「审查 src/api 这个目录下的改动,重点看 边界条件」。 这是最精确的写法:两个位置各管一件事,模型不用猜。

坏处是脆。 用户少写一个词,$2 就留在正文里没人替换——模型看到的是一个美元符号加数字,它可能照抄, 也可能自己猜一个。真机上这两种结果都出现过。 所以位置参数适合「参数个数固定、而且缺了就干不成」的场景。缺了就该停下来问,而不是硬跑。

三、整体参数:把整串交给技能

$ARGUMENTS 是整串原文,不分位置。写「按这个要求审查:$ARGUMENTS」,用户写多长都接得住。 演示里两条要求分别十五和二十七个字符,替换后正文从六十多涨到七十。参数长度直接加在正文上。 整体参数不怕缺,因为它是「剩下全部」; 怕的是多了没人管——用户如果顺手把整段对话粘进来,技能正文就会被撑大。

这就是为什么带 $ARGUMENTS 的技能,正文里通常还会加一句「如果要求不明确,先问一句」。 选哪种取决于参数有几个:一个参数用整体参数,多个有明确含义的用位置参数。 两者混用在同一个技能里,是最常见的出错来源。

四、动态注入:先跑命令,再塞正文

还有一种写法:正文里放一行反引号包起来的命令,前面加个感叹号。

当前分支:!`git branch --show-current`
本次改动:!`git diff --stat HEAD`

技能被触发时,这两条命令先执行,输出替换掉整段标记,再拼进正文。 演示里真跑了一遍:取回 master 和 app.py | 2 +-。模型拿到的就是这两条事实,不用自己去查。 这一招的价值在于省往返。 模型自己查要两三轮(发命令、读输出、再决定),预取是零轮——正文进来的时候事实已经在里面了。

代价也算得出来:注入前七十一 token,注入后九十九,多二十八。换两三轮往返,这笔账是划算的。

五、预取的两条硬风险

第一条:命令的输出不可控。git diff 在大改动上能吐出几千行,全塞进正文会把上下文撑爆。 所以这类命令通常都带 --stat 或者 head。 第二条更要紧:命令是执行的,不是引用的。写进 SKILL.md 的预取命令会在触发时真的跑。 所以它必须只读、必须快、必须不会因为参数不同而删东西。 写一条 rm 进去,就是给自己埋了一个每次触发都响的雷。

还有一层:预取的输出是「当时的」。 技能正文里如果写了「根据上面的分支名判断」,它在分支切换之后仍然拿着旧值——直到下次触发重新跑。 所以预取适合「这次任务开始前就该知道的事实」,不适合「执行过程中会变的状态」。

六、三种写法怎么选

参数个数固定、缺了就不该跑,用位置参数。比如「审查某目录、重点看某方面」。 用户可能说一段话、长度不定,用整体参数。比如「按这个要求改」。 事实在系统里、模型查起来要好几轮,用预取。比如分支名、改动统计、当前时间、依赖版本。

三种可以混用,但混用的时候要在正文里写清楚「哪部分是用户给的、哪部分是命令给的」。 否则模型分不清哪句话是要求、哪句话是事实。 一句话收这一节: 参数是把人的话搬进正文,预取是把系统的事实搬进正文。两者都在做同一件事—— 减少模型自己去问的次数。

七、动手 Demo:参数怎么传、上下文怎么预取

先跑两种参数:位置参数(两个词各管一处)和整体参数(整串塞进去),看替换后的正文长什么样。 再跑动态注入:在一个真建的 git 仓库里执行两条预取命令,看它们取回什么, 以及注入之后正文涨了多少 token。

# 位置参数:按空格切开,第几块就是第几个
args = cmd.split()[1:]
body = TPL_ARG.replace(u"$1", args[0]).replace(u"$2", args[1])

# 动态注入:把 !`cmd` 整段换成命令的输出
for m in re.finditer(u"!`([^`]+)`", TPL_INJECT):
    code, out = run(repo, [u"sh", u"-c", m.group(1)])   # 真的跑
    body = body.replace(m.group(0), out.strip())

实跑输出:

--------------------------------------------------------------------
参数怎么传、上下文怎么预取
--------------------------------------------------------------------


  ① 位置参数:一个坑一个萝卜
  --------------------------------------------------------------------
  调用 /review src/api 边界条件
  审查 src/api 这个目录下的改动,重点看 边界条件。
  输出问题清单,每条写清文件、行号、原因。
  调用 /review src/core 错误处理
  审查 src/core 这个目录下的改动,重点看 错误处理。
  输出问题清单,每条写清文件、行号、原因。
  位置参数的好处是精确:$1 就是第一个词,$2 就是第二个。
  ! 坏处是脆。
  用户少写一个词,$2 就留在正文里没人替换——模型看到的是一个美元符号加数字,它可能照抄,
  也可能自己猜一个。真机上这两种结果都出现过。

  ② 整体参数:把整串交给技能
  --------------------------------------------------------------------
| 传入的要求 | 字符 | 替换后模板 token |
|---|---:|---:|
| 只审查新增的函数,不要评论风格 | 15 | 63 |
| 审查 src/api 下的改动,重点看异常分 | 27 | 70 |

  整体参数不怕缺,因为它是「剩下全部」;
  怕的是多了没人管——用户如果顺手把整段对话粘进来,技能正文就会被撑大。
  ! 选哪种取决于参数有几个。一个参数用整体参数,多个有明确含义的用位置参数。
  两者混用在同一个技能里,是最常见的出错来源。

  ③ 动态注入:先跑命令,再把结果塞进正文
  --------------------------------------------------------------------
| 预取命令 | 状态 | 取回的第一行 |
|---|---|---|
| git branch --show-current | ok | master |
| git diff --stat HEAD | ok | app.py | 2 +- |

  这两条命令在技能被触发之前就跑完了,输出直接拼进正文。
  模型拿到的是「当前分支是 master,改动是 app.py | 2 +-」这样的事实,不用自己去查。

  ④ 预取的代价与风险
  --------------------------------------------------------------------
| 阶段 | token |
|---|---:|
| 注入前 | 71 |
| 注入后 | 99 |

  多了 28 token,换掉的是模型自己去跑一次 git 的两三轮往返。这笔账是划算的。
  ! 但注入有两条硬风险。
  第一,命令的输出不可控:git diff 在大改动上能吐出几千行,
  全塞进正文会把上下文撑爆——所以真机上的这类命令通常都带 --stat 或 head。
  ! 第二,命令是【执行】的,不是引用的。
  写进 SKILL.md 的预取命令会在触发时真的跑,所以它必须只读、必须快、必须不会因为参数不同而删东西。

①位置参数那两段是同一份模板替换出来的:$1 换了目录、$2 换了重点,句子结构一个字没动。 好处是精确,坏处是脆——用户少写一个词,$2 就原样留在正文里。 模型看到一个美元符号加数字,可能照抄,也可能自己猜一个。 ②整体参数那两条,要求从十五个字符到二十七个字符,替换后正文从六十三涨到七十。 参数长度直接加在正文上,用户粘一大段,正文就跟着涨。 ③预取那两条命令是真跑的:取回 master 和 app.py | 2 +-。 这两条事实模型本来要发两轮命令才能拿到。 注意 diff 那行用了 --stat——不加的话,改动一大就是几千行,正文直接爆掉。 预取命令的输出必须自己先掐住。 ④注入前七十一 token、注入后九十九,多二十八。二十八换两三轮往返,划算。 但这里有个前提:命令必须只读、必须快。 预取是执行不是引用,写一条会删东西的命令进去,等于每次触发都踩一脚。

30 秒实验

  1. 把调用行改成只写一个词(只有 $1),重跑第 ① 步。正文里 $2 变成了什么? 如果模型照抄,用户会看到什么?
  2. 把 git diff --stat HEAD 换成 git diff HEAD,在一份真实改动上重跑。 注入后的 token 会涨到多少?这说明预取命令该加什么约束?
  3. 给预取加一条 date,重跑两次(中间隔一分钟)。两次的正文一样吗? 这说明了预取输出的是什么时刻的事实?

常见误区

  1. 以为参数是「传给函数」。它只是正文里的字符串替换,不检查缺不缺、通不通。
  2. 在同一份技能里混用位置参数和整体参数。 模型分不清哪段是 $1 管的、哪段是整串,结果两边都填一半。
  3. 预取命令不带任何截断。git diff 在大改动上几千行,一注入就把上下文撑爆。
  4. 在预取里写会修改状态的命令。它是执行不是引用,每次触发都会跑。
  5. 把会变的状态也做成预取。预取拿到的是「触发那一刻」的值,执行过程中变了它不会知道。
自测三问(点开对答案) 1. 位置参数和整体参数的本质区别是什么? —— 位置参数按空格切开、按序号填,缺了就留空;整体参数是整串原文,多长都接得住但没法分位置。 2. 动态注入为什么能省往返? —— 因为命令在技能被触发之前就执行完了,输出直接拼进正文。 模型拿到正文时事实已经在里面,不用自己发命令、读输出。 3. 预取最大的风险是什么? —— 它是执行而不是引用。 写进去的命令每次触发都会真的跑,所以必须只读、必须快、必须不会因为参数不同而删东西。

参数让一个技能能应付不同输入,下一节看同名的技能撞在一起时谁生效。

相关笔记