Agent X-Ray
RuntimeNotesAbout
Notes/源码拆解/DeepSeek Harness/第4章

第4章:Seam 架构 —— 抽象服务与具体实现的分离

10 分钟 · 更新于 2026-09-01

第4章:Seam 架构 —— 抽象服务与具体实现的分离

第 2 章讲了 cordis 的机制,第 3 章讲了配置怎么拼。但还有一个问题没回答:为什么 dsh 的 185 个包能互相替换,而不需要改别的包的代码?

答案是本章的主角:seam(能力接缝)架构。dsh 把 agent 运行时需要的每一项能力,都拆成「抽象服务(Service Definition)+ 具体实现(Provider)」两层。这一章把整套 seam 清单摊开,并解剖其中一个最完整的例子:文件系统。


一、先看问题:能力可替换为什么这么难

假设你要实现"把 agent 的文件操作包进沙箱"。最直觉的做法:

typescript
// 直接改文件系统实现
class SandboxedFileSystem extends LocalFileSystem {
  write(path, content) {
    if (!path.startsWith(workspaceRoot)) throw new Error('blocked');
    return super.write(path, content);
  }
}

这能用,但马上遇到三个问题:

  1. 谁在用文件系统? agent 循环、read/write/edit 工具、AGENTS.md 加载器、技能发现……全都直接依赖 FileSystem 类。改成 SandboxedFileSystem,所有 new 的地方都要改
  2. 沙箱逻辑和文件逻辑耦合了。 沙箱要"检查路径是否在工作区内",文件系统要"读写文件"。两个关注点挤在一个类里
  3. 策略层没地方放。 "写之前先确认文件没被改过"(版本防护)这种政策,既不属于文件系统,也不属于沙箱,该放哪?

dsh 的解法是把「能力」拆成四层,每层一个包,层与层之间只通过事件词汇通信,互不 import。


二、seam 是什么:一份官方定义

dsh 的文档里反复出现一个句式(本机 100+ 处)[实测]:

服务:FileSystem(ctx 键:fs) —— dsh-fs/README.zh.md

这背后是一对分工:

角色职责
Service Definition(服务定义)dsh-xxx定义 ctx.xxx 的接口、事件词汇、错误分类。只依赖 cordis,绝不依赖任何实现
Provider(提供方)dsh-xxx-local / dsh-xxx-sandbox / …继承 Service 定义,提供具体实现。可替换、可叠加

dsh 官方把这个设计叫做 capability seam(能力接缝)

该拆分使每个关注点可以独立演进和替换(见能力 seam Agent Note 2026-06-13-capability-seams.md) —— dsh-fs/README.zh.md

素材盲区说明 dsh 官方文档大量引用 .agents/notes/ 目录下的 Agent Note(设计决策文档)。本机安装的 npm 包不含这个目录,所以这些设计文档的原文本教程看不到。能看到的是:README 里的结论性描述、打包源码、以及运行时的真实行为。凡涉及 Agent Note 的引述,本教程只转述 README 里出现的内容。

为什么 Service Definition 只依赖 cordis? 这是刻意为之。dsh-sandbox 的 README 说得很清楚 [官方文档]:

作为能力 seam 拆分中的 Service Definition 角色,它只依赖 cordis(及 harness 错误基类),绝不依赖后端。 —— dsh-sandbox/README.zh.md

好处是:定义可以脱离实现独立演进。新增一个沙箱后端(比如未来的容器后端),不用动 dsh-sandbox 一行;改沙箱后端的实现,不用动所有依赖 ctx.sandbox 的插件。


三、完整 seam 清单

我在本机 185 个包的文档里提取了全部 ctx.* 服务键,按域分组如下 [实测]:

3.1 核心运行时(agent 循环直接注入)

ctx 键服务定义包本机实际提供者
ctx.llmdsh-llmdsh-llm-pi-ai(pi-ai 后端)
ctx.toolsdsh-tools工具注册表(本体)
ctx.sessionsdsh-session会话存储(本体)
ctx.agentsdsh-agentAgent 注册表(本体)
ctx.agentLoopdsh-agent-loop循环实现(本体,唯一)
ctx.systemPromptdsh-system-prompt提示词装配(本体)
ctx.compactiondsh-compactiondsh-compaction-basic
ctx.tokenMeterdsh-token-meter计量(本体)
ctx.agentDefaultModeldsh-agent-default-model默认模型(本体)

3.2 能力 seam(抽象 + 可换实现)

