Block 开源 buzz:多 Agent 平台为何建在 Nostr 上

2026-08-05

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

**Block 开源的多 Agent 通信平台 buzz 最值得研究的地方,不是它把人和 Agent 塞进了同一个聊天室,而是它把”谁在说话”这件事从数据库里搬到了密码学里——身份是一对你自己保管的密钥,消息是一条带签名的 JSON,服务器只是那条 JSON 的验签、落库和转发通道。**这三条一旦立住,权限模型、审计链路、跨端迁移、故障半径全都跟着定死了;同时也意味着,私钥丢了,这个身份就永久没了,没有找回流程。

一、先把这个 buzz 是什么说清楚

英文单词 buzz 太常见,这里说的是 Block 开源的那个多 Agent 通信平台,仓库在 https://github.com/block/buzz ,许可证 Apache-2.0(Copyright 2026 Block, Inc.),一个 Rust 单体仓库。它的 ARCHITECTURE.md 把自己描述成一个建在 Nostr 协议 NIP-01 线格式之上的自托管团队通信平台,人和 AI Agent 是同等公民;NOSTR.md 说得更直接:buzz 本身就是一个 Nostr 中继,原生讲 NIP-29。NIP-29 是 Nostr 生态里那套”群组由中继托管”的规范——群的元数据、管理员名单、成员名单都由中继用自己的密钥签名发布,而不是靠每个客户端自己维护一份,所以一个通用 Nostr 客户端不用改代码也能把 buzz 当群聊服务器用。

先给几个规模感,这些数字你 clone 下来 ls 一遍就能自己核对:crates/ 下 28 个 crate,docs/nips/ 下 15 份 NIP 规范 md 加 2 份 fixtures json(crates/buzz-core/src/pairing/NIP-AB.md 是第 16 份,单独放在代码目录里),根目录 8 份 VISION*.md。一个聊天工具不该有这么多 crate——多出来的是协议、存储、鉴权、搜索、工作流、Agent 接入这些被拆开的层。

站内已经聊过一轮 Agent 的权限边界:AI 工具的权限模型讲的是怎么给工具分级授权,Agent 权限太大怎么收讲的是收缩爆炸半径的做法,AI 落地的数据安全风险讲的是数据往哪流。这篇不重复那些,只做一件事:把 buzz 选的这套协议底座拆开,看它把”身份”这个变量放在哪一层,以及这个位置决定了它能做什么、做不到什么。

要读懂后面的内容,先记住三个词的意思。Nostr:一个开放的消息协议,你的账号不是某个平台发给你的,而是你本地生成的一对椭圆曲线密钥。事件(event):Nostr 里唯一的数据单位,一条 JSON,带作者公钥和签名。中继(relay):接收事件、验签、存起来、再按订阅条件转发出去的服务器。

二、密钥即身份:账号是一对密钥,不是一行用户表

在 buzz 里创建一个身份,就是生成一对 secp256k1 密钥。crates/buzz-acp/README.md 里给的命令是 cargo run -p buzz-admin -- generate-key,它打印一对十六进制公私钥,并且明确警告:私钥不会被保存,也无法恢复。之后你把私钥放进 BUZZ_PRIVATE_KEY 环境变量,程序就以这个身份说话。公钥的可读编码是 npub,私钥是 nsec,这是 Nostr 生态的通用写法。

这套东西对 Agent 的意义比对人更大。buzz 里的 Agent 不是”某个用户账号下的一个 bot 开关”,它有自己的密钥、自己的频道成员资格、自己的审计轨迹。docs/nips/NIP-OA.md 定义了一个叫 Owner Attestation 的机制来表达”这个 Agent 是我授权的”:Agent 发的事件里可以带一个 auth 标签,恰好四个元素——owner 公钥十六进制、条件串、BIP-340 Schnorr 签名。签名的原文是 nostr:agent-auth: 拼上 Agent 自己的公钥、冒号、条件串,再取 SHA256。条件串里能写的子句只有三种:kind=<十进制>created_at<时间戳>created_at>时间戳

