练习 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
}

把清单接进 composeSystemPromptmain() 里发现 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 是个工具,不是新机制——这件事值得先说清楚。 它跟 readFileToolbashTool 实现的是同一个 tool 接口,注册进同一个 registry,模型眼里也是清单里同样一条 {"type": "function", ...}。 octo 的真实代码里,SkillToolbash/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,composeSystemPromptskillManifest 判断 长度为零就跳过整段——一个完全没配置 skill 的项目,行为跟练习 15 一样, 什么都不会少。

加分练习

  1. 故意写一份 frontmatter 不完整的 SKILL.md(比如漏掉 description, 或者干脆没有闭合的第二个 ---),确认它被跳过、不影响其他 skill 被正常发现——discoverSkills 应该只丢这一份,不该整个进程都受影响。
  2. go-doc-comment 的 body 里塞一段和 changelog 冲突的指令 (比如"CHANGELOG 也要用这个格式"),看模型会不会因为两份说明书 同时被列在清单里就搞混——正常情况下它只会加载被匹配的那一份, body 之间不该互相干扰,这个实验是在验证这件事。
  3. discoverSkills 加一层"同名目录、不同来源"的覆盖逻辑(参照 octo 真实的 default → user → project 三层),验证后扫的目录能不能 正确覆盖先扫的同名 skill——这是这一章特意跳过的复杂度,自己补一遍 能感觉到"发现"和"发现 + 优先级"中间差的到底是什么。
  4. 记录清单占了多少 token(estimateTokens 练习 12 已经写过),随着 你往 .harness-skills 里加更多 skill,画一条"清单大小 vs skill 数量" 的曲线——这是下一章"上下文成本核算"要用到的数据,可以先自己攒出来。
  5. changelog 目录里加一份 references/format.md,正文里补一句 "更详细的格式规范见 references/format.md",然后出一个会用到这份 附属文件的任务,看模型会不会用 skillTool.execute 返回的那句 "所在目录:.harness-skills/changelog",把相对路径接成 .harness-skills/changelog/references/format.mdread_file—— 而不是接到当前工作目录下、什么都没有的 references/format.md。 这是在验证:一份 SKILL.md 不是必须单文件自包含,也可以只是一个入口, sk.Dir 那句提示到底有没有真的被模型当成"相对路径该往哪儿接"的依据。