DeepSeek Harness 架构全图:host、client、runtime 这三段到底切在哪一刀

2026-08-17

先说清楚本文的前提:DeepSeek Harness 的 README 自述当前处于开发者预览阶段,并明写未来会出现破坏兼容性的变更。下面提到的所有文件路径、字段名与默认值,都是仓库快照里的样子,随时可能被改掉。

一个具体问题:一个值想给两边同时用,该放哪儿

假设你要给 web GUI 加点东西,写着写着发现有个常量、有个 wire 类型,浏览器侧要用,Node 侧也要用。凭直觉你会想:那就放到一个”公共包”里,两边都 import 一下。

在这个仓库里,这个直觉会撞墙——而且不是运行时才撞,是 typecheck 和 build 两道关分别撞一次。要理解为什么,得先知道这条线画在哪。

“三段”这个说法,第一步就得拆开

packages/ 下有 49 个分组目录,packages/*/* 是两层 glob,往下数一共 219 个带 package.json 的叶子包(注意别把 49 当成包数,这两个数完全不是一回事)。其中 packages/client/ 39 个、packages/host/ 8 个、packages/core/ 8 个。

包名里带 “runtime” 字样的,在这 219 个里正好有四个,彼此没有从属关系:

目录包名干什么的
packages/client/runtime@deepseek-ai/dsh-client-runtime客户端 cordis 启动与不依赖 React 的对象服务
packages/code-runtime/code-runtime@deepseek-ai/dsh-code-runtime代码执行能力(ctx.codeRuntime)的 Service Definition
packages/code-runtime/code-runtime-worker-thread@deepseek-ai/dsh-code-runtime-worker-thread同一个 seam 的 worker 线程实现
packages/test-support/client-runtime@deepseek-ai/dsh-client-test-runtime客户端功能包共享的仓库测试支持

另外还有一个分组目录直接叫 runtime-diagnostics,可它底下那个叶子包的名字里反倒没有 runtime 二字,叫 @deepseek-ai/dsh-invariants(README 自述是包自有运行时不变量的注册表服务)。所以按目录名搜和按包名搜,捞到的根本不是同一批东西。

所以”host、client、runtime 三段”这个说法,落到源码上会立刻裂开:前两个词在这个仓库里有精确所指,第三个词没有。packages/client/README.zh.md 那张表里还把上表第四行那个包以 test-runtime 的名义列出来,链接却指向 packages/test-support/client-runtime——同一个东西在两个位置有两个叫法,第一次翻的时候很容易找错地方。

真正的硬切写在 tsconfig.json 里,而且它是空的

打开仓库根的 tsconfig.json,正文只有三个键:extendsfiles: []references。references 里只有两项,./tsconfig.host.json./tsconfig.client.json

文件里的注释自述了这样做的理由:files: [] 让这个 solution 保持 program-less,“这样 host/client 两侧的 cordis Context merge 永远不会碰面”;注释还留了两句硬要求——不要往里加 include/files,也不要把这个 solution 拍平成单个 ts.Program。

tsconfig.client.json 头部的注释把原因说得更细:两侧都在同样的 key(注释举了 sessionsloader)上 merge cordis Context,但注册的是不同的服务实现;共享的叶子包只构建一次,再由两个 program 各自通过 project reference 引到。

这是文档自述的设计理由,我们没有推断更多。但它足以解释开头那个直觉为什么会撞墙:这里不存在”两边都能看见的那个 program”,所以也就不存在一个”两边随便 import”的公共包。

这条线是从包里穿过去的,不是绕着包走

最容易被目录名误导的就是这一步。整个仓库里,同时拥有 tsconfig.host.jsontsconfig.client.json 两份配置的包只有三个:

  • packages/api/gateway
  • packages/api/remotes
  • packages/client/connection

packages/client/connection 看具体形状。它一个包里有三份 tsconfig,其中两份各自写着一份显式的 files 列表(不是 include 通配,是逐个文件点名):

  • tsconfig.host.json 列了 9 个文件,包括 src/api-request-trust.tssrc/http-bridge.tssrc/rpc-host.tssrc/websocket-downlink.ts
  • tsconfig.client.json 列了 10 个文件,包括 src/client/connection.tssrc/client/rpc.tssrc/client/random-uuid.ts

两份名单里同时出现的,只有 3 个文件:src/api-path.tssrc/loopback-hostname.tssrc/rpc.ts。这三个就是这个包里被两个 program 各编一遍的部分,其余按 face 分家。

注意这个包住在 packages/client/ 下,名字里带 client,但它有一半属于 host program。反过来也一样:tsconfig.client.jsonexclude 里明写了 packages/client/*/tests/**/*.host.spec.ts,并附了一句解释——*.host.spec.ts 覆盖的是一个被拆分的 client 包的 host 那一半,它归 host 聚合管,而 host 聚合又反过来排掉这个 program 的 *.client.* 文件;因为 exclude 优先于 include,上面那条测试通配才敢写得很宽。

