Profile 与 Bundles:用分层 patch 叠出插件树

Cordis 把产品拆成可挂载、可卸载的插件之后,下一个问题是启动时到底挂哪些、按什么顺序挂。Web、Headless、SDK 共用模型适配、工具、日志、沙箱策略,入口和界面却不同。若每份应用各写一份插件清单,共享能力一升级就要改三处。dsh 用 Profile(配置档案)和 Bundle(组合包)做分层叠加:Bundle 打包一组 Cordis 配置行,Profile 按顺序叠若干 Bundle,再叠自己的 patch。

同一套 dsh 代码要同时支撑浏览器持续对话、无界面脚本、以及 JSON-RPC SDK 时,共享的是模型与工具主干,差异在入口与界面。分层叠加的目标,就是让共享部分只维护一份 Bundle,场景差异落在 Profile 与上层 patch。

Bundle:可分发的配置行包

Bundle 是普通 npm 包,在 package.json 里多一个 dsh 字段声明自己:

1
2
3
4
5
6
{
"name": "@deepseek-ai/dsh-base",
"dsh": {
"bundle": "./cordis.patch.yml"
}
}

dsh.bundle 指向 patch 文件,里面是要往插件树插入的配置行。dsh-base 会插入模型适配器、工具注册表、会话日志、沙箱策略、凭据管理等基础能力;dsh-web-app 一类 Bundle 则补浏览器界面相关条目。关键特性:插入的行仍可被上层 patch 按 id 覆盖。你可以叠上 dsh-base,再用自己的 patch 改其中某一行的 config,不必 fork 整个 Bundle 仓库。

Profile:点名叠哪些 Bundle

Profile 存放在 Harness home 下,路径是 $DSH_HOME/profiles/<name>。它也是一个包,用 dsh.profile 声明叠了哪些 Bundle、以及 patch 重载策略:

1
2
3
4
5
6
7
8
9
10
11
12
{
"name": "my-web-profile",
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app"
],
"patchReload": "live"
}
}
}

上面这份的意思是:先叠 dsh-base,再叠 dsh-web-apppatchReload: "live" 表示用户改 patch 时配置实时重载。目录里还有用户自己的 cordis.patch.yml(本地覆盖),以及空根 cordis.yml(各层都叠在这个空列表上)。随产品附带的 webheadlesssdksdk-minimalacp 是保留名,自定义 Profile 不要占用这些名字。

想从模板起步时:

1
dsh --profile my-web --from-default-profile web

会把附带的 web 模板复制到 $DSH_HOME/profiles/my-web,带上 Bundle 列表和 reload 策略,之后改自己的 patch 不影响官方模板。

叠层顺序:空列表到最终树

启动时各层 patch 依次应用到一个空条目列表:各 Bundle → Profile 自己的 cordis.patch.yml → home 级 cordis.patch.yml--patch 命令行覆盖。越靠后的层优先级越高。

Profile 分层叠加

每一层都是按 id 定位:可以整行替换某条配置,也可以插入新行。用户改默认模型提供方、运维临时用命令行盖住某条,都走同一套机制。CLI 提供预览:

1
dsh --profile web --dump-config

输出是可加载的 YAML,列出最终会挂载的条目,并用注释标明每条来自哪个源文件、哪一层 patch;!!js 表达式原样保留、不求值。若用户 patch 覆盖了 Bundle 里 llm 那一行的 defaultProvider,在 dump 结果里能直接看到后写覆盖前写。

composeProfile 与应用顺序

当你跑 dsh --profile web 时,CLI 进入 app-bootprepareProfile 加载 Profile 与 Bundles 列表,逐层读 patch,组合成 patch 栈,再交给 Cordis Loader 应用到空根配置。apps/cli/src/profile-boot.tscomposeProfile 负责收集:每个 Bundle 的 patches、Profile 自己的 patches、home 级、以及 --patch overlays。allPatches 规定最终顺序:

1
2
3
4
5
6
7
8
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [
...composed.bundlePatches,
...composed.profile.patches,
...composed.homePatches,
...composed.overlays,
]
}

数组越靠后越后应用,因此覆盖关系清晰,排查「到底是谁改了 llm」时按这个顺序从后往前看即可。

patch 按整行替换

新手常把 patch 当成深度合并(只改写出的字段)。dsh 的行为是整体替换:你只写

1
2
3
- id: llm
config:
defaultProvider: deepseek

会把 llm 那一行的整个 config 换掉;Bundle 里原来的 timeout 等字段会消失。要保留的字段必须在 patch 里重述,例如把 timeout: 30000 一并抄过来。这是踩坑率最高的一点;改完务必再跑一遍 --dump-config 核对完整 config,不要只看自己改过的那一个键。

两种 reload 策略

策略 行为 常见场景
live cordis.patch.yml 时实时重载,无需重启 长时间跑的 Web
startup 仅启动时应用一次 Headless / SDK / ACP

Headless 跑完即退,中途换依赖会打乱一次性任务的生命周期(例如任务进行中把日志插件换掉),所以用 startup 更稳。Web 持续在线,才适合 live。Profile 在 dsh.profile 里声明策略,和 Bundle 列表写在同一处,换场景时连同组合一起换。

合起来:Bundle 提供可覆盖的预制配置行,Profile 点名叠哪些 Bundle 并附用户覆盖,home 与 --patch 再往上盖。空列表叠完就是最终插件树;看不清叠了什么时,先 --dump-config,再对照 allPatches 的顺序从后往前查覆盖来源。

Profile 与 Bundles:用分层 patch 叠出插件树

https://simonsu.net/2026/09/16/dsh-02-profile-bundles/

Author

simonisacoder

Posted on

2026-09-16

Licensed under

Comments