ctx 键Service Definition本机可用的 Provider
ctx.fsdsh-fsdsh-fs-local(宿主)· dsh-fs-sandbox(沙箱)· dsh-fs-observation-policy(政策)
ctx.shelldsh-shelldsh-bash-local · dsh-pwsh-local · dsh-bash-sandbox · dsh-pwsh-sandbox
ctx.subprocessdsh-subprocessdsh-subprocess-local
ctx.sandboxdsh-sandboxdsh-sandbox-local(内探测 bwrap/landlock/Seatbelt/ACL)
ctx.sandboxPolicydsh-sandbox-policy策略解析(本体)
ctx.jobsdsh-jobsdsh-jobs-local
ctx.webdsh-webdsh-web-search-deepseek(搜索)
ctx.spillStoredsh-spilldsh-spill-local
ctx.storagedsh-storagedsh-storage-json
ctx.settingsdsh-settingsdsh-settings-file(settings.yaml)
ctx.credentialsdsh-credentialsdsh-credentials-local$DSH_HOME/.env
ctx.attachmentsdsh-attachmentdsh-attachment-local(内容寻址)
ctx.codeRuntimedsh-code-runtimedsh-code-runtime-worker-thread
ctx.sessionPersistencedsh-session-persistencedsh-session-persistence-jsonl
ctx.sessionQuerydsh-session-querydsh-session-query-sqlite(FTS5)
ctx.sessionTitledsh-session-titledsh-session-title-first-prompt-llm
ctx.subagentsdsh-subagentspawn / fork 两种 in-process 后端
ctx.workflowEnginedsh-workflowdsh-workflow-worker-thread
ctx.goalsdsh-goal目标状态(本体)
ctx.approvaldsh-user-approval审批(本体)
ctx.permissionPresetsdsh-permission-presets权限预设(本体)
ctx.userQuestionsdsh-user-questions提问(本体)

3.3 投影与查询(事件溯源配套)

ctx 键
ctx.sessionProjectionsdsh-session-projection
ctx.sessionProjectionCachedsh-session-projection-cache
ctx.sessionReferenceResolverdsh-session-reference
ctx.invariantsdsh-invariants

3.4 Web 宿主(进程级)

ctx 键
ctx.webServerdsh-host-webserver
ctx.apiProxydsh-host-apiproxy
ctx.remotedsh-api-remotes
ctx.directoryPickerdsh-host-directory-picker-*(三种后端)
ctx.loaderdsh-typert-loader
ctx.typert / ctx.typertGatewaydsh-typert-*
ctx.workspaceRegistrydsh-workspace

阅读方法 这张表不用背。它的价值是给你一个心智模型:dsh 的任何一个能力,你都可以在文档里找到"ctx 键 → Service Definition 包 → Provider 包"三条线索。看到 ctx.fs 就去找 dsh-fs 的定义和 dsh-fs-local 的实现,看到 ctx.sandbox 就去找 dsh-sandboxdsh-sandbox-local


四、解剖一个 seam:文件系统四层栈

文件系统四层栈|900