所以在 packages/client/connection/tests/ 里你能看到 api-request-trust.host.spec.ts 这种文件名。face 的分界线是文件名后缀,不是目录。 这一点如果不知道,光看目录规划改动范围会规划错。

packages/api/ 的 README 自述了这层关系里剩下的一半:运行时依赖方向是 remotes → gateway → connection → webserver,Gateway 把传输交给 Connection,Connection 再挂到 HTTP server 上。同一份 README 的”已知限制与延期工作”里还写着,Connection 和 WebServer 目前仍分别住在 packages/client/connectionpackages/host/webserver,将来可以只移动包、放到 api/connectionapi/webserver 下而不改服务约定;旧的 API Proxy 仍留在 packages/host/apiproxy,作为尚未迁到 Remote 的方法的回退路径。也就是说,目录位置和分层归属在这里本来就是对不齐的,官方自己把这件事记在案上了。

构建侧是同一条线,而且只认两个值

tsdown.config.ts 里有个 isBuildFaceClient(),逻辑短到可以一眼读完:undefined'host' 返回 false,'client' 返回 true,其他任何值直接抛错,错误文案是 tsdown: --env.DSH_BUILD_FACE must be host or client, received ...

这句 throw 就是本文标题那个问题最直接的答案:构建面只有两个,没有第三个。同一份配置里 workspace 写的是 ['vendor/*', 'packages/*/*', 'apps/cli']platform: 'node'target: 'es2024';host 那一趟还会挂 typertPlugin,client 那一趟 plugins 为空数组。

purity gate:浏览器 bundle 能 inline 谁,是白名单说了算

再往下一层是 packages/client/tsdown.client.ts。它给每个 UI 插件包生成浏览器 bundle 的配置,里面挂了一个名为 dsh-client-bundle-purity 的插件,resolveId 只做一件事:凡是以 @deepseek-ai/ 开头的 specifier,不在白名单里就直接 throw new Error

白名单一共三档:

  1. CLIENT_EXTERNALS——由 packages/client/web/src/platform.ts 里的 PLATFORM_MODULES(10 项:reactreact/jsx-runtimereact-domreact-dom/client@deepseek-ai/cordis,加上 ui-slotsweb-reactui-primitivesui-attachmentschema-form 五个客户端包)再加一项例外构成,共 11 项。
  2. INLINE_SAFE——一条正则,匹配 dsh- 后接 host-apiproxysessionllmtoolsbrand 这五个名字之一。源码注释把它们描述为”浏览器安全的契约层,没有需要共享的运行时身份(没有 Symbol / instanceof / 单例状态)“。注意第一个是 dsh-host-apiproxy:名字里带 host,却在允许被浏览器 bundle inline 的名单上。这又是一处目录名/包名和 face 对不上的地方。
  3. 另外两条小口子:被 rescope 进 @deepseek-ai 的 vendored 库(cosmokitschemastery),以及形如 @deepseek-ai/dsh-xxx/remote 的生成式贡献。

除此之外的跨插件值导入一律 build 期报错。报错文案里给了替代路径:跨插件协作走 cordis service,而类型导入会被擦除、根本到不了这个 gate。

“runtime” 在这条线上的真实位置

CLIENT_EXTERNALS 里那”再加一项”,常量名叫 RUNTIME_STORE_EXEMPTION,值是 '@deepseek-ai/dsh-client-runtime/client'

