如何给 Agent 加自定义 Skill?用 DSH 插件从零实现

关键词:DSH 插件、Agent Skill、ctx.skills 注册、Cordis 依赖注入、提示词工程


目录


一、成品长什么样:4 个文件,39 行代码

先看成品,再讲为什么。整个插件就 4 个文件:

dsh-i-have-adhd/
├── package.json          # 插件清单:宿主靠它认出「这是个插件」
├── cordis.patch.yml      # bundle 层:把插件挂进应用树
├── dsh/
│   └── index.js          # 宿主半身:39 行,真正干活的只有 13 行
└── skills/
    └── i-have-adhd/
        └── SKILL.md      # Skill 正文:效果好坏全看这段文字

它做的事情一句话说完:往 Harness 的 Skill 注册表里塞一段 Markdown,让 Agent 在需要时能按需加载这段规则。

图一:四个文件的职责与调用关系

在这里插入图片描述

(图一:package.json 声明插件身份与入口,cordis.patch.yml 决定它被不被挂载,dsh/index.js 在启动时读 SKILL.md 并注册,SKILL.md 是最终注入给模型的内容)

为什么做成插件,而不是配置文件?

这是第一个要想清楚的问题。既然是「一段文本」,为什么不直接写进配置里?原因有三个:

  1. 内容是动态的——Skill 正文放在独立文件里,插件启动时读进来,改文本不用改代码;
  2. 可以被分发——打包成 npm 包,别人一条命令就能装(dsh plugin add github:owner/repo);
  3. 不污染全局——它是一个可插拔的层,卸载就是删掉一行,不残留任何状态。

二、package.json:宿主怎么认出这是一个插件

{
  "name": "dsh-i-have-adhd",
  "version": "0.1.0",
  "type": "module",
  "main": "./dsh/index.js",
  "exports": {
    ".": "./dsh/index.js",
    "./package.json": "./package.json"
  },
  "files": ["dsh", "skills", "cordis.patch.yml", "README.md", "LICENSE"],
  "dsh": {
    "bundle": { "patch": "./cordis.patch.yml" }
  }
}

2.1 main 和 exports:插件入口的两个写法

main 是老字段,exports 是新字段,两个都写上是为了兼容不同的解析路径。这个插件只有一个入口 ./dsh/index.js,所以 exports 里只需要 . 一条。

这里有个容易踩的坑:exports 一旦写了,Node 就只认 exports 里列出的路径,没列的会直接拒绝解析。所以 "./package.json": "./package.json" 这条不是多余的——工具链(比如包管理器读取 manifest)经常需要读 package.json 本身,漏掉它就会报解析失败。

files 字段决定发布时哪些目录进包。skills 目录必须写进去,否则别人装你的包时 SKILL.md 根本不在,插件启动就会失败:

files: ["dsh", "skills", "cordis.patch.yml", "README.md", "LICENSE"]
                ↑ 漏了它,装完就是「插件加载了但 Skill 是空的」

2.2 dsh.bundle.patch:声明「我是一个 profile 层」

dsh.bundle.patch 是这套插件体系的入口声明。它指向一个 patch 文件,宿主在组装 profile 时会把它叠加进去。

另外一个值得注意的点:这个插件没有 dsh.client 字段。

对比一下有 UI 的插件(比如一个侧边栏编辑器),它的 package.json 里会有:

"dsh": {
  "bundle": { "patch": "./cordis.patch.yml" },
  "client": { "platform": "web", "immediately": true }
}

为什么这里不需要 dsh.client? 因为 Skill 是模型面向的能力——它通过 skill 工具被模型加载,界面上不画任何东西。没有浏览器半身,就不需要声明 client 字段,插件也更轻。这条判断规则可以记一下:要画 UI 才需要 dsh.client,纯模型能力的插件只留 dsh.bundle。

三、cordis.patch.yml:把插件挂进应用树

- insert:
    - id: i-have-adhd
      name: dsh-i-have-adhd

只有 6 行,但这里有一个注释里反复强调的坑:

name 写的是「包名」,不是「相对路径」。 它通过 profile 的 node_modules 解析,再经由包的 main 字段找到 dsh/index.js。写成 ./dsh/index.js 会直接挂载失败。

id 是这一条挂载项在应用树里的标识,卸载或覆盖配置时按它定位。

挂载完成后,可以先用一条命令确认它有没有进树,不用真的启动服务:

