ARTICLE · 981202
dsh 的文件系统是个插件,我们换掉了它
之前「别再直连官方 API 了,DeepSeek Harness 这样接才对」篇中,我们使用的 dsh 版本为 0.1.0-rc.8 。不到两周时间,dsh 版本已经来到 0.1.1-rc.2,dsh 仍在快速迭代。
目前,dsh 最常见的运行方式是在单机上执行 npx @deepseek-ai/dsh web,再通过浏览器打开 127.0.0.1:3080。使用过程中,会话里粘贴的截图、模型生成的图片会保存到 DSH_HOME 下,默认路径是 ~/.dsh。
单机运行时,这种方式足够简单。
但如果 dsh 跑进服务器、容器,或者后面需要迁移和多实例部署,附件继续跟着本地磁盘走,就会出现新的存储问题。
这篇文章做的,就是把 dsh 的附件存储从本地目录换成 S3 后端,并以七牛 Kodo 为例,跑一遍 S3 兼容对象存储的接入流程。
01
问题背景
本地附件存储的限制
当 dsh 以 Web 模式部署到服务器上之后,可能会有多个用户访问同一个实例。如果运行在容器中,还需要额外处理容器重启后的数据持久化和挂载卷配置。
这时,附件依然保存在本地磁盘,它的生命周期也会跟具体的机器或存储卷绑定在一起。附件和会话日志的增长速度又有明显差别:一份会话日志主要是文本追加,一张粘贴进去的截图却可能达到几百 KB,甚至几 MB。运行一段时间后,DSH_HOME 里占用空间较多的内容,很可能就是这些附件。
换机器、备份、迁移,以及后面的多实例部署,都会涉及这一层存储。如果附件能够单独放进对象存储,这些问题就可以从 dsh 实例本身拆出去,而 dsh 的插件设计刚好给附件存储留出了这样的替换位置。
02
核心机制
dsh 的三条存储线
dsh 没有把所有「存储」能力放在同一个模块里。从相关 npm 包的设计来看,目前主要存在三条存储线。
dsh-fs 负责 ctx.fs 抽象文件系统能力接缝,提供 FileSystem 服务,以及 fs/* 策略事件词汇。它对应模型读写的工作区文件,这些文件是可变的。目前有两个实现:dsh-fs-local 对应本地文件系统;dsh-fs-sandbox 按每次调用的沙箱模式控制写操作,只读模式拒绝修改,workspace-write 模式把修改限制在工作区和临时目录。模型使用的 read / write / edit 三个工具则位于 dsh-tool-fs,架在这一层之上。
dsh-attachment 负责持久化的不可变附件,默认实现 dsh-attachment-local,对应 DSH_HOME 下的私有内容寻址附件存储。
dsh-session 负责事件溯源的会话存储,持久化由 dsh-session-persistence-jsonl 完成,查询则交给 dsh-session-query-sqlite。
这三类数据面对的存储要求并不相同。工作区文件需要修改,还涉及并发控制和策略事件;会话日志是一条追加式事件流,主要对应高频的小规模写入;附件写入后保持不变,通过内容进行定位。
因此,附件存储具备三个很适合对象存储的特征:不可变、内容寻址、写一次读多次。
dsh 把附件单独拆成了 AttachmentStore 接缝,因此我们只需要替换这一层,文件系统和会话存储都可以保持原样。
03
工程实现
添加 S3 附件后端
这次使用的插件目前由七牛云维护:qiniu/dsh-attachment-s3。项目地址:
github.com/qiniu/dsh-attachment-s3
npm 包名为 dsh-attachment-s3,当前版本为 0.2.1,实现的是和 dsh-attachment-local 相同的 AttachmentStore 接缝。
安装插件:
dsh plugin --profile web add dsh-attachment-s3
这里有一个值得注意的地方。dsh 的附件接缝只保留一个 Provider。安装插件后,包内声明的 Bundle Patch 会关闭 dsh-base 中默认挂载的 attachment-local,再加入新的 attachment-s3。
可以通过 --dump-config 检查最终组合结果
dsh --profile web --dump-config | grep -A6 -B2 'attachment'实际输出中,可以看到 attachment-local 处于 disabled: true 状态,而 attachment-s3 已经挂进最终配置树。这意味着,上层依然调用同一个 AttachmentStore,底层负责存储附件的实现则从本地目录换成了 S3。

接入 Kodo 时,我们使用的是下面这组配置:
export DSH_ATTACHMENT_S3_BUCKET=dsh-attachment-testexport DSH_ATTACHMENT_S3_REGION=cn-south-1export DSH_ATTACHMENT_S3_ENDPOINT=https://s3.cn-south-1.qiniucs.comexport DSH_ATTACHMENT_S3_FORCE_PATH_STYLE=trueexport DSH_ATTACHMENT_S3_ACCESS_KEY_ID_REF=QINIU_ACCESS_KEYexport DSH_ATTACHMENT_S3_SECRET_ACCESS_KEY_REF=QINIU_SECRET_KEYexport QINIU_ACCESS_KEY=<ACCESS_KEY>export QINIU_SECRET_KEY=<SECRET_KEY>其中,
DSH_ATTACHMENT_S3_ACCESS_KEY_ID_REF 和 DSH_ATTACHMENT_S3_SECRET_ACCESS_KEY_REF 保存的是凭据所在的环境变量名,真正的 AccessKey 和 SecretKey 则放在 QINIU_ACCESS_KEY、QINIU_SECRET_KEY 中。
对于模型和会话层,这个变化是透明的。会话日志里保存的仍然是不透明的 sha256: 引用,而不是 Bucket 地址或对象 URL。只要附件后端遵循同一套接缝语义,更换底层存储不会改变 transcript 中附件引用的含义。
内容寻址与完整性校验
这个 S3 后端延续了 dsh 附件存储的内容寻址设计。
在附件进入对象存储前,图片准入、规范化和模型请求图片的生成逻辑仍沿用 dsh-attachment-local 提供的能力。
较大的图片可能先被缩放和重新编码,真正进入 Bucket 的是规范化后的图片。对象键再根据实际写入内容的 SHA-256 生成:
<prefix>/objects/<SHA-256 前两位>/<SHA-256>默认前缀为:
attachments/v1因此,同一份内容对应同一个对象键。重复保存同一张图片时,多个写入会收敛到同一个对象,不需要额外维护「附件 ID → 对象位置」的映射关系。
写入时,插件还会携带 x-amz-checksum-sha256。如果对象存储提供 checksum 校验能力,就可以在上传阶段检查传输中的内容是否发生损坏。读取侧还有第二层完整性校验:插件取回附件后,会重新计算 sha256:,并重新检查图片信息。如果当前读取到的内容与 sha256: 引用不一致,就返回 ATTACHMENT_CORRUPT,这份附件不会进入模型请求。
也就是说,会话日志里的附件引用同时承担了一层内容完整性校验。即使对象存储中的内容因为权限配置、第三方写入等原因发生变化,只要实际字节和原来的摘要对不上,读取阶段就能发现异常。
04
可行性验证
S3 兼容能力检查
S3 兼容并不意味着每一项 API 行为都与 AWS S3 完全相同。对这个插件来说,目前真正依赖的核心能力主要有三项:上传接口能够接受 SHA-256 checksum header、支持 Range 读取、对象不存在时能够返回可区分的错误。
插件提供了一个兼容性探测脚本:
pnpm run probe接入其它 S3 兼容服务时,可以先配置目标 Bucket:
export PROBE_ENDPOINT=https://s3.cn-south-1.qiniucs.comexport PROBE_REGION=cn-south-1export PROBE_BUCKET=dsh-attachment-testexport PROBE_FORCE_PATH_STYLE=trueexport AWS_ACCESS_KEY_ID=<ACCESS_KEY>export AWS_SECRET_ACCESS_KEY=<SECRET_KEY>pnpm run probe脚本会写入和删除少量测试对象,并检查这几项协议行为。其中 checksum 还会多测一步:故意提交错误的 checksum,观察对象存储是拒绝请求,还是接受这次上传。

这里需要区分两种情况。如果对象存储连 x-amz-checksum-sha256 请求头都无法接受,当前插件就无法按现有实现工作;如果请求头能够接受,但服务端没有校验错误 checksum,插件依然可以完成对象读写,只是上传链路少了一层服务端完整性检查,读取阶段的 SHA-256 重算仍会继续执行。
Kodo 实探(实操)
这次我们使用七牛 Kodo 提供的 S3 兼容接口,对插件依赖的几项能力做了一次实际探测。实测命令如下:
export PROBE_ENDPOINT=https://s3.cn-south-1.qiniucs.comexport PROBE_REGION=cn-south-1export PROBE_BUCKET=dsh-attachment-testexport PROBE_FORCE_PATH_STYLE=trueexport AWS_ACCESS_KEY_ID=<ACCESS_KEY>export AWS_SECRET_ACCESS_KEY=<SECRET_KEY>pnpm run probe实测结果如下:
也就是说,Kodo 可以接受插件写入时携带的 x-amz-checksum-sha256,Range 读取和不存在对象的错误识别也能正常工作。不过,在故意提交错误 checksum 时,Kodo 仍然接受了这次上传,因此上传阶段没有提供服务端的 checksum 强制校验。
这不会影响插件当前的读写链路。插件在读取附件时仍会重新计算 SHA-256,并与会话中的 sha256: 引用进行比对,因此内容完整性仍会在读取阶段再次检查。

完成配置后,我们重新启动 dsh,在会话中上传了一张图片,并让模型描述图片内容。模型可以正常读取和理解这张图片,说明附件从对象存储取回、再进入模型请求的这条链路是通的。

与此同时,在 Kodo 的文件管理页面中,可以看到对应对象落在:
attachments/v1/objects/c7/<完整 SHA-256>这也印证了插件当前的对象组织方式:默认前缀为 attachments/v1,对象按照 SHA-256 的前两位分桶,再使用完整摘要作为对象键。
05
存储后的配套能力
附件进入对象存储后,还有两类能力可以从 Harness 中拆出去。
附件回收。
这个插件没有提供附件删除接口。原因在于,一份附件可能仍被某条历史会话引用,而插件自身没有维护全局引用计数,无法判断一个对象是否仍在使用。因此,附件回收更适合交给 Bucket 的生命周期规则。例如,可以根据对象前缀设置对应的生命周期策略,在明确业务保留周期后完成过期回收。这里需要注意:如果生命周期规则删除了一份仍被历史会话引用的附件,后续读取会得到 ATTACHMENT_NOT_FOUND。生命周期时间需要结合会话保留策略一起设计。
图片处理。
附件进入对象存储后,也可以利用对象存储侧的图片处理能力完成缩放、裁剪、格式转换等操作。如果上层模型服务支持 URL 形式的图片输入,也可以在处理好访问权限的前提下,通过对象 URL 或签名 URL 提供图片,减少请求中携带完整图片内容带来的传输开销。
这次改造只替换了附件存储这一层,会话持久化仍由 dsh-session 负责,因此多实例共享会话仍需要单独处理 Session 存储。
06
Kodo 对象存储
七牛云对象存储 Kodo 提供 AWS S3 兼容接口。对于基于 AWS SDK 构建的工具,可以通过配置对应的 Region 和 Endpoint 接入 Kodo,具体支持范围则需要结合兼容 API 和实际使用的协议能力确认。
这也是为什么这次实践没有停在「支持 S3」这一层,而是额外跑了一次插件自己的兼容性探测。附件进入 Kodo 后,存储之外的能力也可以逐步接进来。
Kodo 提供生命周期管理,可以按规则完成对象存储类型转换和过期删除;图片处理能力则覆盖缩放、裁剪、格式转换等操作。
这样,dsh 继续负责 Agent 运行和附件引用,Kodo 承接图片的持久化、生命周期管理和后续处理。
从这个例子也能看到 dsh 插件化设计的一个价值:底层基础设施能力可以沿着接缝被替换,上层 Agent Loop、模型和会话引用不需要跟着存储实现一起变化。
点击「阅读原文」,立即体验。

推荐阅读


