Appearance
第39章 把经验写进说明书——Skill
本章导读: 实现篇扩展第 2 章。概念篇讲过 Skill 是"经验的可发现说明层"。这一章推演 mini-agent 里怎么加一个最小
SkillCatalog——不写进src,而是讲清发现、加载、冲突和失败语义怎么设计。
小a发现自己的 Agent 每次处理"发布任务"都要重复讲一遍流程:先跑测试、再打标签、再推送。他在 system prompt 里塞过几次,上下文越来越长,还经常被新任务挤掉。他问老z:"能不能把流程写成一份说明,让 Agent 需要时才读?"
"能。你要的是一个 Skill 说明包——不是代码,是文字。"老z说,"但'需要时才读'是关键:一次性把几十个 Skill 都塞进上下文,你的 Agent 会像带着一整本手册回答问题。先想清楚发现和加载怎么分离。"
小a追问:"那这跟我在 system prompt 里直接写流程,到底差在哪?"
"差在'什么时候进上下文'。"老z说,"system prompt 是每次请求都带的常驻文本,你的发布流程在用户问'今天天气怎样'时也占着 token。Skill 是按需加载的——模型先看一份轻量索引,判断这次任务要不要用,要了才把正文读进来。mini-agent 的 runAgent 现在只有一个 system 字符串参数(见 agent.ts 的 options),没有动态注入通道;Skill 要进来,就得在循环里多一个'读说明'的动作。这一章不写进 src,是推演这个动作的接口长什么样。"
小a:"那我现在把流程写进 system,跟写进 agent.ts 的 options.system 有什么区别吗?"
"没区别——它们最终都进 client.stream 的同一个参数。"老z说,"runAgent 把 options.system 原样传给 client.stream({ system, ... }),循环里没有别的地方碰它。所以问题不是'写在哪',而是'几时进'。你写进 system,它在每一轮、每一条 user 消息上都占着字符——contextMaxChars 预算统计它,模型每轮都读它。Skill 想要的恰恰相反:只在任务相关时才出现,任务结束就让位。"
老z列了一张对比表:
| 维度 | system prompt 里的流程 | Skill 按需加载 |
|---|---|---|
| 进上下文的时机 | 每轮固定带 | 模型判定相关才加载 |
| 占用的成本 | 常驻,始终计费 | 按需,用完可裁 |
| 更新的方式 | 改代码重部署 | 改 Markdown 文件 |
| 多份共存 | 挤在一处互相稀释 | 各自独立、按名取用 |
| mini-agent 现状 | 已实现(options.system) | 未实现(本章推演) |
"这条表的最后一列最诚实:一个已实现、一个纯推演。 你别读成'该换 Skill 了'——你现在连一个正式加载过的 Skill 都没有。推演的意义是先把接口形状想清楚,不是让你这章就动手写。"
Skill 是软约束,不是代码
"先立边界。"老z说,"Skill 是一份带 frontmatter 的 Markdown 说明——name、description、正文步骤。它通过文本影响模型的决策,模型可能不遵守。真正的硬约束要靠工具和审批。所以 Skill 的接口只负责'找到说明并返回',不负责'保证模型照做'。"
md
---
name: release-process
description: 发布任务的固定流程:先测试、再打标签、再推送
---
1. 运行 npm test,失败则停止
2. 通过后打 git tag
3. 推送到远程小a看着这段 Markdown:"等等,这不就是给模型看的 prompt 片段吗?它跟工具的 description 字段有什么区别?"
"问得好——区别在谁触发、何时生效。"老z说,"工具的 description 是常驻的,模型每轮都看得见,因为它随时可能被调;Skill 的正文是临时进上下文的,任务结束了就该让位。更关键的是:工具 description 描述的是'这个动作怎么用',Skill 描述的是'这类任务该按什么顺序做'。一个讲动作,一个讲流程。mini-agent 里 createWorkspaceTools 给 read/write/bash 各写了一句 description(见 tools.ts),那是动作说明;你发布流程里的'先测试再打标签',是任何单个工具都讲不清的跨步骤编排。Skill 补的就是这一层。"
"那既然是软约束,模型不照办怎么办?"小a问。
"两条退路。"老z数着指头,"第一,模型读了说明却跳过'先测试',直接调 bash 去打标签——这时候 denyBashByDefault(见 approval.ts)会先把 bash 拦下来,硬约束兜底。第二,把'是否真的跑了测试'写进 Skill 的正文,让模型在工具结果里看到证据——比如要求 npm test 的输出贴回上下文。Skill 负责'告诉',工具和审批负责'兜底',两层各司其职。 mini-agent 现在只有后一层,前一层是空的——这就是 Skill 要填的缝。"
小a:"软约束这个性质,会不会让 Skill 的价值打折?我写半天说明,模型照样不听。"
"不会,因为你要的不是'听话',是'概率上升'。"老z说,"模型在任务相关时读到一份结构化的流程,照着做的概率远高于在 2000 字 system prompt 里翻找同一段话。软约束的价值不是 100% 执行,而是把'经常忘'变成'很少忘'。真正不能错的动作,靠工具和审批做硬门槛——denyBashByDefault 拦截 bash 是确定性的,不赌模型表现。你该用 Skill 优化的事,是不能出事的动作;不该赌的部分,别指望一段文字。"
| 约束层级 | 例子 | 失效时 | mini-agent 现状 |
|---|---|---|---|
| Skill 正文 | 发布先跑测试 | 模型忽略,流程被跳过 | 未实现(推演) |
| 工具 schema | bash.command 必须为 string | 模型传坏参数,工具返回 isError | 已实现 |
| 审批策略 | bash 默认拒绝 | 策略异常,循环抛错 | 已实现(denyBashByDefault) |
"这三层里,只有 Skill 那层是文字说服,下面两层是代码强制。你把哪一层当成防线,取决于那句流程是'最好照做'还是'必须照做'。"
设计一个最小 SkillCatalog
老z沿 mini-agent 的风格推演了一个接口:
ts
// 设计草图:SkillCatalog(未进 src,先确认需求)
export interface Skill { name: string; description: string; content: string; }
export interface SkillCatalog {
/** 扫描目录,返回所有 Skill 的名称与描述(不加载正文) */
discover(): Promise<SkillSummary[]>;
/** 按名称加载完整正文 */
load(name: string): Promise<Skill>;
}"发现和加载分离,是 Skill 设计的核心。"老z说,"discover 只读 frontmatter(名称和描述),load 才读正文。这样 Agent 可以用几十个 Skill 的索引做判断,却只在真正需要时加载那一个的正文——上下文不会因为 Skill 数量线性膨胀。这就是概念篇讲的'发现/触发/渐进加载'三步。"
小a盯着接口看了会儿:"那这个 discover 跟 mini-agent 现在哪段代码像?我想找个参照。"
"最像的是 createWorkspaceTools 扫工具那一段。"老z说,"runAgent 在循环开始前,先用 options.tools.map((tool) => tool.spec) 把所有工具的 name 和 description 收集起来,塞进 client.stream 的 tools 参数(见 agent.ts)——模型看到的是一份工具索引,不是每个工具的执行逻辑。discover 干的是一样的事:给模型一份'有哪些 Skill 可选'的目录,正文先不给。工具的 spec 是常驻索引,Skill 的 summary 是按需索引——前者每轮都在,后者只在任务进来时刷新一次。"
小a盯着接口看了会儿:"那这个 discover 跟 mini-agent 现在哪段代码像?我想找个参照。"
"最像的是 createWorkspaceTools 扫工具那一段。"老z说,"runAgent 在循环开始前,先用 options.tools.map((tool) => tool.spec) 把所有工具的 name 和 description 收集起来,塞进 client.stream 的 tools 参数(见 agent.ts)——模型看到的是一份工具索引,不是每个工具的执行逻辑。discover 干的是一样的事:给模型一份'有哪些 Skill 可选'的目录,正文先不给。工具的 spec 是常驻索引,Skill 的 summary 是按需索引——前者每轮都在,后者只在任务进来时刷新一次。"
"为什么 discover 和 load 要拆成两个方法,而不是一个 getAll() 一次全读?"小a问。
"因为一次全读,'轻量索引'就不存在了。"老z说,"getAll() 把正文也加载进来,等于 Skill 越多、每轮请求越重——你还是在往上下文塞东西,只是换了个入口。拆开的依据是读取成本不同:frontmatter 一读就是,正文可能几十行。让模型基于便宜的索引做判断,只把贵的正文留给真正要用的那一个。这就是'发现/触发/渐进加载'三步里,前两步在接口上的落点。"
"那正文到底怎么进上下文?"小a追问。
"两条候选,各有代价。"老z比划着,"一是当成一条 role: 'user' 消息插进去——但 mini-agent 的 Message 类型(见 types.ts)里 user 消息是模型可见的,这样 Skill 正文就成了对话历史的一部分,下一轮还在,会一直占 token。二是做成一个'读 Skill'的工具,让模型主动调——这样正文以 tool result 的形式进来,trimContext(见 context.ts)在上下文超预算时能把它和对应 assistant 消息一起裁掉。第二种更合 mini-agent 的脾气:让模型用工具去取,而不是宿主硬塞。 但这也意味着你得新增一个 read_skill 工具——又回到'先有需求再加工具'的老规矩。"
| 正文进上下文的方式 | 生命周期 | 可被 trim 裁掉 | 代价 |
|---|---|---|---|
宿主塞 role: user 消息 | 成为对话历史,常驻 | 只有整体超预算才被裁 | 下一轮还占 token |
模型调 read_skill 工具 | 以 tool result 形式存在 | 和对应 assistant 成组裁掉 | 要新增一个工具 |
"两条路的分水岭是'谁决定读':前者宿主替模型决定,后者模型自己决定。Skill 的精神是后者——发现和触发都该发生在模型决策链上,宿主只提供目录和通道。"
失败语义:诊断要让模型能改
小a:"如果 Skill 的 frontmatter 写错了呢?加载失败怎么办?"
"Skill 加载失败和工具失败不同——它发生在模型决策之前,模型看不到原始异常。"老z说,"所以失败必须变成模型可读的说明,而不是抛一个堆栈给循环。"
ts
// 设计草图:加载失败的诊断形状
export type LoadResult =
| { ok: true; skill: Skill }
| { ok: false; reason: "not_found" | "frontmatter_invalid" | "missing_description"; detail: string };"注意 missing_description。"老z指着它说,"一个 Skill 没有 description,discover 阶段就应该把它标成 invalid——否则模型在索引里看不到它,等于这个 Skill 不存在。发现阶段的校验决定'模型知不知道有它',加载阶段的校验决定'模型能不能用它',两个阶段各查各的。"
小a:"为什么不让 frontmatter 一错就整个 discover 抛错停下来?那样不是更干净?"
"干净,但会因小失大。"老z摇头,"想象你有 30 个 Skill,其中一个 frontmatter 少了 description——整个 discover 抛错,剩下 29 个全用不了。这跟 mini-agent 的 loadSession 一个道理:它(见 session.ts)遇到一行坏 JSON 就抛 invalid session JSON at line N,因为 session 是单条链,坏一行后面就乱了。但 Skill 目录是一组并列文件,一个坏不该连累别的。所以 discover 应该像 pi-mono 的 loadSkills 那样,把解析失败收集成 diagnostics 返回,让宿主决定是跳过坏的、还是报警——而不是替宿主做'全停'的决定。"
"那 detail 字段里该写什么?"小a追问。
"写到模型能照着改为止。"老z说,"光说 frontmatter_invalid 没用——模型不知道哪行错了。detail 要带文件名、字段名、期望类型,比如 release-process.md: field 'description' missing, expected a one-line summary。你看 mini-agent 的工具失败也这么做:read 工具遇到非法输入,返回 'invalid read input: path must be a string'(见 tools.ts),不是抛 'bad input'。诊断的颗粒度,决定了模型下一轮能不能自己改对。"
小a:"discover 阶段的校验和 load 阶段的校验,各查什么?为什么不能合并成一个?"
"因为'模型知不知道有它'和'模型能不能用它'是两件独立的事。"老z说:
| 阶段 | 校验什么 | 校验不过时 |
|---|---|---|
discover | frontmatter 解析、name/description 存在、name 唯一 | 该 Skill 不进索引,其他不受影响 |
load | 正文完整、步骤可读、内容非空 | 该 Skill 报加载失败,索引里仍在 |
"合并成一个阶段的后果是:你没法区分'模型没见过这个 Skill'和'模型见过但打不开它'——前者是索引问题,后者是内容问题,处理方式完全不同。拆成两个阶段,等于把'看不见'和'打不开'拆成两种可诊断的状态。"
"那加载失败要不要给模型重试机会?"小a追问。
"可以,但重试要有限度。"老z说,"模型读了 detail 自己改文件,然后重新 load 一次,是合理的自愈。但无上限重试会在坏 Skill 上打转,烧完预算。重试次数该由宿主兜底——就像 maxTurns 兜底工具循环一样,runAgent 不会因为工具一直失败就无限转下去。 这个兜底在哪一层,也是推演时要想清楚的:放循环里,还是放 Skill 工具自己的实现里。"
小a:"把整条链路画出来看看?我想确认模型到底在哪一步看到什么。"
老z画了一张时序图:
runAgent 循环(示意) SkillCatalog Skill 文件
│ │ │
│─── discover() ──────────────────────►│───读 frontmatter──►│
│◄── [ {name, description}, ... ] ─────│◄──索引,不读正文───│
│ 模型基于索引判断要不要用 Skill │ │
│(命中时) │ │
│─── read_skill("release-process") ───►│ │
│ 工具执行 → load(name) ─────────────►│───读完整正文───────►│
│◄── Skill{name, description, content}─│◄──正文─────────────│
│ 正文以 tool result 进上下文 │ │
│ 模型按步骤执行 │ │"注意两个箭头的时间差:discover 发生在循环开始、模型第一次决策之前;load 发生在模型决策说'我要用'之后。中间隔着一个模型的判断——这就是 Skill 和 system prompt 的本质区别:system prompt 不经过任何判断,Skill 每次都经过一次判断。"老z指着图上 read_skill 那一行说,"这条线也再次印证:正文走 tool result,而不是宿主塞 user 消息。"
冲突与版本:需求没到就别做
小a:"那两个 Skill 同名怎么办?要不要版本号?"
"到冲突真发生了再设计。"老z说,"现在的触发条件是什么?你只有一个 release-process。等你有两个同名 Skill、并且真的需要在模型决策前区分它们时,需求会告诉你 SkillCatalog 该返回 { name, version } 还是 { namespace, name }。在只有一个 Skill 时设计版本冲突,就是'接口预支需求'——ch35 讲过的负债。"
小a不甘心:"可我现在不加版本字段,将来两个 Skill 撞名了,load(name) 不就返回错的那一个?"
"会。但代价你算过吗?"老z说,"加版本字段意味着:discover 的返回类型从 SkillSummary[] 变成带 version 的结构,所有调用方都得改;load 的签名从 (name) 变成 (name, version?);你还要决定 version 缺省时取最新的还是最旧的。这一整套,为了一个现在不存在的冲突。mini-agent 的 BeforeToolCall 当初也没设计'多策略合并'——它就是 (call) => {approved, reason}(见 types.ts),因为你只需要一个策略。等真有两个策略要叠加,再回来改类型,比现在背着版本字段强。接口的债,晚欠比早欠好还——因为晚欠时你知道确切要还多少。"
"那 namespace 呢?比如 release/git 和 release/npm?"小a再追。
"同样的逻辑——这是路径设计,不是版本设计。"老z说,"namespace 解决的是'同名但不同域',它要等你有'跨域 Skill 库'这个需求才出现。现在你连一个域都没有。与其猜 namespace 长什么样,不如先让 name 唯一性成为一条写入时的约束——Skill 目录里不允许两个文件 frontmatter 的 name 重复,discover 时检测到重复就报 diagnostic。用校验代替版本机制,是最便宜的冲突预防。"
小a:"那如果 discover 返回 SkillSummary[] 里两个元素同名,load(name) 该返回哪个?"
"这正是问题——不解决,因为你不该让它发生。"老z说,"discover 阶段就报 duplicate skill name: release-process in a.md and b.md,load 永远遇不到同名歧义。把冲突挡在最早可检测的门口,比在加载时纠结'取哪个'干净得多。你现在能做的冲突预防,只有'唯一性校验'这一件;版本、namespace 都等需求。"
| 冲突机制 | 解决什么 | 何时需要 | 现在的代价 |
|---|---|---|---|
| name 唯一性校验 | 同名歧义 | 现在就要 | 极低:一条 discover 时检查 |
| 版本号 | 同名不同版本 | 出现并发维护需求时 | 类型、签名、缺省策略全要改 |
| namespace | 同名不同域 | 出现跨域 Skill 库时 | 路径规范与查询语义要设计 |
与 pi-mono 的对照
"pi-mono 的 loadSkills 是更完整的参考。"老z说,"它递归找 SKILL.md、尊重 .gitignore、把解析失败收集成 diagnostics 而不是抛错中断。你推演的最小 SkillCatalog 是它的切片:只保留'发现与加载分离'和'失败可诊断'两条原则。诊断要走到'这是哪个文件、哪个字段、为什么',才配叫诊断。"
小a:"那我是不是该直接照 pi-mono 的 loadSkills 抄一份进 mini-agent?"
"别。"老z拦住,"pi-mono 的 loadSkills 那套 .gitignore 解析、递归扫描、diagnostics 聚合,是重型场景的产物——它面对的是可能有上百个 Skill、散落在 monorepo 各处的真实仓库。mini-agent 现在连一个 Skill 都没有正式加载过。抄过来你会得到一堆没人调用的代码,然后为了'让它工作'编造测试场景——这就是 ch37 讲的'接口预支需求'。 先用你的一个 release-process 把'发现→触发→加载→正文进上下文'这条最小链路走通,等第二个、第三个 Skill 出现,再看哪些复杂度是真被需要拽上来的。pi-mono 是天花板,不是起点。"
小a:"那我从 pi-mono 该抄哪几条?"
"抄两条原则,不抄实现。"老z说,"第一条:发现与加载分离——pi-mono 的 loadSkills 递归找 SKILL.md 时只收索引,正文等到用才读。第二条:失败可诊断——解析失败收集成 diagnostics 而不是抛错中断。这两条你已经有了。剩下那套 .gitignore 尊重、递归扫描、跨目录聚合,是为'上百个 Skill 散落 monorepo'准备的,你现在一个目录一个文件,用不上。"
pi-mono loadSkills 的复杂度 | mini-agent 现在需要吗 |
|---|---|
递归扫 SKILL.md | 否,一个目录即可 |
尊重 .gitignore | 否,没有仓库场景 |
| diagnostics 聚合而非抛错 | 是(本章核心原则之一) |
| 发现与加载分离 | 是(本章核心原则之一) |
"注意一个反向的细节:pi-mono 的 discover 对单个坏 Skill 宽容(收 diagnostics),mini-agent 的 loadSession 对坏行零容忍(抛错)——两个都合理,因为数据结构不同。拷贝别人的设计时,最容易抄错的就是把'因为它那个场景对'当成'它永远对'。"
小结
Skill 加载器让 Agent 能在需要时读入流程经验,而不必把经验塞进每个请求。核心是发现与加载分离——discover 只读 frontmatter 给模型一份轻量索引,load 才读正文。索引便宜、正文昂贵,按需取,上下文不会因为 Skill 数量线性膨胀。这跟 mini-agent 已有的一块很像:runAgent 每轮把 tools.map((tool) => tool.spec) 收集成工具索引发给模型——工具的 spec 是常驻索引,Skill 的 summary 是按需索引,前者每轮都在,后者只在任务进来时刷新一次。
Skill 是软约束:接口只负责找到说明,不负责保证模型照做,硬约束仍要靠工具和审批兜底。它优化的不是 100% 执行,而是把"经常忘"变成"很少忘";真正不能错的动作要靠 denyBashByDefault 这类确定性门槛。正文进上下文有两条路——宿主塞 user 消息会常驻,模型调 read_skill 工具则能以 tool result 形式进来、被 trimContext 成组裁掉;第二条更合 mini-agent 的脾气,但它意味着新增一个工具,绕回"先有需求再加工具"。
失败要变成模型可读的诊断,颗粒度要到文件名和字段名,模型才能自己改对。discover 和 load 各查各的:前者决定"模型知不知道有它"(frontmatter、name 唯一性),后者决定"模型能不能用它"(正文完整性)。坏 Skill 不该连累整组——目录是并列文件,不是 session 那样的单条链——所以解析失败收集成 diagnostics,让宿主决定跳过还是报警。冲突处理则相反:唯一性校验现在就做,版本号、namespace 都等需求真出现了再让接口长出对应的形状。
最后必须再说一遍:mini-agent 没有实现 Skill,这一章是推演。SkillCatalog、LoadResult、时序图都是设计草图,不是 src 里的代码;runAgent 目前只有 system 字符串这一个注入通道。如果你的 Agent 要接 Skill,推演的结论是:接口形状要按"发现/触发/渐进加载"来切,失败语义要按"可诊断、可定位、不连累"来设计,正文通道要按"模型主动取"来选。到动手那天,让需求告诉你还缺什么。
阶段验收
不修改 src。在 examples/mini-agent 写推演笔记:
- 为你的一个重复任务写一份 10 行以内的
SKILL.md(含name和description)。 - 画出
discover和load的调用时序:模型在哪一步看到索引、哪一步拿到正文。标出正文进入上下文后走的是Message的哪条路(user 消息还是 tool result),并说明为什么选这条。 - 构造一个 frontmatter 缺失
description的 Skill,写出它应该在哪个阶段、以什么诊断形状被拒绝。诊断里要包含文件名和缺失字段名,断言模型读到的detail字符串里能照着定位到具体文件。