上一篇我们先回答了一个“为什么”:研究 DeepSeek Harness,不是为了背下某个 Coding Agent 的工具清单,而是为了看清一个通用 Agent Runtime 如何被工程化地组织起来。

这一篇开始追第一条真正的运行链:

用户敲下 dsh --profile headless(执行一次特定任务,无头模式) 之后,系统如何从一组配置文件和 npm 包,组装出一棵可以启动的 Cordis 插件树?

这件事看起来像“读取 YAML,然后启动插件”。但如果只这样理解,很快会遇到三个问题:

  1. 为什么同一套 Agent 能有 webheadless、ACP 等不同产品形态?
  2. 为什么用户不用改源码,就可以替换模型、沙箱、持久化或工具?
  3. 为什么配置 dump、真实启动和热更新必须共享同一套组合算法?

DeepSeek Harness 的答案是:把产品组装本身做成运行时的一等公民。


1. 先建立一个启动时刻的画面

想象一个没有界面的任务:

dsh --profile headless "检查项目测试并修复失败项"

这条命令并没有把“完整 Agent”写在一个巨大的 main.ts 里。它更像是在启动时拼出下面这张图:

用户命令

dsh CLI

选择 Profile

按顺序加载 Bundles

应用 Profile Patch

应用 Harness Home Patch

应用 --patch Overlay

Cordis Loader

插件树与服务依赖

Agent Runtime

这里最重要的词不是“插件”,而是“”。最终运行的配置,不是某个文件的原样内容,而是多个层按顺序作用在一份空条目列表上的结果。


2. 四个概念:Profile 不是插件,Bundle 也不是普通依赖

2.1 Profile:一套产品组装方案(要启动一个什么样的 Agent?)

在 DSH 中,Profile 是 Harness home 下的一个具名目录,是用户最终启动的具名配置方案。
典型位置是:

$DSH_HOME/profiles/<name>/

它至少关心两类东西:

  • package.json:声明树外插件依赖,以及 dsh.profile.bundles 的有序列表;
  • cordis.patch.yml:这个 Profile 自己的覆盖层。

更重要的是,它主要实现了核心配置和访问方式的解耦:

  • 加载哪些 Bundle(决定 Agent 具备哪些核心能力)
  • 加载哪个入口插件(决定用户通过什么方式访问)

所以 Profile 更像“产品装配说明书”,而不是一个具体功能包。

headless 的重点是一次性运行、干净的 stdout 和无浏览器服务;web 则需要浏览器应用、Host API 和前端模块。二者可以共享底层 Agent 能力,却不必共享同一份最终配置。

2.2 Bundle:可被继续覆盖的发行层(这个包贡献什么能力?)

Bundle 是一组预组装好的、相关插件的集合。
它通过 package.json 中类似下面的声明告诉 Harness:

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

Bundle 的关键不是“打包了很多代码”,而是它把一组 Cordis 配置项和这些配置项对应的挂载代码一起分发,并且留下了继续被上层 patch 的位置。

可以把它理解成一层“有出处的默认装配”:

Bundle = 配置条目 + 挂载实现 + 可追踪的来源

这和普通 npm 依赖有一个重要区别:普通依赖解决“代码能不能被 import”,Bundle 解决“这组能力要不要进入本次运行,以及以什么配置进入”。

2.3 Patch:按 id 替换配置,而不是深度合并(如何调整现有功能?)

DSH 的 patch 以条目 id 定位目标。匹配后,替换的是整个 config;也就是说,未修改字段也必须在覆盖层中重新写出。

这是一条很容易被忽略、但很有工程价值的约束:配置覆盖是显式的,结果可审计,不会因为某个深层字段被“悄悄合并”而产生隐性行为。

Patch 也可以通过 insert 插入新条目。因而它既能完成替换,也能完成扩展:

patch = replace(existing id) + insert(new entries)

2.4 Loader:把配置条目变成运行时插件树(如何将配置变成可运行的插件树?)

Loader 负责把配置中的模块说明符解析成真实插件、挂载到 Cordis 上下文,并等待依赖服务结算。

@deepseek-ai/dsh-app-boot 并没有重新发明一套 Loader,而是把启动流程包在几个统一的辅助函数里:

  • 解析配置路径和环境变量;
  • 读取可选或必需的 patch;
  • 挂载根 cordis:include
  • 等待所有条目加载并激活;
  • 失败时释放已经构造的上下文。

因此,Loader 不是“最后一步的工具”,而是把声明式配置翻译成运行时对象的边界。


3. DSH 的真实组合顺序

根据 docs/architecture.zh.mdpackages/boot/app-boot/README.zh.md,一份 Profile 的配置层次是:

空条目列表
  ↓
Profile 中按顺序声明的 Bundle 层
  ↓
Profile 自己的 cordis.patch.yml
  ↓
Harness home 级 cordis.patch.yml
  ↓
命令行 --patch Overlay
  ↓
最终 Cordis 配置树

最终配置 = 空列表 → 依次叠加 Bundle → 叠加 Profile Patch → 叠加 Home Patch → 叠加命令行 Patch

用函数表示,就是:

entries0 = []                                           # 1. 从一张白纸开始
entries1 = apply(bundle1, entries0)                     # 2. 叠上第一个 Bundle
entries2 = apply(bundle2, entries1)                     # 3. 叠上第二个 Bundle
...
entriesN = apply(profilePatch, entriesN-1)              # 4. 叠上 Profile 自己的 Patch
entriesN+1 = apply(homePatch, entriesN)                 # 5. 叠上用户全局的 Patch
final = apply(cliOverlay, entriesN+1)                   # 6. 叠上命令行临时 Patch

这里的 apply 不是“把 YAML 文本拼接起来”,而是由 include 实现提供的条目 patch 算法。它按 id 找到条目,替换配置,或者插入新条目。

这也解释了为什么 DSH 要让 composeEntriesrenderConfigDump 复用同一个算法:

如果 dump 出来的配置和真正 boot 的配置不是同一个组合结果,调试就会失去可信度。


4. 用 headless 配置看一棵最小的 Agent 树

examples/headless-agent/cordis.yml 是一个很好的学习入口。它不是一个只有“模型 + 工具”的简化玩具,而是一份完整的产品组合样本。

从配置中可以观察到几个层次:

headless cordis.yml

基础设施

模型与凭据

Agent 主干

Session 与上下文

工具与外部能力

Subagent / Workflow

subprocess

fs-local

fs-observation-policy

settings

credentials

llm-deepseek

agent-spine

persistence-jsonl

compaction-basic

bash

tool-todo

subagent providers

workflow worker thread

这里有一个特别值得学习的地方:配置条目的顺序携带了依赖意图。

例如,文件系统策略先于面向模型的文件工具加载;子进程服务先于 Bash 后端加载;模型适配器、凭据和设置彼此分离。这样做不是为了让 YAML 看起来整齐,而是让服务提供方和消费者在 Loader 结算时拥有明确的依赖边界。

4.1 模型不是 Agent

在这棵树里,llm-deepseek 只提供模型适配能力。它不会自动拥有:

  • 会话持久化;
  • 工具注册表;
  • 文件系统访问;
  • 子 Agent;
  • 上下文压缩。

这些能力由其他条目分别提供,再由 Agent 主干组合起来。模型是一个 provider,Agent 是一组服务和事件协同后的运行时行为。

这正是通用 Agent Runtime 的重要边界:替换模型,不应该等于重写整个 Agent。

4.2 一个应用可以有多个入口

ACP 的组合图进一步说明了这一点:它复用了 Agent spine、Session persistence、token meter、compaction、subagent 等能力,但入口换成了面向自动化客户端的 JSON-RPC stdio,而且不加载人类 UI、控制台 logger 和交互式提问工具。

所以“产品形态”与“Agent 核心能力”是两个维度:

产品入口(CLI / Web / ACP)
        ×
运行时能力(LLM / Session / Tools / Sandbox / Subagent)

Profile 和 Bundle 正是把这两个维度解耦的装配机制。


5. 启动流程不是 mount 完就结束

app-bootboot() 还承担了几个经常被忽略的工程责任。

5.1 加载失败要 fail loud

配置树是并发挂载的。某个插件可能已经打开终端、建立子进程或注册监听器,另一个插件却在解析阶段失败。

因此 DSH 的启动失败路径大致是:

Plugin Tree Cordis Loader app-boot App Bin Plugin Tree Cordis Loader app-boot App Bin alt [全部成功] [任一条目失败] boot(config, patches) mount root include 并发解析与挂载 entries loaded and activated root context rejection + failed entry dispose 已构造上下文 带标签的启动错误

执行逻辑是这样的

  • 加载:boot() 调用 Cordis Loader 开始解析和挂载所有插件条目。
  • 成功分支:所有条目都成功加载并激活,返回一个健康的 root context。
  • 失败分支:
    一旦有任何一个条目失败(抛出异常),Loader 会立即 reject,并携带着失败条目的具体 id 和错误信息。
    boot() 捕获到这个错误后,第一件事就是调用 dispose——它会遍历所有已经构造成功的插件,调用它们的 stop 或 dispose 方法,尽可能清理掉已经产生的副作用(子进程、监听器等),让系统恢复到干净状态。
    最后,抛出一个“带标签的”启动错误——标签就是失败的插件名,让你一眼知道“谁炸了”。

