练习 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- message 和 go-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"})
已经为会话改名字功能添加了记录...
两边都选了 changelog,commit-message 全程没被碰——而且都没有问我
一句"你是要记 CHANGELOG 还是写 commit message",直接就动手了。
发生了什么
清单和正文,是两本完全不同性质的账。 254 个 token 的清单,只要
.harness-skills 里那三个目录不变,这一章剩下的每一轮、甚至下一次全新
会话,都要一字不差地再付一次——它冻结进了 system prompt。322 个 token
的 changelog 正文,只在被点名的那一轮才产生,commit-message 和
go-doc-comment 的正文这次实验里全程是零成本,因为没有人调用它们。
"清单便宜、正文贵"这句话,练习 16 只是断言,这一章第一次有了两个可以
互相比大小的真实数字。
实验二证明的不是"模型很聪明",是这套触发机制本身的性质。 练习 15
的"触发提醒"是关键词字符串匹配——部署这个词出现在用户输入里,规则
就一定会被念出来,程序做判断,不会看上下文。这一章的 skill 触发不是
这样:commit-message 的 description 挂在系统提示里,但要不要调用
skill 工具,判断权在模型手上,不在任何一行 Go 代码里。"commit"这个词
在任务里出现了四次,如果触发方式和练习 15 一样是关键词子串匹配,这个
skill 一定会被念出来;实际上它一次都没被调用,因为模型判断的是"用户
现在想不想让我写一条 commit message"这件事本身,不是"这段话里有没有
这个词"。这是一个真实的权衡:关键词匹配是代码在判断,慢不了、也
不会看错任务,但认不出"这个词出现了、但意思不是那个意思";skill 触发
是模型在判断,能分清楚"提到 commit"和"要写 commit message"的区别,但
判断权彻底交了出去——第三个实验就是这枚硬币的另一面。
实验三里,两个模型都没有问,直接选了一个。 任务原文没有出现
"commit"、"git"、"CHANGELOG"里的任何一个词,changelog 和
commit-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"这件事,代码确实不参与判断。
加分练习
- 在
basePrompt或 skill 清单的说明文字里加一句"如果任务同时匹配 多个 skill 的 description,且无法确定该用哪一个,先问用户",重新跑 一次实验三,看这句提示能不能让模型在真正拿不准的时候开口问,而不是 默默选一个——这是在验证"沉默的选择"是不是可以用一句话改掉。 - 把
commit-message的 description 改得更贴近实验三那句任务原文 (比如加上"或者简要记录一次代码改动"),再跑一次实验三,看两个 description 用词拉近之后,模型的选择会不会变得不稳定——这是在验证 "主次"到底是不是靠 description 的字面用词撑住的。 - 把
commit-message的 description 换成会被字面关键词命中、但语义 完全无关的版本(比如"当出现'commit'这个词时使用"),重跑实验二—— 这一次它该不该被触发,取决于你把触发依据从"语义"改回了"关键词", 跟练习 15 的触发提醒变成同一种机制之后,行为会不会也变回"逢词必中"。 - 往
.harness-skills里加到 8-10 份 SKILL.md,其中几份的 description 故意写得含糊、互相有点像,观察触发准确率是不是真的会随 skill 数量 变差——这是给"清单越大,选错的概率越高"这句话找一条真实曲线,而不是 凭空断言。