Block 开源多 Agent 平台 buzz:语音在中继与本地如何分工

2026-08-05

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

在 Block 开源的多 Agent 通信平台 buzz 里,实时语音被切成了两段互不越界的东西:中继只搬字节、不解码音频;把声音变成语义(识别成文字、把文字念出来)的活全压在客户端的本地模型上。 想明白这条分界线在哪,你才会知道 Agent 到底是”听见”了你说话,还是只是读到了你机器上跑出来的一段转写文本——这两件事在这套架构里是完全不同的两个平面。

先把底座交代清楚,因为下面每一节都踩在上面。仓库 README 这样定位自己:一个人和 Agent 一起干活的工作区,跑在你自己拥有的中继上;它还直说这东西本身就是一台 Nostr 中继,消息、反应、工作流步骤、评审通过、git 事件全是同一份日志里的签名事件,作者是人还是进程都用同一套身份模型。这是项目的自我陈述,本文只把它当成理解代码的入口,不当成结论。buzz 建在 Nostr 之上。Nostr 里的事件是一条带签名的 JSON 记录,签名用你自己的私钥生成,别人拿你的公钥就能验;中继是一台负责收下事件、存起来、再转发给订阅者的服务器,它不发身份、不管账号,只管事件真假。密钥自持的意思是私钥只在你手上,没有找回流程——丢了私钥就等于丢了这个身份,之前的历史消息也没法再用这个身份接着说话。这套底座和实时语音的关系在于:文字消息走的是事件网络,而语音的字节流走的是另一条完全独立的 WebSocket 通道。

站内已有几篇相邻的拆解,分工是这样的:hermes 的语音链路 讲的是另一个项目里语音怎么接进 Agent 工具调用,opencode 的事件总线 讲的是单机 Agent 内部的事件流转,多 Agent 并发编排 讲的是任务层的并行调度;本篇只管一件事——buzz 这个”多人 + 多 Agent 同处一室”的场景下,实时音频这条线在中继侧和客户端侧各自摆在哪、各自不做什么。

一、这条线要解决的问题:一屋子人和 Agent 同时开口

buzz 的语音会话在代码里叫 huddle。入口是一条 WebSocket 路由,crates/buzz-relay/src/audio/handler.rs 顶部把流程写得很直白:NIP-42 认证 → 加入房间 → 转发帧 → 清理。NIP-42 是 Nostr 的中继认证规范:中继先发一串随机挑战,客户端用自己的私钥签一个事件回来,中继验签就知道你是谁——同一把私钥,既是你在文字频道里的身份,也是你在语音房间里的身份。

会话本身的生死则落回事件网络。crates/buzz-core/src/kind.rs 里定义了这几个 kind:KIND_HUDDLE_STARTED 48100、KIND_HUDDLE_PARTICIPANT_JOINED 48101、KIND_HUDDLE_PARTICIPANT_LEFT 48102、KIND_HUDDLE_ENDED 48103,还有 48106 的频道规则文档。也就是说:谁开了会、谁进来了、谁走了、会什么时候结束,是签名事件;会里说了什么声音,不是事件。 这条分界线是后面所有取舍的源头。

二、中继侧:房间只做扇出,不做混音

crates/buzz-relay/src/audio/room.rs 的开头把整个中继侧的心智模型画完了:

Client A → WS binary frame → Room::broadcast_frame → Client B, C, ...
                                                       (1-byte peer_index prefix)

一个房间就是一张 peer 表。每个连进来的人是一个 AudioPeer,带着 Nostr 公钥、一条音频通道 audio_tx、一条控制通道 ctrl_tx,以及一个入场时分配的 peer_index(0 到 254 的一个字节)。broadcast_frame 做的事简单到有点朴素:把发送者的 peer_index 拍在帧的最前面一个字节,然后复制给房间里除他自己以外的每一个人。

有几处设计值得你记住,因为它们直接决定了你能拿这套东西干什么:

中继从不解码音频。 源码注释原话是帧就是不透明的 Opus 字节。中继唯一会看的是客户端加的 8 字节 v2 头,而且只用来做遥测。crates/buzz-relay/src/audio/wire.rs 把这个头的布局写死了:

 byte 0..=1 : seq         u16
 byte 2..=5 : ts_48k      u32
 byte 6     : level_dbov  i8   range [-127, 0]
 byte 7     : flags       u8   bit 0 = DTX; other bits reserved

