Block 开源多 Agent 平台 buzz:自建中继要想清的几件事

2026-08-05

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

自建一个 buzz 中继,难点不在把进程跑起来,而在于它默认给你的是一台”谁都能进”的开发机——把它变成生产可用的那几个开关,是互相锁死的,你少设一个,进程要么直接拒绝启动,要么用一把硬编码的临时密钥假装启动成功。

这里说的 buzz,是 Block 开源的多 Agent 通信平台(仓库 github.com/block/buzz,许可证 Apache-2.0,Copyright 2026 Block, Inc.),不是”热度”或”蜂鸣”那个普通名词。仓库 README 对自己的定位是:一个人和 Agent 一起干活的工作区,跑在归你自己所有的中继上——这是项目的自我陈述,本文只把它当成理解设计意图的线索。落到实现上,它建在 Nostr 协议之上,中继(relay)就是这张网络的服务端:客户端通过 WebSocket 连上来,把签好名的事件推过去,中继负责校验、落库、按订阅条件扇出给别的连接。

站内已经写过几篇部署主题的文章,分工不同:MCP 服务的生产部署 讲的是无状态工具服务怎么上线,MCP 部署的负载均衡 讲多实例怎么分流,Hermes 的常驻部署 讲单机 Agent 网关怎么守住进程。本篇是另一类东西——一个自带持久身份密钥、自带多租户边界、自带对象存储依赖的有状态服务端,它的”最小可用”标准跟前三者不在一个量纲上。

一、先把”中继”这个词摆平

如果你只做 AI 工程,没碰过去中心化协议,这几个词先各花一句话讲清楚,后面才跟得住。

事件(event):Nostr 里的消息单位,是一个 JSON 对象,带 pubkeycreated_atkindtagscontentsig 这些字段。仓库里 crates/buzz-core/src/event.rsStoredEvent 就是在原生事件外面包了一层中继侧元数据(收到的时间、频道归属、是否已验签)。

事件签名:每条事件由发送方用自己的私钥签名,签名值放在 sig 里,任何人都能用发送方公钥独立验证。所以中继不是”可信的权威”,它只是一个愿意帮你存转的节点——它改不了你的消息内容而不被发现。

kind:事件类型编号,决定这条事件是什么语义。buzz 在 crates/buzz-core/src/kind.rs 里定义了一长串常量,比如 KIND_NIP43_MEMBERSHIP_LIST = 13534 是中继签名的成员名单快照,RELAY_ADMIN_ADD_MEMBER = 9030 / RELAY_ADMIN_REMOVE_MEMBER = 9031 / RELAY_ADMIN_CHANGE_ROLE = 9032 是管理员签名的成员变更命令,KIND_NIP29_GROUP_METADATA = 39000 一类是群组状态。

NIP:Nostr 的协议提案文档,编号化。buzz 在 docs/nips/ 下自带 15 份 NIP 规范 Markdown(另有 2 份 fixtures JSON),另外还有一份 crates/buzz-core/src/pairing/NIP-AB.md 放在代码目录里。中继在根路径 / 对外吐一份 NIP-11 信息文档,声明自己支持哪些编号,构造逻辑在 crates/buzz-relay/src/nip11.rs

密钥自持:身份就是那对密钥,没有”找回密码”这条路。中继自己也有一对——BUZZ_RELAY_PRIVATE_KEY。它用这把私钥签发成员名单、群组状态这些中继权威事件。私钥换了,等于中继换了身份,之前签过的那些可替换事件在客户端眼里就成了另一个主体发的,对不上。这也是为什么部署包的备份清单第一条就是它。

下面是这套东西的组件地图,路径都是我实际读过的:

组成部分它负责什么对应仓库位置你什么时候会碰到它
配置装载把全部运行参数从环境变量读出来,非法值当场变成启动错误crates/buzz-relay/src/config.rs第一次起服务、每次改 env
启动编排连 DB/Redis/S3、决定要不要跑迁移、落地本部署的社区、拉起一堆后台循环、绑监听crates/buzz-relay/src/main.rs启动卡住或直接退出时
路由与探针应用路由,以及独立端口上的 /_liveness/_readiness/_status/_meshcrates/buzz-relay/src/router.rs接编排器健康检查
存储用量扫描周期性列对象存储、缓存快照、发指标crates/buzz-relay/src/storage_sweep.rs想知道桶里到底堆了多少
NIP-11 文档对外声明支持的 NIP 列表crates/buzz-relay/src/nip11.rs客户端探测中继能力
单机部署包Compose 栈加一个 run.sh 包装脚本deploy/compose/拿一台 VPS 起一整套
多租户规范把 community 定为隔离边界的模型与证明docs/multi-tenant-relay.md打算一套进程托多个社区之前

二、配置:一份从环境变量长出来的启动清单

Config::from_env() 是整篇的入口。它没有配置文件,全部读环境变量,读完立刻校验,非法值返回 ConfigError 让进程退出。这个设计对容器部署友好,代价是你的”配置真相”散在编排文件里,得自己收敛。

几个你一定会碰到的默认值,直接来自 config.rsBUZZ_BIND_ADDR 默认 0.0.0.0:3000BUZZ_HEALTH_PORT 默认 8080BUZZ_METRICS_PORT 默认 9102BUZZ_MAX_CONNECTIONS 默认 10000,入站 WebSocket 帧上限 DEFAULT_MAX_FRAME_BYTES 是 512 KiB,Postgres 连接池默认 50、Redis 池默认 16。

健康端口是独立的一个 TCP 监听,跟应用路由分开绑。main.rsserve() 上方画了张监听器示意图,把应用端口、可选的 Unix 域套接字、健康端口、指标端口列成四条独立监听;router.rsbuild_health_router 的注释说明了这个 router 上不挂指标中间件、不挂鉴权、不挂 CORS、不设 body 上限。换句话说,探针走的是一条刻意做薄的路径,不受应用侧中间件影响。所以你在 K8s 或 Compose 里配探针,探的是 8080 而不是 3000——deploy/compose/compose.yml 里那条 healthcheck 就是往 127.0.0.1:8080GET /_readiness,用 bash 的 /dev/tcp 手搓 HTTP,因为运行镜像里没有 curl。

配置校验有几处是”故意做得很凶”的,值得学:

  • 旧变量名 BUZZ_REPLICA_HEAD_MAX_AGE_SECS 改成了 BUZZ_REPLICA_READ_MAX_AGE_MS,代码不做兼容别名,而是检测到旧名字就直接报错退出。理由写在注释里:单位从秒变成毫秒,静默兼容等于给你一千倍的预算。
  • RELAY_OPERATOR_PUBKEYS 里任何一个格式不对的公钥都是硬错误,不是跳过。因为静默丢掉一个运维公钥,等于悄悄关掉了那个人的权限。
  • RELAY_OWNER_PUBKEY 走的是另一套:格式不对只打 warning 然后丢弃。这个不一致是有意的,后果在下一节。

三、准入:开放中继与封闭中继是两套启动前提

默认配置下,BUZZ_REQUIRE_AUTH_TOKENBUZZ_REQUIRE_RELAY_MEMBERSHIP 都是 falseconfig.rs 会在启动日志里主动 warn 一句:REST 请求绕过 token 鉴权,生产环境请设为 true。

更要命的是密钥。main.rs 里的分支是这样的:设了 BUZZ_RELAY_PRIVATE_KEY 就用它;没设、且 BUZZ_REQUIRE_AUTH_TOKEN=false,就落到一段硬编码的开发密钥上——那把私钥的十六进制值是 63 个 0 后面跟一个 1,代码里带一句 warn 说明这是 dev keypair;没设、但要求 token 鉴权,直接 panic。

也就是说,一台什么都没配的 buzz 中继会正常起来、正常收发消息,而且用的是全世界都知道的私钥来签发中继权威事件。这不是 bug,是刻意的开发体验取舍(用固定密钥才能让可替换事件跨重启正确替换),但它意味着”能连上”离”能用”还差得远。

