buzz 中继网状互联:Block 开源多 Agent 平台的四块拼图

2026-08-05

本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。

这张网从头到尾只回答一个问题:现在还有谁活着、能不能拨通它。它被源码明文禁止回答另一个问题——这个会话归谁。 把这条边界记住,buzz-relay-mesh 这个 crate 里所有看起来奇怪的设计都会变得合理:为什么成员表可以过时、为什么八卦帧允许丢、为什么一条合法签名的记录仍然会被拒绝。crate 的 src/lib.rs 文档注释把它写成一条”法律”:mesh membership is a hint(成员关系只是提示),真正的仲裁者是 Redis 里那个带世代号的租约。

一、这张网连的是谁

buzz 是 Block 开源的多 Agent 通信平台,仓库地址 https://github.com/block/buzz ,许可证 Apache-2.0(Copyright 2026 Block, Inc.)。它建在 Nostr 之上。Nostr 用一句话说清:一套去中心化消息协议,每条消息是一个带作者公钥和 Schnorr 签名的事件(event),中继(relay)只负责接收、存储、转发这些事件,它无法冒名改写内容,因为签名是作者私钥签的。仓库 README 这样定位自己——人和 Agent 共用同一批房间的可自托管工作区,底层就是一个 Nostr relay,消息、审批、git 事件都是同一条日志里的签名事件。这是项目自己的说法,不是本文替它下的结论。

关键的消歧在这里:buzz-relay-mesh 连的不是”你家的 relay 和别人家的 relay”,而是同一套部署里的多个 relay 进程。src/wire.rs 的注释写得很直白——helm chart 把同一个 BUZZ_RELAY_PRIVATE_KEY Secret 分发给一个 release 的所有 pod,所以那把 Nostr 密钥不能拿来当 mesh 身份,否则所有 pod 的 id 会一模一样。CHANGELOG.md 里对应的那条记录也写的是 cross-pod tunnel。所以本文谈的”多中继组网”,是一个社区横向扩到多副本之后,副本之间怎么互相看见。

这也决定了本篇和站内几篇邻近文章的分工:Agent 并发编排讲的是一个 Agent 系统内部多任务怎么排队和汇合,MCP 部署与负载均衡讲工具服务端多副本怎么摊流量,缓存与幂等讲重复请求怎么不产生二次副作用;本篇只管一层——同一批进程之间怎么互相发现、怎么判定对方还活着,以及这套判定明确不管什么。

四块拼图长这样:

组成部分它负责什么仓库位置你什么时候会碰到它
就绪注册表新进程进网的唯一入口:往 Redis 写一条带签名的可拨记录,带 TTLcrates/buzz-relay-mesh/src/registry.rs扩容/滚动发布时,新 pod 一直连不上老 pod
成员表汇总注册表种子和八卦记录,回答”谁活着、谁在下线中、能不能拨”crates/buzz-relay-mesh/src/membership.rspeers() 一直空,或某个 pod 被误判成失联
八卦传播周期性交换成员摘要与差集,让各机器的成员表收敛crates/buzz-relay-mesh/src/gossip.rs成员表更新慢、心跳判定阈值要调
状态面把上面三块变成可序列化的 JSON 与计数器crates/buzz-relay-mesh/src/status.rs,路由在 crates/buzz-relay/src/router.rs线上排障,第一眼看 /_mesh
运行时与线缆契约拨号/接受循环、控制流、帧格式与围栏头crates/buzz-relay-mesh/src/runtime.rssrc/wire.rs改协议、查连接为什么被拒

顺带一个可复现的规模感:crates/ 目录下有 28 个 crate,buzz-relay-mesh 只是其中一个,源码是 src/ 下九个文件(endpoint.rsgossip.rslib.rsmembership.rspeer.rsregistry.rsruntime.rsstatus.rswire.rs)。这个体量做的事情不多,但每一件都卡在正确性的关口上。

二、注册表:唯一入口,而且默认拒绝

一个新起的 pod 面对的第一个难题很朴素:它不知道任何同伴的地址。单机中继没有这个问题,多中继有。

registry.rs 的解法是 Redis 里一张带过期时间的表。key 前缀是常量 READY_KEY_PREFIX = "mesh:ready:"ready_key() 拼成 mesh:ready:{runtime_id},值是 ReadyRecord 的 JSON。publish_readySET ... EX,TTL 由 expiry_for 算出:DEFAULT_REGISTRY_REFRESH 是 15 秒,REGISTRY_EXPIRY_MULTIPLIER 是 3,也就是默认 45 秒。干净退出走 clear_readyDEL),崩溃则靠 TTL 自然过期——这两条路径分开写,是因为进程被 kill -9 时没人替它清理。

