Block 开源的多 Agent 通信平台 buzz:一切皆签名事件
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
buzz 把聊天消息、频道动作、Agent 的每一个回合、工作流的每一步,全部压成同一种数据结构——一条带签名的事件,业务类型只由一个整数字段 kind 决定。 这不是图省事,是拿”所有东西都能被同一套规则验证、过滤、回放”去换”每类业务有自己的模型和自己的校验位置”。换来的东西很具体,付出的代价也很具体,下面逐条摊开。
先把名字说清楚。这里的 buzz 指 Block 开源的多 Agent 通信平台,仓库在 github.com/block/buzz,许可证 Apache-2.0(Copyright 2026 Block, Inc.),主体是 Rust 写的,crates/ 目录下有 28 个 crate。它不是”热度”也不是”蜂鸣”,而是一个你能自己部署起来的工作区服务端。它的仓库 README 这样定位自己:人和 Agent 共享同一批房间的可自托管工作区——这是项目自己的说法,下面照它的代码来看它做到了哪一步。
它建在 Nostr 之上。Nostr 是一套很薄的消息协议:每个参与者持有一对密钥,发出去的每条数据都是一个带签名的 JSON 对象(事件),由被称为中继(relay)的服务器接收、校验、存储并转发给订阅者。身份不是账号密码,而是那对密钥本身。buzz 的服务端 crates/buzz-relay 就是这样一个中继,只不过它在标准之上塞进了大量自己的事件类型。
还有一个下面会反复出现的缩写要先交代:NIP 是 Nostr 生态里给协议提案编的号,一份 NIP 就是一份规范文档,约定某类事件长什么样、中继该怎么处理。NIP-01 是最基础的那份(事件结构与订阅过滤),后面带字母的那些(NIP-AM、NIP-AP 之类)是 buzz 自己写的扩展,都放在仓库 docs/nips/ 下。
这篇讲的是数据模型层的取舍。站内另外三篇分工不同:结构化输出为什么不稳 讲的是模型吐出来的 JSON 靠不靠得住,是模型侧的问题;AI 参与的数据库设计 讲的是你自己项目里怎么定表;Pascal Editor 的扁平节点模型 讲的是另一个开源项目在三维场景里做的类似扁平化选择。本篇只盯一件事:buzz 用一个整数当类型系统,好处和账单各自落在哪儿。
一、它先解决的问题:让”谁做了什么”只有一条时间线
在常规的团队协作产品里,聊天记录一张表、审批流一张表、任务执行日志又一张表。人和人协作时这不算大问题,因为最终解释权在人手上。放进有 Agent 的环境里就麻烦了:你要回答”这个改动为什么发生”,得从消息表跳到工作流表,再跳到 Agent 的执行日志,三份数据的时间戳口径、身份口径、保留策略很可能都不一样。
buzz 的做法是取消这种分家。仓库 README 里明确说消息、反应、工作流步骤、评审批准、git 事件都是同一条日志里的签名事件——这句是项目的自我描述,但代码是对得上的:crates/buzz-core/src/kind.rs 这一个文件里就注册了 129 个 kind 常量(把 ALL_KINDS 数组里的条目数出来即可),覆盖从聊天消息到 git 补丁到工作流审批的全部业务。
对你意味着什么?意味着”审计”不是一个额外做的功能,而是模型的副产品。一条事件带着作者公钥和签名,你不需要相信服务端记的操作日志——签名是作者自己签的,服务端伪造不了。人发的和 Agent 发的走同一条路,也就没有”Agent 的动作要不要单独留痕”这个问题,它天然留痕。这一点上它跟你自己搭一套 Agent 可观察日志 的目标是一致的,区别在于它把这件事推到了协议层,而不是应用层的一个 logger。
二、一条事件是什么:签名之外,中继还自己记三件事
标准 Nostr 事件的内容不归 buzz 管,它管的是”收到之后怎么装”。crates/buzz-core/src/event.rs 里的 StoredEvent 就是那个信封:里面装着原始的 nostr::Event,外加中继自己填的三样东西——received_at(中继收到它的墙上时钟时间)、channel_id(频道归属,全局事件和私信为 None)、以及一个私有的 verified 布尔位,只能通过 is_verified() 读。
这三样都值得留意。received_at 和事件自带的 created_at 是两个时间:前者是中继说的,后者是作者说的,作者说的那个在签名覆盖范围内、可以被作者随便填。channel_id 是中继侧的归属信息,不在签名里——后面讲过滤时会看到它带来的一个不对称。verified 私有化则是个小而清晰的设计意图:验证状态不允许外部结构体字面量直接塞,只能走构造函数。
验证本身在 crates/buzz-core/src/verification.rs,只有一个函数,短到可以整段看:
pub fn verify_event(event: &Event) -> Result<(), VerificationError> {
if !event.verify_id() {
let computed = EventId::new(
&event.pubkey,
&event.created_at,
&event.kind,
&event.tags,
&event.content,
)
.to_hex();
return Err(VerificationError::InvalidId { computed, got: event.id.to_hex() });
}
if !event.verify_signature() {
return Err(VerificationError::InvalidSignature);
}
Ok(())
}
两步,顺序有意义。第一步 verify_id() 检查事件 id 是不是那几个字段(公钥、时间、kind、tags、content)算出来的哈希——它只证明 id 和内容自洽,不证明是谁写的。第二步 verify_signature() 才是 Schnorr 签名校验,证明确实是这个公钥的持有者签的。event.rs 底部的测试把这个区别演示得很直白:把一条合法事件的 sig 字段替换成 128 个 0,verify_id() 依然返回 true,verify_signature() 才返回 false。改内容和改签名,是两类不同的攻击,报错也是两个不同的枚举分支。
这个文件顶部有一条给实现者的硬提示:verify_event() 是 CPU 密集的(Schnorr 运算),在异步上下文里必须通过 tokio::task::spawn_blocking 调用,不能直接扔在 async 任务上跑。这是扁平事件模型的第一笔账——所有类型的事件都要签名,也就意味着所有类型的事件都要付这份验证开销,包括那些高频的、你可能觉得”不重要”的输入状态类事件。
关于密钥还有一句必须写清的:身份就是密钥本身,密钥自持。丢了私钥不是”重置密码”的问题,是这个身份没了——别人也无法替你重新签出同一个公钥的事件。仓库里跟”身份退场”最接近的机制是 KIND_IA_ARCHIVE_REQUEST(9035)和 KIND_IA_UNARCHIVE_REQUEST(9036)这组归档请求,按 docs/nips/NIP-IA.md 的说法,它做的是让某个公钥从成员列表和补全候选里隐去、同时保留历史事件,不是把丢掉的密钥找回来。给 Agent 配密钥的时候,这一点决定了你的密钥托管方案不能随便。
三、kind 编号就是它的类型系统
既然所有东西都是同一种结构,区分类型的担子就全落在 kind 这个整数上了。crates/buzz-core/src/kind.rs 文件开头把自己称为”kind 号的权威来源”,并解释了为什么常量都是 u32:NIP-01 规定 kind 是无符号整数,u32 能覆盖全范围不截断。
号段不是随手排的,几个区间有硬语义,代码里就是几个 const fn:
pub const fn is_ephemeral(kind: u32) -> bool {
kind >= EPHEMERAL_KIND_MIN && kind <= EPHEMERAL_KIND_MAX
}
pub const fn is_replaceable(kind: u32) -> bool {
matches!(kind, 0 | 3 | KIND_CHANNEL_METADATA | 10000..=19999)
}
pub const fn is_parameterized_replaceable(kind: u32) -> bool {
kind >= PARAM_REPLACEABLE_KIND_MIN && kind <= PARAM_REPLACEABLE_KIND_MAX
}
翻译成人话:20000–29999 是临时事件,永不落库(文件里注明走 Redis 发布订阅);0、3、41 和 10000–19999 是可替换事件,同一个 (pubkey, kind) 只留最新一条;30000–39999 是带参数的可替换事件,键多了一个 d 标签,即 (pubkey, kind, d_tag) 三元组只留最新。其余号段是普通事件,追加式,不会被替换。
所以选号不是起名字,是选存储语义。你给一个新业务挑 kind 号的那一刻,就同时决定了它是历史流水、是单例状态、还是按 key 的一组状态。这也是这套模型最锋利的地方:不用写迁移,不用建表,选对号段就有了对应的持久化行为。
具体号段的分布也能看出它把哪些东西当成一等公民:聊天消息是 kind 9(KIND_STREAM_MESSAGE),Agent 任务协议占 43001–43006(请求、接受、进度、结果、取消、失败),工作流定义是 30620(带参数可替换,d 是工作流 uuid),工作流执行事件占 46001–46012(触发、步骤开始、步骤完成、步骤失败、整体完成、整体失败、取消,以及三个审批相关的)。Agent 的人格定义 30175、团队 30176、受管 Agent 30177、团队目录投影 30178 挨着排。Agent 每个回合的 token 用量记录是 44200(KIND_AGENT_TURN_METRIC),文件注释写明它是普通存储事件、追加不替换、内容用 NIP-44 加密给 owner。
一个能看出这套模型气质的细节:Agent 的关机没有专门的 kind。KIND_STREAM_MESSAGE 的注释里写着,owner 发一条普通的 kind:9 消息、内容是 "!shutdown"、带上指向 Agent 的 #p 标签,Agent 的宿主进程就优雅退出——文件里明说”这是一个约定,不是新的事件类型”。好处是零协议成本;代价是这条约定不在类型系统里,编译器不会替你守它。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
StoredEvent | 给原始事件包上中继侧元数据(收到时间、频道归属、验证位) | crates/buzz-core/src/event.rs | 任何要读写事件的地方 |
| kind 号注册表 | 129 个 kind 常量、号段判定、可见性集合 | crates/buzz-core/src/kind.rs | 加新业务类型、排查”事件为什么没存下来” |
| 事件校验 | id 哈希自洽 + Schnorr 签名验证 | crates/buzz-core/src/verification.rs | 事件被拒时看是 InvalidId 还是 InvalidSignature |
| 过滤器匹配 | NIP-01 订阅过滤,多过滤器取或、同一过滤器内字段取与 | crates/buzz-core/src/filter.rs | 订阅拿不到预期事件时 |
| 入口分发 | 按 kind 决定所需写权限、路由到各自处理器 | crates/buzz-relay/src/handlers/ingest.rs | 新增 kind、或收到 scope 相关的拒绝 |
| 分页上限 | DEFAULT_MAX_PAGE_LIMIT,同时是对外宣称的 NIP-11 上限 | crates/buzz-db/src/event.rs | 拉历史发现结果被截断时 |
四、读取端:过滤器只认 kind 和标签
crates/buzz-core/src/filter.rs 顶部两行注释就把语义讲完了:多个过滤器之间是或,同一个过滤器内部各字段之间是与。匹配逻辑本身很朴素——比 kind、比作者、比 since/until 时间窗、比事件 id(NIP-01 允许前缀匹配,代码里就是 event_id_hex.starts_with(...))、比通用标签。
有意思的是 #h 标签(频道)的那段特判。反应(kind:7)和删除(kind:5)这类事件不带自己的 h 标签,它们的频道归属是从目标事件推导出来的,于是过滤时会回落到 StoredEvent.channel_id。但这个回落只在”事件完全没有 h 标签”时生效;只要事件带了显式的 h 标签,标签就是权威,不匹配就直接拒。测试里专门写了这一条的理由:防止跨频道泄漏。这就是前面说的不对称——签名覆盖不到的中继侧字段只能当兜底,不能拿来覆盖签名覆盖得到的字段。
真正体现”扁平模型的账单”的,是可见性。因为所有东西都在同一条流里,“哪些事件谁能看”没法靠表隔离,只能靠一层层按 kind 打的补丁。kind.rs 里因此并排放着四个集合:
AUTHOR_ONLY_KINDS:只有作者本人可读。目前是事件提醒(30300)和推送租约(30350)。注释写得很重:中继不得向作者之外的任何人泄露这些事件的存在、数量、标签、内容、排期或搜索命中。P_GATED_KINDS:读者的公钥必须出现在事件的#p标签里才能读。包含私信外壳、成员变更通知、Agent 观察帧、Agent 回合用量。注释还提到,这些 kind 里可持久化的那些在存储层会把全文检索字段写成 NULL,让它无法通过搜索被翻出来。RESULT_GATED_KINDS:连”我已经知道事件 id”这条路也要堵。因为按 id 查询可以不带 kind,过滤层的#p检查会被绕过,所以这些 kind 需要逐事件再判一次。SHARED_GATED_KINDS:默认只有作者可读,除非事件上带了精确的["shared", "true"]标签。用于人格定义(30175)和团队目录投影(30178),保护系统提示词和白名单公钥不因为多端同步而变成全社区可见。
最后这一类的实现细节很值得抄。判断”是否已共享”的 event_is_shared 要求标签必须恰好两个元素、值恰好是 "true",出现两个 shared 标签、或者三元素的 ["shared","true","extra"],一律判为未共享——注释里的说法是”对任何非精确形状失败关闭”,并且强调它不依赖入口校验的保证,自己独立守一遍。另外,为什么共享开关是标签而不是内容字段,注释也解释了:切换共享不能改变内容字节,否则由内容算出的哈希会变,人格同步的漂移检测就乱了。
与之配套的还有一个结果级检查,filter.rs 里的 reader_authorized_for_event:对私信可见性快照和 Agent 回合用量这两类事件,读者公钥必须等于事件的 #p。测试里额外确认了一条容易想当然的事——写这条用量事件的 Agent 自己也读不回来,因为按 NIP-AM 的规定它是”仅 owner 可读”。这类边界正是做 Agent 最小权限设计 时最容易漏掉的地方:写入方和读取方不必是同一个人。
五、边界与代价:它明确不管什么
类型安全被换成了纪律。 一个整数不构成类型系统,编译器只能帮你检查那些你主动写下的断言。kind.rs 底部确实写了一排编译期断言(比如”人格 30175 落在带参数可替换区间”、“Agent 回合用量既不是临时也不是可替换”),测试里还有一个循环 0 到 65535、逐个确认”可替换”和”带参数可替换”两个判定互斥的用例。但这些都是人手写的护栏,不是模型自带的。
号段划错的代价是永久的。 这一点仓库自己留了伤疤。KIND_STREAM_MESSAGE(现在是 kind:9)的注释写着 V1 用过 kind:10001、之后才换到 40001;KIND_STREAM_MESSAGE_V2 的注释写着 V1 用过 10002;KIND_STREAM_MESSAGE_EDIT 的注释写着 V1 用过 10004;论坛那一段的段首注释写着 V1 用的是可寻址区间 30001–30003——这几处旁边都跟着一句”wrong”。原因就是前面那套号段语义:10001、10002 都落在可替换区间,意味着同一个作者发的新消息会把上一条替换掉;10004 除了落错区间,注释里还标了一句跟 NIP-51 撞号。这类错不是逻辑 bug,是模型语义级的错,改起来要动的是所有已经写进库的数据。
这个整数还有上界。 底层 nostr 库的 Kind 是 u16 撑起来的,kind.rs 里的常量是 u32,于是文件底部要用编译期断言把两边钉住(KIND_AUTH <= u16::MAX as u32 之类)。扁平命名空间的通病:所有人共享一个有限编号池,而这个池的实际宽度由依赖决定。
有些 kind 号根本不是事件。 KIND_MEDIA_UPLOAD(49001)的注释直说:“媒体上传审计条目的内部 kind,不是中继事件 kind。“这就是扁平模型的滑坡——编号空间好用到会被借去当别的东西的枚举,读代码的人得看注释才知道哪些号真的会出现在网络上。
它不管未知类型。 crates/buzz-relay/src/handlers/ingest.rs 里的 required_scope_for_kind 对每个 kind 返回所需的写权限,函数文档里最要紧的一句是:未知 kind 返回 Err,中继拒绝。也就是说这不是一个”任何自定义事件都能塞进来”的开放中继,白名单在服务端代码里。你想加一类事件,得改这个函数、重新部署,而不是客户端自己约定一个号就开始发。这是刻意的收紧,但要写在你的预期里。
它也不管无限大的载荷和无限深的历史。 中继的默认帧上限是 512 KiB(crates/buzz-relay/src/config.rs 的 DEFAULT_MAX_FRAME_BYTES = 512 * 1024),单连接订阅数上限 1024(crates/buzz-relay/src/handlers/req.rs 的 MAX_SUBSCRIPTIONS),历史分页硬上限 1000 行(crates/buzz-db/src/event.rs 的 DEFAULT_MAX_PAGE_LIMIT,注释说明这同时就是对外宣称的 NIP-11 上限,宣称值和执行值不会漂移)。设备配对用的是另一套服务 crates/buzz-pair-relay,那边的帧上限是 4096 字节。想让 Agent 一次性把六个月历史吸进上下文,这几个数会先拦住你。
风险面要说明白。 自建中继意味着这条日志——包括所有频道消息、Agent 的每一次回合记录、工作流的每一步——都落在你自己的库里,暴露面是你开出去的那个 WebSocket 端口和 HTTP 入口。推送相关的凭据以密文形式存在推送租约事件里(30350,仅作者可读,实际投递状态另有专用表)。至于 Agent 能改什么:仓库里有 NIP-34 的 git 事件(补丁 1617、拉取请求 1618、议题 1621、状态 1630–1633)和仓库公告 30617,也就是说被授权的 Agent 是可以发补丁和改仓库状态的。这类权限的边界值得单独审一遍,思路可以参考 Agent 权限过大怎么收。
六、上手与避坑清单
先想清楚号段再写代码。 会踩是因为选号看起来像起名字,实际上是在选存储语义。挑到 10000–19999 就等于宣布”同一个人只保留最新一条”,你的消息会互相覆盖。避法:动手前先在 kind.rs 里读一遍 is_ephemeral、is_replaceable、is_parameterized_replaceable 这三个函数,确认你要的是流水、单例还是按 key 的状态,再回头挑号。
别把可见性当成”加个字段”。 会踩是因为业务上”这条别给别人看”听起来像一个布尔位,但同一条流里有四条不同的读取路径——订阅、历史拉取、按 id 查、计数。避法:照着 AUTHOR_ONLY_KINDS、P_GATED_KINDS、RESULT_GATED_KINDS、SHARED_GATED_KINDS 四个集合逐个确认你的新 kind 该进哪个,以及按 id 直查那条路是不是也堵上了。
共享开关用标签,不要用内容字段。 会踩是因为把 shared: true 写进 JSON 正文最顺手。但内容字节一变,由内容算出的哈希就变,依赖内容哈希做漂移检测的同步逻辑会误判成”这条被改过”。避法:照 30175 的做法,开关放标签里,并且判定时对形状失败关闭。
别在异步任务里直接验签。 会踩是因为 verify_event 看起来只是个纯函数。它是 Schnorr 运算,CPU 密集,直接放在 async 任务上会卡住整个执行器。避法:按文件顶部的提示用 spawn_blocking 包一层。
排查”事件收不到”时先分清是过滤还是权限。 会踩是因为两种失败在客户端看起来一模一样,都是”没数据”。避法:先看 kind 是不是临时区间(那就压根没落库),再看你的过滤条件里 #h 有没有写、目标事件本身带不带 h 标签,最后才怀疑可见性集合。三个位置分别在 kind.rs、filter.rs 和 ingest.rs,不用猜。
默认限额要在压测前知道。 会踩是因为 512 KiB 的帧、1024 个订阅、1000 行分页在小规模测试里都碰不到,一上真实工作区就集中爆发。避法:按上一节列的三个常量算清你的客户端行为,尤其是 Agent 侧那种”订阅一切”的写法。
收束
这套模型的判断可以浓成一句:buzz 用一个整数买下了统一的可验证日志,然后用大量注释、编译期断言和几个手写集合,把这个整数没有携带的类型信息一点点补回来。补得挺认真——kind.rs 里注释比代码长的地方不止一处,而且很多注释在解释”为什么不这么做”,这通常是踩过坑才写得出的东西。
想自己顺一遍,读文件的顺序建议是这个:crates/buzz-core/src/event.rs 看信封长什么样(很短),verification.rs 看信任从哪来(更短),然后 kind.rs 从头读到尾——它既是号段字典也是设计说明书,四个可见性集合和底部那排编译期断言是全篇精华。最后 filter.rs 看读取端怎么把这些约束落到查询上,读完再去翻 crates/buzz-relay/src/handlers/ingest.rs 的 required_scope_for_kind,你会对”加一个新事件类型要动几处”有个准确的量感。仓库 docs/nips/ 下还有 15 份自定义规范文档(另有两份 fixtures json),代码里那些 NIP-AE、NIP-AM、NIP-AP 之类的代号都能在那里对上。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 buzz 多 Agent 通信平台:Block 为何先写 NIP 规范 和 Block 开源多 Agent 通信平台 buzz 的两层隔离:租户在中继之上,频道在租户之下。