要开封闭模式(只有名单里的公钥能进),main.rs 的启动流程要求三件事同时成立,缺一个就 fail fast:

  1. BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
  2. RELAY_OWNER_PUBKEY 是合法的公钥:代码先做 trim 和转小写,再要求剩下的正好是 64 个十六进制字符。这里就是上一节那个不一致的后果——公钥写错了,config.rs 只 warn 然后置空,接着 main.rs 发现启用了成员校验却没有 owner,报”没人能管理这台中继”并退出。错误信息给得很准,但你得知道去哪看;
  3. BUZZ_RELAY_PRIVATE_KEY 必须设。错误信息说明了原因:临时密钥签出来的 NIP-43 事件重启后就没法验证了。

三件事都满足后,启动流程会按顺序做几件不可跳过的事:从 RELAY_URL 推导出本部署的 host 并落一行社区记录(推不出 host 且要求成员校验,同样是退出);把历史的 pubkey_allowlist 回填进 relay_members;再把 owner 提升为 owner 角色。回填必须在提升之前,否则开启成员校验的那一刻会把所有老用户锁在门外——这个顺序在代码注释里专门写了。

日常增删成员用 buzz-admindeploy/compose/run.sh 把它包了一层:

  add-member <npub-or-hex> [--role member|admin]
                Add a relay member (default role: member)
  remove-member <npub-or-hex> [--role member|admin]
                Remove a relay member
  list-members  List all relay members

同一份 help 文本里有条容易漏掉的提醒:批量加人要在两次调用之间 sleep 1,不要用 xargs -P 并行加。原因是 kind:13534 那份名单快照事件按秒取时间戳,同秒多次写会撞车。

四、数据落在哪,以及谁在清理它

deploy/compose/compose.yml 把依赖摆得很清楚,一共四个持久卷:buzz-postgres-data(Postgres 17)、buzz-redis-data(Redis 7,开了 appendonly)、buzz-minio-data(MinIO 对象存储)、buzz-git-data(挂在 BUZZ_GIT_REPO_PATH=/data/git)。另有一个 minio-init 一次性容器负责建桶,并执行 mc anonymous set none,把桶的匿名访问关掉。

数据库迁移是显式开关。BUZZ_AUTO_MIGRATE 不设就不迁移,启动日志会明说跳过了。deploy/compose/README.md 给的是两条路:要么设 BUZZ_AUTO_MIGRATE=true,要么在起中继前手动跑 buzz-admin migrate,并提醒自动迁移要求镜像里内嵌了 SQLx 迁移文件。仓库 migrations/ 目录下现在是 27 份 SQL。

清理这块,容易望文生义,得拆开看,因为仓库里有三个不同的东西:

临时频道回收。带 TTL 的频道到期后由一个后台循环归档,间隔由 BUZZ_REAPER_INTERVAL_SECS 控制,默认 60 秒。注意它做的是归档:发一条 channel_auto_archived 系统消息、更新 NIP-29 群组发现事件、把还连着的订阅踢掉,让客户端立刻感知。消息本身没被删。BUZZ_EPHEMERAL_TTL_OVERRIDE 可以强制覆盖客户端给的 TTL,设了会 warn,主要是给测试用的。

过期邀请回收。这个是真删。它挂在用量指标那条 leader-only 的循环里,保留窗口在代码里写死为 30 天,删掉后打一行 reaped expired relay invites

存储扫描storage_sweep.rs)——它不删任何东西。名字里的 sweep 指的是遍历对象存储列出所有对象,算出总字节数、对象数、按社区拆分的占用,以及孤儿 blob、孤儿 sidecar、多变体 SHA、无法识别的键这几类统计,然后发成 Prometheus 指标。默认每 3600 秒扫一次(下限 60 秒),单次超时 120 秒,累计对象数上限 100 万,超了就整次失败、保留旧快照。BUZZ_STORAGE_METRICS=off 是彻底的开关,注释写明是给那些拿不到 s3:ListBucket 权限的部署用的。

所以”存储清理”在这套东西里的真实含义是:中继给你看清楚桶里堆了什么,删不删是你的事。这个区分很重要——你要是指望它自动回收孤儿对象,会白等。

