Block 开源 buzz:多 Agent 通信平台的四道发布订阅闸门
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
这四块代码住在同一个 crate 里,却刻意谁也不复用谁——不是没来得及抽象,而是它们的失败模式根本不同:限流器判错会误伤真人,连接控制失灵会踢不掉刚被封的账号,缓存失效漏投会让权限变更延迟生效,重放防护松一点就让一份签名被用两次。把它们捏成一条通用消息总线,等于让四种事故共用一个开关。
先交代对象,免得和「热度」「蜂鸣」那个普通词混了。这里的 buzz 是 Block 开源的一套多 Agent 通信平台,仓库 https://github.com/block/buzz ,许可证 Apache-2.0(Copyright 2026 Block, Inc.),人和 Agent 在同一张消息网络里协作。仓库 README 是这样定位自己的:一个人与 Agent 一起干活的工作区,而承载它的中继由你自己持有。它建在 Nostr 之上——Nostr 是一套去中心化消息协议:每条消息是一个「事件」,由发送方用自己的私钥做 Schnorr 签名(crates/buzz-auth/src/nip98.rs 里走 buzz_core::verify_event 校验);服务端那一侧的角色叫「中继」(relay),负责收下事件、转发给订阅者;协议的扩展条目叫 NIP,buzz 自己在 docs/nips/ 下写了 15 份 NIP 规范 md 加 2 份 fixtures json。还有一件事必须先说清:Nostr 是密钥自持的,身份就是那对密钥本身,私钥丢了等于身份丢了,没有找回流程——后面讲重放防护时这个前提会反复用到。
本篇只钻 crates/buzz-pubsub 这一个 crate(全仓 crates/ 下共 28 个 crate、413 个文件)。它和站内几篇相邻文章的分工是这样的:AI 网关选型对比 谈在模型调用前面架一层网关时怎么选,AI 缓存策略 谈命中率和成本,Agent 缓存与幂等设计 谈重复执行怎么不出乱子——那三篇给的是通用方法论;本篇是把一份真实开源代码摊开,看这些方法论在多租户、多节点的消息中继里落成了什么样子。
一、四块闸门的全局图
crates/buzz-pubsub/src/lib.rs 的模块声明就是这张图的目录:事件扇出(publisher.rs / subscriber.rs / topic.rs)、在线状态(presence.rs),再加上本篇的四块。它们共享一个 Redis 连接池,但走的是完全不同的语义。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 限流器 | 按(社区,公钥)和按 IP 两套计数,判断这次请求放不放行 | crates/buzz-pubsub/src/rate_limiter.rs | 有人(或某个 Agent)开始高频发消息、狂调 API |
| 连接控制 | 把「断开这个公钥的所有连接」这类命令送到每一个节点 | crates/buzz-pubsub/src/conn_control.rs | 封禁一个成员,而他的 socket 挂在别的节点上 |
| 缓存失效 | 把「丢掉这把缓存键」的提示广播到每一个节点 | crates/buzz-pubsub/src/cache_invalidation.rs | 改了频道成员或可见性,其它节点还在用旧的本地缓存 |
| 重放守卫 | 给 NIP-98 HTTP 认证事件建一份跨节点的已见集合 | crates/buzz-pubsub/src/nip98_replay.rs | 有人抓到一个已签名的认证事件,想再用一次 |
四块的共同点只有一个:都靠 Redis 做跨节点的共享状态。往下每一块的取舍都不一样。
二、限流器:一个 Lua 脚本买回来的原子性
限流器要解决的问题很朴素——多个节点同时给同一个主体计数,计数必须是共享的,不能各算各的。rate_limiter.rs 的做法是固定窗口计数器:一把 Redis 键,INCR 加一,第一次加的时候给它设过期时间。
麻烦出在「第一次加的时候」。如果 INCR 和 EXPIRE 是两条独立命令,进程恰好死在两条中间,那把键就永远没有过期时间了——计数只涨不降,这个主体从此被永久锁死。文件顶部的注释把这个窗口点名了,解法是把两步塞进一段 Lua:
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
这段脚本在源码里叫 RATE_LIMIT_SCRIPT。它顺手把 TTL 也读回来,于是 run_rate_limit 拿到的是 (count, ttl) 两个值。返回的 ttl 如果是负数,说明这把键存在但没有过期时间——历史上崩过一次留下的坏状态。代码不是忽略它,而是当场补一条 EXPIRE 修好,并打一条 tracing::warn!。这是个值得抄的细节:知道自己有历史脏数据,就在读到的时候顺手修,而不是另写一次性清理脚本。
键怎么拼决定了隔离边界。crates/buzz-auth/src/rate_limit.rs 里两个函数说得很直白:公钥维度是 buzz:{community}:ratelimit:{pubkey_hex}:{suffix},带社区前缀;IP 维度是 buzz:ratelimit:ip:{ip}:conn,不带。前者意味着同一个公钥在两个社区里跑就是两份互不干扰的配额;后者是因为 IP 连接闸门在「主机名解析到哪个社区」之前就要生效,硬塞租户上下文会把执行顺序拧反。
suffix 来自 LimitType 的 key_suffix(),四个取值分别是 msg / api / ws / conn,对应 Messages、ApiCalls、WsEvents、IpConnections。同一个公钥的四类行为各计各的账,不会因为拉了一堆历史消息就把发言额度吃光。
配置结构体 RateLimitConfig 里有一处对 Agent 场景很关键的设计:人类用户、标准档 Agent 令牌、提升档、平台档,各自有独立的配额字段。也就是说 buzz 不假设 Agent 和人按同一个速率发消息,而是把 Agent 的档位做成独立配置项,而不是给人类阈值乘个系数。
调用端在 crates/buzz-relay/src/admission.rs。check_principal 把三种结果收敛成两个错误:超限返回 AdmissionError::Exceeded(带上还剩多少秒重置),Redis 出错返回 AdmissionError::Unavailable。注意后者——限流后端挂了不会放行,会拒绝。同一个文件里还有个 ws_admission_budget:桌面端启动时会一次性建好几条订阅,硬扛每秒阈值会被自己误杀,于是把窗口拉成常量 WS_BURST_WINDOW_SECS(源码里写死为 5 秒),额度按比例放大,平均速率不变但允许一次有界突发。注释里也没藏着掖着:这仍然是固定窗口,令牌桶才是更合适的长期形态。
三、两条广播通道:结构一样,语义不同
conn_control.rs 的模块注释花了半屏解释一件事——它为什么不合并进隔壁那个几乎长得一模一样的模块。
场景是这样:横向扩容之后,一个成员的活跃连接可能落在任意一个节点上。管理员在节点 A 上执行封禁,被封的人的 socket 可能挂在节点 C。所以需要一条命令通道,把「断开这个公钥」送到所有节点,每个节点拿自己本地的 ConnectionManager 去执行。
命令类型是个带 #[serde(tag = "op")] 的枚举:
pub enum ConnControl {
/// Disconnect every live socket bound to the carrying community.
DisconnectCommunity,
DisconnectPubkey {
pubkey: Vec<u8>,
event_id: String,
reason: String,
},
}
DisconnectPubkey 带着 event_id 和 reason,是为了让被踢的人在任意节点上都能收到和源节点一样的那一帧 NIP-01 OK 回执——知道自己为什么被断开,而不是莫名其妙掉线。
回到合并这个问题。注释给的理由是语义性质不同:缓存键的丢弃是纯粹的、幂等的提示,多投一次少投一次都不改变最终结果,因为数据库会被重读;而断开连接是命令式的、不幂等的动作,作用在一条活着的 socket 上。把后者折进前者的枚举,就打破了那个模块自己声明的不变量——只投递缓存键的丢弃,绝不投递驱逐载荷。
送达这件事它没打包票。Redis pub/sub 不保证送到离线的节点,兜底是数据库里那条封禁记录:命令丢了,被封的人下次认证时照样在认证关口被拒。这是典型的快路径尽力而为加慢路径最终一致,前提是慢路径真的存在。
订阅端 run_conn_control_subscriber 用 psubscribe 订 buzz:*:conn-control 这个模式,收到消息先用 parse_conn_control_channel 从频道名里解出社区 id——解析函数逐段核对前缀是 buzz、中段能解析成 UUID、尾段正好是 conn-control,多一段都判无效。断线重连是指数退避,从 BACKOFF_INITIAL_SECS(1 秒)翻倍到 BACKOFF_MAX_SECS(30 秒)封顶。
单测里有一条特别值得学:unknown_command_is_rejected_without_affecting_later_messages。收到一个未来版本才有的 op,这一条丢掉、打日志、continue,不影响后面的消息。滚动升级期间新旧版本节点混跑,靠的就是这条不变量。
再看隔壁那条通道。每个中继节点在本地拿 moka 缓存了成员关系、可访问频道列表、频道可见性。写操作只发生在处理它的那个节点上,其它节点原本要靠 10 秒 TTL 自然过期才能感知——cache_invalidation.rs 的模块注释直接把这个数字写出来了。这个模块就是把同样的键丢弃立刻广播出去。
枚举的四个变体一一对应中继本地的四个失效操作:
pub enum CacheInvalidation {
Membership { channel_id: Uuid, pubkey: Vec<u8> },
AccessibleAll,
Visibility { channel_id: Uuid },
ChannelDeleted,
}
注释里标了各自镜像的本地方法:invalidate_membership、invalidate_all_accessible_channels、invalidate_channel_visibility、invalidate_channel_deleted。这种一一对应看着笨,好处是新增一种本地失效时,你会立刻发现跨节点这一侧缺了对应变体。
真正的取舍在这句:消息是纯粹的缓存键丢弃,绝不是「把这些订阅赶走」的载荷。因为每条事件投递前都会过 filter_fanout_by_access 这道统一的访问闸门,丢掉过期的键就够了——下一次读会从数据库把权威状态取回来。换句话说,这条广播通道被刻意设计成弱到不可能出错:最坏的情况是没送到,而不是送错了导致谁被误踢出频道。判定权始终留在那道闸门上。这比靠调用方自觉去保证幂等要可靠得多。
消费端在 crates/buzz-relay/src/main.rs,收到后调 apply_cache_invalidation,并且只应用与消息携带社区匹配的那份本地丢弃:A 社区的一次变更不该冲掉 B 社区的派生状态。
四、重放守卫:SET NX EX 与两个 TTL 常量
NIP-98 是 Nostr 那套标准的 HTTP 认证方式,事件 kind 是 27235。客户端把目标 URL、HTTP 方法、可选的请求体 SHA-256 哈希签进一个短时效事件,塞进 Authorization 头。crates/buzz-auth/src/nip98.rs 的校验列得很全:解析、核 kind、验签、时间戳在服务器时间正负 60 秒内、u 标签与 URL 匹配、method 标签与方法匹配、有 payload 标签且带请求体时核哈希。
它唯一不查的是这个事件 id 有没有被用过——那需要共享状态,单进程里的 moka 或 DashMap 在多节点部署下不管用,所以这条被列成硬性关口。
nip98_replay.rs 的实现是一条命令:
let result: Option<String> = redis::cmd("SET")
.arg(&key)
.arg("1")
.arg("NX")
.arg("EX")
.arg(ttl)
.query_async(&mut *conn)
.await
NX 让它成为原子的「不存在才写」。第一次占坑返回 OK,窗口内再来返回空,被翻译成 Ok(false),调用方据此判定重放。键是 buzz:{community}:nip98:{event_id_hex}——事件 id 是内容寻址的(对事件规范元组做 SHA-256),跨社区天然不会撞,但键照样带社区前缀,注释说这是失败即隔离的兜底。
两个 TTL 常量都写在 crates/buzz-auth/src/nip98_replay.rs:DEFAULT_REPLAY_TTL_SECS 是 120,因为校验器容忍正负 60 秒时钟偏移,同一个 id 有可能被重放的时间跨度就是这 120 秒;MAX_REPLAY_TTL_SECS 是 3600,作为上限。实现里一句 ttl_secs.clamp(...) 同时压两头。这个上限不是洁癖——Redis 的 EX 参数按 64 位有符号整数解析,某个调用方传个 u64::MAX 进来 Redis 会直接报错,而按契约调用方出错时必须拒绝请求,结果是每一个认证请求全挂。单测 above_ceiling_ttl_is_clamped 的注释把这条推理完整写下来了:不夹紧也是正确的,但那种正确会让服务不可用。
顺序上还有一条硬规矩,写在模块文档里:先验签,再占坑。反过来做的话,一个知道受害者未来事件 id 的攻击者可以提前把坑位烧掉,让合法请求被判成重放。这就回到前面那个前提——身份即密钥,认证材料就是一份签名事件,一旦泄露就能被拿来当票据,所以什么时候消费掉这张票据不能凭直觉写。
五、边界与代价
这套设计放弃的东西都是明写的。
放弃了严格限流精度。固定窗口在边界允许最多两倍突发,rate_limiter.rs 和 crates/buzz-auth/src/rate_limit.rs 顶部都挂着这条告警,并指名滑动窗口或令牌桶才是更严格的形态。按平均速率做容量规划会算少。
放弃了跨节点的送达保证。两条 Redis pub/sub 通道都是尽力而为,节点离线期间的消息收不到。兜底分别是数据库封禁记录和下一次数据库读;main.rs 里还有个定时的社区重校验任务,只扫本地有活跃 socket 的社区,间隔由 BUZZ_COMMUNITY_REVALIDATE_INTERVAL_SECS 控制,默认 30 秒并夹在 1 到 300 之间。
明确不管的事:不决定阈值,具体配额来自中继配置,这个 crate 只负责数数;不做鉴权判定,那是 filter_fanout_by_access 的活;不保证消息顺序,不做持久化。它也不理解语义——限流只认公钥和 IP,认不出「这个 Agent 正在跑一个必须完成的长任务」,这类意图得由编排层自己表达。
两处场景不适用。IP 连接闸门是运营方全局的,一个公司出口、一个 NAT 后面、一批共享出口 IP 的云函数会在同一个计数器里互相挤——trait 注释提到,真要做每社区每 IP 的公平性信号,那应该新增一个 LimitType 而不是改这个接口。另外重放守卫只覆盖 NIP-98 认证事件,不是通用去重器,别拿它当业务幂等键用。
六、上手与避坑清单
把 Redis 当强依赖,不是当加速器。 会踩是因为 crates/buzz-auth 里那两个替身实现——AlwaysAllowRateLimiter 一律放行、AlwaysFreshReplayGuard 一律判为新鲜——挂在 cfg(any(test, feature = "test-utils")) 下,跑单测时一切都绿,等于把 Redis 这条依赖从测试链路里摘掉了。真实链路上限流出错走 Unavailable(拒绝),重放守卫出错按契约必须失败即关闭(也是拒绝)。怎么避:把 Redis 纳入可用性预算,压测时主动注入 Redis 故障,看拒绝率而不是看错误日志。
容量按两倍峰值算。 会踩是因为你按配置里的每分钟阈值直接反推并发,而固定窗口在边界能叠出两倍。怎么避:容量按两倍留,或者按注释指的方向换成令牌桶再谈精度。
别动键名的大小写。 会踩是因为有人改了公钥编码或社区 id 的 Display 实现,输出里冒出大写字母,同一个主体从此对应两把 Redis 键,配额直接翻倍,线上完全看不出异常。怎么避:rate_limit_key_components_are_lowercase 和 key_components_are_lowercase 这两条单测就是绊线,改序列化时别顺手删掉。
认证路径一定是先验签后占坑。 会踩是因为先占坑再验签在代码上更顺手、少一次分支。后果是给攻击者一条针对特定受害者的拒绝服务路径。怎么避:照 nip98_replay.rs 模块文档里那段用法示例的顺序写。
别把断连命令折进缓存失效枚举。 会踩是因为两个模块结构几乎一模一样,加个变体是三十秒的事。后果是把幂等提示和不幂等命令混在一条通道上,重连补投时会真的把人踢下线。怎么避:新增跨节点消息前先问一句,它重复投递一百次会不会出问题。
盯住广播滞后计数。 会踩是因为 tokio 的 broadcast 通道在消费端跟不上时直接丢消息并返回 Lagged,业务上表现为偶发的某个节点权限没更新,极难查。怎么避:main.rs 里已经打了 buzz_cache_invalidation_lag_total 和 buzz_conn_control_lag_total 两个计数器,把它们接进告警——这类可观测性的通用做法可以对照 AI 系统的监控与告警设计。
Redis 的访问权限等同于控制面权限。 会踩是因为你以为整条链路都被 Nostr 签名保护着。实际上两条控制通道的载荷只是 serde_json::from_str 解出来就执行,没有签名校验——能往 buzz:*:conn-control 发消息的任何东西,都能把任意公钥的连接踢掉。同时要清楚数据落在哪:Redis 里躺着限流计数、NIP-98 已见集合、在线状态(presence.rs 的键是 buzz:{community}:presence:{pubkey_hex},PRESENCE_TTL_SECS 为 180 秒,注释说是 60 秒心跳的三倍以防抖动),以及跨节点扇出的事件本体。自建部署时这个 Redis 实例要按数据库同级保护,别图省事丢在可公网访问的端口上。权限边界怎么划可以对照 Agent 的最小权限设计。
收个尾
搬这套思路之前,先拿四个问题自检:跨节点的共享计数是不是原子的、崩在中间会留下什么坏状态;控制命令和缓存提示有没有走同一条通道;每条广播消息重复投递一百次会不会出事;认证票据的消费点在验签之前还是之后。
继续读代码的顺序建议:先 crates/buzz-pubsub/src/lib.rs 看 PubSubManager 怎么把四条通道挂在一起,再 crates/buzz-relay/src/main.rs 看消费端怎么落地,最后 crates/buzz-auth/src/rate_limit.rs 和 crates/buzz-auth/src/nip98_replay.rs 看接口契约——那两个文件的注释把「为什么这么定」讲得比代码本身还多。挑参考实现时,这种把取舍写在注释里的仓库往往比功能更全却只有代码的那类更值钱。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 buzz 中继网状互联:Block 开源多 Agent 平台的四块拼图 和 拆解 Block 开源多 Agent 通信平台 buzz 的两套鉴权设计。