这里有个很值得学的判断:level_dbov(音量)是客户端自己写的,所以该文件的威胁模型注释明确规定,任何信任决策——准入、封禁、踢人——都不许用它;解析时把越界值夹到 -127,但绝不因为这个字段有问题而丢掉音频帧。坏的元数据不能变成听得见的丢音,这个分寸拿得很准。

实时音频宁可丢,不排队。 每个 peer 的音频通道容量是 8 帧,按 20 毫秒一帧算就是 160 毫秒;发送一律用 try_send,满了直接扔。控制消息则另开一条 32 槽的通道,因为 joined/left 是带状态的——客户端靠它维护 peer_index 到公钥的映射,丢一条就错位,所以代码在控制通道满时会打 warn 日志把问题暴露出来。

房间人数有硬边界。 MAX_PEERS_PER_ROOM 是 25,注释算得很清楚:N 个人每 20 毫秒产生 N×(N−1) 份帧拷贝,25 个人就是 600 份/tick。255 的索引空间是硬上限,25 是软上限。

准入是一把锁下的一件事。 AdmissionGuard 把”房间是否已结束""索引分配""协议版本钉定”放在同一把互斥锁里。房间的音频协议版本由第一个进来的人钉死,后面的人版本对不上就被拒。错误优先级是刻意设计的:Ended > Full > VersionMismatch——满员优先于版本不匹配,理由是一个反正进不来的客户端不该顺带学到这个房间钉的是哪个版本,这是个(轻微的)信息泄露。这种”拒绝理由本身也是信息”的思路,做任何多租户服务都值得抄。

三、跨机器:一个房间只有一个 owner

单进程好办,多副本就麻烦了:两个人落在不同 pod 上就彼此听不见。crates/buzz-relay/src/audio/mesh.rsjoin.rs 处理的就是这件事,路线是owner 权威制

一个 huddle 的 session_id 就是它的 channel_id,谁持有 Redis 的带围栏 CAS 租约谁就是 owner。这里两个词先解释一下:CAS(compare-and-set)是”只有当这个键还是我以为的那个值时才写进去”,多台 pod 同时抢同一个键,只有一台能成功,这就把”谁当 owner”变成了一次原子仲裁;围栏(fencing)是给每一任 owner 编一个只增不减的号(代码里叫 generation),后面所有跨机数据都带上这个号,收到号更小的就知道它来自已经被顶掉的上一任,直接扔。没有围栏的话,一台网络抖了一下、自以为还是 owner 的旧 pod 会继续往房间里灌数据。resolve_join 的逻辑是:先查有没有活租约;没有就去抢,抢到了自己当 owner,抢输了就把赢家当 owner;已经有主的,如果是自己就复用,是别人就走远端路径。远端路径下,客户端仍然加入本地的一个房间,但这台 pod 同时会向 owner 开一条 Profile::HuddleControl 可靠流,把这个客户端注册成 owner 房间里的一个远端 peer。

这里有两个我觉得写得很漂亮的点:

第一,Room 完全不知道 mesh 的存在。一个远端参会者在 owner 眼里就是一个普通 AudioPeer,只是它的 audio_txspawn_remote_peer_sink 这个任务抽干,每帧包成 MeshDatagram 发给对应的 pod。扇出逻辑一行没改,只换了 peer 的出口。

第二,围栏(fencing)不给音频开后门。每个数据报都带 FencedHeaderGenerationFloor 记住每个 session 见过的最高 generation,比它低的一律丢弃。源码注释里那句判断很值得引用一下意思:对有损音频来说,丢掉一个死掉世代的迟到数据报,和丢包在体感上无法区分,所以这么做恰好是对的。

