Zero 架构 · 第十九课
第十八课把外部能力(MCP 工具)接了进来。这一课接的是外部指令:一叠写在磁盘上的
SKILL.md —— 项目约定、确认策略、评审清单之类可复用的说明。核心问题是:
这些指令怎么进到模型眼前?Zero 的答案很克制:不预先灌进 system prompt,而是做成一个只读工具
skill skill.go:15,由模型按需加载。
对齐 mission:一个 coding agent 想让用户「教」它一些可复用的规矩,最偷懒的做法是把所有规矩一股脑塞进 prompt ——
但那会把第五课的 prompt 预算烧光,而且大部分规矩这一轮根本用不上。Zero 反过来:规矩躺在磁盘上,模型觉得相关时才用
skill 工具把某一条拉进来。看懂这个「按需取用」的取舍,就看懂了 agent 怎么在「可扩展」和「prompt 不膨胀」之间走钢丝。
数据模型小到极致 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
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。
最重要的架构选择在这里。skillTool.Run skill.go:58
被模型调用时,才 skills.Load 读目录、按名字返回那一条 skill 的正文当作工具 Output。它是只读的
(readOnlySafety skill.go:51)——于是它就是第十七课注册表里一个再普通不过的 Tool,进的是只读工具集。
这里藏着一处很讲究的安全考量。skill 是 permission-allow(默认放行)的只读工具,读的又是用户可控的目录——
那它绝不能因为目录里放了个符号链接,就变成一个「任意文件读取器」。confineSkillPath
skills.go:101 就是这道闸:
EvalSymlinks 把 skills 根和每个 SKILL.md 都解析成真实路径,再校验它没跳出根(skills.go:110)——一个指向 /etc/passwd 的软链接 SKILL.md 会被拒。SKILL.md 的 FIFO/设备/目录,os.ReadFile 可能永久阻塞——所以非常规文件一律跳过。skill 默认放行、又读用户目录,那「不越界」就得由加载器自己保证。
「permission-allow」这个便利,是用「把读取范围死锁在根目录内」换来的。这正是第十三/十七课「机制决定能不能跑」在一个具体工具上的落地:能自动跑,前提是它证明得了自己的爆炸半径有限。
两个目录声明了相同的 frontmatter name 怎么办?load
skills.go:179 给了个确定性规则:os.ReadDir 按目录名排序,
所以字典序最靠前的目录胜出,后来的同名者被丢弃,并记进 DuplicateName
skills.go:62 供上层警告用户。不搞「后来覆盖」这种依赖扫描顺序的隐式行为——同样的磁盘状态永远解析出同样的赢家。
模型拉进来的 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.go 的 Run——体会「skill 不过是个只读工具,按需返回正文」。再读 skills.go 的
confineSkillPath,想清楚「一个默认放行的工具为什么必须自己锁死读取范围」。最后看 compaction_preserve.go 的
loadedSkills,理解拉进来的规矩怎么熬过压缩、又为何要截断。
usercommands,用户敲 /name 展开模板当 prompt 提交)在机制上到底差在哪、各自何时用?」、
「plugin 声明的 skill 为什么还没被合进 skill 工具的发现(activate.go:225 收集了 SkillRoots 却未接入 loader)?」,
或者换个大块:「swarm/多 agent 编排的骨架——多个 agent 怎么被拉起、分工、汇合?」