前一篇讨论 Cordis 插件架构「解读 Cordis:dsh 一切皆插件背后的运行时设计」时,我们看到 dsh 把工具、模型适配器、会话、Agent Loop 等运行时能力拆成了插件。这样一来,运行时中的各个组件可以独立地完成注册、替换和退出。
但组件拆开了,得解决外层的组装问题: 一次启动到底要加载哪些插件,以及按照什么顺序把它们组合起来?
Web 模式要用到 Web Server、前端静态资源、API Gateway 和浏览器侧插件;如果以 Headless 模式执行单次任务,则不需要加载这些 Web 相关组件。如果以后加入 TUI、不同 Provider 套件,或者针对特定开发环境的一组插件,也需要分别描述各自启动时要加载哪些组件。
dsh 把这一层交给了 Profile 和 Bundle。
DeepSeek Harness 的架构设计中,一套运行中的 dsh 是由多个有序配置层在启动阶段组合出来的 Cordis 插件树。 Bundle 向这棵树提供配置层,Profile 决定选择哪些 Bundle,以及它们按照什么顺序进入本次运行。
本文基于
@deepseek-ai/dsh 0.1.0-rc.7,对应仓库标签
dsh-v0.1.0-rc.7。
先看 dsh 自带的两种运行方式。
在
packages/boot/app-boot/src/profile.ts 中的
PROFILE_TEMPLATES 定义了两个内置 Profile 模板:
web: [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app' ] headless: [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless' ]
Web 和 Headless 都从
dsh-base 开始。rc.7 的
packages/bundle/base/cordis.patch.yml 中有 78 个 entry,覆盖 LLM、Agent、Session、持久化、Sandbox、权限、工具、Goal、Plan、Compaction、Skill、Subagent、Workflow 等核心能力。这里的 entry 是配置树中的一个节点,用
id 标识,并记录要加载的插件、配置和启停状态。
上面提到的核心能力,大致可以分为下面几种基础功能:
dsh-base ├── LLM、Agent、Session # 模型调用与会话执行 ├── Persistence、Projection # 状态持久化与数据投影 ├── Tools、Commands # 工具与命令执行 ├── Sandbox、Permission # 执行隔离与权限控制 ├── Goal、Plan、Compaction # 目标规划与上下文压缩 ├── Skill、Subagent、Workflow # 能力扩展与任务编排 └── Settings、Credentials、Telemetry # 配置、凭证与运行观测
到了 Web 模式,会再叠加
dsh-web-app:
空配置树 ↓ dsh-base ↓ dsh-web-app ↓ Web Runtime
dsh-web-app 会加入
web-startup、
webserver、
web-runtime、
api-gateway、
workspace、
storage 等 Web 相关的插件条目,以及浏览器侧需要的一组插件。
前端静态资源也在这一层接入,不过它没有单独占一个 YAML entry。因为
packages/bundle/web-app/src/index.ts 文件中的
web-runtime 插件会负责解析并挂载这些静态资源。
Headless 模式则使用另一套上层组合:
空配置树 ↓ dsh-base ↓ dsh-headless ↓ Headless Runtime
dsh-headless 不会加入 Host、HTTP Server 和浏览器插件等 Web 模式需要的插件。它只覆盖少量的运行参数,再加入
code-runtime、
headless-startup 和
headless-runner 等 entry,完成 Agent 创建、任务执行、结果输出后退出。
Web 和 Headless 这两种运行形态会共享大部分的 Agent 核心能力,二者的差异主要集中在上层配置。
也就是,Profile 和 Bundle 在的那一层。
Profile 和 Bundle 分别负责两件事。 Bundle 负责描述一个 npm 包会向配置树贡献哪些配置和插件条目。
一个 Bundle 会在自己的
package.json 中声明:
{
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
}
}
}
这里的
dsh.bundle.patch 指向当前 Bundle 提供的
cordis.patch.yml。Profile 加载这个 Bundle 时,会根据该字段定位并读取 patch 文件,将其解析成 Cordis Loader 可应用的 patch list,再把其中定义的 entry 和配置加入配置树。
对实现感兴趣的小伙伴,可以阅读
packages/boot/app-boot/src/profile.ts 中
loadProfile()的相关逻辑。
一个最小的 Bundle 可以只提供:
- insert: - id: hello name: dsh-hello-plugin
当这个 Bundle 被 Profile 选中后,
hello entry 就进入最终配置树。
Bundle 本身没有额外的运行时协议,它主要通过 patch 描述普通 Cordis 插件和配置如何进入运行时,同时也可以随包携带具体的插件实现。
例如,
dsh-base 的运行时模块为空,主体内容是一份大型
cordis.patch.yml;
dsh-web-app 还包含 Web runtime glue;
dsh-headless 中则带有一次性 runner。
因此,Bundle 更像一个 可分发的运行时配置单元:它可以新增 entry,也可以修改前面配置层中的 entry,还可以把包内的插件实现一并加入当前运行时组合。
Profile 负责定义一次启动采用哪些 Bundle,以及它们的加载顺序。每个 Profile 都对应一个目录:
$DSH_HOME/profiles/<name>/
这个目录下的
package.json 会保存有序的 Bundle 列表:
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app"
]
}
}
}
Profile 目录还可以保存第三方依赖,以及当前 Profile 自己的
cordis.patch.yml。用户可以通过
dsh plugin 初始化和维护 Profile、安装扩展依赖,并更新
dsh.profile.bundles。
这里要区分两个层次:模板和实际配置。代码中的
PROFILE_TEMPLATES 定义了 Web 和 Headless 的内置默认组合;进入加载流程后,
loadProfile() 会读取当前 Profile 的
dsh.profile.bundles,再按照列表中的顺序解析对应的 Bundle。
因此,Bundle 和 Profile 的关系如下:
Bundle 提供可分发的配置层 ↓ Profile 选择并排列配置层 ↓ Runtime 得到最终 Cordis 插件树
需要注意的是,Bundle 和 Profile 并非一对一的关系。同一个 Bundle 可以被多个 Profile 采用,一个 Profile 也可以组合多个 Bundle。
Profile 和 Bundle 能够组装成一套运行时,关键在于这些配置层会按照明确的顺序依次叠加。dsh 启动一个 Profile 时,会从一棵空配置树开始,逐层应用:
空配置树 ↓ Bundle 1 ↓ Bundle 2 ↓ Bundle 3 ↓ Profile Patch ↓ Home Patch ↓ CLI Patch ↓ 启动期 Patch ↓ 最终运行时树
多个 Bundle 会按照
dsh.profile.bundles 中声明的顺序依次进入配置树。Bundle 提供随 npm 包分发的默认配置,Profile 自己的
cordis.patch.yml 用来调整当前运行形态,
$DSH_HOME/cordis.patch.yml 负责修改整个 Harness Home 的共享配置,命令行传入的
--patch 则用来给本次启动追加临时覆盖。如果一次传入多个
--patch,也会按照它们在命令行中的出现顺序依次应用。