peer_index 的分配权也只在 owner 手上,所以跨 pod 索引永不冲突;deliver_prefixed 在投递时跳过作者自己的索引,保证一个人的声音绕了 owner 一圈回到本 pod 时不会让他听见自己。租约每 10 秒续一次(对应目录 30 秒 TTL),续丢了就触发 lost 信号,owner 主动发 Goodbye(StaleGeneration),让对端重新去 Redis 认新主。还有个细节很实在:resolve_join_owner_ready 会在”Redis 说我是 owner、但本机还没装上续约器”的窗口里以 20 毫秒一次、最多 25 次的节奏重试,宁可失败关闭也不肯放一个没有续约器的 owner peer 进来。

这里要分清两个开关,crates/buzz-relay/src/config.rs 里它们默认值相反,混起来看就会得出矛盾的结论。mesh 本身默认是关的:只有环境变量 BUZZ_MESH 显式设成 on,中继之间才组网,默认既不监听也不往 Redis 注册表写东西——注释把这个选择讲得很清楚,为的是让”换个镜像、环境变量一个不动”的升级严格无回归。huddle_audio_available 默认 true,为的是单副本部署维持原样;字段注释直接写了:运维多副本的人必须把 BUZZ_HUDDLE_AUDIO_AVAILABLE 设成 false。设成 false 之后,加入请求会直接收到 huddle_audio_unavailable 错误,客户端至少知道”这里现在没有语音”,而不是连上一个注定听不见人的房间。

四、这条线上都有谁

组成部分它负责什么仓库位置你什么时候会碰到它
WS 接入与认证NIP-42 挑战应答、帧大小上限、心跳、协议版本协商crates/buzz-relay/src/audio/handler.rs客户端连不上、报 upgrade_required
帧头解析8 字节 v2 头、DTX 标志、音量遥测夹取crates/buzz-relay/src/audio/wire.rs自己写客户端、对齐字节序时
房间与扇出peer 表、索引分配、准入仲裁、名册增量crates/buzz-relay/src/audio/room.rs调人数上限、查 room_full
跨 pod 归属Redis 租约、HuddleControl 控制流、名册同步crates/buzz-relay/src/audio/join.rs多副本部署、排查”听不见对方”时
跨 pod 媒体数据报围栏、远端 peer 出口crates/buzz-relay/src/audio/mesh.rs排查跨机丢音、世代切换时
本地语音合成Pocket TTS 引擎装载、文本切块、波形合成crates/buzz-voice/src/pocket.rs换音色、模型目录不全时
模型清单文件名、sha256、体积、量化标记crates/buzz-voice/src/pocket_models.rs校验下载完整性时
本地语音识别重采样、VAD、转写、发文本desktop/src-tauri/src/huddle/stt.rsAgent 收不到你说的话时
抖动与播放每 peer 一个 NetEq、10 毫秒播放节拍desktop/src-tauri/src/huddle/jitter.rsplayout.rs多人同时说话卡顿时

五、客户端侧:两个本地模型,一进一出

中继那边一个语义符号都不认识,所以”听懂”和”开口”这两件事只能在你自己的机器上做。桌面端 desktop/src-tauri/src/huddle/ 下这套东西是完整的两条流水线。

进的方向是语音识别。 stt.rs 的注释把管道画得很清楚:AudioWorklet 采到 48 kHz 的 f32 PCM,经 Tauri 命令推进 SttPipeline,进有界队列,交给一个专门的 std::thread(因为推理是 CPU 密集且不适合跨 await 点搬运);线程里用 rubato 把 48 kHz 降到 16 kHz 单声道,用 earshot 做 VAD 累积语音帧,静音时用 sherpa-onnx 跑 Parakeet TDT-CTC 110M 转写。队列深度 50 槽(100 毫秒一批,约 5 秒 / 1 MB 的最大积压),语音缓冲上限是 16 kHz 下的 30 秒。

转写结果才是 Agent 真正读到的东西。 pipeline.rs 里的 spawn_transcription_task 把每段文本签成 kind:9 事件发给中继,p 标签指向当前在场的 Agent 公钥,而且是发帖那一刻现读的,不是启动时的快照。发之前还过一道 sign_and_guard_stt_body,里面调用出口守卫检查字节里不含密钥备份材料。所以准确说:Agent 没有听见你的声音,它读到的是你本机模型吐出来的一行字,并且这行字是用你自己的私钥签名发出去的。

