Profile 与组合包:dsh 是如何被组装出来的

专栏:DeepSeek Harness · 第 5 / 14 篇
DeepSeek HarnessCordis架构

第 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
}

三条语义规则(这是全文最重要的三句话):

  1. id 的 patch 替换目标行的整个 config——不做深度合并。上层想保留的键必须重述。
  2. 同一 id 后写者胜。所有层拍平成一个列表、对空列表做一次 applyEntryPatches,最后写入的版本生效。
  3. 插入的行立即建索引,同一列表里后面的 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。画成图(自下而上优先级递增):

图表(profiles-and-bundles.md)

组合的完整过程发生在启动时,逐步时序如下:

图表(profiles-and-bundles.md)

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/presetagent 平面(某个会话里的 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 语言 → 层叠规则 → 插件树。下一篇开始进核心主干——先看那棵树里最基础的服务:仅追加的会话日志。

← 返回文章列表