它头顶的注释写得很直白:这是一条有文档记录的临时豁免,不是平台模块(所以没写进 platform.ts);snapshot-store 引擎(createSnapshotStore / defineStore / shallowEqual)眼下住在 runtime 里,等待 promotion 时重新安家;注释还点了名有五个 importer 靠这一条豁免(locale、ui-layout,以及 ui-conversation 的三处),并留了 TODO(webload/store-rehome),说明它会随 store 引擎搬家一起移除。

所以,如果一定要给”第三段”找一个落点,它是这个:dsh-client-runtime 是 client face 内部的一个包,它出现在跨 face 的白名单里,靠的是一条源码里自己标注为临时、并挂了 TODO 的豁免。把它当成和 host、client 并列的第三层来理解,和源码对不上。

顺带一提,packages/client/runtime/README.zh.md 的”已知限制与暂缓事项”里还写了两条你迟早会碰到的:loader.unload 目前是 stub,调用会抛 not-implemented,客户端没有从 fiber dispose 到注册与样式移除的完整卸载链;以及插件 bundle 从这个包导入值时必须走 /client 子路径,裸包名不在 loader 的 externals 表里,会内联出第二个模块实例,它私有的 scope-tag Symbol 永远匹配不上。这两条都是 README 原文写明的当前限制,不是我们的推测。

上面这条线之外,还有一层是运行期才组装的

前面讲的都是编译期的切法。运行期的组装是另一套词:docs/architecture.zh.md 里写明,跑起来的 dsh 是一棵插件树,由启动时按序叠加的各层组成——先按 profile 列出的顺序应用每个组合包,然后是 profile 的 cordis.patch.yml,再是 home 级的那份,最后是任意 --patch overlay。webheadless 作为 profile 模板随发行版交付,dsh-base 是每个 profile 的第一层,dsh-web-app 加浏览器应用,dsh-headless 加一次性运行器且完全不带服务器。

这两套东西的层次是错开的:编译期的 host/client 决定”这段代码进哪个 program、进不进浏览器 bundle”,运行期的 profile/组合包决定”这次启动挂哪些插件条目”。同一个包可以在编译期属于 host face,运行期却因为 profile 没列到而根本不加载。

文档给的自查手段是这一条:

dsh --profile web --dump-config

docs/architecture.zh.md 自述它打印出的任何条目,都可以由你自己的 patch 按 id 定位并替换整个 config,或者插入新条目。至于默认端口,packages/bundle/web-app/cordis.patch.yml 第 120 行写的是 port: !!js ctx.webStartup.port ?? 3080,README.zh.md 里给的地址也是 http://127.0.0.1:3080——这是配置里的默认值与文档口径,不是对你机器上实际行为的保证。命令行入口在 apps/cli,它的 package.jsonname@deepseek-ai/dshversion0.1.0-rc.5bindsh 指到 lib/bin.js。这些同样属于开发者预览期的内容,以仓库最新版本和 --help 的实际输出为准。

改动前,先问自己落在哪个 face

把上面这条线倒过来用,就是一份可执行的判断顺序:

  1. 这段代码要不要进浏览器 bundle?要,就是 client face,去看 tsconfig.client.json 的 include 名单和 purity gate 白名单。
  2. 它所在的包名带不带 client,不作数——看它有没有自己的 tsconfig.host.json / tsconfig.client.json,以及文件名有没有 .host. / .client. 后缀。
  3. 想让两个 face 共用一个值,先确认它落不落进那 11 项 CLIENT_EXTERNALSINLINE_SAFE 那五个前缀。都不落,就别指望直接 import,按报错文案说的走 cordis service。
  4. 编译期通了不代表运行期挂得上,那是 profile 与组合包那一层的事,两层要分开确认。

这张图最反直觉的地方就在第 2 条:目录名给了你一个整齐的三段印象,实际的刀口在 tsconfig 的 files 列表和文件名后缀上。知道这一点,翻这个仓库会省下很多来回。


本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的 架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。 本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目, 因此不涉及界面外观、操作手感与运行速度的任何描述。 该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。