Coding Agent Harness 拆解 · 技能系统 skills:可复用的专家配方(十四)
本文是《Coding Agent Harness 拆解》系列的一篇。整个系列逐个拆开一个真实在用的终端 AI 编程 agent codebot,以及它底下的执行内核 agentcore。本篇聚焦 internal/skill:一个"技能"到底是什么、Catalog 怎么把散落磁盘的 SKILL.md 装成目录、listing 怎么塞进 reminder 让模型知道有哪些技能可用、模型又怎么通过 Skill 工具触发一个技能,以及 inline 与 fork 两种截然不同的执行方式。
技能想解决什么问题
模型每次面对"审代码""重构""调 bug"这类任务,都在重新发明同一套流程:先看 diff,再列出风险,最后补测试。把这套流程写死在系统提示词里太重:不是每次对话都需要;让用户每次手打又太啰嗦。
技能(skill)就是把一段"专家配方"存成文件,需要时才注入。 它是一个带 YAML frontmatter 的 Markdown 文件(约定名 SKILL.md):frontmatter 是元数据(叫什么、什么时候用、用哪个 agent 跑),正文是给模型的指令模板。平时它只以一行摘要出现在模型视野里;被触发时,正文才展开进对话。
一个真实的 bundled 技能长这样(internal/skill/bundled/review.md):
---
name: review
description: Review changes for bugs, regressions, risks, and missing tests.
when_to_use: Use when the user asks for code review, patch review, or a merge-readiness check.
argument-hint: "[scope]"
arguments:
- scope
---
Perform a code review focused on correctness, regressions, security risks, and missing test coverage.
If the user supplied a scope, review that scope first:
$ARGUMENTS
...
五个 frontmatter 字段决定一切
internal/skill/catalog.go 里的 frontmatter 结构体定义了所有可识别的键,落进 Spec(internal/skill/spec.go)。会改变行为的是这几个:
| frontmatter 键 | Spec 字段 | 作用 |
| --- | --- | --- |
| name / description | Name / Description | 唯一标识 + listing 里那行摘要 |
| when_to_use | WhenToUse | listing 里的 when: 行,帮模型判断何时触发 |
| context: inline\|fork | Context | 决定执行方式:正文回填当前对话,还是丢给子 agent |
| agent / model / effort | Agent / Model / Effort | fork 时用哪个子 agent、临时切哪个模型/推理档 |
| allowed-tools / paths | AllowedTools / Paths | 临时放行的工具、以及只在特定目录下才激活 |
| argument-hint / arguments | ArgumentHint / ArgumentNames | $ARGUMENTS、$1 等占位符的元信息 |
| user-invocable / disable-model-invocation | DisableUserInvocation / DisableModelInvocation | 两道触发开关,分别管用户和模型 |
context 只有两种取值,normalizeContext 把一切非 fork 的写法都归一成 inline:默认 inline,只有显式写 context: fork 才走子 agent 路线。这个小小的默认值,是后面整篇的分水岭。
Catalog:把磁盘装成一本目录
Catalog 是技能的中央目录。它的构造签名是 NewCatalog(cwd, baseSpecs, extraDirs...):
// internal/bootstrap/services.go
return skill.NewCatalog(cwd, contrib.SkillSpecs, extraSkillDirs...)
- baseSpecs:内置技能。
BundledSpecs(cwd) 用 //go:embed bundled/*.md 把 review / refactor / debug 三个配方编进二进制,Source 标为 "bundled"。
- extraDirs:项目级、用户级、插件级技能目录,
Reload() 时逐个 loadSkillsFromDir 读进来。
加载一个文件的核心是 parseSkillContent:parseFrontmatter 用 gopkg.in/yaml.v3 解出 frontmatter,StripFrontmatter 切掉头部拿到正文,最后组装成 Spec。目录形式的技能(findSkillInDir)约定读该目录下的 SKILL.md,若 frontmatter 没写 name 就用目录名兜底。
::: tip 同名去重与 Source 优先级
deduplicateSpecs 用两把钥匙去重:NormalizeName 后的名字,和 EvalSymlinks 解析后的真实路径。后来者覆盖先前者,所以项目级技能能盖掉同名的 bundled 技能。Source 一共四类(bundled / project / user / plugin),既影响去重后的排序优先级,也决定权限边界(见下文)。
:::
Catalog.List() 会逐条过一遍 skillIsActive(spec, cwd):如果技能声明了 paths(如 internal/skill/**),只有当前工作目录下真的存在匹配文件时它才出现在列表里。上下文无关的技能不会污染视野。
listing:让模型知道"有哪些技能"
技能存在磁盘上,模型看不见。桥梁是 RenderListing(internal/skill/listing.go):把 []Spec 压成一段纯文本清单:
The following skills are available for use with the Skill tool:
- review [scope]: Review changes for bugs, regressions, risks, and missing tests.
when: Use when the user asks for code review, patch review, or a merge-readiness check.
- refactor [scope]: Improve code structure while preserving behavior.
when: ...
IMPORTANT: Only use Skill for skills listed above - do not guess or use built-in CLI commands.
每条是 - 名字 argument-hint: description,可选带一行 when:。DefaultListingOptions 给了 4000 字符预算、单行 220、when 160 的截断上限,超预算就 break:清单永远不会撑爆上下文。注意 DisableModelInvocation 的技能会被跳过:它们只给用户手动触发,不进模型视野。
这段清单不塞进系统提示词,每轮随用户消息作为 reminder 注入。注入点在 internal/config/prompt.go 的 BuildReminders:
if skillBlock := skill.RenderListing(skills, skill.DefaultListingOptions()); skillBlock != "" {
reminders = append(reminders, "<system-reminder>\n## Skills\n"+skillBlock+"\n</system-reminder>")
}
为什么走 reminder,不走系统提示词?因为系统提示词要保持逐日稳定以命中 prompt cache(连"今天日期"都被挪进了 reminder)。技能清单会随 /reload、随 usage 排序变化而变,天然属于"每轮重算"的动态部分。关于 reminder 作为动态上下文的整体设计,见《上下文管理》。
usage-weighted:把常用技能排前面
清单在渲染前先经过 OrderForPrompt(skills, cwd, usage) 排序。sortSkillsForPrompt 的比较键依次是:
flowchart LR
A["是否 paths 激活<br/>skillIsActive"] --> B["usage 分数<br/>高的靠前"]
B --> C["Source 优先级<br/>project<user<bundled<plugin"]
C --> D["名字字典序"]
usage 分数来自 UsageTracker(internal/skill/usage.go):每次触发调 Record 累加次数并落盘到 ~/.../skill-usage.json。Scores 计算时按 7 天半衰期指数衰减(math.Exp2(-age/halfLife)),并设 0.10 的衰减地板:最近频繁用的技能排前面,很久没碰的会自然沉底,但不会归零。若 tracker 不可用,还会退回到本会话内的 invocationCount 计数(skillUsageScoresLocked)。
排序的意义:模型的注意力对靠前的条目更敏感,把"你最近一直在用的技能"顶到清单头部,等于把最可能相关的配方放在最显眼处。
模型怎么触发一个技能
模型看到清单后,通过 Skill 工具(internal/tools/skill.go 的 SkillTool)发起调用,参数就两个:skill 名和可选 args。Execute 把它们转成一次 ProcessInvocation,Source 标为 SourceModel:
flowchart TB
Disk["磁盘/embed: SKILL.md<br/>frontmatter + 正文"] --> Cat["Catalog<br/>parseSkillContent → Spec"]
Cat --> Order["OrderForPrompt<br/>usage 加权排序"]
Order --> List["RenderListing<br/>压成清单"]
List --> Rem["BuildReminders<br/><system-reminder> 注入"]
Rem --> Model["模型看到清单"]
Model --> Tool["SkillTool.Execute<br/>SourceModel"]
Tool --> Proc["ProcessInvocation<br/>取 Spec / 展开正文 / 定 Mode"]
Proc -->|"ModeInline"| Inline["正文作为工具结果<br/>回填当前对话"]
Proc -->|"ModeFork"| Fork["BuildSkillForkArgs<br/>→ subagent 执行"]
SkillTool.Description() 对模型下了硬约束:"When a matching skill exists, this is a BLOCKING REQUIREMENT: invoke this tool BEFORE generating any other response":匹配到技能就必须先调工具,不能只是嘴上说说;也严禁猜清单里没有的技能名。
ProcessInvocation(internal/skill/invoke.go)是纯函数,做四件事:
- 鉴权:
SourceModel 撞上 DisableModelInvocation 就返回 ErrModelInvocationDenied(SkillTool 会把它翻成"这个技能只能手动 /name 触发")。
- 展开正文:调
spec.GetPrompt:读文件、StripFrontmatter、ExpandVars(${CODEBOT_SKILL_DIR} 等)、按 Source 决定是否 ExpandShellInjections(`!`cmd`` 内联命令)、ExpandArgs 填占位符,最后 WrapPrompt 包成 <skill name="review">...</skill>。
- 定执行方式:
mode := ModeInline,仅当 spec.Context == "fork" 才改成 ModeFork。
- 算 Delta:把
allowed-tools / model / effort / paths 收进 Delta,但若 Source 不是特权来源(SourceAllowsPrivilegedFields,即插件技能),这些字段一律清空。插件不能借技能偷偷提权、切模型。
inline 与 fork:两条执行路线
ExecuteSkillInvocation 拿到 InvocationResult 后按 Mode 分岔,这是整套机制最关键的一步:
| 维度 | inline(默认) | fork(context: fork) |
| --- | --- | --- |
| 正文去向 | 作为工具结果回填当前对话,模型接着读 | 作为 task 交给子 agent,在独立上下文里跑完 |
| 谁在执行 | 当前主 agent 自己 | agentcore/subagent 里的子 agent |
| 主对话看到的 | 完整的技能正文(专家指令) | 只有子 agent 的最终产出摘要 |
| 适合什么 | 轻量流程指导:审查、重构、提交规范 | 重、脏、会占大量 token 的探索/研究 |
| Delta 是否作用于主会话 | 是(临时切模型/放行工具/加 path 提示) | 否,ApplySkillInvocation 对 fork 只记 usage |
inline 路线很直接:SkillTool.Execute 把 PromptText(那段 <skill> 包裹的正文)json.Marshal 成工具结果返回,正文就"长"进了当前对话,主 agent 顺着指令继续干活。
fork 路线则把技能"外包"出去。BuildSkillForkArgs 把结果打包成 {agent, task, model},交给 ForkExecutor:
// internal/bootstrap/assemble_session.go —— fork 执行器就是子 agent 工具的 Execute
skillTool.SetForkExecutor(subagentTool.Execute)
于是一个 fork 技能等价于"用预设好的 agent 类型 + 模型,跑一段预设好的任务",主对话只回收结论。fork 与子 agent 的完整关系,见《多 Agent》。
::: tip 触发方也可以是用户
除了模型经 SkillTool 触发(SourceModel),用户敲 /review 这类斜杠命令走的是 SourceUser。两条路都汇到同一个 ExecuteSkillInvocation:inline 时把正文作为新的用户 prompt 发进会话,fork 时展示子 agent 输出(internal/ui/command.go 的 executeSkillInvocation)。同一份配方,模型和人都能用。
:::
Delta 怎么落到会话上
inline 触发后,Delta 要生效,靠的是装配期接线的回调 session.ApplySkillInvocation:
// internal/bootstrap/assemble_runtime.go
st.SetInvocationApplier(session.ApplySkillInvocation)
ApplySkillInvocation(internal/agent/session_state.go)先 recordInvokedSkill 记一笔 usage,若是 fork 就到此为止;inline 则调 ApplySkillDelta:临时切模型(applyTemporarySkillModel)、临时切推理档、通过 approvalEngine.SetSkillAllows 放行技能声明的工具、给会话加 path 提示。这些都是临时的:一轮结束后 clearSkillDelta 会把模型、推理档、工具放行统统还原,避免一个技能的副作用泄漏到后续对话。关于这层缝合的整体职责,见《Session 编排层》。
三问回顾
- 技能到底是什么,凭什么算"可复用"? 一个带 frontmatter 的
SKILL.md:元数据决定触发与执行方式,正文是专家指令模板。存一次,模型和用户随时按名调用,还能带参数,这就是复用。
- 模型怎么知道有哪些技能、又怎么触发?
Catalog 装目录 → OrderForPrompt 按 usage 加权排序 → RenderListing 压成清单 → BuildReminders 每轮注入 <system-reminder>。模型据此用 Skill 工具按名触发,走 ProcessInvocation。
- inline 和 fork 差在哪,为什么要分? inline 把正文回填当前对话、由主 agent 亲自执行、可临时改会话策略;fork 把任务外包给子 agent、只回收结论、不动主会话。轻活留在原地,重活丢出去,这样既省 token 又不污染主上下文。
延伸阅读