ByteNoteByteNote
DeepSeek Harness 预设组合机制详解
字

字节笔记本

2026年10月6日 · 约 14 分钟读完

DeepSeek Harness 预设组合机制详解

API中转
¥120

在 DeepSeek Harness 里,一个 agent 的全部能力都来自一份 cordis.yml:每一行插件就是一项能力,没有第二种配置语言。想让某个 agent 多一个工具、换一种压缩策略,本质不是写新代码,而是改变为它组合了哪些行。这套机制把 agent 定制变成了一门组合学,而组合学有自己的红线与套路。本文基于 deepseek-harness 的 Cordis 组合系统,把预设(agent preset)的编写规则完整梳理一遍。

Cordis 双平面架构:宿主组合每进程一份,会话预设每会话一份

先划清两条红线

第一条红线:随部署发行的预设不能改。部署自带 standard、code、minimal、cordis 这几个预设,它们位于部署自身配置旁边的 agent-presets 目录。无论改动看起来多省事,都不要编辑、删除或覆盖它们,更不要为此把沙箱权限提上去。原因很直接:一次升级就会覆盖这套安装,而损坏 cordis 预设会直接废掉预设编写能力本身。正确的姿势是读可以、写不行:读一份官方组合是最推荐的起步方式,写回去则完全禁止,为了绕开预设限制而改宿主组合同样被禁止。

想改变官方预设的行为,复制一份副本,改副本。用户自己编写的预设放在 ${DSH_HOME:-$HOME/.dsh}/.agent-presets/ 之下,每个预设一个目录,创建、编辑、删除随意。

两个平面:先判断共享,再决定去处

第二个决策点是把一行插件放进宿主组合,还是放进 agent 预设。判断标准不是它看起来像不像 agent 相关,而是它是否必须被共享。

宿主组合承载注册表本身,也就是 tools、systemPrompt、agents、agent-loop、sessions 这些注册表;一切跨会话的东西:持久化、会话查询、存储、设置、凭据、遥测;再加上沙箱与审批栈、模型路由,以及带 spawn 和 fork 后端的子代理注册表。它们每个进程只有一份。

agent 预设则是一个会话对上述注册表的贡献:它的工具插件、人设与提示词分段、压缩策略。每个会话一份实例,挂在该会话的 scope 之下,随会话结束而卸载。

这里有一条最容易被忽视的判据:凡是在会话平面之外存在消费者的服务,都不能搬进预设。文档里的典型反例是 subagents:子代理注册表要应答宿主 api-proxy 发起的跨会话查询,若把它做成每会话一份,一方面宿主那一行会永远等待一个无人提供的服务,另一方面第二个会话挂载时,同一个 provider 名字会二次注册直接冲突。正确做法是:预设只贡献委派工具,注册表和它的后端留在宿主侧。

一个预设就是一个目录,里面放一份 agent.cordis.yml,旁边可选放一份 preset.yml 承载展示元数据:名字与描述,官方预设另有 roster order 排序字段。元数据一定要写,否则预设会以裸目录名出现在每一个选择器里。

roster 服务:读、复制、验证都走它

ctx.agentPresets 服务负责预设的发现、编写与挂载。要用它,先挂一个临时插件:插件注入该服务,同时给你自己注册一个工具。因为 cordis_mount 只返回挂载确认,注册出来的工具才是服务应答回到你手里的通道,下一步即可调用。

动手写代码之前,先读一次签名:

cordis_inspect what:"api" name:"agentPresets"

这套流程依赖四个接口:

  • list():返回全部预设,含 id、trust(官方为 system,自建为 user)与组合文件的绝对路径。不知道安装布局时,靠它定位任何一份组合,路径的父目录就是预设目录。
  • read(id):直接读某个预设的组合文本,不需要文件工具,也不需要路径。
  • copy(from, id, name?):唯一的编写类写入操作,下文详述。
  • standingKeyFor(id):挂载校验一个预设,后文详述。

用完记得用 cordis_unmount 卸掉探针插件:它是探针,不是留给运行时的能力。

编写五步:从副本开始

官方推荐的流程是五步。

第一步,从副本开始。copy(from, id, name) 会把整个预设目录复制进用户根目录:组合文件、元数据、skill 目录、资源一并带走。它校验 id 必须匹配 [a-z0-9][a-z0-9-]*(id 会成为目录名,所以不允许连字符开头),拒绝任何根目录已占用的 id,复制失败整体回滚,并把副本的 preset.yml 重写为保留源描述、去掉名字与排序。相比 shell 复制,它不需要提权沙箱,副本落在本部署可写的根目录里,副本的可加载性与源完全一致。复制完成后用 resolve(id) 拿到实际生成的文件路径,后续编辑都以这个路径为准,不要靠猜。通常从 standard 复制,它是完整的编码 agent。

第二步,预期副本之后的每次编辑都会撞上文件沙箱。用户预设根目录在会话工作区之外,默认 workspace-write 策略下,第一次写入会被拒绝。注意受限的只有写入:按绝对路径读取任何组合文件都不需要提权。被拒之后,对同一条命令附带 sandbox_permissions 提权重试一次,写上简短理由,用户会看到并批准。建议把写入攒成批,一个文件一条 heredoc,避免为几十条小命令反复提权。copy 本身在宿主侧执行,这些统统不需要。

第三步,补写副本 preset.yml 的 description,如果复制时没有传 name,名字也一并补上。

第四步,逐行编辑 agent.cordis.yml,守住平面规则和下文的 isolate 规则。

第五步,挂载验证结果,然后把真实会话交给用户确认。

从零手写一份组合,最常见的翻车是漏掉组 realm 或漏掉消费者行;从副本起步,起点就是一份可加载的组合。

