DeepSeek Harness 怎么存一张图:持久图片附件的落盘、引用与清理

2026-08-17

先说清楚这篇的立场:以下所有内容都来自 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.tsdurablePromptContent() 里处理。它的顺序值得抄下来:

  1. 纯文本直接放行,不碰附件服务;
  2. 数图片数量,超过 maxImagesPerMessageTOO_MANY_IMAGES
  3. 逐个 base64 解码,解码用的 decodeBase64() 会把结果再编码回去和原串比对,不一致就抛 INVALID_IMAGE_BASE64——非规范形式的 base64 在这里就被挡住;
  4. 汇总字节数,超过 maxMessageImageBytesIMAGES_TOO_LARGE
  5. 对每一张调 validateImage()
  6. 全部通过之后,才开始逐个 saveImage()

第 5 步和第 6 步分开,是文档里明说的规矩:批量调用方在保存任何一个成员之前先校验所有成员,因此校验被拒不会在存储里留下半截对象。packages/attachment/attachment/src/index.tsAttachmentStore 这个抽象类只有三个抽象方法——validateImagesaveImagereadImage,外加一个只读的 imageLimitsvalidateImage 存在的唯一意义就是支撑这个「全过才存」的两段式。

四个默认上限,都写在一个文件里

packages/attachment/attachment-local/src/index.ts 顶部导出了四个常量,这是本地后端的默认准入策略:

常量对应配置字段
DEFAULT_MAX_IMAGE_BYTES5 * 1024 * 1024maxImageBytes
DEFAULT_MAX_IMAGES_PER_MESSAGE20maxImagesPerMessage
DEFAULT_MAX_MESSAGE_IMAGE_BYTES100 * 1024 * 1024maxMessageImageBytes
DEFAULT_MAX_IMAGE_PIXELS40_000_000maxImagePixels

受理的媒体类型在同一个构造函数里被冻结成四项:image/pngimage/jpegimage/webpimage/gif,类型定义在 packages/attachment/attachment/src/types.tsImageMediaType 上,文档给这条路径的措辞是「版本一附件路径受理的光栅格式」。

这些是配置里的默认值,不是运行表现的承诺——它们只决定什么样的字节会被受理,不决定别的任何事情。配置项本身走 LocalAttachmentStore.Config,四个数值项都带 .min(1)dshHome 可显式指定;packages/util/home-pathsresolveDshHome() 的优先级是显式配置、$DSH_HOME、然后 ~/.dsh

像素上限的检查点在 packages/attachment/attachment-local/src/image.tsdetectImage() 里:先取元数据,宽乘高超过 maxPixels 就抛 IMAGE_TOO_MANY_PIXELS,通过之后才 await image.raw().toBuffer() 把整张栅格解出来。也就是说尺寸判定发生在完整解码之前,而完整解码本身是准入的一部分——文档的说法是「准入时完整解码」。

落盘:临时文件、硬链接、目录 fsync

真正写磁盘的是 packages/attachment/attachment-local/src/store.tssaveImageFile()。对象落点是 <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 一共六个字段:attachmentIdmediaTypebyteswidthheight,外加可选的 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() 只解析头部拿到格式与宽高,再逐项比对 mediaTypebyteswidthheight,任何一项对不上都是 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_BYTES1024 * 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 自述处于开发者预览阶段并明确说明未来会有破坏兼容性的变更, 文中出现的命令、配置与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。