深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 Chapter 39

技能文件从哪几个目录冒出来,又怎样进模型视野

SkillFilesystem 按固定 rank 扫六个技能根目录,按项目优先、全局兜底做覆盖;SkillRegistry 缓存与合并同名技能;tool-skill 每轮预步进把目录清单喂给模型,再按需按需加载正文。技能不是插件,它们是"文件即能力"的一层。

Written in Chinese

Source version
dsh-v0.1.7-rc.2
Verified on
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

会话刚开头、第一个工具还没被模型选中的时候,发生了一件事:系统提示区里已经躺着一份技能目录清单——几十行、每行一个技能名加一句说明。这份清单不是某个人手写进 system prompt 的,也不是插件静态注册的,而是 tool-skill 在本轮预步进(pre-step)阶段,现场问 SkillRegistry 要出来、再塞进去的。这一章讲这份清单从哪来:技能文件散落在哪几个磁盘目录、按什么优先级合并、为什么”项目目录压过用户目录”是有意设计,以及目录变了之后怎样只在真变了的时候才重发。

六个根目录与固定 rank

技能发现的底层是一个文件系统扫描器 SkillFilesystem。它维护一组”技能根目录”,每个根有一个固定 rank——rank 小的先入候选层,先入者占住同名名额。

本节源码依据(4 处)
根目录rank含义谁能改
项目 .dsh/skills100仓库自带、随仓库走的技能提交进 git
项目 .agents/skills200项目级旧约定目录提交进 git
自定义(配置声明)300部署方/用户显式加的目录settings
用户 ~/.dsh/skills400跨项目的个人技能本机用户
用户 ~/.agents/skills500用户级旧约定目录本机用户
运行期注册(内存)250插件运行时 register 进来的运行中的插件
打包技能(bundled)600随发行版带的内置技能不可改

表里的 rank 数字不是我估的:PROJECT_DSH_RANK=100 到 USER_AGENTS_RANK=500 是 skill-filesystem/src/index.ts 里的常量;运行期 RUNTIME_RANK=250 与打包 BUNDLED_SKILL_RANK=600 在 skill/src/index.ts。

为什么”最近的目录赢”是有意的。 rank 升序意味着:同名技能,项目目录先占住名额,用户目录在后、进不来。这和”用户目录应该能覆盖项目配置”的直觉相反,但技能是给当前仓库量身定做的操作指南——这个仓库的代码约定、构建命令、测试方式,就该由仓库自己的技能说了算;用户全局技能只是兜底。把项目层放在全局层之前,等于声明:进了这个仓库,这个仓库的写法优先于你的个人写法。 这是覆盖优先级的方向性选择,不是排序笔误。

解析 frontmatter:一次合法加载的入口

每个技能根下面是一组 技能名/SKILL.md 文件。parseSkillFile 读它的 YAML frontmatter,抽出 name、description、是否可被模型调用等字段。旧写法里的 invocable、mode 键已经被显式拒绝:

本节源码依据(3 处)

注意 modelInvocable 的默认值是真:只要没写 disableModelInvocation: true,模型就能点名加载它。这把”默认开放、逐个关闭”作为安全姿态——技能作者想收回模型的加载权,得显式关掉,而不是默认不给。

frontmatter 字段作用默认
name / description进目录清单给模型看的名字与一句说明必填
modelInvocable模型能否点名加载正文真(未写 disable 即允许)
userInvocable用户能否在对话里显式 @ 它真(未写 false 即允许)
invocable / mode旧写法直接抛错,要求改成新键

为什么把 modelInvocable 默认成开。 技能清单的目的是让模型能自助加载操作指南;如果默认关闭,每写一个技能都得显式打开一次,摩擦太大、漏开率高。默认开、显式关的代价是”某个不该被模型碰的技能,作者必须记得写 disable”——这是把默认安全押在作者的注意力上,和密钥系统”默认拦、显式放”的方向相反,因为两者风险面不同:技能被模型读一遍顶多浪费一轮 token,密钥被读一遍就是真泄漏。