最容易踩的规则:服务不能裸放在预设里

一行插件如果发布服务,就不允许松散地摆在预设里。没有 isolate realm 的服务注册会落进进程全局 realm,第二个挂载该预设的会话必然与第一个冲突,挂载会直接拒绝,而不是等冲突在运行期爆发。

某一行是否发布服务,从行名看不出来,安装后的部署里也没有包的 README 可查。办法是问运行时:cordis_inspect what:"services" 列出每一个服务与持有它的 fiber,若某服务归属的 fiber 不是你正在加的这一行,说明这行是消费者而非提供者。对不在当前组合里的行,可以先做挂载校验,拒绝信息会点名问题服务。

从复制副本到挂载验证:五步流水线与 standingKeyFor 的四种拒绝

当预设确实拥有一个服务时,把提供者与所有能触达它的消费者包进同一个携带 isolate realm 的组。官方 standard 组合对 workflows 就是这么处理的,因为工作流服务不会被任何 agent 之外的东西读取:

yaml
- id: delegation
  name: cordis:group
  group: true
  isolate:
    workflows: true
  config:
    - id: workflow-worker-thread
      name: "@deepseek-ai/dsh-workflow-worker-thread"
      config:
        provider: spawn
    - id: tool-workflow
      name: "@deepseek-ai/dsh-tool-workflow"

isolate 里的 true 表示每个挂载会话各自一份私有 realm。换成字符串标签,则是把多个子树并进同一个共享 realm,但 provide() 在同一符号下第二次注册仍然抛错,所以标签并不能汇聚实例,也不是预设想要的语义。

反过来,落在组外的消费者会解析到宿主的注册表,而宿主注册表并没有被这个预设填充,于是这一行什么也没贡献。挂载校验会把这种情况识别为一行从未激活。

还要注意 isolate 不是万能装饰:它只服务于预设自己拥有的服务。预设只是消费的宿主能力必须留在 realm 之外,否则那一行解析不到宿主实例。tool-bash、tool-jobs、tool-goal 这几行不发布任何服务,在 standard 里就是松放的,注释写明了各自解析哪个宿主实例、为什么加 realm 反而会坏。给消费者单独包一个 realm,与让它落在提供者的 realm 之外,是同一类错误。

验证:standingKeyFor 是唯一的关卡

standingKeyFor(id) 会真实地组合预设的插件子树,等价于一次会话启动执行的挂载,只是不带 agent。它能拦下四种失败:

  • 包无法解析:Cannot find package ...
  • 配置非法:invalid config: $.<field> missing required value
  • 行从未激活:N row(s) did not activate: <id>: waiting for <service>
  • 服务发布进了根 realm,又分两种报错。宿主不提供这个名字时,落进根 realm 会被挂载审计拒绝:row(s) published process-global service(s) [<name>]; a preset service must sit behind an isolate realm or move to the host composition,预设自己忘了加 realm 就是这个形态;宿主已提供同名服务时,在审计之前就冲突:service "<name>" has been registered at <Owner>。两种报错都会点名问题服务。

组合能正常挂载时,standingKeyFor 正常返回。它应该作为成品编辑的最终检查,而不是每改一行跑一次:成功的挂载会安装一个存续到进程退出的 standing generation,失败的挂载则丢弃整个子树,什么都不留下。

另一个坑:不要把 list() 返回的 broken 字段当验证。broken 来自形状检查,即文件能被加载器的 YAML 方言解析、含有具名行,上文四种失败全部能通过这个检查。它只能发现文件坏了,不能发现组合不可用。

cordis_inspect 报告的是当前这个会话的组合:它确认某一行在你所处的运行时里的行为,永远不会是你那个新预设的行为。挂载验证干净通过之后,请用户在新预设上开一个真实会话并确认工具列表:预设决定工具 schema 与提示词分段,只有真实会话能展示它组合出的 agent。

最后一条边界:cordis_mount 对活运行时求值 JavaScript,重启即消失。它用于探测,不用于交付能力,能力应该写进组合文件。

原生子代理与不该搬家的东西

Codex 与 Claude Code 两个 provider 已经住在宿主组合里。预设通过贡献一条与 spawn、fork 同款的普通委派工具行来选择产品;不要把产品 provider 搬进预设,也不要新增产品专用的设置字段。做法是从官方完整预设复制禁用模板,只对用户要求的产品去掉 disabled:

yaml
- id: tool-subagent-codex
  name: "@deepseek-ai/dsh-tool-subagent"
  disabled: true
  config:
    provider: codex
    toolName: subagent_codex
    enableRunInBackground: false
    maxDepth: provider-managed

- id: tool-subagent-claude-code
  name: "@deepseek-ai/dsh-tool-subagent"
  disabled: true
  config:
    provider: claude-code
    toolName: subagent_claude_code
    enableRunInBackground: false
    maxDepth: provider-managed

两行彼此独立:都保持禁用,预设维持复制来的原样;启用一行,只暴露该产品的工具;两行都启用,两个产品都暴露。前提是宿主 PATH 里已有 codex 或 claude 可执行文件;预设不负责安装、认证、选模型,也不会去探测产品是否可用。

最后是搬家黑名单:agent-loop 注册唯一的 agent 工厂,第二次注册直接抛错;各注册表自己承担会话内分层,不可能再做成每会话一份;会话持久化必须留在宿主侧,否则会话列表会碎片化;沙箱、审批与权限行是一道刻意设计的边界:预设的特权恰好等于它点名的插件,允许预设放松自身约束,约束就失效了。看清这些边界,预设编写就从碰运气变成了照图施工。

相关文章

分享: