能力 seam:换一个提供方,换掉半个产品

专栏:DeepSeek Harness · 第 10 / 14 篇
DeepSeek Harness架构设计seam

主干读完,这篇换一种读法:不追流程,看设计思想。架构文档里有句话值得抄在开头:「seam 正是替换一个提供方就能改变整个产品的原因。文件系统与进程提供方共享同一个执行世界,因此把它们指向远程沙箱,也就把 Bash、PTY 和 LSP 一并搬了过去,无需提供方专用 fork。」

seam 的正式定义

一个 seam(接缝)是一项可替换能力,包含三种角色:

  1. Service Definition:声明接口——一个 declare module 的 ctx 键 + 一个抽象类(语义契约写在 JSDoc 里)+ 配套的类型化事件词汇。
  2. Service Provider:实现抽象类、作为插件加载、填充该 ctx 键。一个 ctx 一个实现,重复加载抛错。
  3. Consumer:只 import Definition 包、只在运行时从 ctx 读服务,对具体 Provider 零依赖

一个包可以兼任多角色,但单一角色本身不是 seam——添加一项能力意味着把三者一并设计。全仓 60+ 个服务的角色总图由脚本生成(docs/capability-seams.zh.md),每个 ctx 键都标注了实现与消费方。

一条完整链路:文件系统

以 fs 为例,三个角色各自的代码长相:

Definitionpackages/fs/fs)——抽象类即契约,连「读写边界」都定死在接口上:

// packages/fs/fs/src/index.ts:87-126(节选)
export abstract class FileSystem extends Service {
  constructor(ctx: Context) { super(ctx, 'fs') }

  /**
   * The sandbox mode this backend enforces on mutations BY DEFAULT, or
   * `undefined` when it does not confine at all — the capability fact the tool
   * layer reads to advertise the escalation fields honestly …
   */
  get sandboxMode(): SandboxMode | undefined { return undefined }

  /** Resolve a model/plugin-supplied path into a stable FsTarget. … */
  abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>

  /**
   * Return the canonical absolute path a subprocess in this filesystem's
   * execution world can open. … consumers may pass this value to another OS
   * capability, but must continue treating the target key as opaque.
   */
  abstract processPath(target: FsTarget): string
}

Providerpackages/fs/fs-local)——具体实现;同构的还有 fs-sandbox(继承 local,只给写/编辑加围栏)与远程的 fs-e2b

// packages/fs/fs-local/src/index.ts:58-77(节选)
/**
 * The host-filesystem backend. Reads resolve relative paths from Config.cwd
 * (a resolution default, NOT a containment boundary …); enforce containment
 * with a stricter backend or a `tools/execute` permission plugin.
 */
export class LocalFileSystem extends FileSystem {
  /** Per-targetKey tail promise: serializes mutating ops so the read→guard→write
   * window can't interleave, making concurrent writes/edits deterministically
   * ordered … */
  private locks = new Map<string, Promise<unknown>>()

Consumerpackages/fs/tool-fs)——注意 inject 清单里只有 'fs',没有任何后端包:

// packages/fs/tool-fs/src/index.ts:53-79(节选)
export const inject = ['tools', 'fs', 'systemPrompt']

export function apply(ctx: Context, config: Config): void {
  applyReadTool(ctx, {...})
  ctx.inject(['attachments'], (imageCtx) => { applyReadImageTool(imageCtx) })
  // 升权 API 由「挂载的 ctx.fs 是否隔离」决定——能力事实从 seam 读取
  const sandbox = new FsSandboxController(ctx)
  applyWriteTool(ctx, sandbox)
  applyEditTool(ctx, sandbox)
}

write 工具的执行体是三个角色在运行时的会合点——消费接口、路过策略事件、再消费接口:

// packages/fs/tool-fs/src/write.ts:101-121(节选)
async execute(args: WriteToolArgs, exec) {
  const input = parseWriteArgs(args)
  const sandboxPolicy = await sandbox.resolvePolicy('write', args, exec)
  const target = await ctx.fs.resolve(input.filePath, ...)
  // 单槽决策:策略插件产生 createIfAbsent/replaceIfVersion;裸默认无条件
  const intent = await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
  let outcome: FsWriteOutcome
  try {
    outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal, sandboxPolicy)
  } catch (error: unknown) {
    // 沙箱拒绝变成模型认识的 [sandbox: …] 标记
    throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath)
  }
  ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)

三角色关系图

图表(capability-seams.md)

注意右下:连 LLM 适配器都通过 ctx.get('fs')?.processPathFromHostPath(...) 消费 fs seam——附件图片的路径描述跟着执行世界走。

为什么「换提供方 = 换掉半个产品」

四个咬合点,缺一不可:

  1. 同一组 seam 键共享一个「执行世界」抽象ctx.fsprocessPath() 返回「本世界里子进程可打开的路径」——文件身份跨能力族过桥的接口。
  2. E2B 后端成对出现ctx.e2b 持有共享的远程运行时,fs-e2b 与 subprocess-e2b 落在同一个 Linux 沙箱里。换掉这对提供方,bash 执行器(只依赖 ctx.subprocess)、PTY(terminal-bash)、LSP(lsp-stdio)自动全部搬进远程世界——它们的代码一行不改。
  3. 策略跨能力族共享ctx.sandboxPolicy 统一保存模式与工作区根,bash 执行器与 fs 提供方都读它——不会出现「bash 能写但 fs 不能写」的裂缝。
  4. 消费方与策略完全不动。fs 组 README 的总结:「后端可以更换,无需改动工具或策略」。

sandbox:单方法的极简 seam

进程限制 seam 只有一个抽象方法,契约严格到「静默放行是禁止的」:

// packages/sandbox/sandbox/src/index.ts:152-176
export abstract class SandboxProvider extends Service {
  /**
   * Wrap `argv` so it executes confined under `policy` on this host; the
   * caller spawns the returned argv in place of its own.
   * @param argv - the exact argv the caller is about to spawn (program plus
   *   arguments), NOT a shell string — a shell-shaped consumer passes
   *   `['bash', '-c', command]`.
   * @param policy - the file-effect policy this execution runs under, carried
   *   per call.
   */
  abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv
}

本地实现按平台选 runner(Linux: bwrap→landlock,macOS: Seatbelt,Windows: ACL),把 argv 包成 [runner, ...profileArgs, '--', ...argv]

// packages/sandbox/sandbox-local/src/index.ts:316-333
confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv {
  if (this.runnerCommand !== undefined) {
    return {
      argv: [...this.runnerCommand, ...bwrapProfileArgs(policy), '--', ...argv],
      enforcement: 'full',
      denialSignatures: DENIAL_SIGNATURES.runnerCommand,
      runnerFailureRules: [{ fatalSignatures: this.configuredRunnerFailureSignatures }],
    }
  }
  const selected = this.selectRunner(policy.mode)
  const runnerArgv = this.runnerArgv(selected.runner, policy)
  return {
    argv: [...runnerArgv, '--', ...argv],
    enforcement: selected.enforcement,
    denialSignatures: DENIAL_SIGNATURES[selected.runner],
    runnerFailureRules: RUNNER_FAILURE_RULES[selected.runner],
  }
}

消费方(bash-sandbox 执行器)就一行:this.ctx.sandbox.confine(['bash', '-c', command], policy)。seam 之上还能叠 seam——bash 执行器消费沙箱提供方,两者各自可替换。

一次 write 的时序

图表(capability-seams.md)

与其他模块的联系

  • fs/shell/subprocess/sandbox 共享同一个「执行世界」——换 fs-e2b + subprocess-e2b 这对提供方,Bash/PTY/LSP 一并搬进远程(第 7 篇的编译期对照);
  • llm 适配器(第 11 篇)也消费 fs seam:附件路径经 processPathFromHostPath 映射进执行世界;
  • 工具流水线(第 9 篇)的把关事件(fs/write-intent)挂在 seam 的策略事件上——策略插件而非工具内建。

判断一个项目「架构好不好」的土办法:数一数替换一个底层实现要动几个文件。在 dsh 里替换文件系统后端是改一行配置(第 5 篇的 patch:- id: fs ... name: 换成新提供方)。把可替换性做成一等公民的代价是每个能力都要 Definition/Provider/Consumer 三份代码——dsh 认为值得,这份「昂贵的一致性」就是它架构气质的核心。

下一篇看同样按 seam 思路设计的 LLM 适配器层,以及它引以为傲的流式语义。

← 返回文章列表