写入还挂了一道就绪闸门。ReadyHeartbeat::tick(ready) 只在 ready 为真时发布,一旦从就绪跌回不就绪,它会主动清掉自己的记录。publish_ready 的注释特意说明:这个方法故意不内置任何就绪探测,就绪判定属于 relay 那一层,规则要显式留在边界上。

身份这块是整个设计的支点。runtime id 不是 Nostr 那把 secp256k1 relay 密钥,而是 iroh 端点的 ed25519 公钥,且是进程启动时新生成的(MeshEndpoint::bindSecretKey::generate())。wire.rs 把理由写清楚了:relay 私钥全 pod 共用,拿它当 runtime id 会让所有 pod 撞成同一个身份,直接压垮所有权平面。

但 boot-unique 的身份带来新问题:一个随机公钥凭什么进网?答案是让 relay 密钥给它签一张证明。RuntimeAttestation::new 用 relay 私钥对一段固定文本做 Schnorr 签名,被签的文本是:

pub fn attestation_preimage(runtime_id: RuntimeId, relay_pubkey: &str) -> String {
    format!(
        "{ATTESTATION_CONTEXT}\nruntime_pubkey={}\nrelay_pubkey={relay_pubkey}",
        runtime_id.to_hex()
    )
}

ATTESTATION_CONTEXT 的值是 "buzz-relay-mesh-ready-v1"。注释解释了为什么用纯文本而不是直接签 JSON:保持文本化且带版本,别的模块才能一字不差地复现出同一个待签串,不必依赖 JSON 键序。

准入检查在 membership.rsapply_ready_records 里,顺序很讲究——先比对 relay_pubkey 是否等于本部署配置的那把(with_expected_relay_pubkey 设进去的),再验签名。源码注释把这个顺序的意思点破了:签名有效只证明对方持有某把钥匙,不等于这把钥匙被这套部署授权过。更狠的一条是没配 anchor 时的行为——不是”什么都收”,而是”什么都拒”,测试 unanchored_membership_rejects_all_ready_records 把它钉死了。

scan_ready 那边则是另一种取向:坏记录不阻断启动。JSON 解不开、key 与记录里的 runtime id 对不上、签名验不过,三种情况各自 warn 一行然后跳过,剩下的健康 peer 照样能引导。

对你意味着什么:这张表只回答”往哪拨”。谁往 Redis 里塞一条记录都抢不走会话,因为会话归属压根不在这条链路上。

三、成员发现与八卦:让每台机器的表收敛

八卦传播(gossip)也用一句话说清:不设中心目录,每个节点周期性地把”我知道哪些成员、各自到第几版”告诉邻居,邻居只回补对方缺的那部分,几轮之后所有人的表趋于一致。gossip.rs 实现的是 scuttlebutt 风格的两步握手——GossipMessage::Digest 携带一串 GossipDigestEntry(只有 runtime_id 和 version),对方用 delta_for 挑出”你没有或你落后”的记录,回一个 GossipMessage::Delta

单条记录长这样:

pub struct GossipRecord {
    pub runtime_id: RuntimeId,
    pub endpoint_addrs: Vec<String>,
    pub proto_version: u16,
    pub load: f32,
    pub draining: bool,
    pub capabilities: Vec<String>,
    /// Per-runtime monotonic version. Only the owning runtime may increment its
    /// own record; receivers apply last-version-wins.
    pub version: u64,
    pub heartbeat_millis: u64,
}

那句注释是收敛性的全部秘密:版本号按 runtime 各自单调递增,且只有本人能给自己 +1,接收方一律 last-version-wins。apply_deltaapply_gossip_record 里的判断都是 record.version > existing.version 才写入。这样两台机器不会拿各自的旧值互相覆盖来回震荡——多中继组网里,这类”状态回滚”是最难查的一类故障。

编码用 postcard,外面套一个 GOSSIP_PAYLOAD_VERSION = 1decode_message 遇到不认识的版本直接返回错误,不做兼容猜测。传输上它不占独立端口:每条 peer 连接上恰好一条控制子流(StreamRole::Control,由拨号方打开),八卦内容装在 MeshStreamFrame::Gossip { payload } 里,payload 对线缆层不透明——所以八卦格式能独立演进,不用动那份被标为 FROZEN 的 wire.rs 契约。

节奏由 runtime.rs 两个常量定:DEFAULT_GOSSIP_INTERVAL 2 秒,DEFAULT_RECONCILE_INTERVAL 5 秒。reconcile 每轮干两件事——重扫注册表,然后把所有已知、非 draining、尚未连上的 peer 挨个拨一遍。注释直接点明这条循环的意义:它让网保持”热”,故障切换是”下一帧发到别处”,而不是”等一次握手”。

两边同时拨对方怎么办?new_connection_wins 给了一个两端算得出同一结论的规则:

fn new_connection_wins(local: RuntimeId, remote: RuntimeId, new_dialed_by_us: bool) -> bool {
    if local.0 < remote.0 {
        // We are the canonical dialer: our outbound connection wins.
        new_dialed_by_us
    } else {
        // The peer is the canonical dialer: their inbound connection wins.
        !new_dialed_by_us
    }
}

id 小的那一方的出站连接胜出,输的那条被丢弃。没有协商、没有随机退避,纯粹靠一个双方都能独立算出的确定值。

死活判定用的是 phi accrual:不设固定超时,而是用历史心跳间隔算一个连续的怀疑值,越久没收到心跳 phi 越高。membership.rsDEFAULT_PHI_SUSPECT_THRESHOLD = 8.0,超过阈值的 peer 在 peers() 里被直接过滤掉,不会进入路由候选;在 /_mesh 里则显示为 ConnectionState::Suspect。有个细节容易看漏:phi_at 在样本不足时返回 None,而 None 不会被判成 suspect——刚加入、还没攒够心跳的 peer 不会被误杀。

下线走的是另一条路:begin_drain 把本地记录的 draining 置真、版本 +1,随八卦散出去,其他节点 reconcile 时就不再拨它。这是优雅停机与失联的区别——前者是主动声明,后者是被动推断。

四、状态面:把前三块变成能看的数字

status.rs 只是数据模型,但它决定了你排障时能看见什么。MeshStatus 顶层给 enabledlocal_runtime_iddrainingpeer_count;每个 peer 给 endpoint_addrsproto_versiondrainingconnection_statephiloadrecord_versionlast_heartbeat_millisConnectionState 是四态枚举:DisconnectedConnectingConnectedSuspect

计数器分两级。全局的 MeshCounters 有两个”拒绝”计数最值钱:foreign_relay_rejections(种子记录不是本部署签的,或者压根没配 anchor)和 stale_generation_rejections(世代号过期的帧被围栏挡下)。每个 peer 还有一组 MeshPeerCounters:流的开/收、数据报的收发、八卦帧的收发、以及该 peer 的过期世代拒绝数。

这套东西暴露在 /_mesh,路由注册在 crates/buzz-relay/src/router.rs。想把这些数接进告警的话,选指标的思路和可观测日志那篇一致:优先盯”本不该发生的事”的计数,而不是盯总量。foreign_relay_rejections 从 0 变成持续增长,几乎一定是配置错了或者有别家的记录混进了同一个 Redis。

五、边界与代价:它明确不管什么

它不选主。 每个承载会话的帧都带一个 FencedHeader { session_id, generation, owner_runtime_id }generation 来自 Redis 的 CAS 租约,接收方在每一跳都要拿它跟自己已知的世代比对,旧的直接拒。wire.rs 把这条写成”围栏法则”:mesh 可以说”别拨过去”,永远不能说”接管过来”。MeshError 里为此专门列了四个变体——StaleGenerationNoActiveLeaseOwnerMismatchFutureGeneration——注释说明这是为了让 kill -9、网络分区、重放这几类现场证据不含糊,不许塞进一个笼统的 Transport 错误里。

八卦只答活性,不搬数据。 gossip.rs 文件头三句否定写得很硬:它不选所有者、不转移会话、不携带隧道字节。想用它顺路捎点业务状态,是走错门。

八卦帧允许丢。 send_control_frame 用的是 try_send,控制流队列深度常量 CONTROL_QUEUE_DEPTH 是 64,满了就丢——理由写在注释里:八卦是周期性的、幂等的,丢一帧远好过把一个接收循环堵住。反过来说,任何一次性的、丢了就没有的事件都不该塞进控制流。

全连通拓扑的代价。 lib.rs 描述的是 warm full mesh,即每对 peer 之间保持一条已认证的 QUIC 连接外加一条控制流。按全连通拓扑,连接数随实例数呈平方增长,八卦帧也一样;实例规模很大时这不是免费的。

直连要求。 endpoint.rs 里 iroh endpoint 用 RelayMode::Disabled 绑定,ip_addrs() 只取 TransportAddr::Ip。没有中转兜底,pod 之间必须 IP 直达。

身份随进程走。 runtime id 每次启动都换新的,重启后旧 id 在注册表里最多残留一个 TTL 周期(默认 45 秒),期间别人拨过去会失败并 warn。这是 boot-unique 设计换来的代价,也正是它保证 id 不重复的原因。

别把愿景当现状。 仓库根目录有 8 份 VISION*.md,其中 VISION_MESH.md 讲的是另一件事:社区成员把闲置 GPU 汇成共享算力池的构想,用社区成员关系当准入闸门。那是愿景文档,不是本文拆的这个 crate 的已实现功能,两者共用”mesh”这个词而已。仓库里那条线还有自己的痕迹,比如 crates/buzz-test-client/tests/e2e_mesh_llm.rs 里定义的常量 KIND_BUZZ_MESH_MEMBER_STATUS(值 30003)。读源码时别把两条线串在一起。