图注:从公共基础到单次启动:作用范围逐层收窄,配置优先级逐层提高
这些公共配置层应用完成后,启动器还可以根据当前安装或运行环境追加少量的启动期配置。因此,Profile 和 Bundle 负责定义运行时的主体结构,Profile、Harness Home、CLI 和启动期配置则提供了更细粒度的调整位置。这样一来,公共能力可以放在前面的基础层复用,不同运行形态和运行环境只需要补充各自需要的差异配置。
多层配置要能够稳定叠加,还需要一套明确的覆盖规则。dsh 会先根据 entry 的
id 找到对应条目,再用当前 patch 中声明的字段去更新它。假设前一层的配置是:
- id: service-a name: dsh-service-a config: mode: basic timeout: 30
后面的 patch 提供:
- id: service-a config: mode: web
最终
name 仍然会保留,因为后一层没有修改这个字段。但是原来的配置:
config: mode: basic timeout: 30
会变成:
config: mode: web
timeout 不会保留。这套覆盖规则可以概括为:
entry 采用顶层字段浅覆盖;后一层一旦提供新的
config**,整个**
config
字段就会被替换,内部字段不会再合并。
这套覆盖规则同样适用于 dsh 自带的 Bundle。例如,
dsh-base 中的
system-prompt 是:
- id: system-prompt name: '@deepseek-ai/dsh-system-prompt' config: persona: ''
dsh-web-app 和
dsh-headless 都会按照相同的
id 提供新的完整
config,将
persona 调整为各自运行形态需要的配置。
hmr 的情况则不同。基础层中已经定义了它的插件名称和配置,Web 和 Headless 只会追加:
- id: hmr disabled: true
这时,原有的
name 和
config 都会继续保留,后面的配置层只把
disabled 改为
true。
把
system-prompt 和
hmr 两个例子放在一起看,dsh 的配置组合方式就比较清楚了:
后面的配置层会通过相同的 entry id 定位已有条目,再只修改自己需要调整的字段。这样一来,Web、Headless 或其他运行形态都不需要复制整套基础配置,只需要声明相对于
dsh-base 的差异。entry 的
id 也因此成为跨配置层识别同一运行组件的关键标识。
Profile 还有一个关键设计:Bundle 列表是
显式声明并保持顺序的。启动时,
loadProfile() 只会读取:
dsh.profile.bundles
只有出现在这个列表中的 Bundle,才会向配置树贡献内容,加载顺序也由列表中的排列决定。dsh 不会扫描整个依赖树,把所有声明了 Bundle 的包自动加入运行时;Bundle 的传递依赖也不会因此成为新的配置层。
dsh plugin 安装扩展时遵循同样的规则:它只检查 Profile 中声明的直接依赖,并将符合 Bundle 条件的包加入
dsh.profile.bundles。因此,一套 Profile 的主体结构可以明确写成:
Bundle A ↓ Bundle B ↓ Bundle C ↓ Profile Patch ↓ Home Patch ↓ CLI Patch
这里要区分一个事情,一个包是否作为依赖存在,和它是否参与运行时组装,是两回事。如果依靠扫描依赖树自动发现 Bundle,依赖关系一旦变化,运行时结构也可能跟着变化。显式记录 Bundle 列表后,本次运行会加载哪些配置层、按照什么顺序叠加,都能从 Profile 中清楚看到,运行结果也更容易复现和排查。
确定要加载哪些 Bundle 之后,还需要解决另一个问题:这些 Bundle 应该从哪里解析。dsh 会按照固定的顺序查找:
当前 dsh 安装 ↓ Profile 目录
dsh-base、
dsh-web-app、
dsh-headless 等内置 Bundle,会优先从当前 dsh 安装中解析;Profile 自己安装的第三方 Bundle,则从 Profile 的依赖空间中加载。
这条规则进一步明确了 Bundle 的来源。Profile 不只记录需要加载哪些 Bundle,还需要确定这些名称最终对应哪一份安装。内置 Bundle 跟随当前 dsh 安装,第三方扩展保留在各自 Profile 的依赖空间中,也能减少不同来源的包在解析时发生混淆。
当运行时包含几十个插件条目、多层 Bundle 和多级 patch 后,只看单独一份
package.json 或
cordis.patch.yml,很难还原当前 Profile 最终的组合结果。所以,dsh 提供了一份不启动完整运行时的配置组合结果。运行下面命令:
dsh --profile web --dump-config
--dump-config 会按照 Bundle、Profile Patch、Home Patch 和 CLI Patch 的顺序组合公共配置层,并输出最终结果。开发者可以借此检查当前 Profile 包含哪些插件条目、某个 entry 最后保留了哪些配置、后续配置层修改了哪些内容,以及不同 Profile 的主体结构有什么差异。
这里有个注意事项。dsh 会在启动前通过
composeEntries() 组合 entry,用来检查配置和建立索引;真正挂载插件树时,则由 Cordis Include / Loader 按照相同的 patch 规则完成。
--dump-config 也复用了这套覆盖语义,但不会执行完整的启动流程。
因此,它展示的主要是:
Bundles + Profile Patch + Home Patch + CLI Patch ↓ 组合 ↓ 公共配置树
真正启动时,启动器还可能根据当前安装或进程环境追加少量的启动期配置。因此,
--dump-config 更适合用来查看
Profile / Bundle 等公共配置层的组合结果,不能完全代表当前进程的最终运行状态。
把这些机制放在一起,dsh 的运行时扩展可以整理成三个层次:
组件能力 ↓ Plugin 可分发的配置组合 ↓ Bundle 一次启动采用的有序组合 ↓ Profile
Plugin 提供具体的运行时能力,例如模型适配器、工具、Session、Sandbox 和 Agent Loop。Bundle 将一组 entry 和配置组织成可分发的单元,既可以加入新的插件条目,也可以修改或关闭前面配置层中的条目。Profile 则负责选择当前运行形态需要的 Bundle,并确定它们的加载顺序。
在 Profile 之上,Home Patch、CLI Patch 和少量的启动期配置还可以对环境级和单次启动的配置做进一步调整。因此,一次 dsh 启动可以整理成:
Plugin 提供单项能力 ↓ Bundle 组织可分发的配置层 ↓ Profile 确定运行时主体组合 ↓ Home / CLI / Startup Patch 补充环境与调用期配置 ↓ Cordis 挂载最终插件树
前一篇讲 Cordis 时,我们关注的是插件如何进入运行时,以及生命周期、依赖和副作用怎样被管理。到了 Profile 和 Bundle 这一层,问题向外推进了一步: 面对越来越多可替换的插件,一次运行应该选择哪些组件,又怎样把它们组合成一种具体的运行形态。
Web 和 Headless 就是两个现成的例子。它们共享
dsh-base 提供的大部分核心能力,再通过不同的 Bundle 补充各自需要的上层组件和配置差异。以后增加 TUI、Provider Pack 或其他运行形态,也可以沿用同一套组装方式。
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70050060/viewspace-3180677/ ,如需转载,请注明出处,否则将追究法律责任。