乐于分享
好东西不私藏

DeepSeek Harness源码解读:点击 dsh web 后,真正启动 Agent 的并不是 CLI

DeepSeek Harness源码解读:点击 dsh web 后,真正启动 Agent 的并不是 CLI

本文是一个系列,建议从头开始阅读,上一篇:

为什么我要用 10 篇文章,拆解 DeepSeek Harness

你在终端里敲下 dsh web --port 8080,浏览器页面很快出现。一个很自然的猜想是:CLI 读懂了 web,也读懂了 --port,然后亲手启动 Web 服务和 Agent。

这正是读 DeepSeek Harness 入口时最容易带进去的错觉。

dsh 确实先接到整条命令,但它并不拥有命令中每一个参数。它更像一个分流员:识别这次要走哪条启动路线,再把属于具体运行表面的参数继续交下去。

所以,8080 不是 CLI 写进某份“全局插件配置”的值。CLI 甚至不解释它。真正理解 --port 的,是后面 Web 表面的启动插件。

先说结论

先不用记任何内部类型,只记住三个判断。

第一,CLI 选择路线,并把剩余参数原样向后传。

第二,Web 自己解析和校验 --port

第三,Agent Loop 位于更晚才挂载起来的插件树中,不在 CLI 分派代码里。

整条链可以先压成一行:

argv → parseDshArgs() → mode dispatch → runProfile() → plugin tree → Web startup

这行流程比“CLI 启动 Agent”准确得多。前半段回答“走哪条路”,中段回答“装起哪棵树”,后半段才回答“这个表面如何开始工作”。

下面沿着四步往下走。每一步只问一个问题:这一层究竟拥有什么决定权?

第一步:CLI 只拿走自己拥有的参数


先看 apps/cli/src/args.ts 里的 parseDshArgs()

这里负责的是启动器参数:选择 --profile,追加可重复的 --patch,或者要求 --dump-config--dump-default-config。这些参数共同决定的,是启动哪套组合,以及是否根本不要启动。

但 --port 不在这张表里。

解析器允许未知选项继续通过,并保留位置参数的顺序。于是 dsh web --port 8080 被切成两部分:外层知道这是 web profile;内层仍拿到 --port 8080。参数没有消失,只是解释权被留给了真正拥有它的表面。

这也解释了一个看似细小、实际很关键的现象:dsh web --help 应该展示 Web 自己的帮助,而不是只展示最外层启动器的帮助。如果所有 flag 都被 CLI 抢先解释,各个表面就很难拥有独立的语法、默认值和错误提示。

外层参数与表面参数因此不是“公共参数”和“冷门参数”的区别,而是所有权不同。--profile 决定装哪棵树,--patch 决定给组合再叠哪层覆盖;--port 则只在 Web 表面里有意义。

第二步:profile、plugin、dump 是三条路线


解析完成以后,CLI 会得到一个带 mode 的结果。此时它做的仍然不是 Agent 工作,而是分派
普通启动进入 profile 路线;dsh plugin 进入插件依赖管理路线;两个 dump 选项进入只打印组合的路线。web 不是藏在入口里的第四套运行机制,它只是固定选择 web profile 的便捷别名。
来看 apps/cli/src/bin.ts 模块顶层的 launcher dispatch。下面是一段连续源码:

这段代码证明,只有 profile 路线会调用 runProfile();plugin 与 dump 都直接转向别的模块。末尾真实存在的 default 分支既做类型穷尽检查,也保留运行时错误。

更重要的是,CLI 把 profile、patch、环境和剩余 args 一起交出去,却没有在这里读取端口、创建 Agent 或发送消息。

为什么要把旁路分得这么清楚?因为管理 profile 的插件依赖,不需要先启动整套应用;查看配置组合,也不该为了“看一眼”而打开 Web 服务。入口越早分流,无关代码越少被加载,失败边界也越清楚。

dump-config 尤其容易被名字骗到。它打印的是启动前已经合成的配置树,而不是应用运行一段时间后的状态快照。它不会告诉你这次 Web 最终怎样解释 --port,更不会导出某个 Agent 此刻正在执行到哪一步。

