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.),不是任何”热度""蜂鸣”意义上的词。它建在 Nostr 协议之上,人和 Agent 挂在同一张消息网络里收发消息。

站内已经写过几篇相近的:openwork 的桌面端架构拆的是另一套桌面客户端的分层,AI 建站工具横向对比看的是工具选型,opencode 的仓库结构讲的是终端 Agent 的目录组织;这篇只盯一件事——buzz 桌面端与中继之间的职责切分,以及这条切分线怎么把代码量推到了客户端这一侧。

一、先把协议底座与那条分工线讲清楚

读这个仓库之前,有四个词得先落地,它们决定了桌面端为什么长成这样。

事件(event):Nostr 里一切动作的统一载体。仓库的 ARCHITECTURE.md 写得很直白,一条事件是六个字段的 JSON:id(规范序列化后的 sha256)、pubkey(secp256k1 公钥的十六进制)、kind(无符号整数)、tagscontentsig(对 id 的 Schnorr 签名)。一条聊天消息是事件,一个表情反应是事件,一次工作流步骤也是事件。

kind:这个整数是唯一的分发开关。中继按 kind 路由、存储、扇出,客户端按 kind 过滤订阅。crates/buzz-core/src/kind.rs 是这份清单的唯一来源,里面每个 kind 都是一个 pub const u32,比如 KIND_STREAM_MESSAGE 是 9、KIND_CANVAS 是 40100。加功能的方式是加一个新 kind 号,老客户端看不见它,也就不会被它弄崩。

中继(relay):一个 WebSocket 服务端,客户端连上去,按 NIP-01 的消息格式收发。这里的 NIP 是 Nostr 生态给协议提案编的号:NIP-01 定的是最基础的事件结构与 EVENT/REQ/CLOSE 这几条线路消息,NIP-42 定的是客户端怎么向中继证明”我确实持有这把私钥”——中继发一段挑战串,客户端签名回去,中继验完才给写权限。仓库 docs/nips/ 目录下还放着 buzz 自己扩展的一批(15 份 md 加 2 份 json)。ARCHITECTURE.md 把它定位成唯一真相源——所有读写都过它。这里有一句话对理解桌面端特别关键:它明确写了没有点对点事件交换、没有八卦传播、没有复制。“八卦传播”(gossip)指的是节点之间互相转述消息、让数据自己扩散的做法,buzz 不做这个,就是客户端连一个中继,中继做鉴权、验签、落库、扇出、索引。

密钥自持:你的身份就是那把 secp256k1 私钥,事件靠它签名。中继验签,但从不替你保管密钥。这条约定的代价必须说清楚——私钥丢了就等于身份丢了,没有找回流程,也没有哪台服务器能替你重发一个”你”。桌面端里那一大块密钥存储代码,就是为这件事付的账。

中继管到哪一步,桌面端从哪一步接手

ARCHITECTURE.md 里的事件管线摊开看,中继收到一条 ["EVENT", <event>] 之后做的事是固定的一串:检查鉴权状态与写权限、比对事件 pubkey 与认证 pubkey、拒掉 AUTH 事件、把 20000–29999 的临时事件走单独的支线、验 Schnorr 签名与 ID 哈希、查频道成员资格、写库、Redis 发布、扇出、丢进搜索索引队列、写审计链、触发工作流。

注意这串里没有的东西:没有”这条消息应该显示在哪个会话的第几条”、没有”这个人正在打字要不要显示气泡”、没有”断线重连之后先补哪个频道的历史”、没有”这条消息是不是我自己刚发的、要不要本地回显”。这些全都是客户端的事。

中继侧还有几个硬上限直接约束客户端怎么写:单帧最大 65536 字节、每连接最多 1024 个订阅、每个过滤器历史查询最多返回 500 条。500 这个上限意味着客户端必须自己做分页与回溯——桌面端 src/features/messages/useFetchOlderMessages.tsuseLoadMissingAncestors.ts 这类文件就是干这个的。

还有一个容易被忽略的方向:桌面端不只是消费者,它也是事件生产者ARCHITECTURE.md 在讲语音房(huddle)时写得很明确——参与者加入、离开、房间结束这几个事件由中继发出,而”huddle 已开始”和使用指引这两类事件是桌面客户端发的。也就是说,某些业务语义的起点就在客户端,中继只是把它签过名的结果收下来。

