
字节笔记本
2026年10月6日 · 约 9 分钟读完
技能不是事件:DeepSeek Harness 的分层注册表
DeepSeek 在 GitHub 上开源的 agent 工程框架 deepseek-harness(MIT 协议)里,技能(skill)能力族由四个包组成:dsh-skill 提供 ctx.skills 注册表服务,dsh-skill-filesystem 负责本地目录发现,dsh-skill-badge 提供可选的随包技能,dsh-tool-skill 则把面向模型的 skill 工具接进会话。官方文档的子系统参考为每个子系统留了一页,skills 这一页把这套机制的词汇与接线讲得相当细,本文把它整理成一篇导读。

技能是可选的指令,不是会话事件
harness 里的多数能力会以会话事件的形式留痕,skill 却被有意设计成可选的指令:它的词汇定义放在子系统文档而非核心文档里。ctx.skills 组合本地、内嵌、远程等各类提供方,注册是同步完成的,远程初始化、鉴权与发现都发生在 list() 的 await 阶段。提供方对象、选项与候选项全程以只读方式借用,语义字段会被校验,格式错误的候选项直接快速失败。
宿主加分层:重名由更近的层裁决
注册表沿用工具注册表确立的宿主加按 scope 分层的形态:注册落入调用方上下文 scope 对应的层,宿主行与 repository 插件进入全局层,由 agent preset 常驻组合挂载的插件进入该 preset 的层。提供方名称只在每层内要求唯一,而不是进程级唯一。
读取时把全局层与观察 scope 的链合并:最近层的条目直接赢得重名技能,rank、提供方顺序与本地顺序只在单层内裁决重名,摘要按名称排序输出。发现缓存以解析后的 scope 链为键,因此重设 scope 父级之后,下一次读取无需注册表变更即可看到新结果。
容错同样成体系:某个提供方的 list() 被拒绝时记入日志,并从这次观测中整体省略;显式的不完整观测仍会给出可用候选项,但结果不会写入缓存。若提供方代次在发现期间变化,该次发现重试一次;再次变化就返回最新候选项,并把结果标为不完整、不予缓存。提供方与运行时的变更会发出不带过滤条件的 skills/change 事件,它不携带 diff,消费方要按自己的查找选项重新获取快照。
六档 rank:本地目录的发现优先级
随附的本地提供方按 rank 从小到大扫描六个根目录:
| Rank | 来源 | 根目录 |
|---|---|---|
| 100 | project-dsh | <projectRoot>/.dsh/skills |
| 200 | project-agents | <projectRoot>/.agents/skills |
| 300 | custom | Config.customSkillDirs |
| 400 | user-dsh | <dshHome>/skills |
| 500 | user-agents | <agentsHome>/skills |
| 600 | bundled | Config.bundledSkillDir(配置了才启用) |
项目根取包含 .git 的最近祖先目录,找不到时退回当前工作目录;ctx.fs 可用时,向上探测 .git 走文件系统服务完成,远程或沙箱工作区因此不会越界读到宿主文件系统。用户级 dsh 根会跳过 .system 子目录;本地提供方不合成内置系统技能,随包技能由部署方通过 bundled 根目录或专用提供方给出。dsh-skill-badge 在固定 rank 上注册一个不可变的 bundled 候选项,交付的 CLI 默认禁用该插件,启用其组合配置行即属于显式选择加入。

目录监视交给 Chokidar:跟踪直属 bundle 与平铺条目的增删,以及直属技能条目的变更;缺失的根目录从最近的现有祖先开始,逐段补建监听直到可以挂载。模型执行 write、edit 时,目标路径只要与技能目录相关,就同步使提供方目录失效;宿主 watcher 则兜住 IDE、Git、shell 与外部进程造成的变更。watcher 失效只让当前观测不完整,不会在直接加载时隐藏可读的候选项,项目作用域的 watcher 由按配置设限的 LRU 管理。
名字、摘要与调用策略
技能名必须是 kebab-case,匹配 ^[a-z0-9]+(?:-[a-z0-9]+)*$。本地提供方接受目录包 <name>/SKILL.md 与扁平的 <name>.md 两种形态,嵌套递归的 **/SKILL.md 发现不受支持。
注册表对外暴露的是与调用策略无关的摘要:名称、路由描述、可选的 whenToUse,以及来源与提供方标签。模型会话目录只取可调用技能的 name 与 description,从不携带正文或绝对文件路径。调用控制被规范化为两个正向布尔值:modelInvocable 决定是否进入面向模型的目录与加载器,userInvocable 决定是否进入面向人的命令目录,四种组合全部保留。本地提供方读取 frontmatter 里名称完全匹配的 disable-model-invocation 与 user-invocable 两个键,省略的字段默认为 true;两者皆 false 时,技能只能由受信的 ctx.skills.get() 调用方获取。
快照带 complete 标志:只有每个已注册提供方都在无并发目录修订时完成发现,它才为 true;不完整快照不缓存,消费方可以保留上一份可用目录,并在下一个请求边界重试。注册表不缓存完整定义,每次 get() 都让胜出提供方重新读盘;返回的定义与候选项名称不再匹配时会被拒绝,并使该提供方实例失效以便重新发现。运行时注册走 ctx.skills.register(),省略的调用控制与提供方标签补默认值,同层同名先到先得,返回的 disposer 移除贡献并使发现缓存失效。
会话目录注入与 skill 工具
dsh-tool-skill 在会话存活期间第一次观察到非空完整视图时,于 agent/pre-step 注入一条持久的 user-role system-reminder。目录里只有排序后的技能 name 与经 XML 转义的 description,长度上限由 catalogDescriptionMaxLength 控制,默认 500,整数最小值 3。
此后每个模型步骤前,消费方对完整快照中 <available_skills> 标签间的条目计算 digest,与上一条仍可见的目录消息比较;digest 变化就通过 agent.inject() 追加一条完整的目录替换,技能删光则追加显式的空替换。不完整快照保留上一份可用的模型视图;压缩把历史目录消息全部隐藏后,下一份完整快照会重新建立目录;视图为空且从未发布过目录时什么都不发。这些目录消息属于会话历史,不进入 World State。
模型侧的 skill({ name }) 工具先校验 kebab-case 名称,再在与调用策略无关的目录中查找摘要,加载前用 isModelInvocable 拒绝无权访问的技能;随后按调用方 agent 的 cwd 重读完整定义,返回内容前再查一次策略。无法解析的技能会被报告为未知或已不可用,成功加载的工具结果包含 skill_content、skill_resources 与 skill_instructions 三段;resourceBase 只按需解析正文显式引用的脚本、参考资料与资产,不会枚举技能目录。因此仅修改正文只会改变后续工具调用,不会生成目录消息,也不会改写先前的工具结果。
给技能机制设计者的启示
把发现、合并、调用策略与会话呈现拆开,是这套设计最值得借鉴的地方:注册表只管分层合并与失效,提供方只管目录与正文,消费方自行决定渲染什么。重名就近裁决的分层规则、不完整观测不缓存、目录 digest 增量替换、resourceBase 按需解析,这四条工程决策都可以直接搬到自研 agent 的技能机制里。配置面也很克制:注册表只拥有发现缓存上限 collectCacheMaxEntries 一项,文件系统根目录、watcher 行为与目录描述上限各归其主,确切的默认值与校验规则由自动生成的配置目录承载。

