LESSON 03 · 建立全貌

从 pnpm dsh web 看完整启动过程

一条命令怎样一步步变成一套可以接收任务的 Web Agent?

上一课我们把 Profile、Bundle、Preset 和 Plugin 放进了同一张架构图,知道 DSH 会先装配公共 Host,再为 Session 装配具体 Agent。现在沿着一条真实命令进入第一阶段,看看磁盘上的程序和配置怎样变成正在运行的系统。

这一课就跟着一条命令,把这个过程从头走一遍:

pnpm dsh web

它是一条启动 Harness 宿主的命令,通常在已经安装依赖的源码仓库根目录执行。它会选择 Web 运行方案,装配公共服务和 Web 入口,启动本地服务器,并在默认情况下打开浏览器。

整个过程可以先压缩成四步:

选择 Web 运行方案 → 装配插件 → 启动 Web Host → 等待用户创建或打开 Session

本课只跟到 Web Host Ready。当用户在网页里新建或恢复 Session 时,系统还会进行第二次装配,为这个 Session 创建具体 Agent;那是后面“Agent 的出生”一课要讲的内容。

先不用记住每个函数名。阅读下面的流程时,只要始终问一句:这一阶段接到了什么,又准备好了什么?

1

三种写法分别表示什么

在 DeepSeek Harness 源码仓库里,开发者经常使用:

pnpm dsh web

如果已经把 dsh 安装成命令行工具,可以写成:

dsh web

把其中的快捷写法完全展开,则是:

dsh --profile web

web--profile web 的明确别名,意思是“选择名为 web 的整套运行方案”。它不是一个 Web 插件的名字,也不是模型的工作模式。

pnpm 在这里所做的事情也很简单:找到仓库 package.json 中名为 dsh 的脚本,再从源码进入 apps/cli/src/bin.ts。安装后的 dsh 命令则从已经安装的程序进入同一套启动逻辑。

所以,这三种写法在“选择 web Profile”这件事上效果相同。区别主要在代码来自哪里:pnpm dsh web 运行当前仓库里的源码,dsh webdsh --profile web 通常运行已经安装的版本。如果两边版本不同,实际加载的插件与功能也可能不同。

2

第一站:命令先被翻译成启动意图

CLI 入口拿到 web 以后,会调用 parseDshArgs()。这一步只负责理解启动命令,还不会创建 Cordis Context,而是先把命令翻译成一个清晰的结果:

mode = profile
profile = web
patches = []
args = []

这一步像公司前台收到一句“按 Web 方案组建团队”,先把口头要求登记成正式工单。后面的代码不必再猜用户输入了什么,只需要处理“启动 web Profile”这个确定任务。

如果用户还写了 --port--no-open 等参数,启动器会把这些参数原样交给 Web 插件。启动器只负责选择 Profile,不替具体应用理解所有参数。

3

第二站:读取 web Profile

解析完成后,apps/cli/src/bin.ts 会把这个结果交给 runProfile()。它负责组织整个宿主启动过程,并继续查找名为 web 的 Profile。

Profile 可以理解为一份有名字的宿主启动方案。它回答的是“这次要把整套程序启动成什么形态”。Web、一次性命令和其他入口需要的公共能力相似,但交互方式不同,因此可以使用不同 Profile。

Profile 本身不会亲自列出上百个插件。它主要指定要按顺序叠加哪些 Bundle。内置的 web Profile 包含两块主要配置层:

  • dsh-base:公共 Agent 底座;
  • dsh-web-app:浏览器应用和 Web 服务。

这两个 Bundle 的出处在哪里

这里的 dsh-base + dsh-web-app 不是教程为了方便讲解而自行拼出来的组合,它直接来自 DeepSeek Harness 的启动源码。

源码相对路径是:

packages/boot/app-boot/src/profile.ts

在这个文件的 PROFILE_TEMPLATES 常量中,官方明确写出了内置 Profile 与 Bundle 的对应关系:

/** The shipped profile templates auto-initialized on first use, by name. */
export const PROFILE_TEMPLATES: Record<string, readonly string[]> = {
  web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'],
  headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'],
}

这段代码可以直接读成:选择 web Profile 时,按顺序使用 @deepseek-ai/dsh-base@deepseek-ai/dsh-web-app;选择 headless Profile 时,则复用同一个 base,再叠加 @deepseek-ai/dsh-headless

为什么强调“按顺序”?因为后面的 Bundle 可以在前一层基础上增加或调整配置。dsh-base 先准备公共底座,dsh-web-app 再补上 Web 运行形态需要的插件。如果顺序颠倒,第二层就失去了它要叠加的基础。

同一个文件里的 loadProfile() 还展示了这份模板怎样真正参与启动。第一次使用一个尚未生成本地配置的内置 Profile 时,系统会取出对应模板并初始化 Profile;随后再读取其中的 bundles,逐个解析成配置层:

if (!existsSync(join(dir, 'package.json'))) {
  const template = PROFILE_TEMPLATES[name]
  if (template === undefined) {
    throw new Error(
      `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add <package>'`,
    )
  }
  initProfile(dir, template)
}

const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir))
const bundles = manifest.dsh?.profile?.bundles ?? []
const layers = bundles.map((packageName): ProfileLayer => {
  const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir)
  const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest
  const declared = bundleManifest.dsh?.bundle?.patch
  if (declared === undefined) {
    throw new Error(`${binName}: profile bundle ${JSON.stringify(packageName)} declares no dsh.bundle in its package.json`)
  }
  const patchPath = join(packageDir, declared)
  return { packageName, packageDir, patchPath, patches: loadOverlayPatches(binName, patchPath) }
})

