练习 16:最小 skill 加载器
前十五章的知识全在代码里——工具怎么用、危险命令怎么拦、记忆怎么读写,
写死在常量和 basePrompt 里,改一条都要重新编译。这一章加一种新知识:
写在磁盘上、模型按需读的说明书,Claude Code 管它叫 skill。这一章只解决
"发现 + 注入"这一步:让模型知道有哪些说明书能读,以及怎么去读到一份的
正文。它会不会在两份说明书里选对那一份,是下一章的问题。
加载这份说明书的那个入口本身,跟前面十五章加过的每一个工具没有任何
不同——skillTool 一样是一个 definition() 加一个 execute(),
注册进同一张表。特殊的不是"skill 这个东西",是它加载出来的内容碰巧是
一份教模型"怎么用其他工具"的说明书——工具的说明书,还是要靠一个工具
去拿到。
敲进去
在练习 15 的代码上继续写。新增一个 skill 层:发现磁盘上的 SKILL.md、
把清单塞进 system prompt、再加一个按需加载正文的工具。完整文件在
exercises/ex16/。
// ---- skill 层:写在磁盘上、按需读的说明书 ----
// skillsRoot 蒸馏自 octo 的三层发现(default/user/project),本章只留
// 最简单的一层——一个项目一个目录,够用就好:这一章要立住的是"发现 +
// 注入"这一件事,不是完整的优先级覆盖体系。
const skillsRoot = ".harness-skills"
// skill 是一份发现到的说明书。Body 是正文——只有模型真的调用 skill 工具
// 要来的时候才会离开磁盘、进入对话。
type skill struct {
Name string
Description string
Body string
Dir string
}
// discoverSkills 扫 skillsRoot 下的每个子目录,读它的 SKILL.md。跟 octo
// 真实实现一样宽容:目录里没有 SKILL.md、frontmatter 缺 description,
// 就跳过这一个,不中断整个发现过程——一份写坏的说明书不该拖垮整个会话。
// 目录名是权威的 skill 名,frontmatter 里写的 name 只是给人看的,不参与
// 查找——这是 Claude Code 的行为,兼容它意味着别人写好的 skill 目录,
// 挪过来就能用。
func discoverSkills() map[string]skill {
out := map[string]skill{}
entries, err := os.ReadDir(skillsRoot)
if err != nil {
return out
}
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(skillsRoot, e.Name())
data, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
if err != nil {
continue
}
desc, body, ok := parseSkillFile(string(data))
if !ok || desc == "" {
continue
}
out[e.Name()] = skill{Name: e.Name(), Description: desc, Body: body, Dir: dir}
}
return out
}
// parseSkillFile 切开一份 SKILL.md:开头一对 "---" 之间是 frontmatter,
// 之后是正文。frontmatter 只认一行一个 "key: value",够用就好——真正的
// Claude Code 格式用 yaml.v3 解析、能处理嵌套 metadata 块,这里手写的
// 是一个只够识别 description 的子集,其余字段(allowed-tools、license
// 之类)原样跳过,不报错也不生效。
func parseSkillFile(text string) (description, body string, ok bool) {
lines := strings.Split(text, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return "", "", false
}
i := 1
for ; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
break
}
key, val, found := strings.Cut(lines[i], ":")
if found && strings.TrimSpace(key) == "description" {
description = strings.TrimSpace(val)
}
}
if i >= len(lines) {
return "", "", false // 没找到闭合的 "---",frontmatter 不完整
}
body = strings.TrimSpace(strings.Join(lines[i+1:], "\n"))
return description, body, true
}
// skillManifest 渲染 L1 清单:每个 skill 只留名字和 description,这是
// 模型判断"要不要用这个 skill"的唯一依据。正文不放这里——清单要塞进
// 冻结的 system prompt,多数任务用不上大多数 skill,正文太贵,全塞进去
// 不划算,留给 skill 工具按需加载才是这一层存在的意义。
func skillManifest(skills map[string]skill) string {
if len(skills) == 0 {
return ""
}
names := make([]string, 0, len(skills))
for name := range skills {
names = append(names, name)
}
sort.Strings(names) // 顺序必须稳定,否则清单文本每次不同,缓存前缀跟着作废
var b strings.Builder
b.WriteString("# 可用的 skill\n\n任务匹配某条 description 时,先调用 skill 工具" +
"(参数 name)加载完整指令再动手——不要只凭这一句描述去猜正文写了什么。\n\n")
for _, name := range names {
b.WriteString("- " + name + ": " + skills[name].Description + "\n")
}
return strings.TrimSpace(b.String())
}
// skillTool 是 L2:清单只给名字和一句话,正文才是真正的指令,只有模型
// 点名要用了才发。它需要访问这次进程发现到的 skills,不能像 read_file
// 那样是无状态的零值结构体,所以带一个字段。
type skillTool struct {
skills map[string]skill
}
func (t skillTool) definition() toolSpec {
return toolSpec{
Name: "skill",
Description: "加载一个 skill 的完整指令。先看系统提示里“可用的 skill”清单," +
"任务匹配某条 description 时,用这个工具把对应 skill 的正文加载进来再动手。",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string", "description": "要加载的 skill 名字,清单里“-”后面那个词"},
},
"required": []string{"name"},
},
}
}
func (t skillTool) execute(args string) string {
var in struct {
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(args), &in); err != nil {
return "错误: 参数不是合法 JSON: " + err.Error()
}
sk, ok := t.skills[in.Name]
if !ok {
return "错误: 没有叫 " + in.Name + " 的 skill——从系统提示的清单里选一个"
}
return "[skill \"" + sk.Name + "\",所在目录:" + sk.Dir + "]\n\n" + sk.Body
}
把清单接进 composeSystemPrompt,main() 里发现 skill 并按需挂工具:
func composeSystemPrompt(skills map[string]skill) string {
prompt := basePrompt
if rules := readProjectRules(); rules != "" {
prompt += "\n\n---\n\n# 项目约定 (" + projectRulesFile + ")\n\n" + rules
}
if manifest := skillManifest(skills); manifest != "" {
prompt += "\n\n---\n\n" + manifest
}
prompt += "\n\n---\n\n" + memoryGuidance
// ...记忆层不变...
return prompt
}
skills := discoverSkills()
toolList := []tool{readFileTool{}, writeFileTool{}, editFileTool{}, bashTool{}}
if len(skills) > 0 {
// 一个 skill 都没发现就不挂 skill 工具——模型不该看见一个永远
// 调不出东西的空壳工具,蒸馏自 octo DefaultTools() 同一条判断。
toolList = append(toolList, skillTool{skills: skills})
}
reg := newRegistry(toolList...)
跑起来
go build -o ex16 .
放两份 SKILL.md,一份跟即将要做的任务相关,一份故意不相关——不是为了 这一章要考"选没选对"(下一章的题目),是为了这次的清单里至少有两个 候选,证明模型不是"反正只有一个,就随手用了"。
mkdir -p .harness-skills/changelog .harness-skills/go-doc-comment
cat > .harness-skills/changelog/SKILL.md << 'EOF'
---
name: changelog
description: 当用户口头描述一项功能变更,要求把它记到 CHANGELOG 里时使用。
---
# 写 CHANGELOG 条目
- 目标文件是 `CHANGELOG.md`,条目放在 `## Unreleased` 这个小节下面,格式是
`- 一句话描述`,用现在时,不写"新增了/修复了"这类前缀。
- 文件不存在就创建,内容以 `# Changelog\n\n## Unreleased\n\n` 开头。
- 文件已存在但没有 `## Unreleased` 小节,就在文件最开头补一个,原有内容
往后挪,不要删掉任何已有条目。
- 只处理用户这句话里提到的这一项变更,不要自己联想去补别的条目。
EOF
cat > .harness-skills/go-doc-comment/SKILL.md << 'EOF'
---
name: go-doc-comment
description: 当用户要求给一段 Go 代码里的函数或类型写文档注释时使用。
---
# Go 文档注释规范
- 注释是一整句话,以被注释的标识符名字开头,句号结尾。
- 说清楚这个函数/类型做什么,不重复参数类型(签名里已经有)。
- 不用的示例代码块,除非用户明确要求。
EOF
./ex16 "刚上线了一个新功能:用户现在可以给会话改名字了。把这个记到 CHANGELOG 里。"
你应该看到什么
DeepSeek:
[round 1] skill({"name": "changelog"})
[round 2] bash({"command": "ls -la CHANGELOG.md 2>/dev/null && ..."})
⚠️ 模型想执行: ls -la CHANGELOG.md ...
允许吗?(y/N) [错误: 权限拒绝——用户没有批准这条命令。]
[round 3] read_file({"path": "CHANGELOG.md"})
[round 4] write_file({"path": "CHANGELOG.md", "content": "# Changelog\n\n## Unreleased\n\n- 用户可以给会话改名字。\n"})
已记到 `CHANGELOG.md`:
- 新建了文件(原本不存在),以 `# Changelog` + `## Unreleased` 开头
- 条目放在 `## Unreleased` 下,按 skill 要求用现在时、不带"新增了"这类前缀
- 只写了这一项变更,没有联想其他条目
本机 Ollama,同一个任务:
[round 1] skill({"name":"changelog"})
[round 2] read_file({"path":"CHANGELOG.md"})
[round 3] write_file({"content":"# Changelog\n\n## Unreleased\n\n- 用户现在可以给会话改名字了。","path":"CHANGELOG.md"})
已将“用户现在可以给会话改名字了”的功能变更记入 CHANGELOG.md。
两边都只调用了 skill({"name": "changelog"})——清单里明明还摆着
go-doc-comment,没有一次误触。
发生了什么
skillTool 是个工具,不是新机制——这件事值得先说清楚。 它跟
readFileTool、bashTool 实现的是同一个 tool 接口,注册进同一个
registry,模型眼里也是清单里同样一条 {"type": "function", ...}。
octo 的真实代码里,SkillTool 和 bash/read_file 一样实现
tools.ToolExecutor 这同一个接口,没有为"加载 skill 正文"这件事另开
一条通道。全书的主旨在这里第一次以这个形态出现:agent 长出的每一种
新能力,落到代码里都只是往工具箱里加了一个工具,这一个工具只是碰巧
"读出来的东西是另一份指令",跟 read_file 读出来是文件内容,本质上
是同一种动作。
清单便宜,正文贵,这是分两层的唯一原因。 skillManifest 塞进
system prompt 的只有名字和一句话;go-doc-comment 那份完整说明书,
从头到尾都没有真正进过对话——它只在清单里露过一次脸,模型判断"这次
用不上"之后,就再没碰过它。如果两份 SKILL.md 的正文一开始就整段怼进
system prompt,两条都会算进每一轮的输入 token,不管这次任务用不用得上。
"至少一个 skill 才挂 skill 工具"这条判断,在没有 skill 的目录里能
看见效果。 我在一个没有 .harness-skills 目录的地方,让 DeepSeek
"用 changelog 这个 skill"去写 CHANGELOG。它手上根本没有 skill 这个
工具可调,于是转而用 read_file 到处猜文件路径——.claude/skills/ changelog/SKILL.md、~/.claude/skills/...、甚至 /root/.claude/ skills/...,猜的还都是 Claude Code 的真实目录习惯,猜了七轮全部
落空,最后老老实实说"我没法直接定位到这两个东西",回头问我要路径。
这不是它变笨了,是这次它的工具列表里真的没有 skill——`len(skills)
0` 这条判断在裸眼可见的地方生了效:少一个可用的 skill,不是清单 少一行,是整个工具都不存在。
清单顺序为什么要 sort.Strings。 map 在 Go 里遍历顺序不固定,
两次运行清单文本可能字面不同——哪怕内容一样。练习 8 讲过 system
prompt 从会话开始那一刻起要冻结、不能中途改一个字;这里是同一个道理
往前挪了一步:同一份内容,两次渲染出来的文本必须逐字节相同,
不然同一个项目每次启动,缓存都要从头算过。
常见问题
- 为什么正文不直接跟清单一起塞进 system prompt,省得多一次工具 调用:两份 SKILL.md 现在还小,塞得起;一个真实项目攒到几十个 skill,正文全放系统提示,还没开始干活,上下文就先吃掉一大半——多数 任务用不上多数 skill,这个浪费在数量上去之后会很扎眼。多一次工具 调用换来的是"只为真正要用的那一份付费",这一章两个例子看不出差别, 规模大了才看得出来。
skillTool为什么要带skills字段,而不是像readFileTool那样 用空结构体:readFileTool不管在哪次运行里行为都一样,凭文件路径 现查现读就够了;skillTool要回答"这个名字对应哪份正文",答案因这 次进程发现到了什么而不同,不能是无状态的。- 目录名和 frontmatter 里的
name不一致,听谁的:目录名。这不是 随手定的,是照抄 Claude Code 的行为——一份 SKILL.md 从~/.claude/ skills/挪到.harness-skills/,只要目录名不变就能直接用,不用去 改文件内容里的name字段。 - 没有 skill 目录时,模型报错了吗:没有,
discoverSkills读不到 目录就返回一个空 map,composeSystemPrompt里skillManifest判断 长度为零就跳过整段——一个完全没配置 skill 的项目,行为跟练习 15 一样, 什么都不会少。
加分练习
- 故意写一份 frontmatter 不完整的 SKILL.md(比如漏掉
description, 或者干脆没有闭合的第二个---),确认它被跳过、不影响其他 skill 被正常发现——discoverSkills应该只丢这一份,不该整个进程都受影响。 - 往
go-doc-comment的 body 里塞一段和changelog冲突的指令 (比如"CHANGELOG 也要用这个格式"),看模型会不会因为两份说明书 同时被列在清单里就搞混——正常情况下它只会加载被匹配的那一份, body 之间不该互相干扰,这个实验是在验证这件事。 - 给
discoverSkills加一层"同名目录、不同来源"的覆盖逻辑(参照 octo 真实的 default → user → project 三层),验证后扫的目录能不能 正确覆盖先扫的同名 skill——这是这一章特意跳过的复杂度,自己补一遍 能感觉到"发现"和"发现 + 优先级"中间差的到底是什么。 - 记录清单占了多少 token(
estimateTokens练习 12 已经写过),随着 你往.harness-skills里加更多 skill,画一条"清单大小 vs skill 数量" 的曲线——这是下一章"上下文成本核算"要用到的数据,可以先自己攒出来。 - 给
changelog目录里加一份references/format.md,正文里补一句 "更详细的格式规范见references/format.md",然后出一个会用到这份 附属文件的任务,看模型会不会用skillTool.execute返回的那句 "所在目录:.harness-skills/changelog",把相对路径接成.harness-skills/changelog/references/format.md去read_file—— 而不是接到当前工作目录下、什么都没有的references/format.md。 这是在验证:一份 SKILL.md 不是必须单文件自包含,也可以只是一个入口,sk.Dir那句提示到底有没有真的被模型当成"相对路径该往哪儿接"的依据。