Skills:发现、装载和上下文注入
深入剖析 DSH 如何从磁盘发现 Skill 文件、通过 SkillRegistry 合并分层 Provider 目录、解析 YAML frontmatter 与 Markdown body、以及通过 tool-skill 插件将 Skill 目录和内容注入模型系统提示的完整机制链路。
引言:Skill 不是 Tool
在 DSH 的能力模型里,Tool 是可执行操作(读文件、跑命令),而 Skill 是一组结构化指令——它为模型提供知识和行为规范,但本身不执行副作用。你可以把 Skill 理解为”按需装载的系统提示片段”:只有当任务匹配时,才从磁盘读入并注入到模型的上下文窗口。
这一设计要求一条完整的管线:
- 发现(Discovery)——从哪些目录、按什么规则找到 Skill 文件
- 解析(Parse)——如何从 Markdown+YAML frontmatter 提取元数据与指令体
- 合并(Merge)——多来源同名 Skill 如何选出胜者
- 注入(Injection)——胜出 Skill 的摘要目录和完整 body 如何进入模型上下文
下面我们就沿着这四个阶段往下走,把每一层到底做了什么、数据怎么流、边界怎么守讲清楚。
先把 Skill 这事说直白:它不是“可执行能力”,就是一段上下文——往 system prompt 里塞规则和知识用的。真正会产生副作用的是 Tool。
另一件容易被误解的事是“同名 skill 到底谁说了算”。Harness 不靠 import 顺序,也不靠“最后加载覆盖”,它用的是一套明确的排序:多根目录 + rank + providerOrder。你把同名 skill 分别放在项目目录、用户目录、bundled 目录,最后用哪一份是可预测的。
读这一章别背名词。顺着数据流走就行:文件系统先扫出候选,registry 再选出胜者,tool-skill 插件把 catalog 发成系统消息,需要时再把 body 塞进上下文。
第一阶段:磁盘发现——skill-filesystem
多根目录扫描架构
@deepseek-ai/dsh-skill-filesystem 是 SkillRegistry 的默认文件系统 Provider。它在 apply() 阶段向 ctx.skills 注册自身,然后在每次 list() 调用时根据当前 cwd 计算扫描根目录列表。
根目录按优先级从高到低排列(rank 数值越小优先级越高):
| 根目录 | SkillSource 标签 | Rank |
|---|---|---|
{projectRoot}/.dsh/skills | project-dsh | 100 |
{projectRoot}/.agents/skills | project-agents | 200 |
用户配置的 customSkillDirs | custom | 300 |
~/.dsh/skills | user-dsh | 400 |
~/.agents/skills | user-agents | 500 |
$DSH_BUNDLED_SKILL_DIR | bundled | 600 |
其中 projectRoot 通过向上遍历寻找 .git 目录确定。如果 cwd 未提供,则跳过项目级根。
两种 Skill 文件布局
discoverRoot() 扫描每个根目录的直接子条目,支持两种布局:
目录 bundle 布局:子条目是目录,DSH 读取其中的 SKILL.md 文件。目录本身作为 resourceBase,Skill body 中的相对路径将解析到该目录。
.dsh/skills/
my-skill/
SKILL.md ← 入口文件
templates/ ← Skill body 中可引用的资源
扁平文件布局:子条目是 .md 后缀的普通文件,直接作为 Skill 入口。resourceBase 指向根目录本身。
.dsh/skills/
quick-fix.md ← 直接就是 Skill
Frontmatter 解析规则
每个 Skill 文件必须以标准 YAML frontmatter 开头(--- 围栏)。parseSkillFile() 首先调用 parseFrontmatter() 提取 YAML 块和 body:
必须字段:
name:kebab-case 标识符,必须匹配/^[a-z0-9]+(?:-[a-z0-9]+)*$/description:简短的路由描述,供目录展示
可选字段:
whenToUse:额外的路由引导说明disable-model-invocation:布尔值,设为true时此 Skill 不出现在模型目录中,只能由用户手势触发user-invocable:布尔值,设为false时用户手势不触发此 Skillmetadata:任意 key-value 对象,供下游消费者使用
这些字段被解析为 SkillInvocationPolicy 和元数据,与 body(frontmatter 之后的全部文本 .trim())一同返回为 ParsedSkill。
文件读取路径
readSkillText() 有两条读取路径:当 Cordis 上下文提供 ctx.fs(FileSystem 服务)时走沙箱化文件系统 API;否则直接调用 Node.js 的 readFile。bundled 根始终走 Node.js 原生路径以避免沙箱限制。
// 简化示意
async function readSkillText(ctx, path, signal, trustedHost) {
const fs = ctx.get('fs')
if (fs !== undefined && !trustedHost) {
return await readSkillTextFromFileSystem(ctx, fs, path, signal)
}
return await readFile(path, { encoding: 'utf8', signal })
}
缺失文件不会中断发现流程——ENOENT 和 ENOTDIR 被静默吞掉并返回 undefined。
第二阶段:注册表合并——SkillRegistry
Service 定义与分层架构
@deepseek-ai/dsh-skill 包定义了 SkillRegistry 这个 Cordis Service。它不决定 Skill 从哪里来,只负责:
- 管理 Provider 注册
- 合并多 Provider 的候选列表
- 选出同名 Skill 的胜者
- 对外暴露
list()/snapshot()/get()API
核心数据结构是 ScopedLayers<SkillLayer>。每个 SkillLayer 包含:
providers:该层注册的 Provider 列表(NamedEntries<RegisteredProvider>)runtime:该层通过ctx.skills.register()直接注入的运行时 Skill
层的分层逻辑与 DSH 的 Tools Registry 一致:
- 全局层(global layer):宿主级和仓库级插件注册在这里
- Scope 层(per-agent layer):agent preset 内部挂载的插件注册在该 agent 的专属层
读取时,近层覆盖远层——同名 Skill 若出现在 agent scope 层,则直接胜出,无需比较 rank。
候选收集与去重
collectFresh() 从全局层开始,依次叠加 scope 链中的每一层。每层内部按如下顺序排序候选:
rank ASC → providerOrder ASC → localOrder ASC
在同一层内,rank 最小的候选赢得该名称。跨层时,后遍历的层(即离调用 agent 最近的层)无条件覆盖前层的同名条目。
Runtime Skill 在层内的 rank 为 250(RUNTIME_RANK),位于 project-agents(200)和 custom(300)之间。
缓存与失效
Registry 维护一个 collectCache(以 {cwd, scopes[], revision} 为 key 的 Map)。任何 Provider 调用 control.invalidate() 或 runtime skill 注册/注销都会递增 revision 并清空缓存。Watcher 检测到磁盘变化后也通过同一路径触发失效。
缓存容量默认 128 条,LRU 淘汰最早条目。
第三阶段:目录发布——tool-skill 的 pre-step 注入
注入机制概览
@deepseek-ai/dsh-tool-skill 是将 Skill 子系统与模型上下文连接的桥梁。它在 apply() 中做三件事:
- 注册
skilltool(模型可调用) - 注册一个
agent/pre-step监听器,处理用户/name手势 - 注册另一个
agent/pre-step监听器,管理 Skill 目录消息
Catalog 消息的结构
当 agent 的 step 开始时,pre-step 监听器调用 ctx.skills.snapshot() 获取当前可见 Skill 列表,然后过滤出 modelInvocable 为 true 的条目,渲染为一条 system-reminder 格式的 UserMessage:
<system-reminder>
A skill is a reusable set of task-specific instructions.
The following skills are available in this session:
<available_skills>
- `my-skill`: Short description here...
- `another-skill`: Another description...
</available_skills>
If the user names a skill, or the task clearly matches
a skill's description, call the `skill` tool with the
exact skill name before taking task actions.
</system-reminder>
Digest 差分与增量更新
为避免每个 step 都重新注入相同的 catalog,系统计算 entries 数组的 SHA-256 摘要并与历史对比:
const canonical = entries.map(entry =>
JSON.stringify([entry.name, entry.description])
).join('\n')
const digest = createHash('sha256').update(canonical).digest('hex')
只有当 digest 变化时才发布新 catalog。后续更新使用 renderCatalogUpdate(),其文本明确告知模型”此完整目录替换所有先前目录”。
如果 catalog 为空且从未发布过,则不注入任何消息——避免无谓占用上下文窗口。
描述截断
每条 Skill 描述在目录中被规范化(合并空白、trim)并截断到 catalogDescriptionMaxLength(默认 500 字符)。超长描述以 ... 结尾。这确保目录不会爆炸式增长。
第四阶段:Body 装载——两条注入路径
路径 A:模型调用 skill tool
当模型识别到任务匹配某 Skill 时,它调用 skill tool 并传入精确名称。tool 执行流程:
- 校验名称格式(
isSkillName) - 从
ctx.skills.list()确认该 Skill 存在且modelInvocable - 调用
ctx.skills.get(name)触发 Provider 的get()方法读取完整 body - 返回结构化结果,由
renderSkillContent()渲染为<skill_content>包裹的 XML
<skill_content name="my-skill">
<skill_resources>
Base directory for this skill: /path/to/.dsh/skills/my-skill
Resolve relative paths mentioned by this skill against
the base directory before using them.
</skill_resources>
<skill_instructions>
...完整 Markdown body...
</skill_instructions>
</skill_content>
路径 B:用户显式 /name 手势
用户在输入中写 /my-skill 时,pre-step 监听器通过正则 /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g 检测手势 token。只有 source.kind === 'user' 的消息会被扫描——外部来源无法伪造手势。
检测到合法手势后:
- 调用
ctx.skills.get(name)加载完整定义 - 检查
isUserInvocable(skill)是否为true - 将渲染后的
<skill_content>作为UserMessage(source kind 为skill-invocation)追加到 step 消息列表末尾
const source: SkillInvocationSource = {
kind: 'skill-invocation',
name,
form: 'instructions'
}
injections.push(createUserMessage({
content: [{ type: 'text', text: renderSkillContent(skill) }],
source,
}))
这条路径是 disable-model-invocation: true 的 Skill 唯一的装载入口——它们不出现在 catalog 中,模型无法通过 tool 调用加载,只有用户主动键入手势才能触发。
注入顺序与上下文定位
DSH 对注入内容的排列有明确的设计意图:
- Catalog 消息:作为背景上下文,与工作区规则、运行时策略并列,位于对话历史的早期位置
- 手势注入:追加在所有其他注入之后(包括 catalog 之后),紧邻模型即将生成的回复——这保证”模型最后看到的上下文”就是它需要遵循的指令
注册顺序决定了 waterfall 执行顺序:手势监听器先于 catalog 监听器注册,因此它在 waterfall 链中后执行,得到的 decision.messages 已经包含 catalog。
Watcher 子系统:实时失效
为什么需要 Watcher
用户在开发过程中可能随时添加、修改或删除 Skill 文件。DSH 不要求重启会话——SkillWatchManager 通过 Chokidar 监控根目录,检测到变化后调用 control.invalidate() 触发 Registry 缓存清除,下一个 step 的 catalog 注入自然会反映新状态。
两种监控模式
- Root 模式(
kind: 'root'):根目录已存在,Chokidar 直接监控(depth: 1,只看一级子目录和SKILL.md) - Ancestor 模式(
kind: 'ancestor'):根目录尚不存在,使用fs.watchFile轮询最近存在的祖先目录,等待目标路径被创建后切换到 Root 模式
事件过滤
不是所有文件系统事件都有意义。isRelevantWatchEvent() 只关注:
- 根目录自身的创建/删除(
addDir/unlinkDir,segments.length === 0) - 一级子条目的目录创建/删除,或
.md文件变化(segments.length === 1) - 二级
SKILL.md文件的变化(segments.length === 2 && segments[1] === 'SKILL.md')
.system 目录下的变化被跳过(当 root.skipSystem === true 时,主要用于 user-dsh 根)。
宿主写入快速路径
当模型通过 edit 或 write tool 修改了可能是 Skill 的文件时,fs/observed 事件触发 observeHostMutation(),立即同步失效——不等 Chokidar 的稳定性延迟。
SkillInvocationPolicy:谁能调用什么
每个 Skill 的 frontmatter 控制两个布尔维度:
| Frontmatter 字段 | 默认值 | 影响 |
|---|---|---|
disable-model-invocation | false | 设为 true 时,Skill 不出现在 catalog 中,模型无法通过 skill tool 加载 |
user-invocable | true | 设为 false 时,用户 /name 手势不触发该 Skill |
这两个维度是正交的:
- 两者都为默认值:模型和用户都能触发(最常见)
disable-model-invocation: true:仅用户手动触发——适合敏感操作的 guardrail skilluser-invocable: false:仅模型自主触发——适合需要 AI 判断而非人工干预的内部 skill- 两者都禁用:Skill 实际不可达(防御性配置错误时的安全态)
名称验证与安全边界
Skill 名称被严格限制为 kebab-case(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),这带来几个安全属性:
- 无路径穿越:名称中不含
/、..或特殊字符 - XML 属性安全:名称不含
<、>、",可直接嵌入 XML 属性无需额外转义 - 命名空间隔离:Skill 名称与 CLI 命令注册表是不同的封闭命名空间
对于 Skill body 中的 provider 名称和描述文本,escapeText() 和 escapeAttr() 分别处理 XML 特殊字符:
function escapeText(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
}
Runtime Skill 注册
除了磁盘发现,你还可以通过代码直接向 Registry 注入 Skill:
ctx.skills.register({
name: 'my-runtime-skill',
description: 'Injected at runtime',
source: 'runtime',
content: '... markdown instructions ...',
})
Runtime Skill 的 rank 为 250,介于 project-agents(200)和 custom(300)之间。同层内同名 runtime skill 采用 first-wins 策略——后到者被忽略并记录 warn 日志。
注册返回一个 Cordis effect disposer,调用后会移除该 skill 并触发缓存失效。
完整数据流图解
┌─────────────────────────────────────────────────────────────────────┐
│ 磁盘层 │
│ .dsh/skills/ .agents/skills/ ~/.dsh/skills/ bundled/ │
└──────────┬──────────────┬──────────────┬──────────────┬─────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ skill-filesystem: discoverRoot() × N roots │
│ → parseSkillFile() → ParsedSkill │
│ → SkillCandidate{ name, description, rank, locator, ... } │
└──────────────────────────────────┬──────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ SkillRegistry.collectFresh() │
│ global layer → scope chain layers (nearest wins) │
│ within layer: rank ASC → providerOrder ASC → localOrder ASC │
│ → Map<name, IndexedCandidate> │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌─────────────────┐
│ Catalog 注入 │ │ skill │ │ /name 手势注入 │
│ (pre-step) │ │ tool │ │ (pre-step) │
└──────┬───────┘ └────┬─────┘ └───────┬─────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 模型上下文 │
│ [catalog msg] ... [tool result: skill_content] [injected msg] │
└─────────────────────────────────────────────────────────────────────┘
ResourceBase:相对路径解析
当 Skill body 中引用相对路径(如 ./templates/react.md)时,模型需要知道解析基点。SkillResourceBase 有三种形态:
type SkillResourceBase =
| { kind: 'directory'; path: string } // 本地目录
| { kind: 'url'; url: string } // 远程 URL
| { kind: 'opaque'; description: string } // 不透明描述
对于 filesystem provider,基点始终是 { kind: 'directory', path: locator.directory }——即 Skill 文件所在的目录(bundle 布局)或根目录(flat 布局)。
渲染时,renderResourceHint() 在 <skill_resources> 块中告知模型如何解析相对路径:
<skill_resources>
Base directory for this skill: /Users/me/.dsh/skills/my-skill
Resolve relative paths mentioned by this skill against
the base directory before using them.
Load referenced resources only as needed.
</skill_resources>
Provider 生命周期管理
注册
registerProvider() 接受一个同步工厂函数,返回 SkillProvider 实例。工厂在调用时收到 SkillProviderControl:
signal:Provider 注册被撤销时 abortinvalidate():通知 Registry 该 Provider 的数据可能已过期
注册归属于调用上下文的 scope 层——全局插件注册全局 Provider,agent preset 内的插件注册 per-agent Provider。
失效传播
Provider 调用 invalidate() 时,Registry 检查该注册是否仍然存活(layer.providers.get(name)?.provider === provider),只有确认活跃才执行 invalidateCache()。这防止了已注销 Provider 的残余回调污染缓存。
注销
Cordis fiber 销毁时自动调用注册返回的 disposer,执行:
- 从层的
providersNamedEntries 移除 - Abort 生命周期 signal
- 触发缓存失效
异常处理与优雅降级
Provider 列举失败
如果某 Provider 的 list() 抛出异常(网络超时、权限错误等),Registry 会:
- 记录 warn 日志
- 标记本次收集为
cacheable: false - 跳过该 Provider 继续收集其他 Provider 的候选
这意味着一个 Provider 的故障不会阻塞整个 Skill 目录。
Watcher 启动失败
如果 Chokidar 无法监控某个根目录(如不存在的路径),list() 返回 { candidates, complete: false } 而非抛出异常。下次调用会重试监控。
文件消失
get() 调用时如果文件已被删除(parseSkillFile 返回 undefined),Registry 调用 invalidateEntry() 强制刷新缓存,并返回 undefined 给调用者。tool 侧会抛出 “skill is unknown or no longer available” 错误。
性能特征
| 环节 | 开销控制策略 |
|---|---|
| 磁盘扫描 | depth: 1 只扫根目录直接子条目,不递归 |
| 文件读取 | 发现阶段只读 frontmatter(body 在 list 时也读,但结果被缓存) |
| 缓存 | collectCache 按 cwd+scope+revision 缓存,避免重复合并 |
| Watcher | 按项目分组,LRU 淘汰超限项目(默认最多 128 个项目) |
| Catalog digest | SHA-256 对比避免不必要的消息替换 |
| 描述截断 | 500 字符上限防止目录膨胀 |
与 MCP 和 Tool 的关系
Skill 子系统与其他能力接入机制的关键区别:
- Tool:注册在
ctx.tools,模型通过 function calling 执行副作用操作 - Skill:注册在
ctx.skills,通过skilltool 间接加载,内容是指令而非操作 - MCP Server:外部进程通过标准协议提供 tool/resource/prompt,可注册为 SkillProvider(未来扩展点)
skill tool 本身就是一个标准 Tool——它的 execute 方法调用 ctx.skills.get(),本质上是用 Tool 机制承载 Skill 的装载语义。这种设计使得 Skill 可以复用 Tool 的所有基础设施(权限控制、执行追踪、结果渲染)。
收口
Skill 这套东西最后可以被压成一条四段式的管线:发现(skill-filesystem 扫目录找候选)、合并(SkillRegistry 在 ScopedLayers 里去重排序)、目录注入(tool-skill 在 step 开始时发布/更新 <available_skills>),以及 body 注入(模型调用 skill tool 或用户输入 /name 才真正把全文塞进上下文)。
它最关键的取舍不是“功能更强”,而是按需装载:目录可以常驻,全文按需要进窗口。上下文预算有限的情况下,这比“把所有东西一次性都塞进 prompt”更能打。