第3章:Profile 与 Patch 分层 —— 一个会话如何被组装
约 8 分钟 · 更新于 2026-09-01
第3章:Profile 与 Patch 分层 —— 一个会话如何被组装
上一章讲了 cordis 提供的机制,这一章看 dsh 怎么用它。核心问题只有一个:当你敲下 dsh web,那 100 多个插件是按什么规则被装进同一个进程的?
答案是四层配置 patch,加上一个容易让人栽跟头的分野:host 平面和 agent 平面是两套账。
一、从空开始:配置树的四层叠加

配图说明:空根 → 层 1 bundle patches(base 78 行 + web-app 78 行 + toolbelt 8 行)→ 层 2 profile patch(用户 8 行)→ 层 3 home patch → 层 4 --patch 覆盖 → 最终插件树交给 cordis Loader。越靠后的层优先级越高;config 是整体替换而非合并;行序无加载语义(激活由服务可用性驱动)。
dsh 的官方说明只有一句话,但信息密度很高:
配置树以空根为起点,依次叠加以下配置层:
- dsh.profile.bundles 中各组合包的 patch
- profile 自身的 cordis.patch.yml,然后是 home 级的 $DSH_HOME/cordis.patch.yml
- --patch 指定的覆盖层
—— dsh/README.zh.md
画成图:
text
空的 profile 根(一个空数组 [])
│
▼
┌──────────────────────┐
│ 层1 bundle patches │ 按 package.json 的 dsh.profile.bundles 顺序
│ dsh-base 78 行 │ ← 共享内核
│ dsh-web-app 78 行 │ ← 浏览器形态,按 id 覆盖 base 的行
│ dsh-toolbelt 8 行 │ ← 第三方插件包
└──────────────────────┘
│
▼
┌──────────────────────┐
│ 层2 profile patch │ ~/.dsh/profiles/web/cordis.patch.yml
│ (本机 8 行) │ ← 用户自己的覆盖
└──────────────────────┘
│
▼
┌──────────────────────┐
│ 层3 home patch │ ~/.dsh/cordis.patch.yml(本机无)
└──────────────────────┘
│
▼
┌──────────────────────┐
│ 层4 --patch 覆盖 │ 命令行临时覆盖
└──────────────────────┘
│
▼
最终插件树 → cordis Loader 装载
profile 根文件本身是空的,注释写得很坦白 [实测]:
yaml
# dsh profile root — an empty entry list. The tree is composed as patches:
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
# --patch overlays. Edit cordis.patch.yml, not this file.
[]
(~/.dsh/profiles/web/cordis.yml)
而 profile 的 package.json 只声明了要叠哪几层 [实测]:
json
{
"name": "dsh-profile-web",
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"@deepseek-ai/dsh-web-app",
"dsh-toolbelt"
]
}
}
}
三个 bundle,第三个是第三方包 —— 官方 bundle 和社区 bundle 在这里是平级的,没有特权差异。
二、patch 的两种基本操作
2.1 insert:往树里塞行
dsh-base 的 patch 是一次 insert,把整个内核插进空根 [实测]:
yaml
- insert:
- id: timer
name: '@deepseek-ai/cordis-plugin-timer'
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: session
name: '@deepseek-ai/dsh-session'
- id: session-title
name: '@deepseek-ai/dsh-session-title'
config:
fallbackMaxWords: 5
fallbackMaxBytes: 40
maxTitleBytes: 80
(dsh-base/cordis.patch.yml)
每一行就是第 2 章讲的 cordis Loader EntryTree 条目:id 定位、name 指模块、config 传参。
2.2 按 id 定向覆盖
后续 bundle 不用再 insert,而是按 id 找到已有行改它。dsh-web-app 的注释把规则说死了 [实测]:
text
# Applied after dsh-base's insert; rows here override base rows by id, with
# the profile's own cordis.patch.yml and any --patch overlays still to come.
#
# A patch replaces the targeted row's whole `config`, so each row below
# restates every key it owns.
(dsh-web-app/cordis.patch.yml)
关键陷阱:config 是整体替换,不是合并
「A patch replaces the targeted row's whole config」—— 你在自己的 patch 里给某行写 config,会整个替换掉下层的 config,而不是合并进去。漏写一个键,那个键就回到插件默认值,不是保持原样。
官方为此定了个规矩:每个覆盖行都要把自己拥有的每个键重述一遍。dsh-base 的注释里还给出了配套的架构判断 —— 「一个值因模式而异的行不该放在 base 里,它属于各个模式 bundle,这样任何单行都只经过一个 bundle 层加用户层」。
翻译成人话:同一行配置最多被两层碰(一个 bundle + 用户),否则整体替换的语义会让人算不清最终值。
2.3 行的顺序不代表加载顺序
这是最容易误解的一点。dsh-base 的注释直接点破 [实测]:
text
# Row order carries no load semantics (activation is service-availability
# driven); the grouping is for readers.
行序只是给人读的,加载顺序由服务可用性驱动 —— 也就是第 2 章讲的 inject 机制。你把 tool-fs 写在 tools 前面也没关系,cordis 会等 ctx.tools 就绪再启动它。
三、!!js:配置里的动态表达式
dsh 的 patch 支持在 YAML 里写 JavaScript 表达式,用 !!js 标签。这解决了"同一份配置要适配不同平台/环境"的问题。
3.1 平台分流:Windows 走 pwsh,其余走 bash
本机实测的 base patch 片段:
yaml
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
config:
timeoutMs: 60000
- id: pwsh-sandbox
name: '@deepseek-ai/dsh-pwsh-sandbox'
disabled: !!js process.platform !== 'win32'
yaml
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
disabled: !!js process.platform === 'win32'
- id: tool-pwsh
name: '@deepseek-ai/dsh-tool-pwsh'
disabled: !!js process.platform !== 'win32'
(dsh-base/cordis.patch.yml)
两对互斥的 disabled 表达式。同一份配置在 Windows 上给你 pwsh 工具,在 Linux 上给你 bash 工具 —— 这就是为什么本机的工具清单里只有 pwsh 没有 bash。
3.2 环境变量与路径
yaml
- id: session-persistence-jsonl
config:
root: !!js dshHomePath('sessions')
- id: session-telemetry-otel
config:
mode: !!js process.env.DSH_TELEMETRY_MODE || 'DISABLED'
exporter:
url: !!js process.env.DSH_TELEMETRY_OTLP_URL ?? '<官方遥测端点>'
yaml
- id: sandbox-policy
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
三个细节值得注意:
- 遥测默认 DISABLED —— 要显式设环境变量才开
- 沙箱默认 workspace-write —— fail-safe 的默认值,不是 full-access
- 工作区根 = process.cwd() —— 你在哪个目录敲 dsh,那里就是工作区根
四、最大的坑:host 平面 vs agent 平面

