首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >拆解 dsh|能力接缝:底层实现替换的影响边界

拆解 dsh|能力接缝:底层实现替换的影响边界

原创
作者头像
七牛开发者
发布2026-09-09 16:16:40
发布2026-09-09 16:16:40
1060
举报

摘要:替换两个底层 Provider,文件、Bash、PTY 和 LSP 一起迁到远端。

本文更换了版本基线。前几篇基于 0.1.1-rc.2,当前 npx @deepseek-ai/dsh 获取的是 0.1.2-rc.1(2026-09-03 发布),对应仓库标签 dsh-v0.1.2-rc.1,commit a66e470204。文中的能力关系来自仓库生成的 docs/capability-seams.mddocs/event-producer-consumer.md,可以通过 pnpm run gen-doc-graphs 重新生成核对。dsh 仍处于 developer preview。

前面几篇的 dsh 拆解内容是从纵向结构拆解:怎么装配插件树、主循环怎么推进、如何记录状态。这一篇换个方向,看一个横向的问题,如果把一个 Provider 换成新的实现,会影响哪些上层能力

假设一个 Agent 原本运行在本机,现在要把它的执行环境搬到远程沙箱。如果 Bash、文件系统、终端和 LSP 各自持有一套本地的执行逻辑,这次迁移就需要在多个模块里分别修改。Bash 要换进程启动方式,文件工具要换读写后端,终端要换 PTY 的创建方式,语言服务器也要重新处理进程启动问题。

这种结构的问题不只在于修改点多。同一类执行能力的实现细节分布在多个上层模块里,每增加一种执行环境,都需要重新检查相关的调用链。

dsh 会先为一项可替换的能力定义统一契约,再让不同 Provider 按这份契约提供实现,上层 Consumer 也只依赖这份契约。因此,判断一次替换的影响,需要同时看两个范围:

代码语言:javascript
复制
依赖关系
→ 变化会传到哪些模块

契约边界
→ 这些模块能否继续使用新的实现

seam 的三个角色

dsh 把这种可替换能力称为 seam

一个完整的 seam 包含三个角色:

  • Service Definition:声明这项能力的契约
  • Service Provider:提供具体实现
  • Consumer:使用这项能力

以 shell 为例,可以简化成:

代码语言:javascript
复制
              Service Definition
                   ctx.shell
                  /         \
                 /           \
          Provider         Consumer
         bash-local        tool-bash
         bash-sandbox      hooks
         pwsh-local

这组关系中,关键点是三者之间的依赖方向。

Consumer 依赖的是 Definition 声明的能力契约,Provider 则负责实现这份契约。只要新的 Provider 继续满足相同的契约,Consumer 就不用感知具体实现发生了什么变化。

这也是 Service Definition 在 dsh 中以运行期 Service 形式存在的原因。稳定的服务入口让 Consumer 可以声明「我需要 shell 能力」,而无需绑定到 bash-local 这样的具体实现。

同模块的两条 seam

seam 之间并非完全彼此独立。比如本地 Bash(bash-local)执行器向上提供 ctx.shell,同时向下消费 ctx.subprocess

代码语言:javascript
复制
ctx.subprocess
      ↓
  bash-local
      ↓
   ctx.shell
      ↓
  tool-bash

ctx.subprocess 的实现发生变化时,bash-local 仍然能提供相同的 shell 能力,只是启动进程的位置从本地换到了新的执行环境。上层的 tool-bash 可以继续使用 ctx.shell,不需要知道更底层的进程实现发生了变化。这样一来,执行环境的变化可以沿着 Service 逐层传递,同时把修改范围控制在更底层的实现层。

相比让每个工具分别处理本地和远程执行,这种结构更容易控制一次环境切换涉及到的修改点。

沿着能力链传导的变化

dsh 生成的能力图里一共记录了 29 个 seam。以 ctx.subprocess 为例,它位于多项执行能力的下层,适合用来观察 Provider 替换后的传导路径。

代码语言:javascript
复制
    Bash tools       Terminal tool      LSP tool
        ↑                ↑                ↑
     ctx.shell       ctx.terminals      ctx.lsp
        ↑                ↑                ↑
   Bash executor    Terminal backend    LSP host
        └────────────────┼────────────────┘
                         ↑
                    ctx.subprocess

除了 Bash、Terminal 和 LSP,一些进程外的子 Agent 后端也依赖 ctx.subprocess。上图直观地体现了一次 Provider 替换,会沿着哪些依赖关系继续向上传递。

如果只替换 ctx.subprocess 的 Provider,上图中的变化会先到达 Bash executor、Terminal backend 和 LSP host;这些模块如果同时还向上提供其他 Service,执行行为的变化还会继续传到更上层。

例如:

代码语言:javascript
复制
subprocess Provider 被替换
        ↓
bash-local 的进程运行位置改变
        ↓
ctx.shell 的外部行为随之改变
        ↓
tool-bash 执行的命令进入新的环境

tool-bash 全程只依赖 ctx.shell,不需要知道更底层的 subprocess 运行在本机还是远程环境。这也是 seam 带来的一个核心工程收益:底层实现可以替换,原有依赖关系和上层调用方式仍然保持不变。

共享执行世界

只替换 subprocess 还不够。如果命令已经迁到远端,而文件工具仍然在读写本机目录,两边看到的就会是不同的执行环境:

代码语言:javascript
复制
Bash → 远端 /workspace
FS   → 本机 /workspace

所以,dsh 会让文件系统和子进程落在同一个 execution world 里,保证命令执行和文件读写面对的是同一套环境。

dsh 这里以 E2B 作为远程沙箱实现。E2B 提供隔离的 Linux 执行环境,文件系统和子进程分别通过 ctx.fsctx.subprocess 两个 seam 接入:

代码语言:javascript
复制
ctx.fs         → fs-e2b ─────────┐
                                 ├→ 共享一个 E2B 沙箱
ctx.subprocess → subprocess-e2b ─┘

两边共用同一个远程工作目录和沙箱生命周期。于是上层看到的是:

代码语言:javascript
复制
文件读写 ───────┐
Bash ──────────┤
Terminal ──────┼→ 同一个远程 Linux 环境
LSP ───────────┘

文件工具通过 ctx.fs 切到远端环境。Bash、Terminal 和 LSP 则沿各自的 Service 间接使用 ctx.subprocess,因此不需要分别增加一套 E2B 专用实现,上层也不用额外维护「本地 / 远程」两套分支。

这样一来,执行环境的差异主要收敛在底层 Provider。切到 E2B 后,进程启动需要经过远程环境的准备过程,延迟会高于本机执行;环境变量也需要显式传入,宿主环境不会自动带过去。这些运行特性由 Provider 这一层处理,上层工具仍然沿用原来的 Service 接口。

契约边界

到这里很容易得出一个过于简单的结论:

代码语言:javascript
复制
接口一样
→ Provider 就可以随便换

dsh 自己恰好提供了一个反例。

运行语义

ctx.subprocess 的能力图里包含多个 Consumer,但这并不代表它们都能无条件使用任意 subprocess Provider。

subprocess-e2b 创建远程进程时存在异步过程,进程 handle 会先返回,真实 PID 稍后才能得到。

Bash、Terminal 和 LSP 不依赖启动后立即获得有效 PID,因此可以沿用原来的调用方式;ACP 子进程后端则需要在启动后立刻取得有效 PID,所以无法原样切到这个 Provider。

于是这里出现了两种范围:

代码语言:javascript
复制
能力图
→ ACP 依赖 ctx.subprocess

运行契约
→ ACP 依赖的即时 PID 语义
   subprocess-e2b 当前无法满足

这个例子说明,Provider 的可替换性不只取决于接口形式,还取决于契约里约定的运行语义。除了函数名、参数和返回值,调用时序、生命周期、进程身份等行为也属于契约的一部分。

如果 Consumer 依赖了某个 Provider 特有、但没有写进契约的行为,这些隐含依赖同样会增加后续替换的成本。

错误语义

本地沙箱里也出现过类似问题。不同平台的沙箱后端,需要让上层区分几类结果:

代码语言:javascript
复制
命令自身失败
策略拒绝了操作
沙箱执行器自身异常

这三类情况表面上都可能表现为「命令没有成功」,但对应的原因和处理方式完全不同。

dsh 的一次 Landlock 故障就出在错误归因上:ripgrep 返回「没有匹配结果」时,本来只是一次正常的命令退出,却因为沙箱后端输出的信息,被误判成了沙箱不可用。

修复之后,错误判断从简单的字符串匹配改成了更明确的失败规则,让 Consumer 能进一步区分:

代码语言:javascript
复制
runner failure
→ 沙箱执行器自身出错

denial
→ 沙箱工作正常,但策略阻止了操作

这个案例也说明,Provider 的可替换性还取决于错误语义是否被纳入契约。

如果对同一种失败场景,不同的实现给出不同的错误含义,上层就得识别具体 Provider 并分别处理,替换成本也会随之增加。

策略与执行约束分层

权限和沙箱这部分也采用了类似的分层方式。

dsh 把「策略怎么定义」和「约束怎么执行」拆成两层处理。工作区范围、沙箱模式等策略由统一入口维护,Bash、文件系统等执行能力读取同一套约束,再交给具体的沙箱 Provider 落地。

可以简化成:

代码语言:javascript
复制
权限 / 沙箱策略
       ↓
统一的执行边界
       ↓
Bash / FS / Terminal
       ↓
具体沙箱实现

这样一来,工具只用消费权限结果和执行能力,无需自己维护一套沙箱规则。更换底层沙箱实现时,权限策略也不用随着每个工具重新设计。

seam 之外的扩展点

