练习 17:按需触发

练习 16 证明了"发现 + 注入"能跑通:清单进系统提示,正文靠 skill 工具按需加载。但那一章的清单只有两份说明书,一份贴题、一份跟任务毫无 关系——选对不难。这一章把清单加到三份,问两个更尖锐的问题:模型触发 的依据是清单里那句 description,不是正文,它会不会真的先加载正文 确认一遍,还是看着一句话就动手?以及,"清单便宜、正文贵"这句话到底 贵在哪、便宜在哪,这一章要拿真实 token 数字说话,不能只停留在断言。

敲进去

在练习 16 的代码上继续写。不新增结构,只在已经存在的两个地方各加一行 记账:skill 清单造出来的那一刻,和 skill 工具真正被调用的那一刻。

	// 全部工具在这里注册。加第四个工具 = 在这里加一行,别处一个字不用动。
	skills := discoverSkills()
	toolList := []tool{readFileTool{}, writeFileTool{}, editFileTool{}, bashTool{}}
	if len(skills) > 0 {
		// 一个 skill 都没发现就不挂 skill 工具——模型不该看见一个永远
		// 调不出东西的空壳工具,蒸馏自 octo DefaultTools() 同一条判断。
		toolList = append(toolList, skillTool{skills: skills})
		// 清单这一层的账,现在就能算:它冻结进 system prompt,往后
		// 每一轮都要重新算一遍钱,不管这一轮用不用得上任何一个 skill。
		// estimateText 是练习 12 就有的粗略估算,够拿来对比数量级。
		manifest := skillManifest(skills)
		fmt.Fprintf(os.Stderr, "[skill 清单:%d 个 skill,约 %d tokens,随 system prompt 每轮都算钱]\n",
			len(skills), estimateText(manifest))
	}
	reg := newRegistry(toolList...)
	result := t.execute(args)
	// 调用成功就记账:读过的文件可以改;刚写完的文件模型知道最新内容,也算读过。
	if path := pathOf(args); path != "" && !strings.HasPrefix(result, "错误:") {
		r.hasRead[path] = true
	}
	// skill 正文加载这一刻才真的花钱:清单那笔账每轮都付,这笔账只在
	// 被点名的这一轮付一次——两笔账分开打印,账本上的数字自己会说话。
	if name == "skill" && !strings.HasPrefix(result, "错误:") {
		fmt.Fprintf(os.Stderr, "[skill 正文进入对话:约 %d tokens,只这一轮付这笔账]\n", estimateText(result))
	}
	return result
}

跑起来

go build -o ex17 .

在练习 16 那两份 SKILL.md 之外,再加一份——三个候选,才谈得上"选没 选对",而不是"反正只有一个能用":

cat > .harness-skills/commit-message/SKILL.md << 'EOF'
---
name: commit-message
description: 当用户要求写一条 git commit message 时使用。
---

# Commit message 规范

- 格式:`<type>(<scope>): <subject>`,type 从 feat/fix/docs/refactor/chore/test
  里选最贴切的一个,scope 是改动涉及的模块名,subject 是一句话概述,不写句号。
- 只输出这一行 commit message,不要额外的正文说明,除非用户明确要求写详细描述。
EOF

三个实验,一个比一个逼近"触发依据到底是什么"这个问题:

实验一:任务清楚点名,看记账。

./ex17 "刚上线了一个新功能:用户现在可以给会话改名字了。把这个记到 CHANGELOG 里。"

实验二:任务里反复出现"commit"这个词,但压根不是要写 commit message。

./ex17 "git 里的 commit 是什么意思?跟数据库事务里的 commit 概念是不是一回事?不用调用任何工具,直接跟我说说。"

实验三:任务没有点名任何一份说明书,看它怎么选。(在一个干净目录里跑, 避免上一次实验留下的 CHANGELOG.md 干扰判断)

./ex17 "这次改动是给会话加了改名字功能,帮我写一句话记录一下。"

你应该看到什么

实验一,DeepSeek:

[skill 清单:3 个 skill,约 254 tokens,随 system prompt 每轮都算钱]
...
[round 1] skill({"name": "changelog"})
[skill 正文进入对话:约 322 tokens,只这一轮付这笔账]
...
已记到 CHANGELOG.md。文件原本不存在,我创建了它,并在 `## Unreleased` 小节下加了这条:
- 用户可以给会话改名字。

三个 skill 的清单,254 个 token,这一轮从第一个字到最后一个字都在账上; changelog 的正文,322 个 token,只在被点名的这一轮多付这一次——commit- messagego-doc-comment 这两份说明书,从头到尾没有一个字节离开过 磁盘。

实验二,DeepSeek 和本机 Ollama 都是一轮直接回答,全文对比 git commit 和数据库事务 commit 的区别,finish_reason=stop,没有出现一次 skill(...) 调用——"commit"这个词在任务里出现了四次,commit-message 的 description 一次都没被触发。

实验三,DeepSeek:

[round 1] skill({"name": "changelog"})
[skill 正文进入对话:约 322 tokens,只这一轮付这笔账]
[round 2] read_file({"path": "CHANGELOG.md"})
[round 3] write_file({"path": "CHANGELOG.md", "content": "# Changelog\n\n## Unreleased\n\n- 支持为会话改名\n"})

已记录到 CHANGELOG.md:`## Unreleased` 下加了一条「支持为会话改名」。

本机 Ollama,同一个任务:

[round 1] skill({"name":"changelog"})
[skill 正文进入对话:约 322 tokens,只这一轮付这笔账]
[round 2] read_file({"path":"CHANGELOG.md"})
[round 3] write_file({"content":"# Changelog\n\n## Unreleased\n\n- Added ability to rename a session","path":"CHANGELOG.md"})

已经为会话改名字功能添加了记录...

两边都选了 changelogcommit-message 全程没被碰——而且都没有问我 一句"你是要记 CHANGELOG 还是写 commit message",直接就动手了。

发生了什么

清单和正文,是两本完全不同性质的账。 254 个 token 的清单,只要 .harness-skills 里那三个目录不变,这一章剩下的每一轮、甚至下一次全新 会话,都要一字不差地再付一次——它冻结进了 system prompt。322 个 token 的 changelog 正文,只在被点名的那一轮才产生,commit-messagego-doc-comment 的正文这次实验里全程是零成本,因为没有人调用它们。 "清单便宜、正文贵"这句话,练习 16 只是断言,这一章第一次有了两个可以 互相比大小的真实数字。

实验二证明的不是"模型很聪明",是这套触发机制本身的性质。 练习 15 的"触发提醒"是关键词字符串匹配——部署这个词出现在用户输入里,规则 就一定会被念出来,程序做判断,不会看上下文。这一章的 skill 触发不是 这样:commit-message 的 description 挂在系统提示里,但要不要调用 skill 工具,判断权在模型手上,不在任何一行 Go 代码里。"commit"这个词 在任务里出现了四次,如果触发方式和练习 15 一样是关键词子串匹配,这个 skill 一定会被念出来;实际上它一次都没被调用,因为模型判断的是"用户 现在想不想让我写一条 commit message"这件事本身,不是"这段话里有没有 这个词"。这是一个真实的权衡:关键词匹配是代码在判断,慢不了、也 不会看错任务,但认不出"这个词出现了、但意思不是那个意思";skill 触发 是模型在判断,能分清楚"提到 commit"和"要写 commit message"的区别,但 判断权彻底交了出去——第三个实验就是这枚硬币的另一面。

实验三里,两个模型都没有问,直接选了一个。 任务原文没有出现 "commit"、"git"、"CHANGELOG"里的任何一个词,changelogcommit-message 理论上都说得通。DeepSeek 和本机模型都选了 changelog,而且都没有一句"我猜你是要记 CHANGELOG,如果想要 commit message 请告诉我"——选择本身对读者是隐形的,只有翻开 stderr 上那行 skill({"name": "changelog"}) 才看得见。两次都选一样,大概率不是巧合: 任务里的"记录"跟 changelog description 里的"记到 CHANGELOG 里"语义 更贴,commit-message 的 description 明确要求"写一条 git commit message",任务里从没提过要写 commit——两份 description 用词本来就有主次, 不是真的对半开的硬币。但这恰恰是这一章要留的问题:触发是模型的单方 判断,判断错了、或者判断的依据和你以为的不一样,你不会自动知道—— 清单里那句"不要凭一句描述去猜正文"管得住"该不该调用",管不住"调用 之后选哪一个"。

常见问题

  • 为什么不干脆让模型在拿不准的时候反问用户:可以做,但这一章的 basePrompt 和 skill 清单里都没有加这条要求——两个模型在没有被要求 反问的情况下都选择了直接动手。想验证"加一句让它在拿不准时反问"能不能 改变这个行为,见加分练习。
  • estimateTokens/estimateText 是练习 12 的粗略估算,这里的数字 准吗:数量级是准的,逐字节不是——练习 12 讲过它不是真正的分词器。 但这一章要说明的是"清单每轮付、正文一次性付"这个结构性差异,两个数字 只要方向和数量级对,就够撑住这个论点;真要精确记账,得接真实 tokenizer, 这不是这一章要解决的问题。
  • 三份 skill 都用同一个模型判断,如果扩到几十份会怎样:清单里的 description 越多,模型要在一次判断里分辨的候选就越多,误选的概率会 从"接近零"往上走——这一章只有三份,还不够看出这条曲线,加分练习 4 留了这个坑。
  • 既然触发权在模型手上,代码完全不做任何把关吗:做了一道——skill 工具本身校验了 name 是不是清单里真实存在的那几个,编造一个不存在的 名字会拿到"没有叫 XXX 的 skill"的报错,不会静默失败。但"选哪一个真实 存在的 skill"这件事,代码确实不参与判断。

加分练习

  1. basePrompt 或 skill 清单的说明文字里加一句"如果任务同时匹配 多个 skill 的 description,且无法确定该用哪一个,先问用户",重新跑 一次实验三,看这句提示能不能让模型在真正拿不准的时候开口问,而不是 默默选一个——这是在验证"沉默的选择"是不是可以用一句话改掉。
  2. commit-message 的 description 改得更贴近实验三那句任务原文 (比如加上"或者简要记录一次代码改动"),再跑一次实验三,看两个 description 用词拉近之后,模型的选择会不会变得不稳定——这是在验证 "主次"到底是不是靠 description 的字面用词撑住的。
  3. commit-message 的 description 换成会被字面关键词命中、但语义 完全无关的版本(比如"当出现'commit'这个词时使用"),重跑实验二—— 这一次它该不该被触发,取决于你把触发依据从"语义"改回了"关键词", 跟练习 15 的触发提醒变成同一种机制之后,行为会不会也变回"逢词必中"。
  4. .harness-skills 里加到 8-10 份 SKILL.md,其中几份的 description 故意写得含糊、互相有点像,观察触发准确率是不是真的会随 skill 数量 变差——这是给"清单越大,选错的概率越高"这句话找一条真实曲线,而不是 凭空断言。