出的方向是语音合成。 crates/buzz-voice 这个 crate 里装的是 Pocket TTS:pinned 的是 english_2026-04 这个语言包,来源仓库和 revision 都硬编码在 pocket_models.rs 里,8 个构件每个都带 sha256 和字节数,其中 flow_lm_mainflow_lm_flowmimi_decoder 三件走 INT8 量化,而 mimi_encoder.onnxtext_conditioner.onnx 保留全精度。输出是 24 kHz 单声道 PCM,单次输入被该语言包限死在 50 个 token 以内,所以 split_text_into_chunks 会先切块再逐块合成。归属信息该文件写得很规矩:Pocket TTS 与 Mimi 来自 Kyutai(CC-BY-4.0),ONNX 导出来自 KevinAHM/pocket-tts-onnx(CC-BY-4.0)。

放音这一段也全在本地。 jitter.rs 给每个远端 peer 配一个 NetEq 抖动缓冲,用 peer_index 当合成 SSRC,用 v2 头里的 seq 当序号;playout.rs 以 10 毫秒为节拍取音,并且给每个 peer 单独一个 rodio::Player——注释里记了这个改动的来由:以前所有人共用一个 Player,而 Player 是个 FIFO 队列,三人以上同时说话就会串成一个每 20 毫秒换一次嗓子的声音,队列还无限涨。

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

它不做选择性转发,也不做服务端混音。 房间是全量扇出,N 个人 N×(N−1) 份拷贝,25 人是写在常量里的软上限。你要开百人全员发言的大会,这套结构本身就不是为那个场景摆的。

它不做拥塞控制和丢包恢复。 通道满就 try_send 丢帧,跨 pod 链路发失败就打个 debug 日志继续跑。所有的抗抖、拉伸、加速决策都推给了客户端的 NetEq。中继这一层不会为了你的弱网做任何补偿。

中继不解码音频,但这不等于端到端加密。 源码里说的是 peer_index 是中继加的路由元数据、从不触碰密文,媒体载荷对路由层不透明。但”中继不解码”和”中继解不开”是两件事——我在这几个音频文件里没有读到 huddle 音频的端到端加密实现,所以你自建中继时该按”这台机器在转发可还原的语音”来评估暴露面,而不是按”零知识管道”。

语音本身不进事件网络,也不落事件存储。 好处是声音不会变成永久档案;代价是没有服务端录音、没有回放,Agent 也拿不到原始音频——它能拿到的只有转写文本这一路。转写错了,Agent 的输入就是错的,而且中继侧没有任何环节能发现这件事。

本地模型有它的适用面。 pinned 的 TTS 语言包是英文,识别模型也是英文的 Parakeet;模型要下载到本地、占磁盘、吃 CPU 线程(TTS 引擎按单线程装载)。低配机器上,识别延迟和合成延迟都是实打实的本地成本。

跨 pod 依赖外部件。 归属仲裁靠 Redis 的带围栏 CAS 租约,媒体靠 mesh 传输,两者都要你自己部署和守住。Redis 不可达时,注册路径上的非围栏类错误会直接把控制流拆掉。

风险要如实说: 自建中继意味着 WebSocket 端口、mesh 的传输端口和 Redis 都在你的运维责任里;语音字节在中继内存里流过,房间名册、公钥、加入/离开的时间线都在这台机器上;转写文本以 kind:9 事件形式发进事件网络,会像普通消息一样被存储和转发给订阅者,其中包含你在会里说的每一句被识别出来的话。而 Agent 一旦被 p 标签点到,就有权在这个频道里发消息——它发出去的内容同样落在事件流里。

七、上手与避坑清单

协议版本是房间级钉定的,不是连接级。 中继当前最高版本是 2,客户端可协商 1 到 2;房间被第一个进来的人钉死。会踩是因为你灰度发版时新旧客户端混着进同一个房间,第二个人直接收 upgrade_required 而且一脸茫然。避法是把灰度粒度对齐到房间——同一批人用同一版客户端,或者干脆等房间空了被回收(房间清空后版本钉定会随房间对象一起消失,下一代人可以重新协商)。