配图说明:左为 host 平面(进程级,profile 配置树决定:Web 宿主、存储、沙箱后端、UI),右为 agent 平面(会话级,preset 决定:模型可见工具、AGENTS.md 加载、压缩、委派)。web-app 禁掉的 24 行工具由 preset 每会话补回。查任何能力都要看两处,只看一处必然得出错误结论。
这是本章最重要的一节。
4.1 现象:web-app 把工具全禁了
统计本机三层 patch 的行数 [实测]:
| 层 | 行数 | 其中 disabled: true |
|---|
| dsh-base | 78 | 1(skill-badge) |
| dsh-web-app | 78 | 24 |
| dsh-toolbelt | 8 | 0(用户 patch 全开) |
web-app 禁掉的 24 行是哪些?[实测]
text
tool-bash tool-pwsh tool-jobs
tool-fs tool-fs-search tool-str-replace-editor
skill-filesystem tool-skill tool-goal
plan-mode compaction-basic command-compact
tool-result-pruner tool-subagent-control tool-subagent-list-agents
tool-subagent tool-subagent-fork workflow-worker-thread
tool-workflow tool-ralph agent-instructions
tool-todo tool-web hmr
几乎所有模型能用的工具,加上 AGENTS.md 加载、上下文压缩、计划模式 —— 全被 Web 形态禁掉了。
如果你只看到这里,会得出"dsh web 是个没有工具的空壳"的结论。但你正在读的这篇文档就是用 dsh web 写的,工具明明都在。
4.2 解释:两套账
答案在 dsh-web-app 的最后一行 [实测]:
yaml
- id: agent-presets
name: '@deepseek-ai/dsh-agent-presets'
dsh 把插件分成了两个平面:
| 平面 | 生命周期 | 由谁装载 | 典型内容 |
|---|
| Host 平面 | 进程级,启动到退出 | profile 配置树 | Web 服务器、存储、会话持久化、沙箱后端、LLM 适配器、前端 UI |
| Agent 平面 | 会话级,每个 agent 一套 | agent preset | 模型可见的工具、技能、压缩策略、委派能力、计划模式 |
web-app 禁掉那 24 行,不是"不要这些能力",而是**"这些能力不该由进程装,该由每个会话自己装"**。
4.3 agent preset:会话级的插件树
本机的 preset 配置 [实测]:
text
~/.dsh/.agent-presets/dsh-toolbelt/
├── preset.yml (元数据:名称、描述、排序)
└── agent.cordis.yml (13649 字节,33 个插件行)
settings.yaml 里指定默认用哪个:
yaml
agent-presets:
default: standard
preset 里装的正是被 web-app 禁掉的那批,外加 toolbelt 的三个守护插件 [实测]:
text
persona agent-instructions
tool-bash tool-pwsh
tool-fs tool-fs-search
tool-jobs tool-skill / skill-filesystem
tool-goal tool-todo / tool-web
planning ├ plan-mode
compaction ├ compaction-basic / command-compact / tool-result-pruner
delegation ├ tool-subagent / tool-subagent-fork
├ tool-subagent-control / tool-subagent-list-agents
├ workflow-worker-thread / tool-workflow / tool-ralph
python-workdir-guard skill-shell-injection
language-guard
注意 planning / compaction / delegation 三行的 name 是 cordis:group —— 这是第 2 章提过的 cordis-plugin-group,把相关插件收成一组。
4.4 这个设计买到了什么
一句话:换 preset = 换整套 agent 能力,不动进程配置。
具体收益:
- 子 agent 可以有不同能力集 —— 派一个只读的调研 agent,不给它 write/edit
- 同一进程可以跑多种 agent —— 一个会话用完整工具集,另一个用精简集
- 能力边界是数据不是代码 —— preset 是 YAML 文件,可以由用户编辑、由程序生成
dsh 文档里对应的说法是:
preset 文件是输入,不是持久化目标
—— dsh-agent-presets/README.zh.md 的小节标题
代价:你要查"某个工具为什么不在",得同时看 profile 配置和 preset 配置两个地方。只看一处必然得出错误结论 —— 我第一次统计本机插件时就踩了这个坑。
五、实操:加一个插件的完整路径
假设你要启用 MCP 客户端(本机装了 dsh-mcp-client 但没接线)。
第一步:判断它属于哪个平面。
MCP 客户端把外部 server 的工具注册到 ctx.tools。工具是 agent 平面的东西,但 MCP 连接是进程级资源 —— 查它的 README 确认。
第二步:写 patch 行。
如果是 host 平面,编辑 ~/.dsh/profiles/web/cordis.patch.yml:
yaml
- id: mcp-client
name: '@deepseek-ai/dsh-mcp-client'
disabled: false
config:
# 按该包 README 的 schema 填
如果是 agent 平面,编辑 ~/.dsh/.agent-presets/<preset>/agent.cordis.yml。
第三步:确认 id 不撞车。
本机用户 patch 里有个细节值得学 [实测]:
yaml
- id: python-workdir-guard
name: 'dsh-toolbelt/python-workdir-guard'
disabled: false
同时写了 id 和 name。注释解释了原因:「id 定位该行,name 防止 id 冲突」。因为 patch 是按 id 覆盖的,万一你的 id 和某个 bundle 的行撞了,加上 name 能让错误暴露出来而不是静默改错了别人的行。
第四步:不启动,先看合成结果。
text
dsh --dump-default-config # 默认配置树
dsh --dump-config # 叠完所有 patch 的最终树
(dsh/README.zh.md)
第五步:如果包不在 profile 的 node_modules 里,先装。
text
dsh plugin --profile web add <包名>
dsh plugin 是把参数转发给 pnpm 的壳(dsh/README.zh.md)。
六、启动器参数 vs 应用参数
一个小但容易踩的规则。dsh 的启动器只解析自己的 flag,遇到第一个不认识的 token 就把剩下全部交给 profile [官方文档]:
sh
dsh --profile web --port 8080 # --port 属于 web app
dsh --profile headless "run the tests"
dsh --profile web --help # web app 的 flag,不是启动器的
dsh --help # 启动器自己的 help
(dsh/README.zh.md)
dsh web 是 --profile web 的别名。web 和 headless 两个 profile 首次使用时会从模板自动初始化,其他 profile 必须用 dsh plugin 手工创建。
七、动手复核
powershell
# 1. profile 的三层 bundle 声明
Get-Content "$env:DSH_HOME\profiles\web\package.json"
# 2. profile 根是空的
Get-Content "$env:DSH_HOME\profiles\web\cordis.yml"
# 3. 用户自己的 patch 层
Get-Content "$env:DSH_HOME\profiles\web\cordis.patch.yml"
# 4. 三层 bundle patch 各有多少行
foreach ($p in 'dsh-base','dsh-web-app') {
$f = "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\$p\cordis.patch.yml"
"{0}: {1} 行" -f $p, (Select-String -Path $f -Pattern '^\s*-\s*id:').Count
}
# 5. web-app 到底禁了哪些
Select-String -Path "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-web-app\cordis.patch.yml" `
-Pattern 'disabled: true' -Context 2,0
# 6. agent preset 装了什么(另一套账)
Select-String -Path "$env:DSH_HOME\.agent-presets\dsh-toolbelt\agent.cordis.yml" `
-Pattern '^\s*-?\s*(id|name):'
# 7. 所有 !!js 动态表达式
Select-String -Path "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-base\cordis.patch.yml" `
-Pattern '!!js'
八、总结
一个 dsh 会话的插件图,是这样被组装出来的:
- 四层叠加 —— 空根 → bundle patches → profile patch → home patch → --patch
- 两种操作 —— insert 塞新行,按 id 定向覆盖已有行
- config 整体替换 —— 不是合并;官方对策是"每行只被一个 bundle 层加用户层碰"
- 行序无意义 —— 加载顺序由 inject 的服务可用性驱动
- !!js 动态求值 —— 平台分流(Windows→pwsh / 其余→bash)、环境变量、路径
- 两个平面 —— host 平面(profile 配置,进程级)与 agent 平面(preset,会话级),查任何能力都要看两处
理解了这层,你就能回答"这个工具为什么在/不在"这类问题了。但还有一个更基础的问题没回答:这些插件提供的能力,为什么能互相替换?
那是下一章的主题:seam 架构 —— 抽象服务与具体实现的分离。
- README-教程总览
- 第2章-cordis底座-一切皆插件的地基
- 第4章-Seam架构-抽象服务与具体实现的分离
- 第12章-技能人机界面与可扩展点 —— 第三方插件(dsh-toolbelt)的完整接线示例