ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

第 4 篇:Profile / Bundle / Patch 三件套

第 4 篇:Profile / Bundle / Patch 三件套

第 4 篇:Profile / Bundle / Patch 三件套

本篇结论:dsh 的定制只有一条正路 ——Profile 决定「叠哪几层 Bundle」,Bundle 决定「基础插件树长什么样」,Patch 决定「你在上面改哪几行」。三层自上而下覆盖,最后写入者胜。读完你能得到:能独立写出一份cordis.patch.yml,知道自己的改动会盖住谁、会被谁盖住,以及避开三个新手必踩的坑。


开篇:装修套餐、建材包、图纸上的红笔

假设你在装修房子。

  • Profile(套餐名)—— 装修公司给你的方案名,比如「现代简约全包」。它本质上是一张建材包清单,写着「先来基础包,再来厨房包,再来卫浴包」。
  • Bundle(建材包)—— 一层实实在在的建材。基础包给你水电、墙面、地板;厨房包给你橱柜、灶台、油烟机。包是有顺序的,厨房包必须在基础包之上。
  • Patch(图纸上的红笔修改)—— 你在图纸上用红笔圈掉「主卧用白色乳胶漆」,改成「用莫兰迪灰」。改的是已经存在的某一项,或者新增一项「客厅加个投影仪」。

dsh 的定制模型,就是这个三段式。而且这三者的关系非常清晰:

Profile 持有「有序的 Bundle 列表」+「你自己的 Patch」;每个 Bundle 自己携带一份Patch。


4.1 三者各是什么(一句话版 + 证据版)

Profile

一句话:$DSH_HOME/profiles/<名字>/下的一个命名组合。

目录里有什么:

$DSH_HOME/profiles/my-app/ ├── package.json # dsh.profile.bundles(有序 Bundle 列表)+ patchReload 策略 ├── cordis.patch.yml # 你自己的改动层 └── node_modules/ # 额外装的插件(通过 dsh plugin 装)

package.json里的关键字段长这样(packages/bundle/base/README.md给了最小示例):

{"name":"my-profile","private":true,"dsh":{"profile":{"bundles":["@deepseek-ai/dsh-base"]}}}

术语小卡片:patchReload这个 Profile 的 Patch 改动是热生效还是只有重启才生效。

  • live—— 改了cordis.patch.yml立刻重排插件树,不用重启
  • startup—— 只在启动时应用一次

自定义 Profile默认live。官方自带的web是 live,而headless/sdk/acp是 startup —— 因为一次性或 stdio 应用跑起来后换依赖,会让它已经接手的活儿失效。

Bundle

一句话:一个 npm 包,用dsh.bundle.patch字段指向一份cordis.patch.yml,贡献「配置行 + 这些行挂载的代码」。