它的失败处理有个细节值得抄:一次失败之后不等完整间隔,下一个 tick 就重试。注释解释了为什么这样不算滥用——最常见的持久失败是权限缺失,重试成本只有一次廉价的 LIST 调用,而且一旦权限补上就自愈。同时冷缓存(从没成功过)只发健康指标,热缓存在新一次尝试失败时继续发上一份成功快照,避免 S3 抖一下就把面板打空。

进程退出这段也别忽略。收到 SIGTERM 后:先把 readiness 置为 503(让编排器停止路由新流量),等 5 秒,开始优雅排空,同时给所有活着的 WebSocket 连接发 1012 关闭帧让客户端立刻重连,30 秒后硬超时强制退出。注释说得很实在:没有那个 1012,升级过的 WebSocket 连接会活过监听器排空,客户端一直骑在将死的 pod 上,最后只能从 TCP reset 里猜发生了什么。

五、边界与代价:这套设计明确不管的事

多租户隔离是有形式化边界的,但边界之外它明说不管。 docs/multi-tenant-relay.md 这份文档把 community 定为租户与安全边界,用 TLA+ 做隔离模型、用 Tamarin 做授权协议验证。所谓形式化验证,就是把系统写成数学模型,让工具穷举状态或搜索攻击路径,证明某类坏事在模型里不会发生。这份文档的价值一半在它证了什么,另一半在它明确声明没证什么:不证活性与性能、不重证 Postgres 自身正确性、不证密码学原语、不证物理资源隔离(连接池、CPU、缓存是共享的,时序侧信道被列为声明性豁免),也不管接口之上的客户端泄露。文档里有一句话概括了这种态度——不点名信任边界就说”可证明隔离”,经不起推敲。

这份文档是规范加证明,不是”你部署完就自动获得这些性质”。里面每条公理都写了对应的部署侧准入条件(比如 RLS 必须启用限制性策略、请求角色必须 NOBYPASSRLS、租户上下文必须 SET LOCAL),不满足就该拒绝上线。

语音是明确的单 pod 能力。 huddle_audio_available 的字段注释写得很清楚:语音音频帧在单个 pod 内点对点转发,跨 pod 只走生命周期事件。横向扩容后两个人可能落在不同 pod 上互相听不见,所以多 pod 部署必须把 BUZZ_HUDDLE_AUDIO_AVAILABLE 设成 false,中继会给客户端一个明确的”语音不可用”信号,而不是让你调半天音频。默认 true 是为了单 pod 场景不变。

跨中继网格默认关闭。 BUZZ_MESH 不显式设成 on,配置注释里写的是:不绑网格端口、不往 Redis 注册表里写记录,行为与单实例完全一致。mesh_boot.rs 是唯一构造网格机件的地方,关掉时它直接返回空,其它模块只能通过 AppState::mesh() 拿到 None。这种”单一入口 + 默认全关”是刻意的滚动升级零回归设计:升级镜像但不动 env,行为不该有任何变化。

Agent 在这张网上是有实权的。 中继侧的限流配置里,人和 Agent 是分开的档位(BUZZ_RATE_LIMIT_HUMAN_*BUZZ_RATE_LIMIT_AGENT_STANDARD_* / AGENT_ELEVATED / AGENT_PLATFORM),说明设计上就预期 Agent 会以不同强度发消息。同时这套中继还带 git 服务端能力,配置里能看到一串以字节为单位的护栏:单次 push 的 pack 上限(git_max_pack_bytes,默认 500 MB)、单个仓库的历史 pack 总量上限(git_max_repo_bytes,用来兜住 clone/fetch 时的解包工作量)、进程本地不可变 pack 缓存的容量上限,外加钩子用的 HMAC 密钥。你把 Agent 接进来,它能发的消息、能推的代码,暴露面要按这个量级评估,别按”聊天机器人”评估。这块的思路可以对着 Agent 的最小权限设计Agent 改动边界约定 再过一遍。

六、上手与避坑清单

