Zero 架构 · 第十九课

Skill:一个只读工具把「按需指令包」拉进上下文,而不是塞满 prompt

第十八课把外部能力(MCP 工具)接了进来。这一课接的是外部指令:一叠写在磁盘上的 SKILL.md —— 项目约定、确认策略、评审清单之类可复用的说明。核心问题是: 这些指令怎么进到模型眼前?Zero 的答案很克制:不预先灌进 system prompt,而是做成一个只读工具 skill skill.go:15,由模型按需加载。

对齐 mission:一个 coding agent 想让用户「教」它一些可复用的规矩,最偷懒的做法是把所有规矩一股脑塞进 prompt —— 但那会把第五课的 prompt 预算烧光,而且大部分规矩这一轮根本用不上。Zero 反过来:规矩躺在磁盘上,模型觉得相关时才用 skill 工具把某一条拉进来。看懂这个「按需取用」的取舍,就看懂了 agent 怎么在「可扩展」和「prompt 不膨胀」之间走钢丝。

一个 Skill 就是一个目录 + 一份 SKILL.md

数据模型小到极致 skills.go:22:一个 skill = 一个目录,里面一份 SKILL.md;可选的 --- frontmatter 提供 name/description,markdown 正文就是要喂给模型的内容。

~/.local/share/zero/skills/
  code-review/
    SKILL.md          # ---\nname: code-review\ndescription: ...\n---\n<正文>
  commit-style/
    SKILL.md                                    // skills.go:29
刻意「零依赖 + 坏的跳过」 frontmatter 是手写行解析,不引 YAML 库(splitFrontmatter skills.go:261 / frontmatterValue :282); 而且单个 SKILL.md 解析失败就跳过它,而不是让整次加载失败——一个坏 skill 绝不能连累其余好的 (skills.go:170)。这是「best-effort、优雅降级」在扩展点上的又一次体现(呼应第十八课 MCP 的 skip-bad-server)。

发现:一个目录,三级回退,缺了也不报错

DefaultDir skills.go:35 按优先级定位: ZERO_SKILLS_DIR 覆盖 → $XDG_DATA_HOME/zero/skills~/.local/share/zero/skills。 目录不存在就等于「没有 skill」,返回空列表而非错误(skills.go:133)。 这一版是用户级的,没有项目级 skill 目录——想和团队共享的规矩得走 AGENTS.md 或 hook。

关键决定:skill 是个工具,不是一段预注入的 prompt

最重要的架构选择在这里。skillTool.Run skill.go:58 被模型调用时,才 skills.Load 读目录、按名字返回那一条 skill 的正文当作工具 Output。它是只读的 (readOnlySafety skill.go:51)——于是它就是第十七课注册表里一个再普通不过的 Tool,进的是只读工具集。

为什么是「按需拉取」而不是「开局全灌」 如果启动时把所有 skill 正文塞进 system prompt,第五课的预算立刻被无关规矩吃掉。做成工具后,模型判断这一轮相关才拉一条进来—— prompt 保持精简,能力却不设上限。这与第十八课 MCP 工具的延迟广告是同一个母题:可扩展性不能以 prompt 膨胀为代价。 更妙的是自纠错设计:传了未知名字,工具不静默失败,而是返回可用 skill 列表(skill.go:79),让模型看到清单后自己改对。

安全:一个 permission-allow 的只读工具,必须把自己关进根目录里

这里藏着一处很讲究的安全考量。skillpermission-allow(默认放行)的只读工具,读的又是用户可控的目录—— 那它绝不能因为目录里放了个符号链接,就变成一个「任意文件读取器」。confineSkillPath skills.go:101 就是这道闸:

架构启示:自动放行的工具,爆炸半径要靠自己收 一个需要用户点确认的工具,危险还有用户兜底;而 skill 默认放行、又读用户目录,那「不越界」就得由加载器自己保证。 「permission-allow」这个便利,是用「把读取范围死锁在根目录内」换来的。这正是第十三/十七课「机制决定能不能跑」在一个具体工具上的落地:能自动跑,前提是它证明得了自己的爆炸半径有限。

确定性:重名 skill 谁赢,写死了规则

两个目录声明了相同的 frontmatter name 怎么办?load skills.go:179 给了个确定性规则:os.ReadDir 按目录名排序, 所以字典序最靠前的目录胜出,后来的同名者被丢弃,并记进 DuplicateName skills.go:62 供上层警告用户。不搞「后来覆盖」这种依赖扫描顺序的隐式行为——同样的磁盘状态永远解析出同样的赢家。

它怎么熬过压缩:loaded skill 作为「保留状态」落在摘要里

模型拉进来的 skill 正文,如果随着对话变长被第三课的压缩卷走,规矩就丢了。所以压缩时,已加载的 skill 被当作结构化保留状态 以 JSON 形式接到摘要后面(loadedSkills compaction_preserve.go:112), ——但每条正文截到 2 KiB(maxPreservedSkillBytes compaction_preserve.go:45): 让一个巨型 skill 不至于反过来撑爆它自己所参与的那次压缩;名字和开头留着,模型需要全文可以再 skill 一次拉回来。这是第三课「压缩保结构」在 skill 上的具体化。

合起来看:一叠磁盘文件,一个只读工具,三道纪律

Skill 系统几乎没有「系统」——它就是一个目录的约定 + 一个只读工具。真正的功夫全在三条纪律上: 按需取用(不烧 prompt 预算)、根目录封闭(自动放行的工具收住爆炸半径)、确定性去重 + 坏的跳过(可预测、优雅降级)。 一个可复用指令包,就这样以最小的机制被安全地接进了 agent。

动手回忆

一个 skill 的正文是怎么进到模型眼前的?

skill 加载器为什么要解析符号链接、把每个 SKILL.md 锁在根目录内?

两个 skill 声明了相同的名字时,加载器怎么处理?

接下来该读的一手源码

less +15  ../zero/internal/tools/skill.go            # skillTool:只读工具,Run 时按名加载
less +22  ../zero/internal/skills/skills.go          # Skill 结构 + DefaultDir 三级回退
less +101 ../zero/internal/skills/skills.go          # confineSkillPath:符号链接封闭 + 只读常规文件
less +126 ../zero/internal/skills/skills.go          # load:坏的跳过 + 字典序确定性去重
less +112 ../zero/internal/agent/compaction_preserve.go  # loadedSkills:压缩时作为保留状态存活(截 2KiB)

读的顺序:先看 skill.goRun——体会「skill 不过是个只读工具,按需返回正文」。再读 skills.goconfineSkillPath,想清楚「一个默认放行的工具为什么必须自己锁死读取范围」。最后看 compaction_preserve.goloadedSkills,理解拉进来的规矩怎么熬过压缩、又为何要截断。

我是你的老师 —— 随时问我。 适合现在追问: 「skill(模型按需拉)和用户 slash 命令(usercommands,用户敲 /name 展开模板当 prompt 提交)在机制上到底差在哪、各自何时用?」、 「plugin 声明的 skill 为什么还没被合进 skill 工具的发现(activate.go:225 收集了 SkillRoots 却未接入 loader)?」, 或者换个大块:「swarm/多 agent 编排的骨架——多个 agent 怎么被拉起、分工、汇合?」