deepseekHarness插件:如何给 Agent 加自定义 Skill?用 DSH 插件从零实现
如何给 Agent 加自定义 Skill?用 DSH 插件从零实现
关键词:DSH 插件、Agent Skill、ctx.skills 注册、Cordis 依赖注入、提示词工程
目录
- 一、成品长什么样:4 个文件,39 行代码
- 二、package.json:宿主怎么认出这是一个插件
- 三、cordis.patch.yml:把插件挂进应用树
- 四、dsh/index.js:注册 Skill 只用了 39 行里的 13 行
- 五、SKILL.md:效果好坏全看这段文字
- 六、Skill 在 AI 大模型开发里的四个落点
- 七、最小可运行模板与四个常见改动点
- 八、怎么确认 Skill 真的注册上了?
一、成品长什么样: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 是最终注入给模型的内容)
为什么做成插件,而不是配置文件?
这是第一个要想清楚的问题。既然是「一段文本」,为什么不直接写进配置里?原因有三个:
- 内容是动态的——Skill 正文放在独立文件里,插件启动时读进来,改文本不用改代码;
- 可以被分发——打包成 npm 包,别人一条命令就能装(
dsh plugin add github:owner/repo); - 不污染全局——它是一个可插拔的层,卸载就是删掉一行,不残留任何状态。
二、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,而是依赖声明。它有两个作用:
- 保证加载顺序——宿主会先激活提供
skills服务的插件,再激活你; - 保证可用性——服务没就绪时,你的插件不会被激活,不会出现
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 的字段,这几个字段的作用完全不同,别写混:
| 字段 | 作用 | 模型能不能看到 |
|---|---|---|
name | Skill 标识,必须是 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 #插件开发 #提示词工程
更多推荐


所有评论(0)