Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/DeepSeek Harness/第3章

第3章:Profile 与 Patch 分层 —— 一个会话如何被组装

8 分钟 · 更新于 2026-09-01

第3章:Profile 与 Patch 分层 —— 一个会话如何被组装

上一章讲了 cordis 提供的机制,这一章看 dsh 怎么用它。核心问题只有一个:当你敲下 dsh web,那 100 多个插件是按什么规则被装进同一个进程的?

答案是四层配置 patch,加上一个容易让人栽跟头的分野:host 平面和 agent 平面是两套账


一、从空开始:配置树的四层叠加

配置树的四层叠加|900

配图说明:空根 → 层 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()

三个细节值得注意:

  1. 遥测默认 DISABLED —— 要显式设环境变量才开
  2. 沙箱默认 workspace-write —— fail-safe 的默认值,不是 full-access
  3. 工作区根 = process.cwd() —— 你在哪个目录敲 dsh,那里就是工作区根

四、最大的坑:host 平面 vs agent 平面

host 平面与 agent 平面|900

配图说明:左为 host 平面(进程级,profile 配置树决定:Web 宿主、存储、沙箱后端、UI),右为 agent 平面(会话级,preset 决定:模型可见工具、AGENTS.md 加载、压缩、委派)。web-app 禁掉的 24 行工具由 preset 每会话补回。查任何能力都要看两处,只看一处必然得出错误结论。

这是本章最重要的一节。

4.1 现象:web-app 把工具全禁了

统计本机三层 patch 的行数 [实测]:

行数其中 disabled: true
dsh-base781(skill-badge)
dsh-web-app7824
dsh-toolbelt80(用户 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 三行的 namecordis:group —— 这是第 2 章提过的 cordis-plugin-group,把相关插件收成一组。

4.4 这个设计买到了什么

一句话:换 preset = 换整套 agent 能力,不动进程配置。

具体收益:

  1. 子 agent 可以有不同能力集 —— 派一个只读的调研 agent,不给它 write/edit
  2. 同一进程可以跑多种 agent —— 一个会话用完整工具集,另一个用精简集
  3. 能力边界是数据不是代码 —— 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

同时写了 idname。注释解释了原因:「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 的别名。webheadless 两个 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 会话的插件图,是这样被组装出来的:

  1. 四层叠加 —— 空根 → bundle patches → profile patch → home patch → --patch
  2. 两种操作 —— insert 塞新行,按 id 定向覆盖已有行
  3. config 整体替换 —— 不是合并;官方对策是"每行只被一个 bundle 层加用户层碰"
  4. 行序无意义 —— 加载顺序由 inject 的服务可用性驱动
  5. !!js 动态求值 —— 平台分流(Windows→pwsh / 其余→bash)、环境变量、路径
  6. 两个平面 —— host 平面(profile 配置,进程级)与 agent 平面(preset,会话级),查任何能力都要看两处

理解了这层,你就能回答"这个工具为什么在/不在"这类问题了。但还有一个更基础的问题没回答:这些插件提供的能力,为什么能互相替换?

那是下一章的主题:seam 架构 —— 抽象服务与具体实现的分离


  • README-教程总览
  • 第2章-cordis底座-一切皆插件的地基
  • 第4章-Seam架构-抽象服务与具体实现的分离
  • 第12章-技能人机界面与可扩展点 —— 第三方插件(dsh-toolbelt)的完整接线示例

本章目录
一、从空开始:配置树的四层叠加二、patch 的两种基本操作三、!!js:配置里的动态表达式四、最大的坑:host 平面 vs agent 平面五、实操:加一个插件的完整路径六、启动器参数 vs 应用参数七、动手复核八、总结Related Documents
苏ICP备2025204887号-2