dsh --profile web --dump-config | Select-String -Pattern "i-have-adhd"
# == dsh-i-have-adhd
# - id: i-have-adhd
#   name: dsh-i-have-adhd

能看到这两行,说明插件已经正确进入应用树。

四、dsh/index.js:注册 Skill 只用了 39 行里的 13 行

现在看主角。把这个文件拆成四段看:

import { readFileSync } from 'node:fs'

export const name = 'dsh-i-have-adhd'
export const inject = ['skills']

const SKILL_MD = new URL('../skills/i-have-adhd/SKILL.md', import.meta.url)

第一段:零依赖。 只 import 了 Node 内置的 node:fs,没有引入任何第三方包。这是刻意的选择——插件装在 profile 的 node_modules 里,跨包解析第三方依赖经常出问题,零依赖最稳。

第二段:name 和 inject 是 Cordis 插件的协议。 name 是插件标识;inject 声明这个插件需要哪些服务。

4.1 inject 为什么是数组,不是 import?

这是刚开始写 Cordis 插件最容易混的一点。

export const inject = ['skills']     // 声明:我需要 skills 服务
// ...然后在 apply 里直接用 ctx.skills

inject 不是 import,而是依赖声明。它有两个作用:

  1. 保证加载顺序——宿主会先激活提供 skills 服务的插件,再激活你;
  2. 保证可用性——服务没就绪时,你的插件不会被激活,不会出现 ctx.skills is undefined。

所以它和 import 是两件事:import 拉的是代码,inject 拉的是运行时服务。

4.2 ctx.effect 包的到底是什么?

继续看剩下的代码:

export function apply(ctx) {
  let content
  try {
    content = readFileSync(SKILL_MD, 'utf8')
  } catch (error) {
    console.warn(`[dsh-i-have-adhd] SKILL.md unreadable, skill not registered: ${error?.message ?? error}`)
    return
  }

  ctx.effect(
    () =>
      ctx.skills.register({
        name: 'i-have-adhd',
        source: 'runtime',
        description: 'ADHD-friendly output: terse, action-first, no fluff(简短、先给答案、少废话)',
        whenToUse: '当你希望回复「先给答案、编号步骤、无开场白/复盘/客套」时',
        content,
      }),
    'dsh-i-have-adhd: skill',
  )
}

读文件这段加了 try/catch,是有意为之。 如果 SKILL.md 读不到(比如发布时漏了 files 字段),插件应该安静地不注册,而不是抛出异常把整个启动流程拖垮。插件失败不该连累宿主。

ctx.effect(setup, label) 的语义是「注册一个可回收的副作用」:setup 返回一个清理函数,插件被卸载时宿主会调它。这里返回的是 ctx.skills.register(...) 的返回值——也就是注销函数。

所以这个写法等价于:

const dispose = ctx.skills.register({ ... })   // 注册,拿到注销函数
ctx.effect(() => dispose, 'label')             // 交给宿主托管,卸载时自动注销

不写 ctx.effect 会怎样? 插件热重载或卸载后,注册表里会残留一条指向已销毁上下文的记录,再次挂载时就可能报「同名 Skill 重复注册」——这类残留问题排查起来很费时间,所以注册类操作一律套 ctx.effect。

再逐个看 register 的字段,这几个字段的作用完全不同,别写混:

字段作用模型能不能看到
nameSkill 标识,必须是 kebab-case(i-have-adhd 合法,IHaveADHD/i_have_adhd 会被拒)能
source来源标记,运行时注册写 runtime能
description路由信息:模型在 Skill 目录里靠它判断「这个 skill 是干嘛的」能
whenToUse补充路由信息:什么时候该用它能
content正文:真正被加载进上下文的指令加载后能

前四个字段决定模型会不会选它,只有 content 决定选上以后干什么。很多 skill 效果不好,问题就出在 description 写得太含糊——模型压根没意识到该调用它。

4.3 SKILL.md 为什么要放在代码外面?

const SKILL_MD = new URL('../skills/i-have-adhd/SKILL.md', import.meta.url)

这行看着普通,但 import.meta.url 是关键。它拿到的是当前模块文件的 URL,再往前拼一级目录找 SKILL.md。这样无论插件是:

  • 本地开发用 link: 挂的,
  • 还是从 GitHub 装进 node_modules 的,