这张表里还藏着一个容易被忽略的点:userInvocable 默认真、但和 modelInvocable 是两件事——一个对模型开放、一个对用户 @ 开放。一个技能可以”模型看不见、但用户能显式点名”(比如内部调试技能),反过来也可以”模型能自己加载、但不占用户的快捷入口”。两个开关正交,给了作者四种组合,而不是一个笼统的”开/关”。

SkillRegistry:缓存、合并与同名决胜

SkillRegistry 不直接读盘,它从若干”provider”(文件系统根、运行期注册、打包目录)收集候选,再合并。两个关键设计:

  1. collect 缓存键包含 scopeChainOf。收集结果按”当前工作区链”做键缓存;工作区一变,键就变,缓存自然失效,跨会话串味被结构上排除。
  2. provider 失败不炸收集。某一层抛错只作废该层缓存、warn 一行,其它层照常收集;只有能收集到时才写 cacheable 项。
本节源码依据(5 处)

技能发现与注入:六个根目录按 rank 升序进候选层(项目 .dsh=100 最先、打包=600 最后),SkillRegistry 按 rank→providerOrder→localOrder 决胜同名、scopeChainOf 作 collect 缓存键;tool-skill 在 pre-step 阶段把完整目录清单注入 system prompt,模型调 skill() 时才按需加载正文。

tool-skill:把目录清单喂给模型,正文按需加载

tool-skill 做两件事:注册一个 skill 工具供模型点名加载正文,以及在每轮预步进把完整目录清单注入 system prompt。关键是注入时机——它注册的预步进手势监听先于目录清单监听,保证模型在看到任何工具之前先看到”有哪些技能可用”。

本节源码依据(6 处)

为什么”清单常驻、正文按需”是对的。 如果把每个技能的全文都塞进 system prompt,几十个技能的正文会把上下文吃光;如果什么都不给,模型根本不知道有哪些技能可调用。目录清单(名字 + 一句说明,每条截断到 500 字符)是一份廉价的目录,正文只在模型点名时才加载——这是”目录与正文分离”的标准做法,也是 renderCatalogMessage 要强调”完整清单取代此前所有版本”的原因:模型不该拿着过期清单去调一个已经消失的技能。

两种目录布局与 resourceBase

discoverRoot 对每个根下的一级子条目做两种判断:子条目是目录就读里面的 SKILL.md(bundle 布局,目录本身是 resourceBase),子条目是**.md 文件**就直接当入口(flat 布局,resourceBase 指向根本身)。模型加载正文时会收到一句”相对路径以本目录为基”的提示,让它知道正文里引用的 ./templates/... 从哪算起。

本节源码依据(2 处)

运行期注册:磁盘之外的一层

除了磁盘扫描,插件还能在运行时直接 register 一个内存技能。这层 rank 固定为 250,夹在项目 .agents(200)与自定义(300)之间;同层同名后到者被忽略并 warn。注册返回一个 disposer,插件卸载时自动摘除并失效缓存。

本节源码依据(2 处)

热更:目录变了才重发

技能文件被编辑后,chokidar 监听(depth:1、awaitWriteFinish)触发,isRelevantWatchEvent 过滤掉无关事件,命中技能变化就重收那一层、失效缓存、必要时重发目录清单。

本节源码依据(3 处)

小结

技能不是 Cordis 插件,而是”文件即能力”的一层:六个磁盘根目录按固定 rank 排序合并,项目目录压过用户目录是有意的方向;SkillRegistry 用工作区链做缓存键、逐层容错;tool-skill 每轮预步进喂目录清单、正文按需加载,目录真变了才重发。下一章看另一类完全不同的”工具来源”——第 40 章:外部 MCP server 怎样连进来、它的工具名怎样在全局命名空间里不撞车。