assertEntriesLoadedassertEntriesActivated 会分别检查“条目是否真的被解析”和“条目对应的服务是否完成激活”。这比只等待一个 Promise 更可靠,因为 Loader 可能存在已启用但没有 fiber、fiber 存在但服务仍未结算等中间状态。

5.2 配置 dump 必须和真实启动一致

这段话解决的是一个非常实际的调试痛点:我看到的配置,真的是我跑起来的配置吗?

问题场景

很多工具在 --dump-config 时,只是把硬盘上的原始 YAML 文件打印出来。但 DSH 的配置是多层叠加的(Bundle → Profile → Home → CLI),实际生效的配置是“组合后的结果”,而不是某一个原始文件。

如果 --dump-config 只打印原始文件,你看到的配置跟实际生效的配置可能对不上,调试时就会产生**“幽灵问题”**——你以为配置是这样的,但系统实际跑起来是那样的。

DSH 的做法

dsh --profile web --dump-config 不是简单地 cat 某个 YAML 文件,而是:

  1. 离线执行:它会完整运行一遍 apply 算法,把所有 Bundle、Profile、Home、CLI 的 Patch 按顺序叠加。
  2. 加上来源注释:最终输出的 YAML 中,每一段配置都会附带一个注释,标明它来自哪个 Bundle、Profile 还是 Overlay
这样做的价值

当模型、工具或权限行为异常时,你可以:

  • 执行 --dump-config,拿到当前 Profile 下真正生效的完整配置树
  • 通过来源注释,快速定位到“是哪个层的配置导致了问题”。
  • 如果你发现某个配置被覆盖了但你预期不应该被覆盖,来源注释能帮你追查是哪个 Patch 覆盖了它。

核心原则dumpboot 走的是同一个算法,确保“所见即所得”——你 dump 出来看到的东西,就是系统实际启动时使用的东西。如果算法不同,dump 出来的是 A,启动时用的是 B,调试就会失去可信度。

5.3 热更新要保留最后一棵可用树

DSH 的**热加载(Hot Module Replacement, HMR)**策略,但它的设计更谨慎。

问题场景

DSH 支持你修改 ~/.dsh/cordis.patch.yml 后,不重启进程就能让新配置生效(热更新)。这听起来很好,但有一个风险:

如果你改的配置文件里有语法错误,或者写错了一个 id,导致重新组合配置时直接崩溃了,那么你原本还在运行中的 Agent 会话也会一起被干掉。

DSH 的做法(核心:保留最后一棵可用树)

watchUserPatches 这个监听器会:

  1. 串行重新组合 Patch:检测到文件变更后,重新执行组合算法。
  2. 读取或解析失败时不立即应用新配置,也不让进程崩溃。
  3. 保留最后一个可用配置树:当前正在运行的 Agent 会话,继续使用上一棵可用的配置树(即:你现在正在运行的、没问题的版本)。
  4. 通过 HMR 事件报告失败:系统会通过日志或事件通知你“新配置加载失败了,请检查你的 YAML 语法”,但你的 Agent 仍在正常工作,不受影响
类比一下
  • 危险做法:你在高速上开车(Agent 在跑),你边开车边换发动机零件(改配置),换错了瞬间爆炸(进程崩溃)。
  • DSH 做法:你边开车边换零件,但系统有一个“备用发动机”(最后一棵可用配置树)。你换上去的新零件装不上,系统立刻切回备用发动机,车继续平稳行驶,同时仪表盘亮灯告诉你:“零件装错了,检查一下再试。”

这是一种非常成熟的运行时策略

  • 配置修改属于用户平面:用户改配置可能会出错,这是正常现象。
  • 错误应该可见,但不应该摧毁仍然可工作的会话:如果仅仅因为某一行 YAML 写错了,就要杀掉一个已经运行了很长时间的 Agent(它可能正在执行重要任务),这是不可接受的。

6. 一个容易误解的地方:Bundle 不等于“继承代码”

如果把 Bundle 想成传统面向对象里的父类,后续学习会走偏。Bundle 不是父类,而是服务提供者集合。插件依赖的是“服务定义”而非“具体实现”,Patch 可以替换任意服务的 Provider,Loader 自动重新结算依赖。这种设计的核心是可替换性——换掉一个底层能力(如文件系统),不需要修改任何上层逻辑。它不是通过继承重写方法,而是通过配置条目和插件服务共同作用:

Bundle A 提供服务 X
Bundle B 提供服务 Y,并消费 X
Patch C 替换 X 的 provider
Loader 重新结算依赖

