你有没有遇到过这种情况:在本地配了个技能(skill),信心满满让模型用,结果它要么压根不调、要么调到的根本不是你以为的那一版?
问题往往不在你写错了什么,而在「技能到底怎么被框架读进来」,它和工具不一样:工具登记完就全局可见,技能却不是。
技能是更大块的指令集(一段 Markdown 流程 + 配套资源),而且来源不止一个:本地目录、远程注册表、运行时贡献都可能是它的来处。同名听谁的、远程改了为何本地没反应、模型为什么调不到,这 3 个最常踩的坑,根源都在这套加载机制里。
于是框架要解的其实是一道题:怎么把多个来源的技能,并成一份不冲突、可分层、带调用策略的清单。
这篇文章顺着框架处理技能的链路往下拆:先看技能都从哪来(本地目录、远程注册表、运行时贡献都能提供);再看框架怎么把来自多处的技能并成一份清单、同名时听谁的;最后说框架怎么决定「模型能不能自己调这个技能」「用户能不能手动触发它」。一层一层讲清楚。
一、提供方只管「从哪来」,注册表只管「怎么合」
框架把职责切得很干净。每个**提供方(provider)**就是技能的一个来源(本地目录、远程注册表、运行时贡献都算),它只回答两个问题:
- 发现(
list):我能提供哪些技能? 提供方扫一遍自己的地盘,把找到的技能报成一份「候选」列表。候选只是轻量目录条目:名字、描述、来源、能否给模型或用户调用,以及决定胜负的rank------它还不包含正文。返回时要么给完整列表,要么带一个「这次发现是否完整」的标记:远程来源可能半路超时、或正赶上刷新,这时提供方会说「我这次没看全」,注册表就不会把它当最终答案缓存。 - 取正文(
get):把胜出候选的指令正文取回来。 候选里只挂着一个只有提供方看得懂的locator(本地目录里它指向 SKILL.md,运行时技能则直接内联)。真正用到时,注册表才拿着候选回找提供方,由它读出正文、剥掉 frontmatter(文件顶部---之间的 YAML 配置块),变成一段模型可读的 Markdown 指令。正文就是 SKILL.md 里 frontmatter 以外的那段指令。
而**注册表(registry)**完全不管来源,只做三件事:合并、胜出解析、校验。所有提供方在它眼里平等,它只关心这些候选怎么拼成一份不打架的视图。同名技能最终谁留下,规则在第三节细讲。
换句话说:提供方是「情报员」,负责把技能找出来、把正文递上来;注册表是「编辑部」,负责把多方情报编成一份不打架的清单。
二、分层注册:你用的 Agent 预设决定落在哪一层
先厘清两个词,后面才好懂:作用域 是位置,层是挂在这个位置上的技能箱。
- 作用域(scope):agent 在嵌套层级里的位置。一个 agent 往往不是孤立的,它上面还套着更外层的 scope(比如项目级、工作区级),一直到最外层全局。所谓作用域的链,就是从最外层祖先一路到当前 agent 自身的一串层级。
- 层(layer) :每个作用域对应一个层,层里装这个作用域注册进来的技能提供方 (少数技能也可以直接注册进来)。注意进层的是提供方而不是技能本身,真正叫技能的候选,要等读取时由提供方
list出来。
注册表就靠这两个概念运转,有两个动作:
- 注册(把提供方挂进来) :插件挂载时,它自带的技能提供方被放进它所属作用域对应的层:host 插件、仓库插件属于全局层 (人人可见);某个 Agent 预设自带的插件,属于那个 Agent 预设的层(只在该预设视野内)。
- 读取(查当前能用哪些技能) :要查这个 agent 此刻能看到哪些技能时,注册表把全局层 和当前查看作用域的链上的各层合并成一份清单:全局层打底,作用域链从最远的祖先到当前 agent 依次叠加在上面,越近的层越优先。
这就解释了:为什么不同项目、不同 Agent 预设看到的技能集合可以不同。它不是一份全局字典,而是全局层和当前作用域链叠加的结果。
三、同名怎么办:优先级低的赢,近层盖远层
多个提供方、多个层都可能出现同名技能。胜出规则就两条:
- 优先级(
rank)数值低的优先 :先比rank;rank相同,再比提供方的注册顺序;仍相同,比提供方内部的本地顺序。 - 近层遮蔽远层 :全局层和作用域层都有同名技能时,作用域链里更近的那一层整条覆盖更远的那一层。
这点和工具完全不同。工具是注册即全局可见、靠命名空间隔离;技能是同一个名字只会留下一个胜出版本,其余被静默遮蔽(被盖掉的来源会打一条 warning 日志)。
举个例子。假设两个提供方都贡献了 commit-review 这个技能:
- 本地的
project-dsh提供方,rank = 100; - 远程注册表提供方,
rank = 500。
合并时先按 rank 排序,本地那份(100)排在前,于是 commit-review 这个名字胜出的是本地版本 。哪怕远程那份内容更新,只要它的 rank 没压过本地,模型看到的就一直是本地那版,除非你主动提高远程的优先级,或者让远程落在更近的作用域层。
所以当你发现「这个技能不是我以为的那个」,第一反应应该是:有个更高优先级、或更近层的来源把它盖掉了。
四、调用策略矩阵:一份发现结果,两个面
每个技能都带一个调用策略,本质是两个独立的布尔开关:
- 模型可调(
modelInvocable):模型能不能自己决定调用它; - 用户可调(
userInvocable):人能不能手动触发它(比如当命令执行)。
注册表保留全部四种组合。所以一次发现结果,可以同时服务「模型面」和「用户面」,而两个面的目录互不混淆:
| 策略 | 模型工具/目录 | 用户命令 |
|---|---|---|
| 模型可调 + 用户可调 | 包含 | 包含 |
| 只模型可调 | 包含 | 排除 |
| 只用户可调 | 排除 | 包含 |
| 都不可调 | 排除 | 排除 |
为什么这套设计重要?「给模型看见」和「给用户执行」是两件事。一个技能可以是「只给模型当工具用、不许用户手点」,也可以是反过来,比如一个会改写文件的危险操作,只允许模型在特定流程里调用、不允许用户随手触发;又或者一个纯手工流程技能,只出现在命令菜单里、模型不该自作主张去跑。框架用这两个开关精确区分,而不是含糊地说一句「可用」。
五、没有 TTL:过期靠来源主动失效
注册表没有自动过期时间 。提供方接口上只有一个失效信号 invalidate(),由提供方自己决定什么时候说「我变了,请作废缓存」,注册表收到后才把版本号(revision)加一、清空缓存、广播 skills/change 事件。
这意味着:一个远程来源改了内容,却没主动发失效信号,注册表是推断不出来的,会一直用旧的。这和「工具注册即全局可见」那种简单世界形成鲜明对比,也是接远程技能源时最容易踩的坑:改了远端,本地没反应,多半是失效信号没发出来。
六、串行查询的代价
发现查询是串行 执行的:注册表对每一层、每一层的每个提供方,依次 await 它的 list()。一个慢源会拖垮排在它后面的所有源。所以如果你挂了多个提供方(尤其含远程源),最慢那个决定了整体发现延迟。
这也是为什么发现结果会按「工作目录 + 作用域链 + 版本号」缓存(默认最多 128 份):省得每次都重跑这一串串行查询。注意,如果某次发现里某个提供方失败了或声明不完整,这一份结果不会被缓存,下次调用仍会重试,保证尽量拿到完整清单。
七、惰性加载:用到才取正文
注册表里的候选只是「目录条目」:名字、描述、来源、策略这些元数据。真正的指令正文(Markdown 指令、相对资源)是用到时 才通过候选里的 locator 交回给提供方的 get() 取回,再渲染成模型可读文本。技能附带的资源也由对应提供方负责解析,注册表不越界。
八、小结
- 技能 ≠ 工具:不是注册即全局可见,而是多源发现 + 分层合并 + 分面调用策略;
- 提供方管来源 (
list发现、get取正文),注册表管合并(胜出解析 + 校验); - 分层注册:全局层 + 当前作用域链叠加;落到哪层由调用方 Agent 预设决定;
- 同名
rank低的赢 ,rank相同比注册顺序,且近层遮蔽远层; - 调用策略两个开关 (
modelInvocable/userInvocable)四种组合,模型面与用户面目录互不混淆; - 没有 TTL :过期全靠提供方主动调
invalidate(),注册表不会自己过期; - 提供方串行查询,慢源拖累后续,结果按目录/来源缓存,失败或不完整的那次不缓存;
- 惰性加载 :候选只是目录条目,正文
get()用到时才取、才渲染。
排查「技能不对劲」时照这三点查:
- 是不是被遮蔽了 :同名技能按
rank胜出、近层盖远层,先看有没有更高优先级、或更近层的来源盖掉了你想要的那个; - 远程改了没反应:检查远端来源有没有发失效信号,注册表不会自动发现远程变更;
- 模型调不到 :确认技能的
modelInvocable是开的,而不是userInvocable单独开。
所以下次再遇到技能「不对劲」,先别怀疑自己写错了,十有八九,是这套加载机制在背后替你做了主。
本文基于 DeepSeek Harness v0.2.1-rc.2,涉及机制由 skill/*(skill、skill-filesystem、tool-skill)协同实现。
我是小金,如果这篇帮你解决了一个小问题,欢迎关注我的公众号:小金的研发日志。