路径都能正确解析——因为它是相对模块自身算的,不是相对 process.cwd()。如果写成 process.cwd(),那插件只有在特定工作目录下启动时才能找到文件,换个目录启动就废了。

把正文外置,换来的是「改文案不用碰代码」:

skills/i-have-adhd/SKILL.md   ← 想调整规则,改这个文件就行
dsh/index.js                  ← 除非改注册逻辑,否则不用动

这也是我把它设计成 skill 而不是硬编码在 JS 里的原因——内容归内容,机制归机制。

图二:Skill 从注册到被模型加载的完整链路

在这里插入图片描述

(图二:插件启动时读入 SKILL.md 并注册到 ctx.skills,Skill 目录里出现摘要供模型路由选择,模型调用 skill 工具后才把 content 正文加载进当前上下文)

五、SKILL.md:效果好坏全看这段文字

机制讲完了,但真正决定效果的是这个文件的内容。它是一段 Markdown,本质是一段会被注入到模型上下文里的指令:

# i-have-adhd — ADHD-friendly output

1. **Lead with the next action.** / **先给下一步动作。**
2. **Number multi-step tasks.** / **多步骤任务要编号。**
3. **End with one concrete next step.** / **结尾只留一个具体的「下一步」。**
...
10. **No preamble. No recap. No closers.** / **不要开场白、不要复盘、不要收尾客套。**

写这类内容有三条经验:

第一,规则要可判定。 「回答简洁一点」是没法执行的;「不要开场白、不要复盘、不要收尾客套」是可以对照检查的。能判定的规则才会被稳定执行。

第二,给反例比给定义有效。 光说「先给答案」,模型可能理解成别的;直接贴一段「改前 vs 改后」的对照,它立刻知道你要什么:

改前:好的,让我想想。你的 verifyToken 函数用的旧版 API……希望这对你有帮助!
改后:1. read src/auth.ts   2. 用 edit 替换 42-58 行   3. pwsh 跑测试
      下一步:把第一个报错贴给我。

第三,加一条底线约束。 风格类 skill 最容易翻车的场景是「为了简短把答案说错了」。所以正文里要显式写一句:简短绝不牺牲正确。

六、Skill 在 AI 大模型开发里的四个落点

这套机制不只是用来管输出风格的。把「注册一段可被按需加载的指令」这个能力抽象出来,在 AI 大模型开发里有四个高频落点。

落点一:统一输出风格(本项目)

这也是最开始的需求——让 Agent 的回复先给结论、编号步骤、不写客套话。它的价值在于把「问法」变成「配置」:不用每次在提问里重复「简洁点」,而是让风格成为会话的默认行为。

落点二:把 SOP / 排查手册变成可加载技能

团队里那些「先查这三件事、再按这个顺序排查」的流程,最适合做成 skill:

# 把排查手册注册成 skill,模型在处理线上问题时按需加载
SOP_ALERT = """
# 线上告警排查 SOP

1. 先看监控大盘的错误率曲线,确认是突增还是缓慢劣化
2. 查最近 30 分钟的发布记录,有发布优先回滚
3. 检查依赖服务的健康检查端点
4. 以上都正常,再查数据库慢查询日志
"""

ctx.skills.register({
    "name": "incident-sop",
    "source": "runtime",
    "description": "线上告警排查流程",           # 路由:模型靠这句判断该不该用
    "when_to_use": "处理线上告警、错误率突增、接口超时时",
    "content": SOP_ALERT,                        # 正文:加载后才进上下文
})

这里体现的是「按需加载」的价值:SOP 有几千字,塞进系统提示词会挤占每一次请求的上下文;做成 skill,只在真排查问题时才被加载。

落点三:注入项目规范,约束生成代码

比如让 Agent 生成的代码自动遵守团队的目录约定和异常处理风格:

PROJECT_RULES = """
# 本项目代码规范

- 所有外部调用必须包重试,用 `tenacity`,重试 3 次、指数退避
- 日志统一用 `logger = logging.getLogger(__name__)`,不写 print
- 配置一律走 `settings.py`,不散落在业务代码里
"""

ctx.skills.register({
    "name": "project-conventions",
    "description": "本项目代码规范:重试策略、日志、配置管理",
    "when_to_use": "在本仓库新增或修改 Python 代码时",
    "content": PROJECT_RULES,
})

落点四:给不同 Agent 挂不同技能