这里有个刻意的设计取向:这份规范在开头就写了非目标——它不定义冒充,不定义密钥派生,不定义中继侧改写作者。带了 auth 标签的事件,作者仍然是 event.pubkey,也就是 Agent 自己。换句话说,owner 授权的是”能力”,不是”身份”。这跟常见的服务号代发模式是相反的。

密钥即身份也带来一条硬边界:秘密绝不能进入世界可读的事件。crates/buzz-core/src/kind.rsKIND_MANAGED_AGENT(30177)的注释把这条写死了——这个事件的内容必须是显式白名单投影,绝不能携带 Agent 的私钥、NIP-OA 的 auth 标签、环境变量或运行时字段,因为这些事件在中继上是全世界可读的。同一个文件里 30178 的注释也强调,团队目录投影里不带环境变量、不带 respond_to 白名单公钥、不带文件系统路径、不带任何 secret。

换设备怎么办?crates/buzz-core/src/pairing/NIP-AB.md 定义了配对流程:二维码里不含任何私钥材料,即使被截获,攻击者拿到的只是一个临时公钥和会话密钥,会话超时(120 秒)内没完成握手就作废;内容用 NIP-44 v2 加密,版本字节不是 0x02 的必须拒绝;两端在完成或超时后都必须把临时私钥、会话密钥和解密后的明文从内存中清零。

至于丢私钥,VISION_SOVEREIGN.md 里项目自己有一节专门写代价:密钥管理比”用 Google 登录”难,丢了私钥就是丢了身份,没有忘记密码流程,没有工单可以提,没有账号找回;硬件密钥和好习惯有帮助,但这是实打实的取舍。同一段还写了另一面:让身份不可审查的那个属性,同时让它在密钥丢失时不可恢复。这是一句话的两面。

三、事件即消息:六个字段和一个 kind 整数

ARCHITECTURE.md 里给出的线格式就是 NIP-01 的原样:

{
  "id":      "<sha256 of canonical serialization>",
  "pubkey":  "<secp256k1 public key, hex>",
  "kind":    <unsigned integer>,
  "tags":    [["e", "<event-id>"], ["p", "<pubkey>"], ...],
  "content": "<JSON payload or plain text>",
  "sig":     "<Schnorr signature over id>"
}

id 是规范序列化后的 SHA256,sig 是对这个 id 的 Schnorr 签名。所以验签是两步,不是一步——crates/buzz-core/src/verification.rsverify_event()verify_id()verify_signature()

pub fn verify_event(event: &Event) -> Result<(), VerificationError> {
    if !event.verify_id() { /* ... InvalidId ... */ }
    if !event.verify_signature() {
        return Err(VerificationError::InvalidSignature);
    }
    Ok(())
}

文件顶部还留了条运维级提醒:Schnorr 验签是 CPU 密集操作,在异步上下文里必须走 tokio::task::spawn_blocking。单元测试把两种篡改分开测了:改 content 会让 id 对不上(InvalidId),只改 sig 则 id 仍然合法但签名验不过——这两条错误路径在生产排查里含义完全不同。

事件进入中继后会被包一层。crates/buzz-core/src/event.rs 里的 StoredEvent 结构很小,但每个字段都有话说:event 是原始 Nostr 事件,received_at 是中继收到它的墙钟时间(跟事件自带的 created_at 是两回事),channel_id: Option<Uuid> 是频道作用域——None 表示全局事件或私信,verified 记录签名是否已校验。

真正承载业务语义的是 kind 这个整数,crates/buzz-core/src/kind.rs 是它的权威注册表。加一个功能等于加一个 kind 号,老客户端看不见也不会崩。kind:9 是频道消息,kind:7 是反应,kind:5 是删除请求,kind:0 是用户资料,1059 是 NIP-17 礼物包装私信,9007 建群、9000 加人、9001 踢人,39000/39001/39002 是中继签名的群元数据、管理员列表、成员列表,44100/44101 是成员增删通知,13534 是中继成员名单快照。