PROFILE_TEMPLATES 决定默认名单,loadProfile() 再按照名单顺序加载每个 Bundle。

dsh-base 带来 Session、模型注册、工具系统、Agent Loop、持久化、设置、凭据和安全策略等公共能力。

dsh-web-app 在它上面增加 API、Web Server、前端资源、Preset 管理界面等 Web 入口需要的能力。

到这里,输入的 web 已经不再只是一个命令行单词,而是变成了两组有先后顺序的配置层。至于 Profile、Bundle 和 Patch 在磁盘上分别是什么,后面的配置课会单独拆解;本课先关注它们在启动主线中的位置。

下面这张图先把第一阶段的主线串起来。顺着箭头读,就能看到一条命令怎样逐渐变成正在运行的 Web Host。

流程图 · 手机端可横向滑动
正在绘制流程图…
4

第三站:把 Bundle 展开成插件配置树

每个 Bundle 的 package.json 会声明自己的配置补丁文件。loadProfile() 找到这些文件后,composeEntries() 再按顺序把 Bundle、Profile 自己的修改以及用户附加的修改叠加起来。

可以把 Bundle 想成装修公司提供的“整屋套餐”。套餐不是一件家具,而是一张清单:客厅放什么、厨房放什么、每件东西怎样配置。

Profile 选择多个套餐;Patch 再对套餐里的具体项目进行替换或新增。最后交给启动器的已经不是“两个 Bundle”,而是一棵完整的 Cordis 插件配置树。树中的每一行都会告诉 Loader:加载哪个插件、使用什么配置,以及它在这套系统中的标识是什么。

如果想看自己的机器最终会启动什么,可以使用:

dsh --profile web --dump-config

它比只读默认配置更可靠,因为它展示了 Bundle、Profile Patch、Home Patch 和命令行 --patch 叠加后的最终结果。

5

第四站:Cordis 真正挂载插件

插件树准备好以后,boot() 创建 Cordis Context,安装 Loader,然后把最终配置树挂进去。

Cordis Context 是所有插件共同工作的运行环境。插件可以在这里提供服务、声明依赖、监听事件,也可以在卸载时清理自己注册的内容。下一课会专门解释 Cordis;在这条启动主线上,先把它看作负责组织插件上岗的系统即可。

Loader 会逐项加载插件。一个插件需要某项服务时,会通过 inject 声明依赖;服务尚未出现,它就先等待。提供服务的插件启动后,依赖满足的插件才会继续激活。

如果一个启用的插件加载失败,或者一直等不到必须服务,启动检查会明确报错。

因此,“系统启动完成”并不只是 Node.js 进程还活着,而是配置树中所有应该激活的插件都已经就绪。到了这里,前面那份静态配置才真正变成一套正在运行的 Harness。

下面用一张图来整体展示一下这个过程:

DeepSeek Harness Web 宿主的五步启动过程

图 3-1:命令选择 Profile,Profile 展开 Bundle,Cordis 挂载插件,最后得到可运行的 Web Host。

图里的 parseDshArgs()runProfile()composeProfile()loadProfile()composeEntries()boot(),可以当成启动源码中的几个重要路标。函数内部还有很多错误处理和生命周期管理,但第一遍阅读时,不必马上钻进去。

6

第五站:Web Host 就绪,但 Agent 还没出生

boot() 返回以后,公共宿主已经具备了运行条件:

  • Sessions 可以创建和读取会话;
  • LLM 服务可以找到可用模型;
  • Tools Registry 可以接受工具注册;
  • Agent Loop 可以创建并驱动 Agent;
  • API 与 Web Server 可以接收浏览器请求;
  • AgentPresets 可以发现可用 Preset。

如果没有使用 --no-open,系统通常还会打开默认浏览器;即使不自动打开,终端也会给出可以访问的本地地址。此时网页能够加载,API 能够接收请求,进程会继续运行,等待用户操作。

但“网页已经打开”仍不等于“某个 Agent 已经出生”。就像办公楼、网络、会议室和公共系统都准备好了,但某个项目小组还没有组建。

只有用户创建或恢复 Session 时,系统才进入第二阶段:选择 Preset,为这个 Session 准备一个 Agent。

现在回头看,pnpm 决定从当前源码运行,web 决定选择哪套 Profile,Profile 与 Bundle 决定宿主要安装哪些插件,Cordis 负责让这些插件真正开始协作。它们共同完成的是第一阶段的宿主装配,而不是一次模型调用。

7

源码阅读路线

第一次结合源码阅读,建议先看下面五处:

  1. apps/cli/src/bin.ts:命令行总入口;
  2. apps/cli/src/args.ts:parseDshArgs() 如何识别 web;
  3. apps/cli/src/profile-boot.ts:runProfile() 与 composeProfile() 怎样收集各层配置并发起启动;
  4. packages/boot/app-boot/src/profile.ts:loadProfile() 与 composeEntries() 怎样解析和叠加配置;
  5. packages/boot/app-boot/src/index.ts:boot() 如何创建 Context 并挂载插件树。

阅读时不必从每个文件的第一行看到最后一行。先沿着这几个函数确认“上一步交来了什么、下一步又拿走什么”,把主干连起来,再去看某个 Bundle 里到底列了哪些插件,会轻松很多。

下一课,我们要解决更根本的疑问:这些插件明明互相独立,Cordis 到底用什么办法把它们连接起来?

下一课 · 04

零散插件为什么能组成完整系统?

插件彼此独立,谁来连接它们、检查依赖并管理生命周期?

0 人点赞,0 人看过