组成部分它负责什么对应仓库位置你什么时候会碰到它
中继服务端鉴权、验签、落库、扇出、搜索索引、审计、工作流触发crates/buzz-relay排查消息发不出去、订阅被 CLOSED
事件与 kind 定义事件类型的唯一真相源,零 I/Ocrates/buzz-core/src/event.rskind.rs要加一类新消息、要确认某个 kind 的含义
桌面前端路由、会话状态、渲染、乐观更新、重连补数desktop/src/featuresdesktop/src/app改界面、改交互、加一个新功能模块
桌面 Rust 侧原生 WebSocket、密钥存储、托盘菜单、终端、深链desktop/src-tauri/src涉及系统能力、密钥、进程管理
中继客户端封装连接会话、重连重放、限流退避、频道过滤器desktop/src/shared/api网络层出问题、要复用请求逻辑
构建与路由生成Vite 配置、TanStack Router 路由树生成desktop/vite.config.tsdesktop/src/app/routes.ts加页面、改开发端口

二、Vite 前端套 Tauri 壳:这个壳到底套了什么

desktop/README.md 把自己定位成一个”桌面聊天外壳”,技术栈是 Tauri + React + TypeScript + Vite,加 Tailwind,加 Biome 做 lint 与格式化,前端按 feature 纵向切分。目录约定就三条:src/shared 放全应用复用的东西(uilibstyles),src/features 放功能模块,src/app 放顶层组装。

vite.config.ts 里有几处是踩过坑之后写死的:

const host = process.env.TAURI_DEV_HOST;
// ...
clearScreen: false,
server: {
  port: parseInt(process.env.VITE_PORT || "1420", 10),
  strictPort: true,
  host: host || false,
  watch: {
    ignored: ["**/src-tauri/**"],
  },
},

clearScreen: false 的注释写着”防止 Vite 遮住 Rust 报错”——Tauri 的 Rust 侧编译错误和 Vite 的输出抢同一个终端,清屏会把真正的错误刷掉。strictPort: true 是因为 Tauri 期望一个固定端口,端口被占就直接失败而不是悄悄换一个。watch.ignored 排除 src-tauri,避免 Rust 产物变化触发前端热更新。远程调试时可以用 TAURI_DEV_HOST 指定主机,此时 HMR 走 VITE_HMR_PORT(默认 1421)。

路由这块不是手写的路由表,而是 @tanstack/router-plugin 在构建时生成的。配置里指定了路由目录 ./src/app/routes、生成产物 ./src/app/routeTree.gen.ts、虚拟路由配置 ./src/app/routes.ts,并给生成文件加了 biome-ignore-all 头。src/app/routes.ts 里的声明就是这个应用的全部顶层页面:首页、/agents/pulse/reminders/settings/workflows 与其详情、/projects 与其详情、/messages/new/channels/$channelId 及其帖子详情。

还有一条别名值得单独说:

alias: {
  "@": "/src",
  "@features-manifest": path.resolve(__dirname, "../preview-features.json"),
},

@features-manifest 指向的是仓库根目录的 preview-features.json,里面按 id / name / description / platforms 列着 workflows、projects、pulse 这类预览功能。前端不是在自己代码里硬编码”哪些功能算预览”,而是把这份清单当成构建期的输入。

三、29 个 feature 目录:为什么切得这么碎

desktop/src/features 下现在有 29 个目录(在仓库里 ls -1 desktop/src/features | wc -l 就能数出来):agent-memory、agents、channel-templates、channels、chat、communities、community-members、custom-emoji、forum、home、huddle、identity-archive、local-archive、mesh-compute、messages、moderation、notifications、onboarding、presence、profile、projects、pulse、reminders、search、settings、sidebar、terminal、user-status、workflows。

但这 29 个目录的体量极不均匀。用 find 数每个目录下的文件数,排在前面的是 agents(312 个文件)、messages(233)、channels(141)、projects(119)、profile(66)、onboarding(62);尾部则有只有 1 个文件的 chat 和 identity-archive。这个分布本身就是一份路线图:Agent 相关的配置、复用、运行时状态、观察者接入是这个项目当前最重的一块,比消息本身还重。

模块内部的组织也有固定形状。以 src/features/agents 为例,纯逻辑放平铺的 .tsagentManagement.tsknownAgentPubkeys.tsmanagedAgentRuntimeStatus.ts),React 组件收进 ui/,共享工具进 lib/,每个逻辑文件旁边跟一个同名 .test.mjs。测试用 Node 内置 test runner 跑,package.json 里是 node --import ./test-loader.mjs --experimental-strip-types --test "src/**/*.test.mjs"——直接跑 TypeScript,不额外套测试框架。

更有意思的是 src/features/agents/AGENTS.md:这个模块给自己写了一份贡献者规则,第一条就是”harness 能力事实只有一个来源——Rust 侧那份运行时能力清单”。它规定 KnownAcpRuntime(在 desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs)声明每个 harness 的模型、提供方、effort 环境变量与能力,前端不许维护一份对照表,也不许在组件里写 runtime.id === "claude" 这样的判断。跨语言边界上最容易长出双份真相,这份文档就是拿来堵这个口子的。

四、代码量为什么压在桌面端