{"dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}

一个容易震惊的事实:dsh-base这个包的 JS 代码是空的(export {})。它的全部实质就是那份 YAML 文件。

官方文档这么说(packages/bundle/base/README.md):

The bundle is a static patch document: oneinsertlist applied over the empty profile root. It mounts no service, emits no events, and holds no mutable state.(这个 Bundle 就是一份静态补丁文档:一个应用到空 Profile 根上的insert列表。它不挂服务、不发事件、不持有可变状态。)

所以 Bundle 不是「一个功能模块」,是「一张装配清单」。真正的代码在它点名的那些包里。

Patch

一句话:cordis.patch.yml补丁清单 —— 按id定位某一行,重写它的整个 config;或者insert新行。

两种操作:

# 操作一:改已有的一行(按 id 定位,整体替换 config)-id:system-promptconfig:personaPrefix:你是一个代码助手# 操作二:插入新行-insert:-id:tool-str-replace-editorname:'@deepseek-ai/dsh-tool-str-replace-editor'config:maxOutputChars:16000

4.2 装配顺序:四层,从下往上盖

④ 临时覆盖层
命令行 --patch
(每次运行临时指定,优先级最高)

③ 机器级
$DSH_HOME/cordis.patch.yml
(这台机器上所有 Profile 都生效)

② Profile 用户层
$DSH_HOME/profiles/<名字>/cordis.patch.yml
(你自己对这个套餐的修改)

① Bundle 层
dsh.profile.bundles 列表里每个 Bundle 的 cordis.patch.yml
(按列表顺序,后者盖前者)

根:空的 cordis.yml = []
(从白纸开始)

箭头方向 = 覆盖方向。越往上优先级越高。

用装修的话说:建材包铺好 → 你在图纸上改 → 你老婆(家里统一规矩)再改 → 开工当天工长拿到的临时变更单再改。最后改的说话。

记住这条推论:Home 级(③)能盖过 Profile 级(②)。所以如果你发现自己的 Profile 改动「不生效」,第一件事就是去看看$DSH_HOME/cordis.patch.yml是不是有一行同样的 id 把它盖了。

六个内置 Bundle

Bundle作用用在哪些 Profile
dsh-base共享基座:模型连接、全套工具、会话持久化、沙箱与审批策略、设置、凭据、遥测web/headless/sdk/acp的共同第一层
dsh-web-app加浏览器应用web
dsh-headless加一次性命令行执行器(无服务器)headless
dsh-sdk-app加 SDK JSON-RPC stdio 服务sdk
dsh-acp-app加自动化专用的 ACP stdio 服务acp
dsh-sdk-minimal例外:一个包自带完整精简树,不套basesdk-minimal

对应的 Profile 模板(packages/boot/app-boot/src/profile.ts):

web: [base, web-app] patchReload: live headless: [base, headless] patchReload: startup sdk: [base, sdk-app] patchReload: startup acp: [base, acp-app] patchReload: startup sdk-minimal: [sdk-minimal] patchReload: startup ← 不套 base

看懂这张表,就看懂了 dsh 的所有官方产品形态。它们不是五个不同的程序,是同一个基座上叠了五种不同的「表面」。


4.3 真实例子:逐行读一份 Patch

来看packages/bundle/headless/cordis.patch.yml(31 行,这是dsh-headless这个 Bundle 的全部实质):

# 改已有的一行:system-prompt-id:system-promptconfig:personaSuffix:Your working directory is{{cwd}}.personaPrefix:>-You are a coding agent powered by the{{model}}model.# 改已有的一行:tools-id:toolsconfig:mode:!!jsprocess.env.DSH_TOOLS_MODE# ← JS 表达式,挂载时求值# 插入两行新的-insert:-id:headless-startupname:'@deepseek-ai/dsh-headless/startup'-id:headless-runnername:'@deepseek-ai/dsh-headless'inject:[headlessStartup]# ← 声明依赖config:task:!!jsctx.headlessStartup.task# ← 挂载时才求值

四个值得学的点:

① 它只做了三件事。改提示词、改工具模式、插入两个新行。所有别的(模型连接、会话、文件工具、bash 工具……)一概不碰 —— 因为那些是dsh-base已经铺好的。这就是「分层」的正确姿势:每层只管自己该管的。

②{{cwd}}和{{model}}是提示词变量。由agent-loop注册,组装时替换。

③!!js是 JavaScript 表达式,挂载时求值。所以!!js ctx.headlessStartup.task能读到运行时才存在的值。这是 dsh 配置灵活性的关键 —— 你的配置不是死的字符串,可以「活」的。

④inject: [headlessStartup]在配置里也能写。第 1 篇讲过inject决定加载顺序,这里就是它在 YAML 里的样子。

一个可直接抄的片段

官方给的「加一个str_replace_editor工具」示例(packages/bundle/base/README.md):

-insert:-id:tool-str-replace-editorname:'@deepseek-ai/dsh-tool-str-replace-editor'config:maxOutputChars:16000

这段可以直接贴进你 Profile 的cordis.patch.yml。


4.4 ⚠️ 一个必须澄清的术语陷阱

仓库根目录下有个patches/文件夹,里面有三个.patch文件。它们和本篇讲的 Patch 毫无关系。

这是初学者最容易混淆的地方,请务必分清这三个东西:

东西是什么作用对象
Cordis Patch(本篇讲的)cordis.patch.yml补丁清单改 dsh 的插件组合
patches/*.patchpnpm 的patchedDependencies机制,标准 git diff改第三方 npm 依赖(@yao-pkg/pkg、node-pty、@electron/osx-sign)在pnpm install时的源码
vendor/内置的 Cordis 框架源码改Cordis 框架本身

记忆口诀:

  • patches/→ 修别人的包(第三方依赖)
  • vendor/→ 修框架本体
  • cordis.patch.yml→ 修你自己的插件组合

三者分属完全不同的机制,不要互相联想。


4.5 三个新手必踩的坑

坑一:Patch 是整体替换,不是字段合并

这是第一大坑。

# 假设 base 里原本是:-id:system-promptconfig:personaPrefix:你是助手personaSuffix:工作目录{{cwd}}# 你只想改 personaPrefix,于是写:-id:system-promptconfig:personaPrefix:你是代码专家

结果:personaSuffix消失了。因为你的补丁整体替换了那一行的 config,不是合并。

正确写法:想保留的字段必须全部重写一遍。

-id:system-promptconfig:personaPrefix:你是代码专家personaSuffix:工作目录{{cwd}}# ← 必须重写

官方原话(packages/boot/app-boot/README.md):A user patch replaces the whole matched config — an id-targeted patch does not deep-merge.

坑二:同一服务不能双重注册

第 2 篇讲过:fs-local和fs-sandbox都注册ctx.fs,同时挂会让Profile 拒绝加载。

遇到「Profile 加载失败」,第一反应查这个。

坑三:每一行配置只应属于一层

官方的设计约定(packages/bundle/base/README.md):

Rows whose value differs by mode do not live here: each mode bundle restates its complete configuration, keeping any single row down to one bundle layer plus the user’s.(因模式而异的行不会放在基座里:每个模式 Bundle 会重述它的完整配置,让任何一行都只属于「一层 Bundle + 用户层」。)

为什么?因为「整体替换」语义下,如果一行配置散落在三层里,你就得在每一层都重写全部字段,很快失控。

给你的实践建议:不要图省事把同一行的配置复制到多层。想改某一行,就在你自己的 Profile 层重述它的完整配置,让它只存在于「基座 + 你」这两层。


4.6 两条保命命令

# 1. 不启动应用,直接看最终会加载成什么样dsh--profileweb --dump-config# 2. 给某个 Profile 装一个外部 Bundledsh plugin--profilemy-appadd<包名>

--dump-config是你最重要的调试工具。它的输出会带注释标明每一行来自哪个文件、被哪一层改过。你的 Patch 到底生效没有,一眼就能看出来。

改配置的正确节奏永远是:改 →--dump-config验证 → 再跑。不要跳过中间那步。

还有一个创建 Profile 的命令:

dsh--profilemy-app --from-default-profile headless

从官方模板复制出一个你自己的 Profile,然后在这个基础上改(名字不能用官方保留的那几个,也不能用desktop)。


动手试试

练习 1跑dsh --profile web --dump-config,在输出里找一行注释,看看它标注了这行来自哪个文件。然后打开$DSH_HOME/profiles/web/cordis.patch.yml(大概率是空的[]),确认「用户层」现在什么都没改。

练习 2(进阶)给你的 headless Profile 加一行自定义 persona:

# $DSH_HOME/profiles/headless/cordis.patch.yml-id:system-promptconfig:personaPrefix:你是一个说话简短的技术助手personaSuffix:Your working directory is{{cwd}}.

注意personaSuffix是必须重写的(坑一)。然后用--dump-config验证,再跑一次任务看看语气变了没有。


本篇小结

  • Profile=$DSH_HOME/profiles/<名字>/,持有「有序 Bundle 列表」(在package.json的dsh.profile.bundles)+「你自己的cordis.patch.yml」+patchReload策略。
  • Bundle= 一个 npm 包,dsh.bundle.patch指向一份cordis.patch.yml。dsh-base的 JS 代码是空的 ——Bundle 的实质就是那张装配清单。
  • Patch= 按id定位、整体替换config,或insert新行。支持!!js表达式(挂载时求值)。
  • 四层覆盖(从下到上):Bundle 层 → Profile 用户层 → 机器级 →--patch临时层。最后写入者胜。
  • 六个内置 Bundle:base(共享基座)+ 四个「表面」+sdk-minimal(例外,不套 base)。
  • 术语陷阱:patches/*.patch是 pnpm 改第三方依赖,与 Cordis Patch无关。
  • 三个坑:整体替换非合并 / 同 key 不可双重注册 / 一行只属一层。
  • 保命命令:--dump-config验证,dsh plugin add装包。

下篇预告

理论全都齐了。你已经知道:

  • dsh 由插件组成,一切可换(第 1 篇)
  • 250 个包分五层,你要动的在顶层(第 2 篇)
  • 启动是装配、执行是循环(第 3 篇)
  • 定制靠 Profile + Bundle + Patch(第 4 篇)

缺的只剩一件事:亲手做一遍。

👉[第 5 篇:动手实战 —— 组装你自己的产品]—— 我们从零拼一个「每周代码巡检小助手」,并配一份排错手册。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进