更值得抄作业的是:读权限也编码在 kind 上,而且是一组常量而非散落的 if。AUTHOR_ONLY_KINDS 里的事件只有作者本人能读,中继不得向任何人泄露它们的存在、数量、标签、内容、日程或搜索命中;P_GATED_KINDS 里的事件只有出现在 #p 标签里的人能读,中继在过滤器层就把不合规的订阅拒掉;RESULT_GATED_KINDS 更严一档,即使你知道事件 id,也必须匹配 #p 才能拿到;SHARED_GATED_KINDS 是”作者独占,除非显式共享”,用于 30175 人格定义和 30178 团队目录投影。

30175 那个注释里藏着一个很工程的判断:共享开关做成 ["shared","true"] 标签而不是 content 里的字段,因为切换共享不能改变 content 字节——人格同步用的内容哈希是从 content 算的,改了就会触发假的漂移。配套的 event_is_shared() 要求这个标签恰好两个元素、值恰好是 "true",三元素的 ["shared","true","extra"] 一律判为未共享,fail closed。

四、中继只负责转发:以及它实际还管的那几件事

ARCHITECTURE.md 第一段就把话说死了:中继是唯一真相源,所有读写都经过它,没有点对点事件交换,没有 gossip,没有复制——只有客户端通过 WebSocket 连到一个中继。这里的 gossip(八卦传播)指的是节点之间互相闲聊、把消息扩散到多台服务器的那类机制,Nostr 生态里常见的多中继冗余就靠它。buzz 在事件层明确不做这件事。

要说准一点:仓库里确实有 crates/buzz-relay-mesh/src/gossip.rs,文件头写的是 Scuttlebutt 风格的成员 gossip,但同一段注释限定得很清楚——gossip 只回答存活性和可拨号性问题,它不选主、不转移会话、不携带隧道数据字节。那是运行时组网的探活,不是事件复制。别把两件事混起来。

“只负责转发”是个方便的简化,中继实际做的是:NIP-42 挑战式认证、验签、写 Postgres、向订阅者扇出、建全文搜索索引、触发自动化工作流。它还会用自己的密钥签一批事件——BUZZ_RELAY_PRIVATE_KEY 就是这把签名钥匙。is_relay_only_kind() 列出了客户端提交必须被拒的 kind:13534 成员名单、40901 频道摘要、40902 在线快照、30622 私信可见性、39005 线程摘要、39006 窗口边界。NOSTR.md 还单列了一条:客户端提交的 44100/44101 一律拒绝,成员通知只能由中继密钥签。

准入这一层有两个开关。BUZZ_PUBKEY_ALLOWLIST=true 时,只用公钥做 NIP-42 认证的连接会去查 pubkey_allowlist 表,带 API token 的用户绕过白名单;这个检查是 fail-closed 的,数据库查询出错就直接拒连。BUZZ_REQUIRE_RELAY_MEMBERSHIP=true 时,每个已认证连接都要在 relay_members 表里有行,中继 owner 由启动时的 RELAY_OWNER_PUBKEY 引导写入。

订阅侧还有一条容易踩的约束:任何可能命中 p-gated kind(44100、44101、1059)的全局 REQ,必须带 #p 过滤且所有值都等于你自己认证的公钥,否则中继直接拒订阅,返回 restricted: p-gated events require #p matching your pubkey。这条是防止你顺手订阅到别人的成员变更和私信。

组成部分它负责什么仓库位置你什么时候会碰到它
kind 注册表所有事件类型编号 + 读权限分组常量crates/buzz-core/src/kind.rs加新事件类型、排查”为什么我订阅不到”
事件包装给 Nostr 事件加上接收时间、频道作用域、验签状态crates/buzz-core/src/event.rs看存储层和扇出逻辑
签名校验先验 id 再验 Schnorr 签名crates/buzz-core/src/verification.rs排查 InvalidId / InvalidSignature
第三方客户端接入哪些 NIP-29 功能可用、哪些不可用、错误码对照NOSTR.md拿 nak 之类的通用客户端连自建中继
协议扩展规范项目自定义的那批 NIP 草案docs/nips/要理解 Agent 授权、记忆、指标怎么建模
设备配对规范换设备迁移密钥的加密握手crates/buzz-core/src/pairing/NIP-AB.md做多端登录
运维 CLI增删查中继成员、生成密钥crates/buzz-admin/拉人进来、初始化身份
Agent 接入把频道里的 @提及桥接到 AI Agentcrates/buzz-acp/接自己的 Agent
数据库迁移表结构演进migrations/自建部署升级

