buzz 多 Agent 通信平台:Block 为何先写 NIP 规范
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
buzz(Block 开源的多 Agent 通信平台,人和 Agent 在同一张消息网络里协作)把「协议先于实现」当成了工程纪律:docs/nips/ 下有 15 份自己写的规范文档,合计 5403 行、694 处 MUST,全都是在 Rust 代码之前把行为写死的。 这套做法真正的成本不在写文档,而在每写下一条 MUST,就等于给实现、评审和测试各加了一处必须被落实、被核对的约束。这篇文章要说清的就是这两件事:这 15 份规范补的是哪些空缺,以及先写规范的账单长什么样。
站内已有几篇相邻的文章各管一段——Agent 协议生态横向对比比的是不同协议之间怎么选,结构化输出不稳怎么治讲的是模型输出层面的约束手段,规格驱动开发 SDD谈的是方法论本身;本篇只盯 buzz 一家,看一个真实仓库把规范先行做到底之后,代码库里多出了什么东西。
一、先把底座讲清楚:它是一个 Nostr 中继
读下去之前有几个概念要先摆平,否则后面每一条规则都会像天书。
Nostr 事件:一条带签名的 JSON 对象,字段固定——pubkey(作者公钥)、created_at(时间戳)、kind(事件类型编号)、tags(标签数组)、content(内容)、sig(签名)。中继(relay):接收、存储、转发这些事件的服务器,它不生产身份,只做守门和分发。事件签名:作者用私钥对事件内容做签名,任何人拿公钥就能验,中继无法伪造别人的消息。密钥自持:身份就是那把私钥,没有找回密码这回事——私钥丢了,那个身份就丢了;私钥泄露了,别人就是你。还有两个后面反复出现的词:可寻址事件,指那种按 (作者公钥, kind, d 标签) 寻址、只保留最新一版的事件,语义上更像一条可覆盖的记录而不是一条流水消息;NIP-11 文档,指中继在自己的 HTTP 根路径上返回的那份公开自述 JSON,不需要任何鉴权就能取。签名算法这一层则统一是 BIP-340 定义的 Schnorr 签名——比特币那套 x-only 公钥格式,Nostr 直接沿用。
buzz 的核心取向由此而来:仓库 README 把自己定位成「一个你自己拥有的中继上的、人与 Agent 共建的工作区」,NOSTR.md 则说明它原生讲 NIP-29(基于中继的群组)和 NIP-42(客户端向中继认证),第三方 Nostr 客户端可以直连。也就是说,能用上游标准的地方它一律用上游标准:群聊是 kind:9、反应是 kind:7、删除是 kind:5、群元数据是 kind:39000/39001/39002、私信走 NIP-17 礼物包裹。自定义只出现在上游确实没有对应物的位置。
一个细节能说明它的自我定位:上游 NIP 的编号是数字(NIP-01、NIP-29、NIP-42),buzz 自己写的这批一律用两个字母(NIP-OA、NIP-AM、NIP-WP),没有去占上游的编号空间。
二、15 份规范各自补了什么空缺
docs/nips/ 目录下是 15 份 md 加 2 份 fixtures json,另外 crates/buzz-core/src/pairing/NIP-AB.md 是第 16 份、跟着配对代码一起放。挑几份跟 Agent 工程最相关的列表如下。
| 规范 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| NIP-OA 归属证明 | 一个 auth 标签,证明 owner 授权了某个 agent 密钥发事件 | docs/nips/NIP-OA.md、crates/buzz-sdk/src/nip_oa.rs | 要在界面上区分「谁发的」和「谁授权的」时 |
| NIP-AA Agent 认证 | 持有 NIP-OA 凭证的 agent 靠 owner 的成员资格连中继,不用单独入册 | docs/nips/NIP-AA.md | 一个人开了一堆 agent,不想逐个加白名单时 |
| NIP-AM 用量计量 | 每回合一条 kind:44200,加密记录 token 与估算成本 | docs/nips/NIP-AM.md、crates/buzz-core/src/agent_turn_metric.rs | 要跨 harness 统计「我的 agent 上周烧了多少」时 |
| NIP-AO 可观测 | kind:24200,加密且短暂的会话遥测流 | docs/nips/NIP-AO.md、crates/buzz-core/src/observer.rs | 想实时看 agent 在干什么、又不想留档时 |
| NIP-AE 记忆 | kind:30174 可寻址事件,agent 的持久结构化记忆 | crates/buzz-core/src/engram.rs | 要让 agent 跨会话记住东西时 |
| NIP-AP 人格 | kind:30175 人格定义,agent 的实例化蓝图 | crates/buzz-persona/ | 批量按模板拉起 agent 时 |
| NIP-WP 工作区档案 | kind:9033 命令设置工作区图标,从 NIP-11 文档读出 | docs/nips/NIP-WP.md | 一个客户端连了好几个工作区、需要区分时 |
| NIP-GS git 签名 | 用 Nostr 密钥签 git 提交与标签 | crates/git-sign-nostr/ | agent 替你提交代码、你要能验来源时 |
剩下的几份覆盖面同样具体:NIP-MP 定义 kind:30621 多仓项目分组,NIP-PL 定义 kind:30350 推送租约,NIP-RS 用加密的 kind:30078 同步跨设备已读位置,NIP-CW 定义频道时间线的游标翻页,NIP-DV 用 kind:30622 表达私信隐藏状态,NIP-ER 用 kind:30300 做加密提醒,NIP-IA 用 9035/9036/8002/8003/13535 一组事件做身份归档。这些编号在 crates/buzz-core/src/kind.rs 里都有常量,比如 KIND_AGENT_TURN_METRIC、RELAY_ADMIN_SET_WORKSPACE_PROFILE,文件里还写着编译期断言,检查可寻址范围有没有用错。
三、规范到底细到什么颗粒度
抽三份看,颗粒度差异很大,但共同点是把歧义留给实现者的地方都被提前堵死了。
NIP-OA 处理的是「授权不等于冒名」。 标签形状固定四个元素:
["auth", "<owner-pubkey-hex>", "<conditions>", "<sig-hex>"]
签名原文是 nostr:agent-auth: 拼上 agent 公钥、冒号、条件串,取 SHA256 之后用 owner 私钥做 BIP-340 Schnorr 签名。条件串的语法窄到近乎苛刻:只允许 kind=<十进制>、created_at<时间戳>、created_at>时间戳 三种子句,用 & 连接,任何位置不许有空白,十进制必须规范(kind=01 因为前导零直接判非法),首尾 & 和 && 一律拒绝,kind= 的取值范围是 0 到 65535,时间戳范围是 0 到 4294967295。文档还明确禁止实现者在计算原文之前对条件串重排、去重或规范化——因为那串字符本身就在签名覆盖范围里。
语义那一半更关键:文档写死了中继不需要为此改动、也不得据此改写事件作者;客户端必须把 event.pubkey 当作唯一作者,不得因为一个有效的 auth 标签就把事件并进 owner 的时间线、作者索引或按公钥过滤的结果里,展示归属时必须明显区别于作者身份(文中举的例子是 authorized by 加 owner 名)。规范末尾附了完整测试向量和六条必须被拒的反例,包括两个 auth 标签、自签(owner 公钥等于事件公钥)、以及挂在签名无效事件上的合法标签。
NIP-AM 处理的是「用量记账」。 事件骨架是这样:
{
"kind": 44200,
"pubkey": "<agent_pubkey>",
"content": "<NIP-44 v2 ciphertext>",
"tags": [
["p", "<owner_pubkey>"],
["agent", "<agent_pubkey>"]
]
}
明文用 NIP-44 v2 加密给 owner,解密后不得超过 65535 字节。刻意不带频道标签,理由写得很直白:频道属于私有用量元数据,放进标签会把每频道的活动频率泄露给中继运营者。数值语义规定得极细——harness 和 timestamp 必填;harness 没上报的计数必须是 null,且 null 不得被当作 0 累加;缓存读写这两个字段更严,不可用时必须整个省略而不是给 null;totalTokens 不许由输入输出相加推导出来,因为服务商可能统计了简单相加漏掉的类别。计费身份 pricingIdentity 只在能从真实端点证明适用性时才出现,authority 是注册过的三个主机名,一旦请求穿过自定义 base URL、网关或未解析的别名就必须整个省略,消费方按「价格未知」处理,并且不得把清单估算出来的成本和线上报的成本合并成一个没有标注的总数。
中继侧的义务也一条条列着:验签、核对 event.pubkey 等于 agent 标签、通过鉴权过的归属查询确认 owner 关系、按 owner 持久化、不进全文索引。读取必须经过 NIP-42 认证且读者公钥等于 #p 值,连按事件 id 直查也不放行。限速建议是每个 agent 公钥每分钟 60 条。
NIP-WP 处理的是一个看起来很小的问题:工作区图标。 它的价值在于把「为什么不用现成方案」写清楚了:上游 NIP-86 有 changerelayicon,但那是一套独立的 JSON-RPC 管理面,带自己的鉴权模型,和 buzz 中继已经在执行的角色状态是两回事;NIP-29 的群元数据图片是每个群的,工作区图标是每个中继的。所以它只加一个命令 kind,读取路径原样复用 NIP-11 的 icon 字段——任何认识 NIP-11 的客户端零改动就能渲染。写入面只允许 http(s) 或 data:image/*,不许有空白和控制字符,普通 URL 建议上限 2048 字节,内联 data URL 建议上限 96 KiB,非图片的 data: 必须拒绝。
四、先写规范的账单
代价是可以数出来的,分四笔。
第一笔是规范体量本身。 15 份文档 5403 行,694 处 MUST 平均下来每份 46 条硬约束。约束最多的是 NIP-RS:一份 796 行里 149 处 MUST,15 份里没有第二份到这个密度(篇幅最长的另有 871 行的 NIP-GS);而 NIP-RS 解决的只是「多设备之间同步已读位置」。每一条 MUST 都要有人实现、有人评审、有人写测试。
第二笔是为了守住规范额外造的检查装置。 docs/spec/ 下有 MultiTenantRelay.tla 和 GitOnObjectStore.tla 两份 TLA+ 形式化模型——形式化验证的意思是用数学语言把状态机和不变式写下来,再用工具穷举检查这些不变式会不会被打破;同目录的 MultiTenantAuth.spthy 是 Tamarin(一种符号化安全协议验证器)的模型文件,覆盖 NIP-98 令牌铸造、按社区分离的签名密钥和审计链。这两处都得看清成熟度:.tla 文件头写的是「proposed 多租户隔离」的模型,.spthy 文件头自标 draft skeleton 并写明最终定理措辞还要收紧——它们是正在建的检查装置,不是已经结案的证明。crates/buzz-conformance 是一个独立的重放检查器,它的 Cargo.toml 里写着一条独立性规则:不许依赖任何生产 crate,连社区 ID 类型都要自己重新定义一个不透明的新类型,理由写得很清楚——让检查器无法从生产类型机制里继承 bug,检查器从零重新实现规范的状态转移关系,这样生产代码的 bug 就不会机械地变成检查器的 bug。docs/formal/nip-pl/ 下另有六个 Python 脚本(三组模型各配一支变异测试),其中 acceptance.py 穷举一个地址上七类候选事件的全部 5040 种顺序,mutation_test.py 则反过来要求:把规范里的两条子句删掉之后,模型必须能给出反例——否则说明这两条子句根本没被检查到。
第三笔是规范必须能被机器核。 NIP-MP 把 31 个用例(11 个接受、20 个拒绝)写进 docs/nips/NIP-MP.fixtures.json,中继的 ingest 模块和 SDK 的 builders 都用 include_str! 把这份 json 直接编进测试二进制,crates/buzz-sdk/src/builders.rs 里还有一条断言,用例数对不上就报「是不是有人改了 NIP-MP.fixtures.json」。好处是文档不会悄悄和实现脱节,代价是规范文件成了构建依赖——文档和代码从此不能各改各的。
第四笔是兼容面收窄。 NIP-MP 自己把这笔账写在正文里:kind:30621 是 buzz 专有的,第三方 NIP-34 客户端能看到成员仓库、看不到分组,成员仓库本身仍是标准可移植的事件。NOSTR.md 的兼容表里还有两行标着警告:kind:40002 富内容和 kind:40003 编辑在协议线上能跑,但没有标准 NIP-29 客户端会渲染它们。
五、边界与代价:它明确不管的事
这套设计放弃了什么,文档里大多是白纸黑字写着的,不用猜。
NIP-OA 的非目标只有三行:不定义冒名,不定义密钥派生,不定义中继侧的作者改写。它也不提供墙钟意义上的过期——created_at< 约束的是事件自己声明的时间戳,而那个字段由 agent 自己填,一个行为不端的 agent 可以回填时间来满足一个早该失效的窗口;要真正的时效性,得由中继或客户端独立实现。撤销的路径同样朴素:owner 拒绝签发新的凭证,仅此而已,撤销延迟是真实存在的。
NIP-AM 把三条风险摆在明处:p、agent、created_at 是明文,中继运营者能知道 agent X 在为 owner Y 完成回合以及大致频率;NIP-44 没有前向保密,agent 私钥一旦泄露,此前被截获的密文都能解开;指标由 agent 进程自报,被攻陷的 agent 可以少报或多报,要更强的保证只能拿服务商账单去对账。costUsd 也被明确降级为估算值,不是账单记录。
密钥自持这件事在这里有具体后果:owner 和 agent 是两把独立的密钥,规范要求 agent 私钥泄露不得蕴含 owner 私钥泄露——但反过来,任何一把私钥丢了,那个身份就没了,没有服务端可以帮你重置。自建中继的暴露面也要算清:NOSTR.md 的快速上手把中继开在 :3000,成员和白名单落在 Postgres 里(pubkey_allowlist、relay_members 表,白名单目前只能直接写 SQL 增删),中继签名私钥通过 BUZZ_RELAY_PRIVATE_KEY 环境变量注入,而 NIP-WP 的图标走的是无鉴权的 NIP-11 文档——文档里因此写明:管理员不得把非公开信息放进图标。
Agent 能改动什么也有边界。NIP-GS 让 agent 用自己的 Nostr 私钥签 git 提交和标签,走的是 git 的可插拔签名程序接口,而 crates/git-sign-nostr/src/lib.rs 的模块注释标着 Unix-only(它依赖 --status-fd 的文件描述符传递)。NIP-MP 则反过来划清:项目分组是元数据,签名者对成员仓库不获得任何权限——不能编辑、删除、推送或管理,git 推送策略从不查询项目事件。这类边界值得和最小权限设计一起读。
NOSTR.md 里还有一张诚实的「不工作」表:kind:9009 创建邀请会被接收和存储,但副作用处理是延后的空实现,只留一条警告日志;kind:39003 群角色在 kind 注册表里定义了,中继并不发出;NIP-04/NIP-44 直接私信没有实现,DM 中继列表也延后了。
六、上手与避坑清单
把 auth 标签当成「用 owner 身份发言」。 会踩是因为 NIP-26 委托的语义就是把事件归给委托人,直觉会顺延。避法:NIP-OA 只借用了它的凭证格式和签名流程,语义上明确是授权证据,客户端必须把 event.pubkey 当唯一作者,展示归属时必须与作者身份视觉可分。
对条件串做规范化。 会踩是因为写解析器的人本能会 trim、排序、去重。避法:条件串原样进签名原文,动一个字节签名就不成立,验证时直接照抄标签里的字符串去算原文。
把 created_at< 当过期时间。 会踩是因为看到一个小于号加时间戳就默认它到点自动失效。避法:那是事件自报的时间,要墙钟新鲜度必须另外做一层。
token 计数缺失时填 0。 会踩是因为数值字段缺省填零是最省事的写法,而且下游求和不会报错。避法:NIP-AM 规定 null 不得被记录或当 0 累加,缓存字段不可用时必须整个省略;把没上报的类别当成零是错的。
顺手填 pricingIdentity。 会踩是因为会话里已经配了模型名,看起来正好能填。避法:那个字段是配置态模型,不是计费模型;穿了自定义 base URL、网关或未解析别名就必须整个省略,消费方按价格未知处理。这块的取舍可以对照Agent 成本实时管控一起看。
批量加成员时开并发。 会踩是因为 add-member 看起来是个幂等小命令,用 xargs -P 加速很自然。避法:NOSTR.md 的已知限制第二条写明,时间戳加一秒的抬升能解决串行调用的同秒争抢,但不串行化并发进程,循环里要加 sleep 1。
用只带 kind 的全局订阅收反应或群发现事件。 会踩是因为在别的中继上这么订阅通常收得到。避法:buzz 的实时分发把频道作用域和全局作用域严格分开,反应要带 #h 才收得到;39000/39001/39002 是频道作用域存储,靠历史 REQ 查询发现。另外,可能命中 p 门控事件的全局订阅必须带 #p 且值全部等于自己的公钥,否则会被 restricted 拒掉。
收束:接下来该读哪几个文件
判断标准其实很简单:如果你的多 Agent 系统只在一个进程里跑,这套规范体系对你是纯负担;当「谁说的、谁授权的、烧了多少 token、能不能改仓库」这四个问题要跨设备、跨组织、跨 harness 成立时,先写规范才开始变便宜——因为那时候的分歧不再是代码风格分歧,而是两个实现对同一段字节的理解分歧,只能靠规范和测试向量收敛。
按这个顺序读最省时间:先 NOSTR.md 把底座和兼容边界看一遍,再 docs/nips/NIP-OA.md 看最短的一份完整规范长什么样(150 行,30 处 MUST,带测试向量),接着 docs/nips/NIP-AM.md 看数值语义能被规定到什么程度,然后 CONTRIBUTING.md 的「How to Add a New Event Kind」九步——从 kind.rs 定义常量、注册所需权限范围、挂副作用处理、落库、决定要不要进搜索索引,到写测试和更新文档,这九步就是这套规范纪律在日常开发里的成本形态。最后翻 crates/buzz-core/src/kind.rs,那是所有事件编号的权威登记处,也是判断某个想法该不该新开一个 kind 的第一现场。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源 buzz:多 Agent 平台为何建在 Nostr 上 和 Block 开源的多 Agent 通信平台 buzz:一切皆签名事件。