先把数字摆出来,这些都是在仓库里 find 一下就能复现的结构性统计:全仓 3704 个受版本控制文件,其中 bin/ 下 41 个是 hermit 工具链软链接;crates/ 下 28 个 crate 一共 413 个文件;而 desktop/ 一个目录就有 2359 个文件。移动端 mobile/ 467、web/ 65、admin-web/ 14。

再往 desktop/ 里看:desktop/src 下有 1283 个 .ts/.tsx 文件,此外还有 395 个 .test.mjs 测试文件与之并列;desktop/src-tauri/srcdesktop/src-tauri/crates 下另有 328 个 .rs。也就是说桌面端自己还带着一个不小的 Rust 工程。

为什么会这样?三个原因叠在一起。

第一,中继不管的东西都得客户端管。 中继的模型是”事件进来、验完、存下、发出去”。而一个人要用的聊天界面需要:会话的已读位置、未读计数、线程折叠、消息编辑与撤回的本地表现、断线时的乐观发送、重连后哪些订阅先恢复。src/shared/api 下 97 个文件里有一整排 relayClient* 系列(relayClientSession.tsrelayClosedPolicy.tsrelayClosedRecovery.tsrelayConnectionStateEmitter.tsrelayChannelFilters.tsrelayAuthPolicy.ts),单是”连接掉了之后怎么办”就拆成了好几个策略文件。relayClient.ts 里那个 setVisibleChannel 的注释直说了目的:重连时把当前可见频道的订阅放进第一批重放,让用户在弱网下先看到自己正在看的频道恢复。

第二,密钥自持把安全责任搬到了本机。 src-tauri/src/secret_store.rs 的模块注释写着:所有 secret 存成一个 JSON blob,塞在一条 keychain 记录下(service 是 store 的服务名,username 固定为 "secrets"),这样一个进程生命周期内只会弹一次系统钥匙串授权。后端按目标平台在编译期选定,macOS 走 legacy keyring crate 的 SecKeychain API,让签名的正式版和未签名的开发版共用同一个存储;Windows 与 Linux 直接用 keyring crate。而 system-keyring 这个 feature 一旦关掉,SecretStore 就不可用,调用方回退到自己的 0o600 文件存储——这条回退路径意味着私钥会以文件形式落在本机磁盘上,权限位是唯一的防线identity_storage.rs 把身份的存放位置枚举成四种:EphemeralSystemKeyringLocalFileEnvironment。哪一种在生效,直接决定了这台机器被拿到之后攻击面有多大。

第三,Rust 侧承担了一批浏览器给不了的能力。 src-tauri/src 下能看到 native_websocket.rs(用 tokio-tungstenite 自己建连,带 10 秒连接超时、10 秒写超时、64 长度的发送队列)、event_sync.rs(开机时把磁盘上的 personas.jsonteams.jsonmanaged-agents.json 对账成签名事件再排队发给中继)、relay.rs(HTTP 桥调用,中继地址默认 ws://localhost:3000,可用 BUZZ_RELAY_URL 环境变量覆盖)、relay_admission.rs(中继返回 429 时的全局退避闸门)、还有 terminal_runtimetray_menu.rsprevent_sleep.rsptt_shortcut.rsegress_guard.rsmedia_proxy.rs 这些明显是原生能力的模块。

有一处风险必须点名:这个桌面端带终端运行时(src-tauri/crates/buzz-terminalsrc-tauri/src/terminal_runtime)和受管 Agent 进程(managed_agents)。当 Agent 能在你本机起进程、能读写你的工作目录时,“一条频道消息”和”一次本地命令执行”之间就只隔着这层客户端的判断逻辑了。 谁能 @ 到这个 Agent、它被允许改哪些路径,这些边界得在部署前想清楚,可以参考Agent 改动边界约定Agent 最小权限设计里的思路。

五、边界与代价:这个设计放弃了什么

放弃了离线优先的完整性。 中继是唯一真相源,没有 gossip、没有复制。中继挂了,客户端手里就只剩本地缓存与归档(src/features/local-archive 那一小撮文件),协作本身停摆。这不是缺陷,是”单一真相源”这个选择的必然代价。

放弃了服务端托底的账号体系。 密钥自持意味着没有”忘记密码”。仓库里 key_backup.rsidentity-archive 这些模块的存在,正说明这件事需要产品层面反复兜。

放弃了瘦客户端。 桌面端 2359 个文件的直接后果是:任何一个跨模块的交互改动都可能牵扯多个 feature 目录;Web 端只有 65 个文件、移动端 467,它们显然不可能提供同等的功能面。想在浏览器里得到桌面端的完整体验,短期内不现实。

它明确不管的事。 ARCHITECTURE.md 的”已知限制”一节列得很坦白,其中几条直接影响客户端预期:限流没有实现(RateLimiter trait 只有测试桩,RateLimitConfig 定义的四档只是设计目标);工作流的审批闸门没有端到端打通,跑到审批步骤的 run 会被标记为失败;send_dmset_channel_topic 两个工作流动作在 schema 里存在但返回 NotImplemented;语音房的录制与分轨发布只保留了 kind,没有生产者。把这些当成”已经能用”来设计上层功能,会直接踩空。

六、上手与避坑清单

别把 pnpm dev 当成跑桌面应用。 README 里 pnpm dev 只起 Vite 前端,pnpm tauri dev 才是跑桌面应用。前者能开,但所有走 Tauri IPC 的能力(密钥、终端、托盘、原生 WebSocket)都不在。会踩是因为浏览器里页面确实渲染出来了,看起来像是”跑起来了”。避法:调纯 UI 用 pnpm dev,只要碰到 IPC 就切 pnpm tauri dev

pnpm check 不等于 biome check package.jsoncheckbiome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation 四件套。会踩是因为你本地跑了 Biome 全绿就提交,CI 却挂在后面三个自定义脚本上。避法:提交前跑完整的 pnpm check

单文件超过 1000 行会被拦。 scripts/check-file-sizes.mjsMAX_LINES = 1000,覆盖 src-tauri/srcsrc-tauri/cratessrc/appsrc/featuressrc/shared/apisrc/shared/context。这个脚本的注释里还留了一条教训:如果不显式把 src-tauri/crates 加进规则,新建在那儿的 crate 会绕开体积纪律,而且是静默的——检查照样 exit 0。避法:新增受管目录时同步加规则,别指望默认覆盖。

不要随手 pubkey.slice(0, 8) scripts/check-pubkey-truncation.mjs 的注释给了理由:截断后的 pubkey 前缀是可以被”靓号爆破”(vanity grinding)伪造的,所以所有展示用截断必须走统一的 truncatePubkey<PubKey> 组件。会踩是因为这行代码看起来人畜无害。避法:显示身份就用规范助手;确实是非展示用途(比如取数组前 N 个 pubkey、或者拿来派生头像颜色),才走脚本里的 overrides 白名单,并且要写清理由——现有白名单每条都带注释。

文本字号不许写 px。 scripts/check-px-text.mjs 强制全应用用 rem token(text-base/text-sm/text-xs,或 text-2xs/text-3xs 这类小字号 token),扫描整个 src.ts/.tsx/.css。注释里说了来由:一次 rem→px 的缩放回归发生在消息时间线渲染路径上,之后任意字号字面量在全应用都漂移过。会踩是因为 Tailwind 的任意值语法写起来太顺手。避法:可读文本一律用 token,只有装饰性字形(比如头像里的 emoji 大字)才进 overrides。

跑 e2e 前要用专用构建。 test:e2epnpm build:e2e && playwright test,而 build:e2e 用的是 vite build --mode e2e。这个模式下有一层 mock 桥(src/testing/e2eBridge.ts)。会踩是因为直接对普通构建产物跑 Playwright,行为对不上。另外 playwright.config.tsworkers: 1,别指望并行提速;baseURLhttp://127.0.0.1:4173,端口被占会直接连不上。

中继地址是有优先级的。 src-tauri/src/relay.rsrelay_ws_url() 先读环境变量 BUZZ_RELAY_URL,再看编译期注入的 BUZZ_DESKTOP_BUILD_RELAY_URL,最后才落到默认的 ws://localhost:3000。会踩是因为你改了打包配置却发现客户端还连着旧地址——很可能是环境变量在前面截胡了。

收束:先读哪几个文件。

如果你要真正上手改这个桌面端,按这个顺序读收敛最快:

  1. ARCHITECTURE.md 的第 2、4、5 节——搞清事件、kind、订阅扇出这三件事,之后所有客户端行为都能对上号。
  2. desktop/vite.config.tsdesktop/src/app/routes.ts——构建期做了什么、应用有哪些页面,十分钟能读完。
  3. desktop/package.json 的 scripts 段——把 dev / tauri dev / check / test / test:e2e 的差别记住,能省掉大半天的无效调试。
  4. 你要动的那个 feature 目录,先找有没有 AGENTS.md——src/features/agents 那份就明确禁掉了前端自建能力对照表。
  5. desktop/src-tauri/src/secret_store.rsidentity_storage.rs——只要你的改动沾到身份或密钥,这两个文件的边界必须先看懂。

一条自检:你打算加的这个功能,它的状态该放在事件里(走一个新 kind,中继存、别的客户端也能看见),还是只放在本机(纯客户端状态,换台机器就没了)?这个问题答错,后面所有同步问题都会跟着来。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 平台 buzz:自建中继要想清的几件事Block 开源 buzz:多 Agent 通信平台的移动端为何重写协议

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