配图说明:工具层(dsh-tool-fs,面向模型 schema)→ 政策层(dsh-fs-observation-policy,无服务,只监听 fs/* 事件门禁)→ 提供方约定层(dsh-fs,12 个原语 + 结构化错误码,只依赖 cordis)→ 提供方层(dsh-fs-local / dsh-fs-sandbox 平级,换实现 = 改配置)。层间靠事件通信而非 import。

dsh-fs 的 README 给出了 dsh 里最完整的分层示例,原文表格:

角色
工具/执行器dsh-tool-fs面向模型的 read/write/edit schema、读取窗口和文本渲染;通过 ctx.fs 读写编辑,并分派 fs/* 事件
政策dsh-fs-observation-policy已观察状态、编辑前读取和版本防护的写入/编辑,通过 fs/* 事件门禁贡献(无服务
提供方约定dsh-fsctx.fs:执行世界路径、文本 I/O 与原子变更原语(可选版本防护);拥有 fs/* 事件词汇
提供方dsh-fs-local宿主文件系统实现

dsh-fs/README.zh.md

四层的依赖方向是单向的:工具层用 ctx.fs,政策层监听 fs/* 事件,约定层定义接口,提供方层实现接口。层与层之间通过事件通信,而不是 import。

4.1 提供方约定层:12 个原语

ctx.fs 的接口是 12 个方法,每个都有明确语义 [官方文档]:

成员语义要点
resolve(path)解析为稳定的 FsTarget(不透明 targetKey + displayPath)
processPath(target)子进程可打开的执行世界绝对路径
fileUrl(target)规范化 file: URI
contains(parent, child)包含关系检查(不暴露 target key)
stat(target)元数据(version/type/size),绝不返回内容
lstat(path)不跟随符号链接的元数据
readText(target)完整文本读取;负责 UTF-8 解码与二进制拒绝FS_NOT_TEXT
streamText(target)大文件分片流式读取
readBytes(target, signal, maxBytes)原始字节读取,maxBytes 必填,超限 FS_TOO_LARGE
listDir(target)按稳定名称列出直接子项,绝不读内容
writeText(target, content, expected?)原子创建/替换;expected 可选版本防护
editText(target, edit, expected?)字面量编辑;版本防护 + 原子重写

dsh-fs/README.zh.md

三个值得注意的设计决策:

决策一:ctx.fs 只负责"文本"层。 readText 会拒绝二进制(FS_NOT_TEXT),writeText 只写文本。原始字节只有 readBytes 一条路。README 解释:

ctx.fs 有意接近 fsspec 风格的存储原语,比字节级 cat/open 高半层,因为它会解码文本并拒绝二进制,使政策层绝不接触原始字节

决策二:明确的"不做"清单。 12 个原语没有:删除、重命名、复制、监视、glob、分页。README 的"已知限制"直接点名:

只有十二个原语:没有删除、重命名/移动、复制或监视;listDir 只支持一层,递归、glob、分页和搜索不在范围内。

glob 和 grep 是另一个 seam 的事(dsh-tool-fs-search 用打包的 ripgrep 二进制实现)。

决策三:结构化错误分类。 所有失败都抛 FsError,携带稳定错误码:FS_NOT_FOUND / FS_NOT_DIRECTORY / FS_NOT_TEXT / FS_TOO_LARGE / FS_PERMISSION_DENIED / FS_STALE_VERSION / FS_NOT_OBSERVED / FS_AMBIGUOUS_EDIT / FS_EDIT_NOT_FOUND / FS_ABORTED。工具注册表会把 {name, code} 附到 isError 结果上。

为什么重要?沙箱判断"能不能写"和文件系统"能不能写"是两种错误FS_PERMISSION_DENIED 是文件系统层的,SANDBOX_UNAVAILABLE 是沙箱层的。结构化错误让上层能区分"策略拒绝"和"系统失败",而不是看到一个笼统的 EACCES。

4.2 政策层:不是服务,是事件门禁

四层里最特殊的是 dsh-fs-observation-policy —— 它不是服务,README 明确标注"(无服务)"。它通过 fs/* 事件词汇工作:

事件类型作用
fs/write-intent单槽决策 waterfall监听器完整决策,绝不调用 next()
fs/edit-intent单槽决策 waterfall同上
fs/observed发后即忘记录 FsObservation(存在带版本 / 确认缺失)

dsh-fs/README.zh.md

这就是第 2 章"插件可以插进事件流"的实际应用。 工具层(dsh-tool-fs)做执行器,在写之前广播 fs/write-intent;政策层监听这个事件,决定放行还是拒绝。"编辑前先读"、"版本防护"、"已观察状态"这些政策都在这层实现,而工具层完全不知道政策层的存在。

README 用一句话点破这个分工:

工具就是执行器;策略是事件门禁。 —— dsh-tool-fs/README.zh.md 的小节标题

4.3 提供方层:local 和 sandbox 是平级的

dsh-fs-local 是宿主文件系统实现。dsh-fs-sandbox 是沙箱实现。README 特别指出:

fs-sandboxfs-e2b 实现该接口,无需更改政策层和工具层。

也就是说:换提供方,上面的三层一行不用动。 这就是 seam 架构的终极回报。


五、三条强制路径:沙箱到底拦在哪

光有 seam 还不够 —— 你还需要回答"谁负责强制执行"。dsh 的做法是每个能力 seam 都带一个沙箱变体

能力无沙箱提供方沙箱提供方
文件dsh-fs-localdsh-fs-sandbox
Shelldsh-pwsh-local / dsh-bash-localdsh-pwsh-sandbox / dsh-bash-sandbox
子进程dsh-subprocess-local(由 shell 沙箱覆盖)

第 3 章实测过:base patch 用 !!js process.platform 动态选择——Windows 走 pwsh-sandbox,非 Windows 走 bash-sandbox。沙箱是默认启用的,不是可选项。

沙箱的语义在 dsh-sandbox 里定义:

概念取值含义
SandboxModeread-only / workspace-write / danger-full-access文件操作的三种权限档
SandboxEnforcementfull / partial每种内核 ABI 的强制强度
SANDBOX_UNAVAILABLE错误后端不可用时 fail-closed 抛出的错误

第 10 章会完整展开沙箱与审批。这里只需要记住:seam 架构让"换沙箱后端"变成纯配置操作


六、横切关注点:不占 seam 的策略插件

有些能力不属于任何单一 seam,而是横切所有工具调用。dsh 的做法是做成 tools/* 事件监听器:

插件挂在哪个事件干什么
dsh-tool-call-timeout-policytools/execute 包装层给每个工具装截止时间,超时返回 TOOL_TIMEOUT
dsh-spill-policytools/post-execute 转换器超长纯文本结果溢出到文件,返回预览 + 路径
dsh-repeat-tool-remindertools/result重复工具调用时提醒
dsh-session-checkpoint-policyllm/stream + tools/execute + agent/pre-step在模型请求和工具副作用前写持久化检查点

这些插件不注册服务、不占 ctx 键,只是监听事件。 它们的存在本身证明了第 2 章的设计价值:事件总线让"横切"不需要侵入任何具体包。


七、seam 架构的代价

这一章必须诚实列出代价,否则第 13 章无法做总结。

代价一:调用链变长。 ctx.fs.readText() 一次调用可能经过:工具层 → fs/read-text 事件 → 约定层 → 提供方层 → 操作系统。每个环节都可能抛结构化错误。调试时你需要追踪事件流,而不是单步进一个类。

代价二:类型跳转多。 ctx.fs 的类型来自 dsh-fs 的声明合并,实现在 dsh-fs-local。IDE 里 Ctrl+点击经常跳到接口而不是实现,你要自己知道"谁是当前 provider"。

代价三:小包多到吓人。 文件系统一个能力拆 4 个包(tool / policy / definition / local),185 个包就是这么来的。新读者面对目录列表会先被吓一跳 —— 第 1 章讲过的"包数量是拆分粒度不是代码规模",在这里再次体现。

代价四:部分 seam 是"单实现"的,抽象显冗余。 比如 ctx.storage 目前只有 dsh-storage-json 一个后端。对这种 seam,抽象层的价值暂时是"未来可换",而不是"今天能换"。

那为什么还值得? 因为 dsh 面对的是一个多平台、多形态、多用户偏好的问题空间:Windows/Linux/macOS、Web GUI/headless CLI、不同的沙箱后端、不同的模型提供方。没有 seam,这些维度会交叉爆炸;有了 seam,每个维度独立演进。


八、动手复核

powershell
# 1. 验证"每个 seam 定义只依赖 cordis"(抽查两个)
(Get-Content "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-fs\package.json" -Raw |
  ConvertFrom-Json).dependencies | ConvertTo-Json

(Get-Content "$env:DSH_HOME\profiles\node_modules\@deepseek-ai\dsh-sandbox\package.json" -Raw |
  ConvertFrom-Json).dependencies | ConvertTo-Json

# 2. 完整 ctx 键清单
$base="$env:DSH_HOME\profiles\node_modules\@deepseek-ai"
Get-ChildItem $base -Directory -Filter 'dsh-*' | ForEach-Object {
  $f = Join-Path $_.FullName 'README.zh.md'
  if (Test-Path $f) {
    $m = Select-String -Path $f -Pattern 'ctx 键[::]\s*`?([a-zA-Z]+)`?' | Select-Object -First 1
    if ($m) { "{0,-24} ctx.{1}" -f $_.Name, $m.Matches[0].Groups[1].Value }
  }
} | Sort-Object

# 3. fs seam 的四个包都在
'@deepseek-ai/dsh-fs','@deepseek-ai/dsh-fs-local','@deepseek-ai/dsh-fs-sandbox','@deepseek-ai/dsh-tool-fs','@deepseek-ai/dsh-fs-observation-policy' |
  ForEach-Object { "{0} : {1}" -f $_, (Test-Path "$env:DSH_HOME\profiles\node_modules\$_") }

九、总结

seam 架构是 dsh 的第二个支柱(第一个是 cordis):

  1. 每个能力 = Service Definition + Provider,定义只依赖 cordis,实现可替换
  2. 完整清单 30+ 个 ctx 键,覆盖核心运行时、能力 seam、投影查询、Web 宿主
  3. 文件系统是最完整样例:工具层 → 政策层 → 约定层 → 提供方层,四层单向依赖
  4. 政策是事件门禁fs/* 事件让"写前检查"不侵入任何具体包
  5. 沙箱是能力 seam 的沙箱变体:换后端 = 改配置
  6. 横切关注点:超时、溢出、检查点做成事件监听器,不占 seam

到这里,骨架部分(第 1-4 章)完成。从下一章开始进入主干:agent loop —— 整个 harness 唯一包含具体循环逻辑的包。


  • README-教程总览
  • 第2章-cordis底座-一切皆插件的地基
  • 第3章-Profile与Patch分层-一个会话如何被组装
  • 第5章-Agent-Loop-唯一含循环逻辑的包
  • 第10章-沙箱与权限-fail-closed的四层防线 —— 沙箱 seam 的完整展开

本章目录
一、先看问题:能力可替换为什么这么难二、seam 是什么:一份官方定义三、完整 seam 清单四、解剖一个 seam:文件系统四层栈五、三条强制路径:沙箱到底拦在哪六、横切关注点:不占 seam 的策略插件七、seam 架构的代价八、动手复核九、总结Related Documents
苏ICP备2025204887号-2