同一套机制,不同预设挂不同 skill,就得到了分工不同的 Agent:写代码的挂「代码规范」,做评审的挂「评审清单」,跑数据的挂「SQL 规范」。这也是 skill 比 persona 更灵活的地方——persona 改的是全局身份,skill 可以按需组合。

图三:Skill、Persona、Tool 三种能力注入的定位差异

在这里插入图片描述

(图三:Persona 常驻在系统提示词里改身份与风格,Skill 按需加载注入任务指令,Tool 提供可调用的动作能力;三者解决的不是同一层问题)

七、最小可运行模板与四个常见改动点

7.1 复制即用的最小模板

把上面讲的东西压到最小,就是一个能跑的 skill 插件。四个文件:

// package.json
{
  "name": "dsh-my-skill",
  "version": "0.1.0",
  "type": "module",
  "main": "./dsh/index.js",
  "exports": { ".": "./dsh/index.js", "./package.json": "./package.json" },
  "files": ["dsh", "skills", "cordis.patch.yml"],
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
# cordis.patch.yml —— name 写包名,不是路径
- insert:
    - id: my-skill
      name: dsh-my-skill
// dsh/index.js
import { readFileSync } from 'node:fs'

export const name = 'dsh-my-skill'
export const inject = ['skills']                       // 声明依赖,不是 import

const SKILL_MD = new URL('../skills/my-skill/SKILL.md', import.meta.url)

export function apply(ctx) {
  let content
  try {
    content = readFileSync(SKILL_MD, 'utf8')           // 读不到就安静退出,别拖垮宿主
  } catch {
    console.warn('[dsh-my-skill] SKILL.md unreadable')
    return
  }

  ctx.effect(
    () => ctx.skills.register({
      name: 'my-skill',                                // kebab-case
      source: 'runtime',
      description: '一句话说清这个 skill 是干嘛的',      // 路由用,别写含糊
      whenToUse: '什么场景该用它',
      content,
    }),
    'dsh-my-skill: skill',
  )
}
<!-- skills/my-skill/SKILL.md —— 这段文字决定最终效果 -->
# my-skill

1. 规则一(要可判定)
2. 规则二
3. 底线:准确优先于简短

装进 profile 并生效:

dsh plugin --profile web add "link:D:/path/to/dsh-my-skill"
# 插件是启动时组装的,改 bundle 层要重启应用

7.2 最常见的四个改动点

想改什么改哪儿注意
改 Skill 文案只动 SKILL.md不用碰任何 JS,重启后生效
改 Skill 名称SKILL.md 所在目录名 + register 里的 name + os 路径三处要一致,且必须是 kebab-case
一个插件塞多个 Skill在 apply 里调多次 ctx.skills.register每个都要有自己的 name,各自套 ctx.effect
控制谁能调用register 时加 invocation 字段{ modelInvocable, userInvocable } 分别控制模型侧和人侧

图四:四个 AI 开发落点与对应改动位置

在这里插入图片描述

(图四:输出风格、SOP 注入、项目规范、多 Agent 分工四个落点,共用同一套注册机制,差异只在 SKILL.md 的内容与 description 的写法)

八、怎么确认 Skill 真的注册上了?

写完别急着高兴,先验证。分两步:

第一步,确认插件进了应用树(不用启动服务):

dsh --profile web --dump-config | Select-String "my-skill"

第二步,重启后确认 Skill 进了目录。 Skill 是启动时注册的,所以必须重启应用;重启后新会话的 Skill 目录里应该能看到它。

如果没出现,按这个顺序排查:

现象大概率原因
dump-config 里就没有cordis.patch.yml 的 name 写成了路径,或没加进 dsh.profile.bundles
进树了但目录里没有files 字段漏了 skills,发布后 SKILL.md 不在包里
目录里有但模型不调用description / whenToUse 写得太含糊,模型没判断出该用它
调用后效果不对直接改 SKILL.md 正文,风格类规则有没有给「改前改后」对照

走到这里,这个知识点就闭环了。回过头看,整套机制的复杂度其实集中在「声明」而不是「实现」:注册只要一次 ctx.skills.register,剩下的都是三个 yml/json 里的声明在起作用。真正需要花心思的是 SKILL.md 里那几十行文字——插件负责让它「能被加载」,文案负责让它「加载了有用」。


#DSH #Agent开发 #Skill #插件开发 #提示词工程

更多推荐