因此,它的扩展方式更接近:

  • 声明一个 Service Definition;
  • 提供一个可替换的 Service Provider;
  • 让 Consumer 依赖服务,而不是依赖某个具体实现包。

这与 docs/capability-seams.md 中的 seam 思路是一致的。未来要把本地文件系统换成远程沙箱,理想情况不是复制一份新的 Agent Loop,而是替换 ctx.fsctx.subprocessctx.sandbox 的 provider。


7. 现在做两个实验:先不运行模型,也能验证架构

实验 A:画出 headless 的能力分类

打开:

deepseek-harness-master/examples/headless-agent/cordis.yml

请把每个条目放进五类:

类别 你要找的条目
模型平面 settings、credentials、llm-deepseek
执行平面 subprocess、bash、fs-local
Agent 主干 agent-spine
状态平面 persistence、token-meter、compaction
扩展平面 subagent、workflow、todo、policy

如果删掉 agent-spine,其他条目还会不会自动变成 Agent?
删除 agent-spine 后,剩下的所有插件仍然能正常工作,但它们只能被“单独调用”,无法被“串联执行”。Agent 不是一个“插件仓库”,而是一个“流程引擎”——这个引擎就是 agent-spine 提供的。

实验 B:预测 patch 的覆盖结果

假设原始配置中有:

- id: bash
  name: '@deepseek-ai/dsh-bash-local'
  config:
    timeoutMs: 60000

Profile patch 将它替换为:

- id: bash
  config:
    timeoutMs: 5000
  1. name 是否会自动保留?
  2. 答:name 不会自动保留,它会被覆盖掉。DSH 的 Patch 是“整体替换条目”,而不是“深度合并对象的某个字段”。
  3. 如果没有写 name,Loader 最终会怎样?
  4. 答:如果没有写 name,Loader 会找不到对应的 npm 包,导致加载失败。

根据 DSH 的规则,patch 替换整个 config,而不是深度合并;条目本身的字段也必须符合最终条目 schema。这个实验的价值在于区分“配置层覆盖”和“对象深合并”。

可选运行实验

当依赖安装完成后,可以尝试:

dsh --profile headless --dump-config

再对比:

dsh --profile headless "只输出当前工作目录,不要修改文件"

前者观察最终装配结果,后者观察装配结果如何进入 Agent Loop。两者不要混为同一个实验:一个验证 composition,一个验证 runtime behavior。


8. 这一篇真正应该带走的结论

结论一:Agent 是组装出来的,不是一个类实例

模型、会话、工具、权限、沙箱、压缩和子 Agent 都可以由不同插件提供。Agent Loop 只是其中一个协调者。

结论二:Profile 是产品边界,Bundle 是发行边界,Patch 是用户控制边界

三者解决的是不同问题:

Profile  -> 这次产品运行加载哪些能力
Bundle   -> 一组默认能力如何被分发
Patch    -> 用户如何覆盖或扩展这次运行

结论三:组合算法本身是运行时契约

真实 boot、配置 dump、HMR 重新加载都必须遵守同一套 applyEntryPatches 语义。否则看到的配置就不能解释实际行为。

结论四:可替换性来自服务边界,而不是来自“插件很多”

如果插件只是把代码拆成很多目录,却彼此直接 import 实现,系统仍然不可替换。真正的可替换性来自 Service Definition、Provider、Consumer 三者之间的 seam。


9. 闭卷复述:不要马上回看源码

请合上文档,用自己的话回答:

  1. 为什么 Profile 不能简单等同于一个 npm package?
  2. DSH 的 Bundle 为什么必须携带 patch,而不只是携带实现代码?
  3. Profile patch、home patch、CLI overlay 的优先级顺序是什么?
  4. 为什么 patch 替换整个 config 会提高可审计性,同时增加什么维护成本?
  5. boot() 除了挂载插件,还负责哪些失败与清理工作?
  6. 为什么 ACP 可以复用 Agent spine,却不加载 Web 或人机交互相关能力?
  7. 如果把本地文件系统换成远程沙箱,最理想的改动边界在哪里?

如果你能不看原文画出“命令 → Profile → Bundle → Patch → Loader → Agent Runtime”这条链,才算真正完成这一篇。


下一篇预告:Cordis 为什么能承载这么多变化?

这一篇我们看到的是“运行时如何被装配”。下一篇继续追问:

插件被 Loader 挂载之后,服务是怎样互相发现、等待、注册和卸载的?

答案会落到 Cordis 的 Context、Service、Fiber、事件和可逆副作用上。那一篇开始,我们会从“配置结构”进入“运行时生命周期”。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