多模态与 Attachment:dsh 怎么让 agent"看图"
dsh 不把图片字节写进会话日志。日志和模型请求里流通的是内容寻址的不可变引用,字节待在独立的 attachment 存储里,先进存储拿到引用,才允许写会话事件;获准的图从此跟随会话的每一轮请求,所以校验、限额、模态门控全部前移到进历史之前。 这一篇沿着一张图从用户丢进会话走到模型看到它:四类入口如何汇进同一道准入,超大图和"模型不吃图"分别会发生什么,请求装不下时最老的图怎么降级成文字。
一张图进会话,就不是一轮的事了
多数 agent 的图片处理停在"把图塞进下一个请求"。一次性对话这样够用,但 dsh 的会话是持久日志,要支撑 fork、resume、换 provider、重放。一张图一旦被接受,就不只服务当前这轮,它会作为历史的一部分,跟随这个会话之后的每一次模型请求。这个事实决定了 attachment 子系统的整个形状。
先看不做专门设计会发生什么。图片是二进制大对象,base64 编码后体积还要涨约三分之一,直接写进日志,日志立刻变成 MB 级的文本和二进制混合体,序列化、持久化、传输全受拖累。浏览器 object URL、宿主机临时路径这类引用活不过进程重启,写进日志的图换台机器就取不回来。不同 provider 要的图片格式不同,有的要 base64 data URL,有的要图床 URL,把任何一种写进日志,日志就和那个 provider 焊死了。
dsh 的解法是一句心智模型:**会话日志里流通的是引用,字节待在存储里。**生产者把图片字节交给 ctx.attachments 这个接缝,服务完成校验、确认字节已经耐久落盘之后,才发布一个内容寻址的不可变引用。之后会话事件和模型可见的 ImageBlock 里装的都是这个引用加元数据,没有浏览器 URL、没有本地路径、没有 base64。
落盘和记事件的顺序是定死的:先把字节写进 <DSH_HOME>/attachments/v1,拿到引用,然后才把引用追加进会话日志。反过来会发生什么不难想象:先写了事件,存图失败,日志里挂着一个指向不存在对象的引用,这个会话从此半残。先持久化、后记事件,日志才永远自洽。
四个入口,一道门
图片进会话不止"用户拖一张图"一条路。dsh 里有四类入口,全部汇进 ctx.attachments 的同一道准入:同一套校验、同一套限额、同一个存储。
第一类是用户上传。浏览器或 API 客户端把 base64 图片随消息提交,宿主先过一道模态预检(下一节展开),然后批量验证:先把这条消息里的每张图都验完,才开始存任何一张,某张被拒不会留下半截已存的对象。验完落盘,引用按原顺序写进用户消息。
第二类是模型自己读图。dsh 提供一个 read_image 工具:模型给它一个文件路径,它把 PNG/JPEG/WebP/GIF 文件的字节走一遍和用户上传完全相同的生命周期,返回一个图片块,图从下一轮请求起进入模型上下文。路径上的扩展名只用来声明类型,真正的裁决权在字节的魔数校验。没有 attachment 存储的部署,这个工具根本不会注册。
第三类是 MCP 工具结果。MCP 服务器返回的内容可以带图,这些图走同一道准入。这里有个刻意的差别:任何一个图被拒,整个结果的图全部降级成文字诊断,说明媒体类型和被拒原因,原始数据保留给程序调用方,工具调用本身仍然成功。用户上传被拒是硬拒绝,工具结果被拒是软降级,因为一个 MCP 结果里通常还有文字部分,没必要整单作废。
第四类是命令附件,斜杠命令声明自己接受图片才收。ACP 这类客户端协议的桥接同理,连接初始化时没宣告图片能力,图片提示直接被拒。
门禁强度在入口之间有梯度。用户上传的预检在"模型已知不吃图"时拒绝;工具侧的门更严,路由没有明确声明 image 模态就拒绝。源码注释给的理由很直接:工具结果会进持久历史,在不确定的路由上产图等于给会话埋雷,宁可拒绝也不赌适配器兜底。
准入量什么:在进历史之前把坏图挡掉
准入校验不是看文件名。每张图被完整解码一遍:声明的媒体类型必须和解码结果一致,改扩展名混不过去;格式限定四种栅格图,PNG、JPEG、WebP、GIF;尺寸和体积要在限额内。部署级限额管着单图体积、单边尺寸、像素总数、每消息张数和总字节,默认值里最容易撞上的是单图 3.5MB 和单边 2000px。
为什么限额设在准入这道门,而不是发送时再看?因为获准的图要陪跑会话的每一轮请求。2000px 这个默认值的出处写在源码注释里:请求里图片一多,部署的模型路由就会拒绝历史里带单边超 2000px 图的请求,与其让一张图进历史之后在每轮请求上被 provider 反复拒绝,不如在门口就拒。read_image 对超大图返回的是可恢复的工具错误,并明确建议缩小后重读,注释的原意是超大图绝不能进持久历史。
落盘本身也有讲究。本地实现是内容寻址的:对字节算出 sha256 摘要,对象按摘要存进分桶目录,引用就是 sha256:<digest>。引用的发布有硬条件:临时写入、强制同步、目录条目落定,字节确定能活过崩溃之后才对外发布。同一张图存两次不占双份空间,内容寻址天然去重,这也是 read_image 被标记为并发安全的原因:同一文件的并发读不会冲突。
引用里记录的是够用的元数据,宽高和字节数让客户端不解码就能排版历史,展示名会剥掉本地路径信息。引用对消费者是不透明的:今天它是 sha256 前缀,明天实现换成别的,消费引用的代码不该受影响,也不许从它反推出文件系统路径。
模态门控:声明,不是试错
图存好了,模型也得声明自己吃图。能不能看图在 dsh 里是模型元数据(inputModalities),不是运行期试出来的。手填的模型默认按纯文本处理,因为没有任何协议能问一个端点"你支持图片吗",宁可保守。
这个声明被三道门消费。上传预检:提交带图消息时,当前模型已声明不吃图,请求被拒,一张字节都不会落盘。工具门:read_image 和 MCP 图片准入都要求路由明确声明 image 模态,未声明即拒。切换守卫:会话历史里已经有图,用户想把模型切到一个已知不吃图的模型,切换被拒绝。适配器是最后兜底:历史带图而模型没声明,或者 attachment 存储没挂载,请求以 UNSUPPORTED_CONTENT 失败。
声明保守会把图挡在门外,声明过头更糟。官方文档的排错表里有一条:模型声明了图片能力,端点实际不支持,provider 会拒绝这个请求;因为图在持久日志里,每轮请求都带着它,同一个请求会一直重复失败,直到会话换路由。修法是撤掉声明并开新会话。这张表把整个设计的动机说透了:图片进历史是一步的事,出历史没有出口,所以所有判断都必须发生在进历史之前。
组装请求:引用换回字节,装不下就丢最老的
到发送这一步,适配器才把引用换回字节。每轮组装请求时,适配器按引用调 readImage:先对整个文件重算摘要,确认字节没被动过,再探测头部核对媒体类型和尺寸,都过了才把字节编码成这个 provider 要的形态。读路径刻意不做完整解码:准入时这批字节已经被完整解码过一次,摘要证明它们就是那批字节,读时做的只剩哈希和头部探测。
这也是日志和 provider 解耦的兑现处。同一份带图的会话日志,今天用带视觉能力的模型跑,字节被编码成 data URL;明天换一个 provider,同一个引用被另一个适配器用另一种格式编码,日志一个字不用改。每个适配器还是要自己写图片编码逻辑,省掉的是"日志里存什么"这一摊。
历史越长、图越多,每轮请求要背的 base64 就越多。dsh 在请求级再设一道限额:累计 base64 图片负载超过上限(DeepSeek 适配器默认 20MB)时,从最老的图开始替换成文字占位符,直到装得下。替换只发生在本次请求的临时副本上,持久历史不动,下一轮换了路由、限额变了,历史里的图一张不少。占位符也不是干巴巴的一句"图已省略",它告诉模型图为什么不在(请求体积限制,老的先省略),以及怎么办(有路径就重新读文件,没路径就请用户再贴)。降级的出口正好指回那两个入口:read_image 和用户上传。
两级限额各管一段:消息级限额在准入,挡的是坏图进历史;请求级限额在组装,保的是请求发得出去。前者一旦放行就赖在历史里不走,后者每轮都可以重算。
权衡
只支持四种栅格图,没有音频、视频、PDF。门槛不在解析,而在每加一个模态都要适配器翻译、UI 渲染、压缩、持久化重放一起跟上,图片是当前唯一落地的非文本模态。ImageBlock 类型本身是角色中立的,给模型产图预留了通道,但当前生产适配器都只声明纯文本输出,模型产图还是设计上的可能性,不是现状。
存储只增不减。fork 和 resume 出来的会话可能共享同一批图片对象,删掉一个会话就删它引用的图会误伤别的会话,所以 attachment 服务刻意保持存留中立,引用感知的垃圾回收推迟给更高层。代价是磁盘占用随使用单调增长,部署方需要自己的清理策略。
读时校验有真实成本。每个请求里每张在场的图都要被完整哈希一遍,成本线性于字节数,换来的是任何篡改和损坏在读时就能被发现,而不是变成模型眼里的乱码。适配器各自维护图片编码,接一个新 vision provider,编码这摊事省不掉。
还有一条隐含约束值得说破:会话里一旦进了图,路由自由就受限了。不能切到不吃图的模型,声明过头会卡死会话。这是"图跟随每一轮请求"这个决定的另一面。
结论
dsh 让 agent 看图的方式,是把图片当成会话的持久资产,而不是一次性的请求负载。一道准入在图进历史之前把该做的判断全部做完:完整解码校验、部署限额、模态声明,任何一项不过就拒在门外,用户上传是硬拒绝,工具结果是降级成文字。历史里只留内容寻址的轻引用,字节待在独立存储里,先持久化后记事件,读时重验。运行期的问题用运行期的手段解决:请求装不下就丢最老的图换文字占位符,历史不动。日志因此可以很小,却能驱动一个看图 agent:图不在日志里,日志里只有一把随时能取回图的钥匙。
延伸阅读
- Durable Image Attachments 官方文档:接缝定义、先持久化后事件规则、存留中立
- Provider 配置指南:模态声明的配置方法与两条图片排错项
- Capability Seams:ctx.attachments 接缝在三角色表中的定位
- Tool Catalog:read_image 的注册条件与执行门控
上一篇:LLM 适配器与 stream 契约:dsh 把 provider 差异关在适配器一层 下一篇:给 dsh 写一个 LLM 适配器:接 OpenAI 兼容端点
GitHub 原文:17-multimodal-attachments.md
评论
EMPTY