第三步:runProfile() 负责组装,不负责思考


profile 路线接下来进入 apps/cli/src/profile-boot.ts。函数名叫 runProfile(),很容易让人脑补成“开始运行 Agent”。源码里的实际职责更接近搭台:找到 profile,合成 patch 层,建立退出与信号处理,再把组合后的树交给 boot

这棵树并非只有一份写死的配置。

最下面是随发行物提供的 bundle 层,它给出一套能工作的默认答案;其上还可以叠 profile 自己的层、用户 home 层,以及命令行 --patch 指定的 overlay。后来的层能够覆盖或补充前面的组合。

因此,Web 与 Headless 是“shipped defaults”,不是两条不可改变的铁轨。发行版默认把哪些插件装进 Web、把哪种 runner 装进 Headless,只是这个固定提交给出的组合。使用者仍能通过 profile 和 patch 替换、增加或调整其中的行。

可替换也不等于可以随意拼装。插件之间仍有明确的服务依赖:消费者要等提供者出现,配置要通过校验,生命周期也要正确收尾。patch 改变的是树怎样组成,并不会把所有命令行 flag 变成任意插件都能读取的全局字典。

runProfile() 还会在配置树正式挂载前提供两类启动事实:一份分层环境的快照,以及包含剩余 argv 和受控退出能力的命令行服务。表面插件显式取得这份服务,才有资格解释自己的参数。

这条窄门很有价值。CLI 无需提前知道未来每个表面会增加什么选项;表面也不必偷偷读取进程的全局 argv。双方只约定“原样交付参数”,语义与校验留在参数真正所属的边界上。

第四步:Web 与 Headless 共享核心,寿命不同


参数终于来到 Web 表面时,packages/bundle/web-app/src/startup.ts 才建立 Web 命令、读取 host 和 port,并把成功解析的结果发布成 webStartup 服务。

来看这个文件中 apply() 的连续源码:

这段代码证明,端口的语义、数字校验和类型转换都属于 Web;通过以后,Web 才提供自己的启动服务。

CLI 没有全局拥有 --port,其他插件也不会因为命令里出现这个词就自动收到端口配置。

Web 后续会在自己的组合里准备服务器、前端资源、运行时信息和页面交互。它提供的是一个持续等待浏览器请求的表面,但这不意味着它复制了一套 Agent 核心。

对照 packages/bundle/headless/src/index.ts 会更清楚。Headless 也是建立在共享核心服务上,却采用一次性 driver:等待插件装稳,创建 Agent,投递用户任务,等它空闲,刷新 Session,打印最后的 assistant 文本,然后请求退出。

两者的差别首先是交互表面和生命周期Web 面向持续会话与浏览器连接;Headless 面向一项任务及一次进程退出。Agent、Session、模型和工具等核心能力可以共享,驱动这些能力的方式却不同。

这也给阅读源码提供了一个实用方法:

  • 想查命令为什么走到这里,回看 apps/cli/src/args.ts 和 apps/cli/src/bin.ts

  • 想查组合装了什么,去看 apps/cli/src/profile-boot.ts

  • 想查端口为什么报错,直接去 Web startup;

  • 想查一次性任务怎样真正进入 Agent,再去 Headless runner。

不要让一个 dsh 命令名遮住后面几层不同责任。

换三条命令,再看一次边界


只读抽象流程,有时仍会把几层责任揉在一起。换三条相近的命令,边界会变得很直观。

先看 dsh web --port abc。外层仍然能够完成分流,因为它只需确认这是 Web profile,并把剩余参数送进树。直到 Web startup 取得参数,abc 才因为不是纯数字而被拒绝。错误出现在这里,并非 CLI “漏做了校验”,而是校验被放在真正知道端口语义的位置。

再看 dsh web --dump-config。这次 profile 名仍然是 web,但 mode 已经改成 dump。入口会打印合成结果然后结束,不会走进 runProfile(),自然也不会执行 Web startup。于是它既不开端口,也不解析端口,更不会为了展示配置而悄悄创建一个 Agent。

