技能运行时与打包模式
问题
如果没有技能系统,每个可复用的工作流都必须在每次对话中重新解释。Agent 无法从指令库中自动选择行为,也没有结构化的方式将权限、模型覆盖或生命周期 hook 与 prompt 打包在一起。Agent 在每次调用时都成为一张白纸,需要用户手动重建重复任务的上下文。
技能系统本身也带来了扩展性问题:如果运行时将每个技能的完整内容加载到系统 prompt 中,上下文窗口在第一条用户消息到达之前就会被填满。运行时需要一个发现层——足够廉价可以在启动时运行,以及一个加载层——将昂贵的内容推迟到技能被实际调用的时刻。
这些问题出现在任何支持可复用指令集的 Agent 运行时中——并非特定于某个具体实现。
黄金法则
元数据必须有单一事实来源
所有技能元数据——名称、描述、触发提示、工具权限、执行模式、hook——应与技能的指令内容放在同一位置,而非拆分到单独的附属文件中。单独的元数据文件(JSON 清单、YAML 配置等)会产生同步问题:两个来源可能出现偏差,运行时必须决定以哪个为准。单一事实来源彻底消除了这一类缺陷。常见实现使用 frontmatter 块、头部注解或行内结构化注释——具体机制不重要,重要的是保证每个元数据字段恰好有一个权威来源。
发现受预算约束;加载是懒加载的
启动时,运行时在系统 prompt 中列出所有可用技能,让模型知道可以调用什么。这个列表只包含元数据——每个技能一段简短描述和触发提示——永远不含完整正文。每个条目的字符数有硬上限,总列表被限制在上下文窗口的很小比例(大约百分之一)。完整技能正文仅在模型或用户实际调用时才加载。这种两阶段方法——廉价发现,延迟加载——使首轮 prompt 保持小巧且可缓存,同时保留模型自主选择正确技能的能力。
触发语言是设计面,不是事后补充
一个技能有两个不同的文本字段用于发现:简短标签(描述)和详细的触发指南("何时使用"字段)。描述是出现在列表中的内容。触发指南包含示例短语、情境线索和调用条件,帮助模型决定何时自动激活该技能。两个字段在发现列表中被拼接,但分离它们可以让作者写一个简洁标签和一个详细触发指南,而不会混为一谈。触发语言的质量直接决定模型能否在不需要用户干预的情况下选择正确的技能。
内置技能有特权但不免除所有限制
编译进 Agent 二进制文件的技能占据特权信任层级。当预算压力迫使列表缩减时,内置条目保留其完整描述,而外部条目则被逐步裁剪。但所有条目——包括内置的——都受制于拼接描述的单条目字符上限。内置技能也是通过编程注册而非从文件系统发现的,其资产在运行时被安全地解压。这种特权反映了信任边界:内置技能由运行时维护者编写,可以被授予外部技能无法获得的能力。
内联是默认;隔离是可选的
技能以内联方式执行时,其内容作为用户消息注入当前对话,模型在现有上下文中处理它。用户可以在执行过程中引导技能,技能的工作在对话历史中可见。隔离(fork)执行则生成一个独立的子 Agent,拥有自己的 token 预算和对话上下文。父级只看到最终结果。隔离是可选的——技能作者必须明确声明——因为内联执行几乎总是更优:更简单、更透明,并且允许人在回路中引导。
去重基于文件系统,而非名称
当技能从多个重叠目录(用户级、项目本地、插件)加载时,同一物理文件可能出现在不同的搜索路径下。运行时将每个技能文件解析为其规范文件系统路径,并基于该标识去重。这防止同一技能在列表中出现两次,避免了重复注册缺陷,而无需跨独立技能来源强制统一命名约定。
适用场景
- 你的 Agent 需要一个跨对话持久化的可复用指令集库。
- 你需要在系统 prompt 中列出可用能力,而不耗尽上下文窗口。
- 技能来自多个信任级别——内置、用户安装、项目本地和远程/插件来源。
- 某些技能必须在隔离环境中运行,以避免污染父对话的上下文。
- 你希望技能作者将权限、模型偏好和 hook 与指令本身打包在一起。
- 技能目录足够大,朴素的全文包含会挤占用户的实际工作空间。
权衡
| 决策 | 收益 | 代价 |
|---|---|---|
| 共置元数据作为唯一来源 | 单一事实来源,无同步缺陷 | 所有元数据必须适配所选的共置格式 |
| 受预算约束的发现列表 | 首轮 prompt 保持小巧且可缓存 | 过长的描述被静默截断;模型可能漏掉描述不佳的技能 |
| 调用时懒加载正文 | 上下文窗口不被未使用的技能浪费 | 调用时多一次加载往返 |
| 默认内联执行 | 用户可中途引导;工作过程可见 | 技能输出消耗父级上下文窗口的 token |
| 可选的隔离(fork)执行 | 干净的上下文边界;父级保持整洁 | fork 技能无法读写父对话状态 |
| 内置技能免于预算降级 | 核心能力在压力下保留描述 | 随着内置目录增长,非内置技能获得的预算空间更少 |
| 基于 realpath 的去重 | 同一文件无论符号链接如何都不会列出两次 | 依赖文件系统返回一致的规范路径 |
| 内置资产解压使用排他创建语义 | 防止临时目录上的符号链接攻击 | 文件创建稍复杂;需要排他打开语义 |
实现模式
- 启动时从四个来源类别发现技能:内置(编译在二进制中)、用户安装(用户主目录)、项目本地(工作区目录)和插件/远程(MCP 或等效方式)。按此顺序加载,使优先级可预测。
- 仅解析每个技能文件的共置元数据块(如 YAML frontmatter)。至少提取:名称、描述、触发提示、工具权限、执行模式和所有 hook 定义。完全忽略附属文件。
- 将每个发现的技能注册为一个命令对象,附带延迟正文加载器——一个闭包或回调,仅在技能被调用时读取完整 Markdown 内容。不要在发现时读取正文。
- 注册前将每个技能文件解析为其规范文件系统路径。如果两个搜索路径产生相同的规范路径,只注册一次。
- 构建发现列表时拼接每个技能的描述和触发提示,然后对每个条目硬截断到固定字符上限。累计所有条目并将总量限制在上下文窗口的一个小百分比内。
- 当预算压力迫使截断时,应用优雅降级序列:首先去除非内置技能的触发提示,然后完全去除描述,最后退回到仅显示名称。内置技能免于降级,但仍受单条目字符上限约束。
- 调用时检查技能声明的执行模式。如果是内联(默认),将技能的完整 Markdown 正文作为用户消息注入当前对话。如果是隔离,生成一个拥有独立 token 预算的子 Agent,在其中运行技能正文,只将结果文本返回给父级。
- 对于包含参考文件的内置技能,在首次调用时将这些文件解压到进程专属的临时目录。使用排他创建文件语义(如果文件已存在则失败),并在解压过程中拒绝跟随符号链接,以防止基于符号链接的攻击。
- 阻止来自不受信远程来源的技能进行内联 shell 执行。只有内置和本地磁盘技能应该能够嵌入可执行命令。
- 验证声明了受限调用模式(如"模型不能调用此技能")的技能在工具验证层强制执行,而非在列表层。如果合适,技能仍应出现在列表中;限制仅适用于模型的编程调用。
踩坑指南
不存在附属元数据文件。 所有元数据必须与技能的指令内容共置(如 frontmatter 块中)。如果运行时使用者在技能文档旁放置了单独的 JSON 或 YAML 元数据文件,它应该被静默忽略。这是最常见的集成错误。
描述截断是静默的。 如果描述和触发提示文本的合计超过单条目字符上限,列表条目会被硬截断并加省略号。模型看到的是截断版本,可能无法触发该技能。保持两个字段简洁,否则就接受自动调用可靠性的降低。
fork 执行丢弃父对话状态。 以隔离模式运行的技能无法读取或修改父级的对话消息。它获得一个全新的上下文。对于任何需要观察或更新进行中对话的技能,使用内联执行。
预算溢出首先降级非内置技能。 如果非内置技能的字符总量在内置条目预留后超出剩余预算,非内置条目会逐步失去描述,最终退化为仅剩名称。模型仍可按名称调用它们,但无法基于用途自动选择。依赖自动调用的作者必须保持其元数据紧凑。
realpath 去重依赖于文件系统的可靠性。 在返回退化 inode 值的虚拟、容器或网络文件系统上,基于 realpath 的去重可能出现意外行为。在 inode 精度丢失的文件系统上,可能出现误去重。运行时应一致使用规范路径解析,而非混合 inode 和路径策略。
受限调用模式与隐藏技能不同。 阻止模型以编程方式调用技能的标志与在用户可见列表中隐藏技能的标志是不同的。技能可以对用户可见但阻止模型调用,反之亦然。混淆两者要么产生安全漏洞,要么产生可用性缺口。
无效的 hook schema 被静默丢弃。 如果技能在 frontmatter 中声明了 hook,但 schema 不符合运行时的预期,hook 会被忽略且不报错。技能正常加载运行,但 hook 永远不会触发。在开发阶段而非生产环境中验证 hook schema。
Claude Code 实证
Claude Code 的技能系统是这些原则的生产实现,支持四种不同的技能来源,并在严格的预算约束下管理发现。
四来源发现架构。 技能从内置(直接编译进二进制文件)、用户安装(用户主目录配置下)、项目本地(工作区配置目录下)和 MCP 来源(远程插件)加载。每个来源在启动时独立扫描并合并到单一命令注册表中。感知符号链接的去重确保重叠目录——当用户主目录配置和项目配置共享符号链接的技能目录时很常见——永远不会产生重复列表。
YAML frontmatter 作为元数据契约。 Claude Code 使用每个技能 Markdown 文件顶部的 YAML frontmatter 作为所有元数据的单一事实来源。不存在附属元数据文件——如果一个附属文件存在于技能文档旁边,它会被静默忽略。这消除了元数据存储在两个位置时产生的同步缺陷。
受预算约束的列表与优雅降级。 列表预算设为上下文窗口的百分之一,每个条目上限 250 个字符——这个上限适用于所有条目,包括内置的。每个条目拼接技能的简短描述、分隔符和触发提示文本。当非内置目录在内置条目预留后超出剩余预算时,运行时逐步去除非内置技能的描述,最终将它们缩减为仅剩名称。内置技能不受此降级影响——无论预算压力如何,它们保留其完整(但受字符上限约束的)描述。
通过延迟闭包实现懒正文加载。 每个技能在发现时注册一个延迟正文加载器,而非预先读取完整 Markdown 内容。正文仅在模型或用户调用技能时才实体化。这使启动保持快速,首轮系统 prompt 保持紧凑——对于拥有大型技能目录的生产工作负载而言,首轮 cache 效率至关重要。
内联与 fork 执行。 默认执行路径将技能正文直接注入父对话。当技能声明隔离上下文时,运行时生成一个拥有独立 token 预算的 fork 子 Agent,在其中运行技能正文,只返回结果文本。fork 路径是明确可选的,因为内联执行几乎总是更优——它允许用户中途引导,并使技能的中间推理过程可见。
内置技能资产的安全解压。 附带参考文件的内置技能使用排他创建语义和符号链接跟随防护将这些文件解压到进程专属的临时目录。目录名包含加密随机数以抵抗路径预测攻击。这一设计反映了即使是受信的编译内资产也应防御性解压的原则——临时文件系统是共享的攻击面。
触发语言作为一等设计面。 描述和触发提示字段的分离是刻意的。技能作者被引导编写简短的、面向人类的描述和单独的详细触发指南,以 "Use when..." 等短语开头,后接示例用户话语。拼接后的形式是模型在发现列表中看到的内容,但结构性的分离鼓励作者针对各自用途优化每个字段,而非将所有内容塞进一行。