
本文是一个系列,建议从头开始阅读,上一篇:
为什么我要用 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 是三条路线

这段代码证明,只有 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,看看服务可见性、所有权和清理为何其实是同一套生命周期问题。
夜雨聆风