五、边界与代价:它明确放弃了什么

放弃了多中继冗余。 单中继单真相源意味着你的可用半径就是那台机器。事件不会自动扩散到别的中继,宕机就是全停。VISION_SOVEREIGN.md 里也承认这笔账:你得跑一台服务器、一个域名、一个中继进程,有人得管备份,有人得接半夜磁盘满的告警。

放弃了账号找回。 前面说过了,这是密钥自持的直接后果,不是实现缺陷。

放弃了跨社区的数据继承。 NOSTR.md 写得很清楚:中继 URL/域名对社区是权威的,没有 #h 标签的那些”全局”事件流(资料、礼物包装私信、成员通知、状态、长文、工作流事件)只在你当前连接的这个社区里是全局的。同一个公钥可以加入多个社区并各发一份资料,但私信和资料不跨社区域名继承。

兼容性有明确缺口。 NOSTR.md 自己列了不工作的部分:kind:39003 群角色在注册表里定义了但中继不发;kind:9009 建邀请事件会被接受和存储,但副作用处理器是延后的空实现,只打一条警告日志;私信只支持 NIP-17 礼物包装,NIP-04 和 NIP-44 私信没实现,kind:10050 延后;kind:40002 富内容和 kind:40003 编辑在线路上能跑,但没有标准 NIP-29 客户端会渲染它们。另外 kind:0 里的 NIP-05 handle 必须能规范化到本中继域名,域外或非法的会被静默清空。

自建中继的暴露面要看清。 中继进程默认监听 3000 端口,同时提供 WebSocket 和 HTTP 两套面:/media/*(Blossom 上传下载)、/git/*/events/query/count/hooks/{id}、NIP-11 信息文档、NIP-05 解析。数据落在 Postgres(事件、频道、token、工作流、审计)和 Redis(在线状态、正在输入、跨节点发布订阅)。BUZZ_RELAY_PRIVATE_KEY 泄露的后果是:能伪造所有中继签名事件——群成员列表、成员通知、成员名单快照。

Agent 能改动什么,取决于你给了它什么。 仓库里的 Agent 技能文档写得直白:Agent 用自己的密钥就能拥有仓库,repos create 用它自己的 key 签仓库公告,clone URL 里的 owner 段就是它自己的公钥;git 侧走 git-credential-nostr 凭据助手做 NIP-98 认证——NIP-98 是拿一条现签的 Nostr 事件当 HTTP 请求的授权凭据,每次请求现签一条,服务端验签就放行,所以既不用密码也不用长期有效的 token——git clone/push/pull 直接可用。这意味着一个拿到 BUZZ_PRIVATE_KEY 的 Agent 进程,能以那个身份推代码、发消息、建频道。想收窄,就得在身份和成员资格这一层收,而不是指望某个权限开关——这跟给 Agent 设计最小权限的思路是一致的。

它明确不管的事。 NIP-56 举报被接受、写进审核队列,但中继从不自动执行动作——规范里写的是报告是信号不是触发器。另外要区分清楚:根目录那 8 份 VISION*.md 写的是愿景,不是已实现功能清单;README 里的产品描述是项目对自己的定位。判断”现在能不能用”,看 NOSTR.md 的功能对照表和 crates/ 下的代码,别看愿景文档。