满员会伪装成版本问题,反过来不会。 准入错误优先级是 Ended > Full > VersionMismatch,所以一个既超员又版本不对的人拿到的是 room_full。会踩是因为你按错误码去查版本,怎么查都对得上。避法是遇到 room_full 先数人头,别急着怀疑协议。

二进制帧上限 4 KB,文本帧上限 8 KB。 会踩是因为你调大了编码器码率或帧长,单包超了上限,连接层直接把消息拒掉。避法是改编码参数前先回 handler.rs 看这两个常量。

多副本部署不开 mesh,语音会”安静地半瘫”。 huddle_audio_available 默认为 true,是为了让单副本部署保持原状;你扩到多副本却没打开 mesh,落在不同 pod 的人就是互相听不见。避法是横向扩容时要么显式把 BUZZ_HUDDLE_AUDIO_AVAILABLE 设成 false 让客户端拿到明确的 huddle_audio_unavailable,要么把 mesh 这条线真正接通——两者都比”看起来连上了但没声音”强。

同一个频道 UUID 撞到两个社区,跨机音频会被主动丢弃。 当前的媒体数据报信封里不带社区标识,所以 get_unambiguous_by_channel 在发现两个社区共用同一个 channel UUID 时选择失败关闭,宁可不投递也不把 A 社区的声音送进 B 社区。会踩是因为你在多租户环境里复用了 UUID 生成逻辑。避法是保证频道 UUID 全局唯一,别指望”社区内唯一”就够。

不开转写,Agent 就是聋的。 语音链路和 Agent 链路的唯一接口是那条 kind:9 事件。会踩是因为你以为进了同一个房间 Agent 就在听。避法是确认转写管道确实起来了——代码里有”有 Agent 在场时自动开启转写”的逻辑,但它有前提条件(模型就绪、用户没有显式关过),别当成必然。

合成播放期间识别是被压住的。 stt.rs 的注释解释得很实在:开麦 VAD 没有声学回声参考,分不清旁边的人和本机自己的 TTS 播放,所以 TTS 活跃时会丢弃累积的语音,停止后还有一段冷却。会踩是因为你想”打断 Agent 说话”,结果发现打断的话没被识别。避法是用显式的打断命令,别指望靠喊。

模型目录缺文件是硬失败,不是降级。 装载前会逐个检查清单里的 8 个构件,缺一个就报”incomplete bundle”。会踩是因为下载被中断、留了个半截目录。避法是拿 pocket_models.rs 里的 sha256 和字节数当校验依据重下,别只看文件在不在。

控制通道打满会导致名册错位。 32 槽的通道理论上不该满,满了会打一条 warn,说明客户端的索引到公钥映射可能已经失配。会踩是因为你在客户端侧阻塞了控制消息的消费。避法是把控制通道的消费和音频渲染彻底解耦,并且把这条 warn 接进告警。

八、收个尾

这条线的形状其实可以用一句话记住:中继管”谁在房间里、字节往哪送”,客户端管”这些字节是什么意思”。 中间那道墙是刻意砌的——正因为中继不认识语义,它才能把音频当成不透明字节高速扇出;也正因为语义只在端上产生,Agent 想参与就必须经过”转写成事件”这一道显式的、带签名的转换。

如果你要接着往下读,我的顺序建议是:先把 crates/buzz-relay/src/audio/room.rs 从头到尾读一遍(含底部测试,那几个测试把并发准入的意图钉得比注释还清楚),再读 wire.rs 建立字节层的直觉,然后跳到 desktop/src-tauri/src/huddle/stt.rs 看语义是在哪一步被造出来的;跨机那部分(join.rsmesh.rs)留到你真的要多副本部署时再啃,它的复杂度几乎全部来自”一个房间只能有一个 owner”这一条约束。

动手前给自己过一遍这四问:这个房间预期几个人(对得上 25 吗)?客户端版本是不是同一批(会不会撞钉定)?是单副本还是多副本(mesh 通不通)?Agent 需不需要听懂内容(转写有没有开、转写文本进事件流你是否接受)?这四个答案定下来,剩下的多半只是参数问题。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 通信平台 buzz 的推送网关:授权、令牌与设备证明拆解 Block 开源 buzz 多 Agent 平台的 MCP 服务器

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