最后看 dsh --profile headless "整理当前目录"。外层识别 profile 以后,把任务文本继续交给 Headless 表面。后面的 startup 把位置参数组织成 task,runner 才借助已经挂载好的 Agent、模型和 Session 服务执行一次任务。这里真正驱动 Agent 的,是组合树中的 Headless runner,不是最前面的参数解析器。

三条命令的共同点,是 CLI 始终只回答路线问题。三条命令的不同点,则由各自走到的边界决定:Web 负责端口,dump 负责展示组合,Headless 负责一次性任务。这样对照以后,再遇到一个新 flag,可以先问“谁拥有它”,而不是先在入口文件里全文搜索。

这套分层为什么值得在意


对普通使用者来说,最直接的收益是报错更诚实

Web 的参数错误由 Web 给出,Headless 的任务错误由 Headless 给出,外层只报告 profile、patch 或 mode 的问题。看到一条错误信息时,可以顺着所有权定位,而不用猜它究竟来自哪一层万能配置。

对扩展者来说收益是新增表面不必不断膨胀中央 CLI

一个新表面可以拥有自己的命令描述、帮助信息、参数校验和启动服务;入口只需把它作为某个 profile 组合起来。没有使用这个表面的进程,也不必为它加载整套运行代码。

对维护者来说收益是启动时事实与长期配置被分开了

一次调用里的端口和任务会随着进程结束而消失;profile、home 与 patch 层描述的则是插件树怎样组成。前者适合由 startup provider 发布成服务,后者适合进入可重组的配置树。把二者混在一起,热重载、测试和错误恢复都会变得含糊。

这套设计也没有承诺“任何组合都安全”。插件仍然运行在进程里,错误 patch 仍可能破坏依赖,错误表面也可能暴露不该暴露的能力。

分层提供的是清楚的解释权和生命周期边界,不是自动生成的安全沙箱。读源码时把这两件事分开,才能既看见设计的价值,也不夸大它的保证。

以后再读任何入口,都可以先给三个动作找主人:谁解释输入,谁组装依赖,谁执行长期工作。文件名可能变化,表面也会增加,但这三个问题能稳定地把调用链重新拆开。

三个常见误解


误解一:CLI 就是 Agent Loop。

CLI 只解析自己拥有的外层语法、选择 mode,并把启动事实送进插件树。真正创建 Agent、投递消息、等待执行的代码位于后续组合中;不同表面甚至可以用不同寿命去驱动同一套核心能力。

误解二:所有 flag 都是全局插件配置。

--patch 会影响配置树的组合,--port 却由 Web startup 解释并发布成领域服务。二者都出现在一条命令里,不代表它们属于同一层,更不代表每个插件都能直接读取。

误解三:dump-config 能导出运行状态。

dump 路线在入口处分流,只展示 boot 之前的组合结果。它没有挂载表面,也没有创建 Agent,所以不能回答运行中的 Session、端口绑定或 Agent 进度。

小结


回到最开始的 dsh web --port 8080:CLI 认出 Web 路线,保留 --port 8080 的顺序,把它随 profile 启动事实交给组合树;Web startup 再自行校验端口并提供服务。Agent Loop 是后续挂载的共享核心插件;Web 与 Headless 的具体 driver 只按各自生命周期触发、投递并等待它。

这个边界让入口保持稳定,也让表面能够各自拥有参数、帮助和错误信息。随发行物提供的 Web、Headless 组合是一套默认答案,profile 与 patch 仍可替换它们;能替换的是组合,不能绕过的是服务依赖和生命周期契约。

本文依据源码提交为 47f943859bef60e4160492346772ded9b24f765a

下一篇


CLI 把插件树挂起来,只解决了“哪些东西会被装进来”。树开始运行后,还有更微妙的问题:一旦插件树被挂载,谁能看见一个服务,又是谁负责移除一项注册?

下一篇,我们进入 Cordis 的 Context 与 Fiber,看看服务可见性、所有权和清理为何其实是同一套生命周期问题。