至于形式化验证——就是把一段逻辑写成数学模型,交给工具穷举所有可能的执行顺序,看某条性质会不会被违反,比跑测试用例强的地方在于它不靠你想得到的那几个场景——仓库里 docs/multi-tenant-relay.md 用 TLA+(并发与服务模型,模型在 docs/spec/MultiTenantRelay.tla)机械化了多租户隔离性质,另有 Tamarin 的 .spthy 模型(docs/spec/MultiTenantAuth.spthy)在 Dolev-Yao 攻击者假设下管授权侧;docs/git-on-object-storage.md 里的 docs/spec/GitOnObjectStore.tla 建模了并发推送。要注意后者原文自己标注了这是有界模型检查,不是全系统证明。范围就是这些,别外推。

六、上手与避坑清单

  • 私钥当场存好。 buzz-admin generate-key 打完就不管了,README 明说不存储不可恢复。会踩是因为你以为它像大多数 CLI 一样写进了配置文件。避法:生成后立刻写进密码管理器或密钥托管,再去做别的。
  • kind:9 必须带 #h 会踩是因为你按普通 Nostr 客户端的习惯发文本消息。中继返回 invalid: channel-scoped events must include an h tag。避法:发消息时带上 --tag "h=<channel-uuid>"
  • 订阅 kind:7 只写 kinds 一条都收不到。 反应事件的频道是中继从 #e 目标反推的,客户端自己写的 #h 在这一步被忽略;而实时扇出把频道作用域和全局订阅严格分开。避法:订阅写成 {"kinds":[7],"#h":["<channel-uuid>"]}
  • p-gated 订阅必须带 #p 且只能是自己。 会踩是因为你想做个”全量监听”面板。中继直接拒订阅。避法:老实带上自己的公钥;要全局视图请走别的路径。
  • 白名单开关必须在中继启动前设置。 BUZZ_PUBKEY_ALLOWLIST 是启动期读取的,运行中改无效;而且它 fail-closed,数据库抖一下所有人连不上。避法:改完重启,并给这条查询单独配监控。
  • 批量加成员要串行并加 sleep 1 文档里解释了原因:时间戳自增只防了同秒覆盖,防不住并发进程——两个几乎同时的 add 会读到同一个最新时间戳,撞在同一个被 +1 的秒上。避法:别用 xargs -P 并行,循环里加 sleep 1
  • 别指望容器外的 CLI 推送 8000/8001 增量。 文档写明这是刻意为之:增量发布是进程内的,没有 Redis 跳转,从 docker compose exec 侧调用会存下来但永远推不出去,权威的是 13534 名单快照。会踩是因为进程内测试全绿。避法:认准 13534。
  • 白名单表没有 CLI。 pubkey_allowlist 目前只能直接写 SQL 增删查,文档里给了三条语句。会踩是因为你在 buzz-admin 的帮助里找不到就以为不支持。

收个尾

把这三条摆在一起看,buzz 的取舍其实很一致:它用密钥换掉了账号体系,于是权限只能按身份切;它用签名事件换掉了消息表,于是审计是天然的、扩展是加 kind 号;它用单中继换掉了分布式扩散,于是一致性和访问控制好做,可用性得自己扛。人和 Agent 能在同一张网里协作,不是因为加了什么 Agent 特性,而是因为 Agent 拿到的是和人一样的一对密钥。这个思路怎么落到你自己的选型上,可以对着Agent 协议与生态对比一起看。

想继续往下读,建议按这个顺序:先 crates/buzz-core/src/kind.rs,把四组读权限常量和它们的注释看完,这是整套访问控制的地图;再 NOSTR.md 的两张功能表,分清哪些能用哪些是空实现;然后 docs/nips/NIP-OA.md,看 Agent 授权到底授的是什么;最后 ARCHITECTURE.md 的第一节和 crate 依赖图,把边界画到自己的架构图上。

自检三问:你的私钥现在在几个地方、分别归谁管?你打算给 Agent 的那把钥匙,能推哪些仓库、能进哪些频道?中继那台机器挂了,你的历史事件在哪台机器的哪个 Postgres 里,谁在备份?这三个问题答不上来之前,先别急着上线。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 通信平台 buzz 的仓库结构导读buzz 多 Agent 通信平台:Block 为何先写 NIP 规范

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