单实例不受影响。 lib.rs 明说,BUZZ_MESH=off 或者根本没有 peer 时,relay 不会构造 mesh,同 pod 内的进程内快路径原样不动。

六、上手与避坑清单

没设 relay 身份锚点,mesh 会永远是空的。 为什么会踩:MeshMembership::new 默认 expected_relay_pubkeyNone,进程照样起得来,日志里只有 warn,你会先去查网络查防火墙。怎么避:启动路径必须链上 with_expected_relay_pubkey,并把 foreign_relay_rejections 接进告警——它是这个错误唯一的正向信号。

多个部署共用一个 Redis。 为什么会踩:key 前缀写死是 mesh:ready:scan_readySCAN MATCH mesh:ready:*,另一套部署的记录会被扫进来。虽然 anchor 会把它们拒掉,但你的拒绝计数会一直在涨,真问题被噪声盖住。怎么避:不同部署分开 Redis 实例或逻辑库。

/_mesh 暴露到公网。 为什么会踩:这个路由挂在 build_health_router 上,那个函数的注释写明它没有 auth、没有 CORS、没有 body limit(给 K8s 探针用的);返回体里带着每个 pod 的 runtime_id 和 endpoint_addrs,等于把内网可拨地址表白送。怎么避:探针端口只对集群内开放,反向代理上明确拦掉这个前缀。

mesh 的 UDP 端口被 sidecar 劫持。 为什么会踩:BUZZ_MESH_BIND_ADDR 默认 0.0.0.0:3478lib.rs 的注释特意提醒在 k8s 里要把它排除在 istio sidecar 捕获之外;被捕获后 iroh 直连失败,现象是一堆 dial failed,可网络”看着是通的”。怎么避:部署清单里显式排除该端口,上线后用 /_mesh 确认 peer 不是长期停在 Connecting

低估 relay 私钥的爆炸半径。 为什么会踩:BUZZ_RELAY_PRIVATE_KEY 是这套部署的 secp256k1 身份,所有 ready 记录的签名都来自它。泄露意味着任何人都能给任意 runtime id 签一张合法记录,从而被成员表接纳;而密钥自持的另一面是,这把私钥丢了这个身份就没了,没有客服能帮你找回。怎么避:Secret 只挂载给 relay 进程,轮换前先想清楚历史签名事件怎么处理。

load 字段当负载均衡输入。 为什么会踩:GossipRecord 里有 load: f32,看着就像调度依据。但 PeerInfo 的注释写的是 advisory(仅供参考)——它经由八卦传播,天然滞后,且没有任何机制保证它诚实。怎么避:当提示用,真正的路由结论仍然要过围栏世代校验。

改了八卦间隔却没动 phi 阈值。 为什么会踩:阈值 8.0 是配着 2 秒心跳节奏调出来的;你把 gossip_interval 放大到十几秒,误判窗口跟着放大,短暂 GC 停顿就可能把健康 peer 打成 suspect。怎么避:两个参数一起改,MeshRuntime::start_with_intervalswith_phi_suspect_threshold 就是给这件事准备的。

收束:接下来该读哪个文件

多个中继连成一片,比单机中继多出来的难题其实就三类——新成员怎么进来(且不让不该进的进来)、成员表怎么在各机器之间收敛而不震荡、以及”我以为它死了”这件事怎么表达才不至于引发误接管。buzz 的答案分别是:带签名和 TTL 的注册表当唯一入口、per-runtime 单调版本的 scuttlebutt、以及把活性判定和所有权判定彻底切开。

想顺着源码走一遍,建议这个顺序:先读 crates/buzz-relay-mesh/src/wire.rs,它是被标为 FROZEN 的契约,读完就知道哪些东西不许猜;再读 src/runtime.rs 看三条循环(accept、reconcile、gossip tick)怎么转;然后 src/registry.rssrc/membership.rs 看准入是怎么一层层收紧的;最后打开 src/status.rs,对着自己环境的 /_mesh 输出逐字段核一遍。

上线前的四条自检:anchor 配了没、/_mesh 关外网了没、mesh UDP 端口在服务网格里排除了没、foreign_relay_rejectionsstale_generation_rejections 接告警了没。这四条都不需要理解 Nostr 才能做,但漏掉任何一条,问题都会在扩容那天才浮出来。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 谁能连上你的 buzz 中继:Block 开源多 Agent 通信平台的三层门禁Block 开源 buzz:多 Agent 通信平台的四道发布订阅闸门

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