换掉一个 Provider 会波及哪里:dsh 的 seam 设计拆解

Feng 20 阅读 AI技术

把一个 Agent 的执行环境从本机搬到远程沙箱,听起来只是换个运行位置。真动手才发现:Bash 要换进程启动方式,文件工具要换读写后端,终端要换 PTY 创建方式,语言服务器也得重接。dsh 用一套叫 seam 的抽象把这个问题收敛了。本文以 dsh-v0.1.2-rc.1 为基线,讨论一个横向问题:替换某个 Provider,影响面怎么判断。

seam 的三个角色

先定义 ServiceDefinition 契约,再由 ServiceProvider 给出实现,Consumer 只依赖稳定接口,对底层逻辑的变动无需感知。以 shell 为例:ctx.shell 是 Definition,bash-local、bash-sandbox、pwsh-local 是 Provider,tool-bash 和 hooks 是 Consumer。

关键在依赖方向。Consumer 依赖的是契约,不是实现。新 Provider 满足同一契约,Consumer 就无需感知变化——理论上。

判断影响面的三步

第一步查依赖图:哪些 Consumer 依赖这个能力。第二步看这些 Consumer 自身还向上提供什么服务,行为会不会继续上传。第三步核对契约里的运行语义:方法签名、调用时序、生命周期、错误语义、进程身份。

dsh 生成的能力图共记录 29 个 seam。ctx.subprocess 处在多项执行能力的下层,是观察传导路径的好样本:只换它的 Provider,变化先抵达 Bash executor、Terminal backend 和 LSP host;这些模块若同时向上提供其他 Service,行为变动还会继续外扩。一条完整传导链:subprocess Provider 被替换,bash-local 的进程运行位置改变,ctx.shell 的外部行为随之改变,tool-bash 执行的命令进入新环境——而 tool-bash 全程只依赖 ctx.shell。

接口一样,不等于能随便换

dsh 自己提供了两个反例。

运行语义:subprocess-e2b 创建远程进程是异步的,进程 handle 先返回,真实 PID 稍后才能拿到。Bash、Terminal、LSP 不依赖启动后立即获得有效 PID,可以原样切换;ACP 子进程后端则要在启动后立刻取得 PID,切过去直接不可用。

错误语义:dsh 出过一次真实故障——ripgrep 返回「没有匹配结果」,这本是正常命令退出,却因为沙箱后端的输出,被误判成沙箱不可用。修复后,错误判断从字符串匹配改成明确的失败规则,Consumer 能区分 runner failure(执行器自身出错)和 denial(沙箱正常,策略拒绝了操作)。

这些差异背后是平台差异。Landlock 在 Linux 5.13 之后的内核才完整可用,做本地沙箱先确认内核版本;E2B 这类远程沙箱跑在 Firecracker microVM 上,冷启动和进程创建都带网络延迟,交互式命令的体验明显不同。选 Provider 不只是选功能,是选运行环境。

策略与执行分层

dsh 把「策略怎么定义」和「约束怎么执行」拆成两层:工作区范围、沙箱模式等策略由统一入口维护,Bash、文件系统等执行能力读取同一套约束,再交给具体沙箱 Provider 实现。错误归因因此一致,权限规则也能各自独立维护。

什么时候值得做这层抽象

两个信号同时满足才值得:能力确实要跨多种后端(本地、容器、远程),且 Consumer 已经多于一个。只有一种实现、短期也不会变时,抽象层就是纯开销,接口收多窄都没有回报。

替换后的验证清单

三件事跑完再收工。能力图 diff:确认 seam 数量和依赖方向没有意外变化。错误归因冒烟:故意执行一条空结果的 rg,确认返回的是「无匹配」而不是「沙箱不可用」。时序敏感路径回归:PID 获取、日志落盘这类依赖启动时序的链路,逐条过一遍。

窄接口的取舍

ctx.lsp 是个典型:它没有把完整 LSP 协议暴露给 Consumer,只保留四类只读导航——跳定义、找引用、跳实现、读 hover 文档——也不提供通用 JSON-RPC 通道让上层绕过抽象。不同的语言服务器都得把能力适配到这几类标准操作里。

接口更宽,Provider 能表达更多差异,共同维护的契约面更大;接口更窄,替换成本更可控,一部分实现特有能力送不出去。收多窄,最终取决于这项能力准备容纳多大的实现差异。

Feng
这位作者很神秘,还没有填写简介。