还有一类需求,不适合通过替换 Provider 来实现。

比如文件修改里的「先读后改」。文件系统 Provider 负责的核心问题是:

代码语言:javascript
复制
read
write
edit

至于「写入前是否读取过文件」「当前文件版本是否仍然匹配」这类要求,更接近文件操作的策略约束。

dsh 没有把这些规则放进文件系统 Provider,而是通过独立的 policy 插件接入文件操作流程。整体关系可以简化成:

代码语言:javascript
复制
文件工具
   ↓
策略检查
   ↓
文件系统 Provider

这样设计有两个好处。

第一,文件系统仍然保持完整的基础能力,不安装这层 policy 也可以工作。

第二,策略可以独立增加和调整,不需要为了增加「先读后改」规则再造一套文件系统 Provider。

这也给 seam 的边界提供了一种比较清晰的判断方式:如果变化的是能力本身的实现,适合放在 Provider;如果变化的是这项能力在什么条件下可以使用,更适合交给独立的策略层。

窄接口与替换成本

seam 的接口应该收多窄,是另一个需要考虑的问题。

ctx.lsp 是一个比较典型的例子。它没有把完整的 LSP 协议暴露给 Consumer,只保留四类只读导航能力:

  • 跳转到定义
  • 查找引用
  • 跳到实现
  • 读取 hover 文档

同时,它也没有提供通用的 JSON-RPC 通道,让上层绕过这层抽象。因此,不同的语言服务器都要把自身的能力适配到这几类标准操作里。某个语言服务器提供的额外功能,也不会自动通过这层 seam 暴露给上层。

相应地,这层接口的替换范围也更容易控制。对于 Consumer 来说:

代码语言:javascript
复制
Go LSP
Python LSP
TypeScript LSP
   ↓
统一的导航能力
   ↓
模型工具

Provider 发生变化时,上层工具仍然可以沿用相同的请求方式和结果结构。

如果以后要增加新的导航能力,就需要扩展 Definition,并重新检查现有 Provider 和 Consumer 是否都能适配。这也体现了 seam 设计中的一个基本取舍:

代码语言:javascript
复制
接口更宽
→ Provider 可以表达更多差异
→ 共同维护的契约面更大

接口更窄
→ 替换成本更容易控制
→ 一部分实现特有能力无法暴露

所以 seam 应该收多窄,最终取决于这项能力准备容纳多大的实现差异。

替换范围的判断方法

回到开头的问题:换掉一个 Provider,影响范围有多大?

沿着 dsh 的能力关系,可以从三个层面判断。第一步,看依赖图:

代码语言:javascript
复制
Provider
   ↓
哪些 Consumer 依赖这项能力

这决定变化可能传到哪里。第二步,看 Consumer 同时提供了什么能力:

代码语言:javascript
复制
底层 Provider
      ↓
   Consumer
      ↓
上层 Service
      ↓
更多 Consumer

这决定行为还会不会继续向上传导。第三步,看契约里的运行语义:

代码语言:javascript
复制
方法签名
调用时序
生命周期
错误语义
进程身份
……

这决定了新的 Provider 能否继续适配这些 Consumer。

因此,想降低 Provider 的替换成本,需要满足两个条件:Consumer 依赖稳定的 Definition,同时不依赖契约之外、某个实现特有的行为。

E2B 的例子正好把这两层关系串了起来。替换文件系统和 subprocess 两个底层 Provider 后,文件、Bash、Terminal 和 LSP 可以沿原有能力链迁到同一个远程环境;ACP 由于依赖即时 PID,则停在了当前 Provider 的兼容边界之外。

能力接缝的作用也体现在这里:一次底层实现发生变化后,可以沿着明确的依赖关系判断变化会传到哪里,再结合契约判断哪些 Consumer 能继续工作,从而把替换范围控制在可预期的边界内。

下一篇是这个系列的最后一篇,我们会继续看这套设计带来了哪些额外复杂度、适合哪些场景,以及其中哪些设计方法可以脱离 Cordis 单独使用。

参考源码

以下路径以 dsh-v0.1.2-rc.1 为基线:

代码语言:javascript
复制
docs/capability-seams.md
docs/event-producer-consumer.md
docs/architecture.md
docs/glossary.md
docs/postmortem/0004-landlock-partial-notice-misclassified-child-failures.md

packages/shell/
packages/subprocess/
packages/fs/
packages/e2b/
packages/sandbox/
packages/interaction/
packages/lsp/
packages/code-runtime/

#DeepSeek #dsh #seam #Harness #工程设计 #环境迁移

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • seam 的三个角色
    • 同模块的两条 seam
  • 沿着能力链传导的变化
  • 共享执行世界
  • 契约边界
    • 运行语义
    • 错误语义
    • 策略与执行约束分层
  • seam 之外的扩展点
  • 窄接口与替换成本
  • 替换范围的判断方法
  • 参考源码
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档