Block 开源多 Agent 平台 buzz:一张图上传的四道关
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
在 Block 开源的多 Agent 通信平台 buzz 里,媒体这条线上最贵的一段不是存储,是校验:它对图片和视频做的不是”扫一遍有没有 EXIF”,而是逐个容器结构做白名单——凡是它不认识的块,一律当作可能夹带定位信息的元数据通道拒掉。 明白了这个取向,后面的上传编排、缩略图、桶索引三段代码为什么长成那样,就都顺了。
这里说的 buzz,是 Block 开源的那个多 Agent 通信平台(仓库 github.com/block/buzz,Apache-2.0,Copyright 2026 Block, Inc.),不是”热度""蜂鸣”之类的泛指。仓库 README 这样定位自己:一个人和 Agent 一起干活的工作空间,跑在你自己拥有的中继上。它建在 Nostr 之上——Nostr 是一套极简的去中心化消息协议,核心只有两件东西:一条条带签名的 JSON 事件(event),和一批负责收发这些事件的服务器,叫中继(relay)。用户的身份就是一对密钥,私钥自己拿着,事件用私钥签名,中继只负责转发和存储,不替你保管身份。密钥自持的含义要说白:丢了私钥就等于丢了这个身份,没有找回入口。人和 Agent 在 buzz 里是同一张网络上的两类发言者,所以”某个 Agent 往房间里丢了一张图”和”某个人丢了一张图”,走的是同一条媒体链路。
一、这条线到底要解决什么
聊天里发附件,看起来只是”存下来、给个链接”。放到一个人和 Agent 混住、中继可以自建的系统里,问题会变成另外几个:
第一,谁有资格往这个中继上传。中继不是自己家的私有后端,它对外开着口,任何拿得到地址的客户端都能戳。
第二,字节本身可信吗。客户端声明的 Content-Type 是一句纯口头承诺,改一个 header 就能把一段 HTML 说成 PNG。
第三,这些字节里夹带了什么。手机拍的照片默认带 GPS,视频容器里能塞下的私有字段更多。一个 Agent 帮你转发的截图,如果原样进桶再原样发出去,泄的是发图那个人的位置。
第四,桶里的东西过一段时间对不对得上。这套存储是内容寻址的——对象的键就是字节内容的哈希,同一份字节永远只存一份,天然去重;代价是键和”谁在引用它”彻底脱钩,写一半失败留下的字节看上去和正常字节一模一样。这种残留物没人盘点就一直烂在那儿,还会被记进账单。
buzz 把这四件事拆在 crates/buzz-media 这个 crate 里(整个仓库 crates/ 下有 28 个 crate,这是其中之一),crates/buzz-media/src/lib.rs 里导出的模块正好对应:auth、upload、validation、thumbnail、storage、bucket_index、upload_record、config、types、error。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 上传编排 | 串起鉴权、算哈希、幂等短路、写 blob、写元数据 | crates/buzz-media/src/upload.rs | 排查”上传返回 200 但图片打不开” |
| 内容校验 | 魔数签名嗅探、MIME 白名单、尺寸上限、容器结构白名单 | crates/buzz-media/src/validation.rs | 客户端导出的图被拒,要定位是哪一条规则 |
| 缩略图与 blurhash | 生成 320px 缩略图,从缩略图算 blurhash 占位色块 | crates/buzz-media/src/thumbnail.rs | 列表页占位图不出、或缩略图方向不对 |
| 对象存储客户端 | 封装 S3 兼容桶的读写、范围读、分页列举 | crates/buzz-media/src/storage.rs | 换存储供应商、改寻址风格、配 IAM 角色 |
| 桶键分类与盘点 | 把桶里每个 key 归类并折叠成一份统计快照 | crates/buzz-media/src/bucket_index.rs | 对账单、查孤儿对象 |
| 上传事件记录 | 每次被接受的上传写一条 JSON 记录,供审核侧消费 | crates/buzz-media/src/upload_record.rs | 开了 BUZZ_MEDIA_UPLOAD_RECORDS 之后 |
| Blossom 鉴权 | 校验 kind 24242 授权事件的签名、动词、有效期、目标主机 | crates/buzz-media/src/auth.rs | 客户端 401,要分清是签名问题还是主机不匹配 |
二、四步各自在做什么
第一步,鉴权与哈希。 upload.rs 里图片和普通文件走同一个内部函数 process_buffered_upload,只有两处通过闭包注入:一是校验函数,二是元数据构造函数。这个函数把三件 CPU 密集的事塞进同一个 spawn_blocking:跑校验拿到 (mime, ext)、对整段字节算 SHA-256、然后用这个哈希去验 Blossom 授权事件。Blossom 是 Nostr 生态里给二进制大文件用的一组规范(规范条目按 BUD-xx 编号),auth.rs 的模块注释写明它对齐的是 BUD-11。授权事件是一条 kind 为 24242 的 Nostr 事件,verify_blossom_auth_event_for_verb 按顺序检查:Schnorr 签名有效、kind 等于 24242、t 标签的动词匹配、expiration 标签在未来、created_at 不超前于现在(容忍 5 秒时钟偏差)、若带了 server 标签则必须包含本次请求绑定到的那个主机。
有意思的是时间窗的分叉:缓冲路径(图片和普通文件)传的 max_age_secs 是 600,视频路径传 3600。代码注释解释得很直白——大文件走慢网络需要余量。这不是随手写的常数,是两条链路的物理差异。
第二步,校验。 校验永远从磁盘签名开始,validate_content 第一行就是 infer::get(bytes),注释写着”never trust Content-Type header”。图片路径的白名单只有四个:
const ALLOWED_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
video/mp4 被刻意排除在外,因为视频有独立的流式管线;一个 MP4 想从图片口混进来,会在这里被 infer 认出并拒掉。之后是尺寸上限(GIF 用单独的 max_gif_bytes)、容器结构校验,最后是像素炸弹防护——常量 MAX_PIXELS 写死 25_000_000,也就是 2500 万像素,注释说明这对应大约 100MB 的 RGBA 解码内存。这一步用 imagesize::blob_size 只读文件头拿宽高,解析不出来就直接拒,不让未知几何的图进到后面真正的解码器。
第三步,缩略图。 thumbnail.rs 全文只有五十来行,是四段里最短的。它同步执行(由调用方放进 spawn_blocking),逻辑就三句:解码、缩到最长边 320、编码成 JPEG,再从缩略图算 blurhash:
let thumb = img.thumbnail(320, 320);
let rgba = thumb.to_rgba8();
let bh = blurhash::encode(4, 3, thumb.width(), thumb.height(), rgba.as_raw()).unwrap_or_default();
blurhash 是一串短字符串,客户端拿它渲染一块模糊色块当占位,图没下完时先顶上。这里两个细节值得抄:blurhash 从缩略图算而不是从原图算,注释直说是为了快;编码失败走 unwrap_or_default() 拿到空串,而不是让整个上传失败——占位图缺失是可降级的,上传失败不是。
第四步,落桶与索引。 storage.rs 是薄封装,真正定规矩的是键的形状。原始字节按内容寻址存成 {sha256}.{ext},缩略图是 {sha256}.thumb.jpg,而元数据边车(sidecar,指跟主对象并排放、只装元数据的那个小 JSON)带社区维度:
pub fn sidecar_key(community: CommunityId, sha256: &str) -> String {
format!("_meta/{community}/{sha256}.json")
}
这行代码是整条线的读权限闸门。同一份字节可能同时被两个社区引用,哈希一样、blob 一样,但边车按社区隔开;read_sidecar_mime 还刻意把”边车不存在”和”读边车失败”两种情况都折成 None,让公开读接口一律返回 404——不这么做,一个社区就能靠响应码差异探测另一个社区里有没有某张图。
三、写入顺序里藏着这条线的全部脾气
process_buffered_upload 的写入顺序是:先 PUT blob,再生成派生物(缩略图),再写上传记录,最后才写边车。边车是”可以对外提供”的闸门,所以它排在最后。任何一步失败,媒体都不可能被服务出去。
代价是会留下孤儿对象。代码里明确选择了不清理:并发上传同一个哈希时,删 blob 可能删掉另一个请求马上要引用的字节,所以宁可留着。注释里把这笔账算得很清楚——孤儿 blob 是内容寻址的、受上传大小上限约束,存储成本可忽略,留给后台清扫任务处理。
幂等短路也有讲究:只有边车和 blob 都存在时才短路返回;边车在而 blob 不在,会穿透下去重新上传。这条判断是防”元数据还在、字节没了”的半残状态被当成成功。而短路这条路径上仍然会写一条上传记录,因为重传已知字节在审核视角里依然是一次独立的上传事件——upload_record.rs 的注释直接点名了这个场景:被下架过的内容再传一次,如果只在 blob 创建时触发扫描,这次重传就是隐身的。
上传记录本身默认关闭,由 BUZZ_MEDIA_UPLOAD_RECORDS 打开,键的形状是 _uploads/{community}/{sha256}/{event_id}.json,event_id 是 ULID。IP 采集是第二道独立开关(BUZZ_MEDIA_UPLOAD_IP_HEADER),并且是失败即空——header 缺失、格式不对、不是公网地址,都记成没有,注释的原话是错的 IP 比没有 IP 更糟。这条设计取向值得单独品:采集能力和采集动作是两个开关,且默认都关。
到这里可以顺手说清本篇和站内几篇相近文章的分工:AI 项目的数据安全风险讲的是数据交给模型侧之后的风险面,是”往外送”的方向;pi 的会话存储讲的是单机 Agent 怎么把对话落到本地,是”自己留档”;Agent 工具返回值怎么设计讲的是工具调用结果的结构约定。本篇只管一件事——别人(包括 Agent)往你自建的中继上塞二进制字节时,这条链路上每一步的输入、输出和残渣。
四、桶索引:把桶本身当成事实来源
bucket_index.rs 是四段里最容易被忽略、但设计意图最清楚的一段。它的模块注释第一句就声明:这个模块零 S3 I/O。classify_key 和 BucketAggregate 只处理 (key, size) 这样的纯数据对,翻页拉取由调用方以闭包形式传进来。好处是这套逻辑能拿合成的假清单跑测试,不需要真桶。
分类只认五类:thumb、blob、sidecar、auxiliary,剩下全归 Unknown。而且识别规则严得有点固执:SHA-256 必须是 64 位小写十六进制,UUID 必须是 36 字符的规范小写带连字符形式(花括号、urn、无连字符这些 Uuid::parse_str 本来能接受的写法一律不认),ULID 必须是 26 位大写 Crockford base32。注释解释了为什么不用现成的宽松解析器:这台服务器写进 key 的每一个 UUID 都是规范小写格式,凡是别的形状,都不是它自己写的,就不该被当成自己人。
折叠出来的 BucketSnapshot 里有几个字段值得直接抄进你自己的对账逻辑:physical_* 是桶里所有对象的实打实占用;logical_* 是按社区绑定累加的,同一份字节被 N 个社区绑定就计 N 次,注释写明这是有意为之;orphan_blob_* 是没有任何社区边车绑定的字节;orphan_sidecar_count 是反过来——边车指向的哈希在桶里根本没有字节;multi_variant_shas 是同一个哈希出现了多个扩展名变体,属于异常;unknown_key_* 则是能见度指标,它涨了说明有人往桶里写了这套规则不认识的东西。
翻页折叠 fold_bucket_listing 还有两个防御:对象总数上限在折叠每页之前检查,超了就整轮失败而不是给一份不完整的快照;某页声称还有后续却没给续页令牌时,报 MalformedPage。失败语义统一成一句话——扫描失败就沿用旧快照,绝不产出半份。
五、边界与代价:它明确不管的事
它不管内容是什么。 整条链路里没有任何一处判断这张图画的是什么。上传记录模块的注释把定位说得很明白:内容寻址存的是关于字节的事实,而审核与法务上报需要的是关于上传事件的事实。判定本身在别的消费者手里,这个 crate 只负责把可供判定的输入摆好、并保证记录存在时被引用的对象一定读得到。
它不做转码,也不做兼容性兜底。 视频校验的规则是硬白名单:容器必须是 ISO 基础 MP4(QuickTime 的 qt 品牌直接拒),视频轨恰好一条且必须是 H.264(avc1),音轨最多一条且必须是 AAC,时长上限 600 秒,分辨率上限 3840×2160,并且 moov 必须排在 mdat 之前(否则播放器要拉完整个文件才能起播)。多出一条视频轨就报错,理由写在注释里——备用轨道可能携带客户端并不打算发布的内容。这些约束意味着相当一部分手机原生录制的文件在客户端不做处理时会被拒,代价由客户端的编码器承担。
它不接受任何”顺带”的元数据。 图片这块不是 EXIF 黑名单,而是结构白名单,注释说明了原因:位置信息也能藏在 XMP、注释段、PNG 文本块、ICC 描述或私有块里。JPEG 只放行规范的 JFIF 与 Adobe 色彩头,APP1 到 APP13、APP15 和注释段一律拒;PNG 拒 eXIf、zTXt、iTXt、iCCP,未知的辅助块一律拒,连 pHYs 都被刻意排除,理由是任意取值本身就是一条身份通道;WebP 只放行 VP8 、VP8L、VP8X、ALPH、ANIM、ANMF,并且 VP8X 的 ICC/EXIF/XMP 标志位只要置上就拒——哪怕对应的块根本没写;GIF 只放行 NETSCAPE2.0 和 ANIMEXTS1.0 两种循环扩展。唯一的例外是 PNG 的 tEXt:当且仅当关键字是 buzz_agent_snapshot 或 buzz_team_snapshot、且全文件只有一个这样的块时放行,因为那是产品自己的分享载荷。
普通文件路径不给任何推断。 validate_file_content 是兜底附件通道,凡是嗅探出 image/、video/、audio/ 前缀的,一律拒——不能让被识别出的媒体绕过各自的格式策略进到原样存储;HTML、XHTML、SVG、JS 以及各类原生可执行文件在拒绝列表里;嗅不出签名的(纯文本、CSV、源码、JSON 这些本就没有魔数)接受为 application/octet-stream,扩展名给 bin。这些文件一律以附件形式下发,serve_inline 只对 image/ 和 video/ 返回真,PDF 被明确注明暂不内联。
自建中继意味着这些字节落在你自己的桶里。 storage.rs 支持两种凭据模式:配了静态 access key/secret 就用静态凭据,两个都为空则回落到 AWS 默认凭据链(含 EKS 上的 IRSA web-identity);只配一个会直接报错拒绝启动,防止悄悄滑到凭据链上。桶的可访问范围、公开基址 public_base_url、以及你是否打开 IP 采集,都是你自己的暴露面。开了 BUZZ_MEDIA_UPLOAD_RECORDS 就意味着 _uploads/ 下开始沉淀上传者的公钥、npub、社区主机,再开 IP header 就多一个公网地址——这些数据在谁的手里、保留多久,代码不替你决定。
六、上手与避坑清单
客户端不做编码器就一定被拒。 校验是结构白名单,随手用系统 API 导出的图往往带着 ICC、方向或厂商块。仓库里带了 Android 与 iOS 的测试夹具正好说明这件事——crates/buzz-media/tests/fixtures/android/sanitized/ 下经过处理的样本能通过校验,同目录上一层未经处理的 bitmap-display-p3.png 之类会因元数据被拒(校验模块的单元测试正是拿这两组夹具一正一反跑的)。避法:先在客户端把像素重新编码一遍再上传,别指望服务端帮你清洗。
上传成功了但图打不开,先查边车。 边车是最后写的闸门,blob 在而边车不在正是失败留下的中间态。避法:拿哈希直接去桶里对 _meta/{community}/{sha256}.json,比翻日志快。
换存储供应商时先确认寻址风格。 S3AddressingStyle 有 Path 和 Virtual 两种,默认 Path,注释说明这是为了兼容内置 MinIO 部署(其内部 DNS 只解析端点主机名),而某些新型托管桶要求标准的虚拟主机形式。避法:换供应商时把这一项和 s3_region 一起对一遍,区域不匹配会导致签名的凭据作用域不对而被拒。
public_base_url 配错会在启动期炸。 MediaConfig::validate 要求它必须以 /media 结尾且不能以斜杠收尾。避法:这是启动期校验,别等到第一次上传才发现。
带 server 标签的客户端在多租户下最容易 401。 授权事件里的 server 标签会和本次请求绑定到的那个租户主机比对,而不是和某个进程级全局域名比对。避法:排查 401 时先分清是签名无效、动词不对、令牌过期,还是主机不匹配——error.rs 里这几种是不同的错误变体,日志里能区分。
别用 Unknown 计数当噪音忽略。 桶索引把不认识的 key 单独计数,就是为了让它响。避法:把 unknown_key_objects 接进你的监控,它非零通常意味着有别的进程在往同一个桶里写东西。
盘点任务要设上限和超时。 fold_bucket_listing 的对象上限是先检查后折叠,超限整轮失败;调用方还会在外面套超时。避法:别把这个扫描当成随时可跑的轻量查询,给它单独的调度和失败沿用旧值的语义。
收束
这条媒体线可以浓缩成一句判断:内容寻址负责去重,边车负责授权,校验负责杜绝夹带,索引负责事后对账,四件事谁也不替谁兜底。 如果你正在给自己的 Agent 系统加附件能力,这套分法比”存个文件返回 URL”值钱得多——尤其是”发起方可能是一个 Agent”的时候,权限与残渣的账要算得更清楚,这也是最小权限设计和可观察日志那两篇要解决的同一类问题在存储侧的投影。
接下来该读哪个文件,按你的关注点分:想搞清楚”为什么我的图被拒”,从 crates/buzz-media/src/validation.rs 的 validate_image_metadata_free 往下读,四个格式的校验函数各自独立;想搞清楚失败态和残渣,读 crates/buzz-media/src/upload.rs 里 process_buffered_upload 的注释块,写入顺序的每一步都注明了失败后果;想做容量对账,crates/buzz-media/src/bucket_index.rs 可以整段照抄思路,它本来就不依赖任何 S3 类型。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源 buzz:多 Agent 通信平台的移动端为何重写协议 和 Block 开源多 Agent 通信平台 buzz 的推送网关:授权、令牌与设备证明。