青雲的博客
深入浅出 DeepSeek Harness 第六部:能力接入——Skills、MCP和插件 第 35 章

Skills:发现、装载和上下文注入

深入剖析 DSH 如何从磁盘发现 Skill 文件、通过 SkillRegistry 合并分层 Provider 目录、解析 YAML frontmatter 与 Markdown body、以及通过 tool-skill 插件将 Skill 目录和内容注入模型系统提示的完整机制链路。

源码版本
47f943859bef60e4160492346772ded9b24f765a
验证日期
Commit
47f943859bef60e4160492346772ded9b24f765a

引言:Skill 不是 Tool

在 DSH 的能力模型里,Tool 是可执行操作(读文件、跑命令),而 Skill 是一组结构化指令——它为模型提供知识和行为规范,但本身不执行副作用。你可以把 Skill 理解为”按需装载的系统提示片段”:只有当任务匹配时,才从磁盘读入并注入到模型的上下文窗口。

这一设计要求一条完整的管线:

  1. 发现(Discovery)——从哪些目录、按什么规则找到 Skill 文件
  2. 解析(Parse)——如何从 Markdown+YAML frontmatter 提取元数据与指令体
  3. 合并(Merge)——多来源同名 Skill 如何选出胜者
  4. 注入(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/skillsproject-dsh100
{projectRoot}/.agents/skillsproject-agents200
用户配置的 customSkillDirscustom300
~/.dsh/skillsuser-dsh400
~/.agents/skillsuser-agents500
$DSH_BUNDLED_SKILL_DIRbundled600

其中 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 时用户手势不触发此 Skill
  • metadata:任意 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 })
}

缺失文件不会中断发现流程——ENOENTENOTDIR 被静默吞掉并返回 undefined


第二阶段:注册表合并——SkillRegistry

Service 定义与分层架构

@deepseek-ai/dsh-skill 包定义了 SkillRegistry 这个 Cordis Service。它不决定 Skill 从哪里来,只负责:

  1. 管理 Provider 注册
  2. 合并多 Provider 的候选列表
  3. 选出同名 Skill 的胜者
  4. 对外暴露 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() 中做三件事:

  1. 注册 skill tool(模型可调用)
  2. 注册一个 agent/pre-step 监听器,处理用户 /name 手势
  3. 注册另一个 agent/pre-step 监听器,管理 Skill 目录消息

Catalog 消息的结构

当 agent 的 step 开始时,pre-step 监听器调用 ctx.skills.snapshot() 获取当前可见 Skill 列表,然后过滤出 modelInvocabletrue 的条目,渲染为一条 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 执行流程:

  1. 校验名称格式(isSkillName
  2. ctx.skills.list() 确认该 Skill 存在且 modelInvocable
  3. 调用 ctx.skills.get(name) 触发 Provider 的 get() 方法读取完整 body
  4. 返回结构化结果,由 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' 的消息会被扫描——外部来源无法伪造手势。

检测到合法手势后:

  1. 调用 ctx.skills.get(name) 加载完整定义
  2. 检查 isUserInvocable(skill) 是否为 true
  3. 将渲染后的 <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/unlinkDirsegments.length === 0
  • 一级子条目的目录创建/删除,或 .md 文件变化(segments.length === 1
  • 二级 SKILL.md 文件的变化(segments.length === 2 && segments[1] === 'SKILL.md'

.system 目录下的变化被跳过(当 root.skipSystem === true 时,主要用于 user-dsh 根)。

宿主写入快速路径

当模型通过 editwrite tool 修改了可能是 Skill 的文件时,fs/observed 事件触发 observeHostMutation(),立即同步失效——不等 Chokidar 的稳定性延迟。


SkillInvocationPolicy:谁能调用什么

每个 Skill 的 frontmatter 控制两个布尔维度:

Frontmatter 字段默认值影响
disable-model-invocationfalse设为 true 时,Skill 不出现在 catalog 中,模型无法通过 skill tool 加载
user-invocabletrue设为 false 时,用户 /name 手势不触发该 Skill

这两个维度是正交的:

  • 两者都为默认值:模型和用户都能触发(最常见)
  • disable-model-invocation: true:仅用户手动触发——适合敏感操作的 guardrail skill
  • user-invocable: false:仅模型自主触发——适合需要 AI 判断而非人工干预的内部 skill
  • 两者都禁用:Skill 实际不可达(防御性配置错误时的安全态)

名称验证与安全边界

Skill 名称被严格限制为 kebab-case(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),这带来几个安全属性:

  1. 无路径穿越:名称中不含 /.. 或特殊字符
  2. XML 属性安全:名称不含 <>",可直接嵌入 XML 属性无需额外转义
  3. 命名空间隔离:Skill 名称与 CLI 命令注册表是不同的封闭命名空间

对于 Skill body 中的 provider 名称和描述文本,escapeText()escapeAttr() 分别处理 XML 特殊字符:

function escapeText(value: string): string {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
}

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 注册被撤销时 abort
  • invalidate():通知 Registry 该 Provider 的数据可能已过期

注册归属于调用上下文的 scope 层——全局插件注册全局 Provider,agent preset 内的插件注册 per-agent Provider。

失效传播

Provider 调用 invalidate() 时,Registry 检查该注册是否仍然存活(layer.providers.get(name)?.provider === provider),只有确认活跃才执行 invalidateCache()。这防止了已注销 Provider 的残余回调污染缓存。

注销

Cordis fiber 销毁时自动调用注册返回的 disposer,执行:

  1. 从层的 providers NamedEntries 移除
  2. Abort 生命周期 signal
  3. 触发缓存失效

异常处理与优雅降级

Provider 列举失败

如果某 Provider 的 list() 抛出异常(网络超时、权限错误等),Registry 会:

  1. 记录 warn 日志
  2. 标记本次收集为 cacheable: false
  3. 跳过该 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 digestSHA-256 对比避免不必要的消息替换
描述截断500 字符上限防止目录膨胀

与 MCP 和 Tool 的关系

Skill 子系统与其他能力接入机制的关键区别:

  • Tool:注册在 ctx.tools,模型通过 function calling 执行副作用操作
  • Skill:注册在 ctx.skills,通过 skill tool 间接加载,内容是指令而非操作
  • 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”更能打。