Profile 与组合包:dsh 是如何被组装出来的
专栏:DeepSeek Harness · 第 5 / 14 篇第 2 篇我们 dump 过 web profile 的 162 行配置树,第 4 篇弄懂了插件如何挂载。还剩一个关键问题:这棵树是按什么规则叠出来的? 这就是 profile(配置档)与 bundle(组合包)机制。
两个角色,一个 package.json 字段
- 组合包(bundle):插件配置的分发格式。在
package.json里声明一份 patch 文件:// packages/bundle/base/package.json:30-35 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } - profile:一份有序组合包列表 + 用户覆盖层,存放在
$DSH_HOME/profiles/<name>/目录:"dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"], "patchReload": "live" } }
五种随附 profile 直接定义在源码里,首次使用时自动初始化成目录:
// packages/boot/app-boot/src/profile.ts:137-158
export const PROFILE_TEMPLATES: Record<string, ProfileTemplate> = {
acp: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'],
patchReload: 'startup',
},
web: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
patchReload: 'live',
},
headless: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
patchReload: 'startup',
},
sdk: {
bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'],
patchReload: 'startup',
},
'sdk-minimal': {
bundles: ['@deepseek-ai/dsh-sdk-minimal'],
patchReload: 'startup',
},
}
五个 profile 是同一个底座(dsh-base,约 85 个服务行)+ 不同应用壳的组合;唯一的例外 sdk-minimal 刻意不用底座,自带一份完整显式配置。
patch:按 id 寻址的配置语言
patch 文件是顶层 YAML 数组,每项一条 patch。核心字段(vendor/include/src/index.ts:144-156):
export interface PatchOptions {
id?: string // 定位目标行
insert?: EntryOptions[] // 插入新行(无 id 追加根列表,有 id 插入指定 group)
name?: string // 可选校验:目标行必须是这个插件名
config?: any // 整体替换目标行的 config
disabled?: boolean | null
inject?: any
group?: boolean | null
isolate?: any
[key: string]: any
}
三条语义规则(这是全文最重要的三句话):
- 带
id的 patch 替换目标行的整个config——不做深度合并。上层想保留的键必须重述。 - 同一 id 后写者胜。所有层拍平成一个列表、对空列表做一次
applyEntryPatches,最后写入的版本生效。 - 插入的行立即建索引,同一列表里后面的 patch 可以继续定位它——层可以配置/禁用早前层插入的行。
看真实的例子。dsh-base 里定义沙箱与审批策略的行(!!js 表达式在挂载时求值):
# packages/bundle/base/cordis.patch.yml:208-233(节选)
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
- id: bash-sandbox
name: '@deepseek-ai/dsh-bash-sandbox'
disabled: !!js process.platform === 'win32'
config:
timeoutMs: 60000
- id: approval
name: '@deepseek-ai/dsh-user-approval'
config:
policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'"
而应用壳负责插入自己的行、改写底座行。最小的 acp-app 全文仅 20 行,两种动作都有:
# packages/bundle/acp-app/cordis.patch.yml:1-20
- id: system-prompt
config:
persona: >-
You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.
- id: session-title-llm
disabled: true
- insert:
- id: acp-app-startup
name: '@deepseek-ai/dsh-acp-app'
- id: acp
name: '@deepseek-ai/dsh-acp'
inject: [acpAppStartup]
config:
provider: deepseek-official
model: deepseek-v4-flash
这就是「改配置不改代码」的全部机制:想让某个 profile 的会话标题生成走 LLM?- id: session-title-llm 那行 disabled: true 删掉即可——用你自己的用户层 patch。
层的叠加顺序
启动器把五层 patch 按固定顺序拍平(apps/cli/src/profile-boot.ts:136-141):
function allPatches(composed: ComposedProfile): PatchOptions[] {
return [
...composed.bundlePatches, // ① 各组合包层,按 profile 声明顺序
...composed.profile.patches, // ② profile 自己的 cordis.patch.yml
...composed.homePatches, // ③ home 级 $DSH_HOME/cordis.patch.yml(机器级偏好)
...composed.overlays, // ④ --patch overlay(argv 顺序)
]
}
外加一个恒排最后的第 ⑤ 层:DSH_TELEMETRY_DISABLED 生成的遥测关闭 patch。画成图(自下而上优先级递增):
组合的完整过程发生在启动时,逐步时序如下:
live 与 startup:热重载的边界
注意模板里 patchReload 的两种取值:
live(web + 所有自定义 profile):启动后监视 L3/L4 两份用户 patch 文件,保存即重新组合挂载,无需重启。组合时 bundle 层垫底、overlay 垫顶,用户层每次全量重读并深拷贝——用户编辑既挤不掉发行版层,也不会因引用别名污染默认值。被拒绝的编辑让最后一个可用配置继续跑。startup(headless/sdk/sdk-minimal/acp):所有层只在启动时应用一次。原因写在架构文档里:一次性或 stdio 应用已经拥有工作之后,替换其依赖会破坏生命周期——任务跑到一半换了它的工具集合,算什么。
一个容易误解的点:patch 的「替换」是整行 config 替换,不是 merge。所以你会在真实 patch 里看到上层重述下层写过的键——这不是冗余,是语义。
与其他模块的联系
- patch 每一行 = 一个插件挂载(第 4 篇的 Loader 条目),
id是它在你 dump-config 输出里的名字; - 热重载只作用于 patch(第 4 篇的 fiber 重载机制)——改代码仍需重启;
!!js表达式引用的环境变量在第 2 篇的loadLayeredEnv冻结快照里解析;- 想加一行自己的配置,写进 L3/L4 用户层即可,永远不要改发行版组合包(升级会被覆盖)。
preset:另一个平面的组装
preset:另一个平面的组装
最后区分一对容易混淆的概念:profile/bundle 是部署平面(这个进程长什么样),packages/preset 是 agent 平面(某个会话里的 agent 长什么样)。preset 的 agent.cordis.yml 挂载在会话作用域下,且服务行必须放进带 isolate 的 group(否则会泄漏成进程全局):
# packages/preset/agent-presets/presets/standard/agent.cordis.yml:137-156
- id: compaction
name: cordis:group
group: true
isolate:
compaction: true
toolResultPruner: true
config:
- id: compaction-basic
name: '@deepseek-ai/dsh-compaction-basic'
- id: tool-result-pruner
name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
config:
thresholdChars: 8192
到这里,「组装」这条线全部打通:patch 语言 → 层叠规则 → 插件树。下一篇开始进核心主干——先看那棵树里最基础的服务:仅追加的会话日志。