DeepSeek Harness 怎么存一张图:持久图片附件的落盘、引用与清理
先说清楚这篇的立场:以下所有内容都来自 deepseek-harness 仓库快照 47f9438(版本 0.1.0-rc.5)里的源码和 docs/ 文档,我们没有安装、也没有运行过它。该仓库 README 自述处于开发者预览阶段,并明写会有破坏兼容性的变更,所以下面提到的每一个字段名、默认值和目录布局,都可能在你读到时已经换了。
一个具体问题:会话日志里到底存了什么
假设你往对话框里粘了一张截图。这张图的字节要么留在浏览器内存里,要么被写到某个临时目录,要么被 base64 塞进请求体。问题是会话日志是要长期存在、要能恢复、要能 fork 的,如果日志里记的是浏览器 object URL 或者宿主的临时路径,重启之后这条记录就是一条死引用。
docs/subsystems/attachment.md 开篇对这件事给的口径很硬:会话事件和模型可见的 ImageBlock 里装的只能是引用和元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。同一份文档还写明了顺序:宿主接受用户消息后,先把图片移到 <DSH_HOME>/attachments/v1 下,再追加用户事件;结构化模型图片输出走同样的「先持久化、后追加事件」规则。
这句「先持久化、后追加事件」是整条链路的主心骨,往下每一处设计基本都是为了兑现它。
提交路径:先整批校验,再逐个落盘
浏览器侧提交的提示词在 packages/host/apiproxy/src/api-proxy.ts 的 durablePromptContent() 里处理。它的顺序值得抄下来:
- 纯文本直接放行,不碰附件服务;
- 数图片数量,超过
maxImagesPerMessage抛TOO_MANY_IMAGES; - 逐个 base64 解码,解码用的
decodeBase64()会把结果再编码回去和原串比对,不一致就抛INVALID_IMAGE_BASE64——非规范形式的 base64 在这里就被挡住; - 汇总字节数,超过
maxMessageImageBytes抛IMAGES_TOO_LARGE; - 对每一张调
validateImage(); - 全部通过之后,才开始逐个
saveImage()。
第 5 步和第 6 步分开,是文档里明说的规矩:批量调用方在保存任何一个成员之前先校验所有成员,因此校验被拒不会在存储里留下半截对象。packages/attachment/attachment/src/index.ts 里 AttachmentStore 这个抽象类只有三个抽象方法——validateImage、saveImage、readImage,外加一个只读的 imageLimits,validateImage 存在的唯一意义就是支撑这个「全过才存」的两段式。
四个默认上限,都写在一个文件里
packages/attachment/attachment-local/src/index.ts 顶部导出了四个常量,这是本地后端的默认准入策略:
| 常量 | 值 | 对应配置字段 |
|---|---|---|
DEFAULT_MAX_IMAGE_BYTES | 5 * 1024 * 1024 | maxImageBytes |
DEFAULT_MAX_IMAGES_PER_MESSAGE | 20 | maxImagesPerMessage |
DEFAULT_MAX_MESSAGE_IMAGE_BYTES | 100 * 1024 * 1024 | maxMessageImageBytes |
DEFAULT_MAX_IMAGE_PIXELS | 40_000_000 | maxImagePixels |
受理的媒体类型在同一个构造函数里被冻结成四项:image/png、image/jpeg、image/webp、image/gif,类型定义在 packages/attachment/attachment/src/types.ts 的 ImageMediaType 上,文档给这条路径的措辞是「版本一附件路径受理的光栅格式」。
这些是配置里的默认值,不是运行表现的承诺——它们只决定什么样的字节会被受理,不决定别的任何事情。配置项本身走 LocalAttachmentStore.Config,四个数值项都带 .min(1),dshHome 可显式指定;packages/util/home-paths 里 resolveDshHome() 的优先级是显式配置、$DSH_HOME、然后 ~/.dsh。
像素上限的检查点在 packages/attachment/attachment-local/src/image.ts 的 detectImage() 里:先取元数据,宽乘高超过 maxPixels 就抛 IMAGE_TOO_MANY_PIXELS,通过之后才 await image.raw().toBuffer() 把整张栅格解出来。也就是说尺寸判定发生在完整解码之前,而完整解码本身是准入的一部分——文档的说法是「准入时完整解码」。
落盘:临时文件、硬链接、目录 fsync
真正写磁盘的是 packages/attachment/attachment-local/src/store.ts 的 saveImageFile()。对象落点是 <DSH_HOME>/attachments/v1/objects/<sha256 前两位>/<sha256>,写入过程按源码顺序是:在 attachments/v1/tmp 下用 randomUUID() 开一个 O_CREAT | O_EXCL | O_WRONLY、权限 0o600 的临时文件,写完 handle.sync(),关闭,然后用 link() 把它硬链接到目标路径完成发布,发布之后再依次 syncDirectory() 桶目录和它上面的 objects 目录,最后才 unlink() 掉临时文件。目录一律以 0o700 创建并再 chmod 一次。
两处细节值得单拎出来。
一是重复字节。link() 抛 EEXIST 时代码不当成错误,而是把已存在的对象读出来重算摘要,对得上就复用,对不上抛 ATTACHMENT_CORRUPT。内容寻址的去重就落在这一个 catch 分支里。
二是 Windows。syncDirectory() 的第一行是 if (process.platform === 'win32') return,源码注释自述的理由是 Windows 打不开目录句柄,那里由 NTFS 元数据日志负责目录项的持久性。packages/attachment/attachment-local/README.md 也把这层差异写在正文里:目录 sync 是 POSIX 侧的手段,Windows 依赖文件系统元数据日志。所以你如果在 Windows 上读这段代码,会发现整条 ensureDurableDirectory 的祖先目录遍历实际上是空转的——这是源码与 README 都白纸黑字承认的平台差异,不是遗漏。
ensureDurableHome() 上面那段注释还写明了一个反直觉的取舍:目录「已经存在」不等于「已经持久」,因为可能是另一个进程刚建好但还没 sync,所以每个进程都会独立地把 DSH_HOME 一路 sync 到文件系统根,重复 sync 一个已持久的目录项无害,漏掉一个未 sync 的则有害。这属于文档自述的理由,照抄即可,不必替它延伸。
引用里有什么,没有什么
ImageAttachmentRef 一共六个字段:attachmentId、mediaType、bytes、width、height,外加可选的 name。每个字段的注释都写得很克制:attachmentId 是「不透明的存储标识,绝不是文件系统路径或 bearer URL」,mediaType 是「从存储字节校验出来的媒体类型」,bytes 是「精确的编码字节长度」,width / height 是「编码固有的像素宽高」,name 是「已剥除本地路径信息的可选显示名」。子系统文档给这几个字段的用途是:引用里记下固有尺寸与编码长度,客户端不解码就能排布历史,而每一次权威读取仍会重新核对摘要、格式签名、尺寸与元数据。
attachmentId 是打了品牌标记的不透明字符串(packages/attachment/attachment/src/brand.ts)。文档明写:本地后端目前生成 sha256:<digest>,但消费方既不能解析这种表示,也不能据此派生文件系统路径。校验它的正则在 store.ts 里是 /^sha256:([a-f0-9]{64})$/,不匹配直接 INVALID_ATTACHMENT_REF。
name 这个可选字段处理得比想象中细。displayName() 手工取 / 和 \ 两种分隔符里靠后的那一个来切文件名,源码注释自述的原因是:POSIX 宿主上 path.basename 会把 \ 当普通字符,于是 Windows 客户端的完整本地路径会原样进入引用和会话日志。切完还要去掉控制字符、trim()、截到 255 个字符,全空则返回 undefined。对本站以 Windows 为主的读者来说,这一处正是为你们这一侧写的。
读回:摘要之后只探头部
readImage() 的实现在 readImageFile():先 signal?.throwIfAborted(),校验引用格式,读文件(ENOENT 映射成 ATTACHMENT_NOT_FOUND),重算 sha256 与 id 比对,然后调 probeImage() 只解析头部拿到格式与宽高,再逐项比对 mediaType、bytes、width、height,任何一项对不上都是 ATTACHMENT_CORRUPT。
为什么读路径不再完整解码?源码注释自述:摘要已经证明这就是准入时被完整解码过的那批字节,所以重放历史时不必再付一次整张栅格的解码代价。这两个函数都在 image.ts 里,共用同一段 imageMetadata() 取格式与宽高;差别有两处——detectImage() 多收一个 maxPixels 参数并在中间做像素上限判定,末尾多一句 await image.raw().toBuffer(),probeImage() 两者都没有。
顺带记一处口径差异:packages/attachment/attachment-local/README.md 的正文写的是「写入准入与读取都会完整解码栅格之后才接受其格式与尺寸」,而 store.ts 的读路径注释与实现走的是只解析头部的 probeImage()。两处不一致,以我们实读的源码为准。
取消信号也做了区分:中断被原样抛出、保留原因,而不是被包成 ATTACHMENT_READ_FAILED——README 里专门写了这一句。
清理:目前只有「不清理」
这是标题第三块,也是最容易被想当然的一块。packages/attachment/attachment-local/README.md 的「Known Limitations and Deferred Work」第一条原文写着:对象无限期保留,基于引用的垃圾回收延后实现。子系统文档给的口径一致——该服务刻意对保留策略保持中立,因为恢复的会话和 fork 出来的会话可能共享同一个对象,所以回收不与任何单个会话的删除绑定。
翻译成运维语言就一句:删掉一个会话,不会带走它的图。整个 attachment 包里唯一的删除动作,是 saveImageFile() 结尾对 staging 临时文件的 unlink(),以及失败路径上那次 best-effort 清理(吞掉 ENOENT,其它错误照抛)。除此之外没有任何清理逻辑。
顺带一提另一条让字节离开 DSH_HOME 的路径:packages/host/apiproxy/src/session-export.ts 在导出会话 ZIP 时,会把收集到的图片引用逐个 readImage() 出来,写成 media/<attachmentId>.<扩展名> 放进包里。导出一个会话就等于把它引用过的图一并带走,这一点在处理含截图的会话时值得先想一想。
两个容易被咬到的连带约束
第一个在 packages/client/connection/src/index.ts:插件启动时会断言 maxRequestBodyBytes 不小于 ceil(maxMessageImageBytes * 4 / 3) 加上 REQUEST_ENVELOPE_HEADROOM_BYTES(1024 * 1024),不满足直接抛错。也就是说你把聚合图片上限调大,就必须同步调大请求体上限,否则起不来。
第二个在 packages/fs/tool-fs/src/read-image.ts:读图工具取的字节上限是 Math.min(maxImageBytes, maxMessageImageBytes),源码注释自述的理由是一次工具结果就是一条带一张图的消息,所以两个界都得管。它还专门处理了 IMAGE_TYPE_MISMATCH,把「扩展名声明的类型和实际字节格式不符」翻译成一句可读的提示。
你可以自己去核的地方
想验证以上任意一条,只看四个文件就够:docs/subsystems/attachment.md(语义口径,同目录有官方中文版)、packages/attachment/attachment/src/types.ts(字段定义)、packages/attachment/attachment-local/src/index.ts(四个默认值)、packages/attachment/attachment-local/src/store.ts(落盘与读回全过程)。packages/attachment/attachment-local/tests/store.spec.ts 里的用例名也可以当索引读,比如「限制收紧后已受理的历史仍可读」这一条,对应的正是 README 里那句「字节与像素上限是写入时的准入策略」。
再重复一次开头的限定:这是开发者预览阶段的仓库,README 自述会有破坏兼容性的变更,上面每个常量名和路径都以你手上那份快照为准。
本文依据 DeepSeek Harness 官方仓库(github.com/deepseek-ai/deepseek-harness)的 README、docs/ 下的
架构与子系统文档、以及 packages/ 下的源码整理,核对日 2026-08-17,对应仓库快照 47f9438(版本 0.1.0-rc.5)。
本文内容为仓库源码与文档口径,我们没有安装、也没有运行过这个项目,
因此不涉及界面外观、操作手感与运行速度的任何描述。
该仓库 README 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更,
文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。