别用默认配置对公网开端口。 会踩是因为默认值全都朝开发体验优化:无 token 鉴权、无成员校验、硬编码开发私钥。怎么避:起服务前先把 deploy/compose/.env.example 通读一遍,它已经把生产该开的开关列全了:

BUZZ_REQUIRE_AUTH_TOKEN=true
BUZZ_REQUIRE_RELAY_MEMBERSHIP=true
BUZZ_ALLOW_NIP_OA_AUTH=true
BUZZ_AUTO_MIGRATE=true
BUZZ_GIT_CONFORMANCE_PROBE=true

别让 CHANGE_ME 混进 .env。 会踩是因为示例文件里有一堆占位值,人工替换很容易漏。怎么避:run.shrequire_env 已经帮你挡了——它 grep 整个 .env,发现任何一行的值里含 CHANGE_ME 就拒绝启动。所以务必走 ./run.sh start 而不是自己敲 docker compose up

别让密钥在重启后漂移。 会踩是因为 BUZZ_GIT_HOOK_HMAC_SECRET 没设时会自动生成一个随机值,看起来一切正常。怎么避:deploy/compose/README.md 明确要求 BUZZ_RELAY_PRIVATE_KEYBUZZ_GIT_HOOK_HMAC_SECRET、数据库/Redis/S3 密钥跨重启保持稳定。另外这个 HMAC 密钥一旦显式配置,长度不到 32 字符会被拒绝——弱值不如不配。

别忘了 RELAY_URL 不只是个展示字段。 会踩是因为它看起来只是 NIP-11 里对外公布的地址。怎么避:启动时会从它推导 host 来确定本部署对应哪个社区,推不出来且开了成员校验就直接退出。填成 ws://localhost:3000(默认值)上生产,社区归属就是错的。

别并行加成员。 会踩是因为写个循环批量导入名单太自然了。怎么避:两次之间 sleep 1,理由见上一节。

别在多 pod 部署里保留语音默认值。 会踩是因为它默认 true 且不会报错,只会让用户在会议里互相听不见。怎么避:pod 数大于 1 就把 BUZZ_HUDDLE_AUDIO_AVAILABLE 设为 false。

别忽略 Compose 版本要求和 S3 兼容性。 会踩是因为报错信息会指向别的地方。怎么避:deploy/compose/README.md 写明需要 Docker Compose v2.24.4 或更新(TLS override 用到了 !reset 标签);另外这套 Compose 把 S3 端点写死成 http://minio:9000 并强制 path 寻址风格,因为 Docker DNS 解析得了 minio 但解析不了 <bucket>.minio。要接需要 virtual 寻址的外部对象存储,得走 Helm chart 或自定义 Compose,改 .env 是改不动的。

别在没验证过的镜像标签上跑生产。 会踩是因为默认 BUZZ_IMAGE 指向 ghcr.io/block/buzz:main。怎么避:deploy/compose/README.md 明说这个标签是给早期测试用的,生产要钉到 ghcr.io/block/buzz:sha-<7> 或语义化版本标签。

收个尾

自建 buzz 中继的最小可用清单,其实就三行:三个准入开关互相锁死、四份持久状态各自有备份路径、密钥丢了等于身份丢了。前两条你能靠 run.sh backup-hint 打出的备份清单逐项核对;第三条没有兜底,只能靠流程。

想继续往下读的话,顺序建议是:crates/buzz-relay/src/config.rs 从头到尾扫一遍,把所有环境变量和默认值抄成你自己的一张表;然后读 main.rsmain() 函数体,它就是一份带注释的启动顺序说明书,每个 fail fast 分支旁边都写了为什么必须在这个位置失败;最后如果你打算一套进程托多个社区,docs/multi-tenant-relay.md 的 Scope and Non-Goals 与 Conformance 两节是必读——前者告诉你哪些性质它不保证,后者告诉你要在部署侧断言哪些前提,才配得上那份证明。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源的多 Agent 通信平台 buzz:用 ACP 把现成编码智能体接进去Block 开源多 Agent 通信平台 buzz 桌面端与中继分工拆解

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