Block 开源多 Agent 通信平台 buzz 的可观测最小实现
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
buzz 把「Agent 可观测」这件事拆成了互不重叠的两半:实时看见用一条不落盘的加密帧,事后算账用一条落盘但只装数字的加密事件。这个拆法值得抄的地方不是代码,而是它在两半之间划的那条线——凡是可能泄露对话内容的东西,一律不进入可持久化的那一半。
先说清楚这是哪个 buzz:Block 开源的多 Agent 通信平台,仓库在 https://github.com/block/buzz,许可证 Apache-2.0(Copyright 2026 Block, Inc.)。仓库 README 这样定位自己——一个人和 Agent 一起建设的工作区,跑在你自己拥有的中继上。它建在 Nostr 协议之上——Nostr 里的「事件」是一条带发送方公钥签名的 JSON 记录,「中继」(relay)是转发这些事件的服务器,客户端拿着自己的私钥签名和解密,服务器只负责存转。这条底座决定了 buzz 的可观测长什么样:中继看得见谁在什么时候发了帧,但看不见帧里写了什么。
站内讲 Agent 可观测的另外三篇分工不同——Agent 可观测日志该记什么 讲的是自建日志的字段设计,Hermes 的监控与可观测 讲单机 Agent 网关怎么把指标接出来,Claude Code 状态栏与用量 讲终端工具里怎么看自己花了多少;这篇只盯 buzz 一个仓库,看它在协议层面把可观测削到多小还能用。
一、两半可观测,先分清哪一半解决你的问题
buzz 用两个事件 kind 承载两件事,数值写死在 crates/buzz-core/src/kind.rs:
KIND_AGENT_OBSERVER_FRAME = 24200,Nostr 里 20000–29999 属于「临时事件」(ephemeral)区间,规范要求中继不得持久化。它承载 Agent 内部活动的实时流。KIND_AGENT_TURN_METRIC = 44200,普通事件,落盘、只追加、不被替换。它承载每轮的 token 计数与费用估算。
同一个文件里有四行编译期断言把 44200 钉死在「普通事件」这一档:!is_ephemeral、!is_replaceable、!is_parameterized_replaceable,外加一条不超过 u16::MAX。改错 kind 号会在编译阶段炸掉,而不是上线后才发现指标被中继当临时事件丢了。
两者的规范文本分别是 docs/nips/NIP-AO.md(Agent Observability)和 docs/nips/NIP-AM.md(Agent Turn Metrics)。NIP 是 Nostr 生态里描述某类事件该长什么样、中继该怎么对待它的提案文档,buzz 沿用了这套写法,给自己新增的事件类型各写一份。仓库 docs/nips/ 下一共 15 份 NIP 规范 md 加 2 份 fixtures json,另有 crates/buzz-core/src/pairing/NIP-AB.md 是第 16 份——这些规范文档不是注释,源码里的常量和它们是逐条对上的。
NIP-AM 自己写了为什么要分成两份:NIP-AO 是故意做成临时的,中继不得持久化,所以它回答不了「我的 Agent 上周用了多少 token」这个问题;而把完整会话遥测做成可持久化的,泄露面又太大。于是 44200 只存指标——计数、估算成本、几个关联 ID,全部加密给拥有者。
二、核心侧:观察者帧只是一层加密信封
crates/buzz-core/src/observer.rs 全文不到两百行,末尾还挂着一组单元测试。它没有定义任何「遥测语义」,只做一件事:把任意可序列化对象加密成事件正文,再反着解回来。
// crates/buzz-core/src/observer.rs
pub const OBSERVER_AGENT_TAG: &str = "agent";
pub const OBSERVER_FRAME_TAG: &str = "frame";
pub const OBSERVER_FRAME_TELEMETRY: &str = "telemetry";
pub const OBSERVER_FRAME_CONTROL: &str = "control";
pub const NIP44_MIN_CONTENT_LEN: usize = 132;
pub const NIP44_MAX_CONTENT_LEN: usize = 87_472;
pub const OBSERVER_MAX_PLAINTEXT_LEN: usize = 65_535;
这几个常量就是整块设计的支点。agent 和 frame 是明文标签:中继靠它们路由,不需要读懂里面的 ACP 内容。frame 只有 telemetry(Agent 发给拥有者)和 control(拥有者发给 Agent)两个取值,方向由这个标签在明文里点明。
加密用的是 NIP-44 v2——NIP-AO 文档里写清了它是「secp256k1 ECDH 共享密钥之上的 XChaCha20-Poly1305」,通俗说就是双方用各自的密钥对算出一个共享密钥再做对称加密。遥测方向用 (agent_privkey, owner_pubkey),控制方向反过来。这意味着密钥是自持的:私钥丢了,身份就丢了,历史密文也没人能替你解开;私钥泄漏了,攻击者能解开他抓到的所有历史密文——NIP-AO 和 NIP-AM 的安全章节都明说 NIP-44 不提供前向保密。
encrypt_observer_payload 和 decrypt_observer_payload 这对函数里有三处细节值得抄:
一是明文用完立刻 zeroize(),包括超长时提前返回的那条错误路径。二是解密前先跑 content_looks_like_nip44,用 132 到 87472 字节这个长度区间挡掉明显不是密文的正文——测试 observer_payload_rejects_short_ciphertext 就是拿字符串 not encrypted 直接构造事件,断言它被 InvalidCiphertextLength 拒掉。三是明文上限 65535 字节,加密前和解密后各卡一次。
三、ACP 侧:进程内总线先攒,再决定发不发
crates/buzz-acp/src/observer.rs 的文件头一句话点明了取向:这是刻意做成进程本地的基础设施,目的是让 harness 收集原始 ACP JSON-RPC 活动并发布加密帧,而不用开一个本地 HTTP 端口。少一个监听端口,就少一个暴露面。
ObserverHandle 内部是一个 tokio 广播通道加一个 Mutex<VecDeque> 重放缓冲,两者共用容量常量 OBSERVER_BUFFER_CAP = 1_000;seq 是从 1 开始的 AtomicU64,每次 emit 自增。emit 里的两步顺序是:先写重放缓冲(满了从队头丢),再往广播通道发。缓冲锁中毒(poisoned)时不 panic,只记一条 warn 然后跳过——遥测挂了不该拖垮 Agent 本身。
一条 ObserverEvent 携带 seq / timestamp / kind / agent_index / channel_id / session_id / turn_id / started_at / payload。其中 kind 是字符串,源码注释举的例子是 acp_read 和 turn_started;NIP-AO 的帧类型表里列了四个:acp_read(模型到 harness 的入站帧)、acp_write(出站帧)、turn_started、session_resolved。crates/buzz-acp/src/pool.rs 里另有一个 TurnCompletionGuard,在 Drop 时发 turn_completed,覆盖成功、报错、超时、取消、panic 全部退出路径——用 Rust 的析构语义保证「这一轮结束了」这条信号不会漏发,比在每个 return 前手写一遍靠谱。
关键在于 acp_read / acp_write 发的是什么。crates/buzz-acp/src/acp.rs 里是 self.observe("acp_write", value.clone()),整条 JSON-RPC 消息原样克隆进 payload。NIP-AO 文档给的示例 payload 就是一次 tools/call,参数里带着 shell 命令。也就是说,打开这个通道等于把 Agent 的工具调用参数完整送到拥有者面前——加密的,但内容确实是全的。
从进程内总线到中继这一段在 crates/buzz-acp/src/lib.rs,有三处工程处理值得单独说:
订阅先于快照。 代码先 observer.subscribe() 再 observer.snapshot(),两次调用之间产生的事件会同时落在快照和活跃接收器里,靠快照的最高 seq 做去重,保证不重不漏。
自我限速比中继要求严得多。 OBSERVER_PUBLISH_INTERVAL = 167ms、OBSERVER_PUBLISH_LIMIT_PER_MINUTE = 90,而 NIP-AO 给中继的建议限速是每个 Agent 公钥 100 事件每秒。发布端主动把自己压到远低于中继阈值,且第一帧也要等一个间隔,没有初始突发。
超长帧裁剪而不是整帧丢弃。 fit_observer_event_to_budget 每轮挑出「裁剪后能严格变短」的最长字符串叶子,把中间换成 …[elided N bytes]…,头尾各保留 OBSERVER_LEAF_RETAIN_BYTES = 3_000 字节并对齐 UTF-8 边界;实在压不下去就把整个 payload 换成一个带 originalBytes 的存根。注释里把终止性论证写清楚了:已经到保留下限的叶子不会被重复裁剪,所以循环有界。
最后一件事:这条链路默认关着。配置项是 --relay-observer / 环境变量 BUZZ_ACP_RELAY_OBSERVER,default_value_t = false。
四、用量记录:把累计计数还原成每轮增量
crates/buzz-acp/src/usage.rs 解决的是一个很具体的脏活。支持用量上报的 Agent 在每轮结束时发一条 _goose/unstable/session/update 通知,sessionUpdate 字段为 usage_update,payload 里是会话累计值:
{
"sessionId": "...",
"update": {
"sessionUpdate": "usage_update",
"accumulatedInputTokens": 10000,
"accumulatedOutputTokens": 2345,
"accumulatedCost": 0.0234
}
}
累计值要变成「这一轮花了多少」,就得做减法,而减法在三种情况下会骗人。UsageTracker 用 begin_turn / record / take 三个动作把这三种情况拆开:没有历史基线的第一轮、计数器倒退(harness 重启或溢出)、以及换了新 session_id 的会话重启——三种都把每轮字段置空并标 delta_reliable: false,绝不吐负数。
真正精细的是基线推进时机。基线和 turn_seq 只在 take()(也就是真正发布的时刻)推进,record() 不推进。这样一轮内来多少条通知都不影响结果:所有通知都相对同一个冻结基线算差,最后一条覆盖前面的,turn_seq 在一轮内保持不变。测试 last_update_wins_multiple_updates_same_turn 就是拿一轮两条通知验这件事。
还有一条跨会话的坑被专门测过。record 分三支:在飞会话匹配则更新待发记录;完全没有在飞会话则推进基线(处理 session/new 期间的建立通知);如果在飞的是另一个会话,则整条忽略。测试 cross_session_notification_does_not_corrupt_other_sessions_delta 的注释写明了旧代码的错法——会话 A 的迟到通知在 B 在飞时推进了 A 的基线,导致 A 下一轮少算。
字段可靠性也不是一刀切。输入/输出 token 或成本倒退会让整条记录 delta_reliable = false;但累计总数和缓存读取这两项是「字段局部」的——它们缺失或倒退只让自己那一项为空,不牵连输入输出。缓存字段还专门区分了 None(harness 根本没报)和 Some(0)(明确报了零次命中),源码注释里直说不能加 #[serde(default)],否则会把「不知道」塌缩成「零」,毁掉只追加归档里的溯源信息。
到了发布这一步,crates/buzz-core/src/agent_turn_metric.rs 定义 AgentTurnMetricPayload 和 TokenCounts。两条硬规矩:total_tokens 是提供方报的总数,不允许用输入加输出凑出来(提供方可能统计了简单相加漏掉的类别);cost_usd 必须有限且非负,validate() 在加密和解密两侧都跑一遍,负数和 NaN 都会被 InvalidPayload 挡回去。StopReason 手写了 Deserialize,任何不认识的取值映射成 Unknown 而不是让整条载荷解析失败——acp_stop_to_core 里 MaxTurnRequests 和 Refusal 就是这么落到 Unknown 的。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 观察者帧加解密 | 序列化、NIP-44 加密、长度校验、明文擦除 | crates/buzz-core/src/observer.rs | 自己写消费端解帧时 |
| 进程内观察者总线 | 收集本地事件、广播、1000 条重放缓冲 | crates/buzz-acp/src/observer.rs | 想在本地拿到 Agent 实时活动 |
| 中继发布器 | 订阅、去重、限速、超长裁剪、签名投递 24200 | crates/buzz-acp/src/lib.rs | 开了开关却发现帧变少或被省略 |
| 用量跟踪器 | 累计计数换算每轮增量、标可靠性 | crates/buzz-acp/src/usage.rs | 排查 token 数字对不上 |
| 轮次指标载荷 | 44200 的 JSON 结构、停止原因、数值校验 | crates/buzz-core/src/agent_turn_metric.rs | 做成本归集与账单对齐 |
| 事件 kind 与读权限门 | kind 数值、p-gated 集合、编译期断言 | crates/buzz-core/src/kind.rs | 关心中继侧谁能读到 |
| 两份规范文本 | 24200 与 44200 的契约条款 | docs/nips/NIP-AO.md、docs/nips/NIP-AM.md | 与官方语义对齐时 |
五、边界与代价:它明确不管什么
24200 没有历史。 NIP-AO 要求中继不得持久化、不得进搜索索引、不得进审计日志,并要求客户端用 since=<now> 订阅、不得请求历史帧。断线重连那段时间的活动就是断档,没有补拉。想事后复盘就得自己在本地落一份,那份的安全责任归你。
44200 不装内容。 NIP-AM 明写 44200 事件不得携带对话内容、工具调用或协议帧,只有数字和标识符。想从指标反推 Agent 干了什么,做不到,这是设计目标不是缺陷。
数字是自报的。 NIP-AM 安全章节直说指标由 Agent 进程自报,被攻陷的 Agent 可以少报或多报;要更强保证,只能拿提供方账单对账。把它当成本管理的输入信号可以,当审计凭证不行。
元数据是明文的。 p、agent、created_at 都不加密。中继运营者能知道「Agent X 在什么时刻为拥有者 Y 完成了一轮」,也就能推出活跃度曲线。token 数、成本、模型、频道 ID 才在密文里。自建中继时这条要写进威胁模型:数据落在你自己的机器上,明文部分就是活动指纹。
读权限靠中继策略,不靠密码学。 kind.rs 里 P_GATED_KINDS 同时包含 24200 和 44200,44200 还额外进了 RESULT_GATED_KINDS——NIP-AM 特意堵死了「知道事件 ID 就能读」的路径,理由是 44200 长期留存、明文信封会泄露轮次活动。但这些都是中继实现的检查,跑一个不遵守规范的中继,密文依然打不开,元数据却全都看得见。
遥测会在别处留下痕迹。 NIP-AO 的运维章节点了三个持久化向量:进程内存、崩溃转储、应用日志,并要求实现不得在 INFO 及以上级别记录解密后的载荷。前面说过 acp_read / acp_write 携带的是完整 JSON-RPC 帧,包括工具调用参数——一次不当的日志级别配置就能把这些落到磁盘上。
控制方向是尽力而为。 NIP-AO 里写明定义了的控制类型只有 cancel_turn 一个,但代码侧的分发分支比规范多认一个 switch_model——读这类项目时,规范和实现的进度差是常态,以哪一边为准取决于你写的是消费端还是中继。NIP-AO 说控制帧可能在重连或队列溢出时丢失,命令应当按幂等语义处理,Agent 不得依赖控制帧的可靠送达。代码侧 handle_relay_observer_control_event 做了纵深防御:即使中继已经校验过,本地也再验一次签名,并核对发送方就是解析出的拥有者,另有 OBSERVER_CONTROL_FRESHNESS_SECS = 300 的时效窗口挡重放。
高频场景会丢细节。 每分钟 90 帧的自我限速加上 3000 字节头尾保留的裁剪,意味着密集的长输出流式过程在拥有者那一端看到的是被压缩过的版本。要完整的原始帧,只能在 Agent 本机取。
六、上手与避坑清单
收不到任何帧,先看开关。 会踩是因为 relay_observer 默认 false,代码路径存在但不会跑。避法:显式加 --relay-observer 或设 BUZZ_ACP_RELAY_OBSERVER,同时确认 Agent 侧配置了拥有者公钥——发布路径在拿不到拥有者时直接返回,不报错。
把 turn 当权威账单。 会踩是因为它看起来就是「这轮花了多少」。但 NIP-AM 说做精确核算的消费方应当从相邻 cumulative 值自己重算差值,turn 只是便利字段。避法:入库时两者都存,对账以累计序列为准。
把 null 当零求和。 会踩是因为 JSON 里 null 和 0 长得都像「没花钱」。规范明说 null 不得被记录或当作零累加,缓存类字段更要求缺失时整个省略而不是写 null。避法:内部类型用可空整数,聚合时把 null 计入「未知」桶单独展示。
跨 sessionId 做差。 会踩是因为同一个 Agent 的累计值看起来是连续的。规范要求累计值只在单个 sessionId 内按 turnSeq 构成序列,跨会话不得相减;发布方重启丢了计数器必须换新 sessionId 而不是复用旧的重置 turnSeq。避法:以 (sessionId, turnSeq) 为主键,换会话就换一条序列。
用 created_at 排序。 会踩是因为它是事件里最顺手的时间字段。但它是秒级精度,同一秒内的多轮无法定序。避法:会话内排序一律用 turnSeq,created_at 只做粗粒度时间窗筛选。
指望帧里能看到完整长输出。 会踩是因为本地日志里是全的,到了拥有者那边被裁了。避法:看到 …[elided N bytes]… 或带 originalBytes 的存根,就知道触到了 65535 字节明文上限,别把它当成 Agent 输出被截断。
打开遥测却没评估内容敏感度。 会踩是因为「加密的」听起来就安全。但明文标签暴露活动节奏,解密后的帧包含工具调用参数,且会经过进程内存和可能的日志。避法:开之前先确认拥有者密钥的保管方式、日志级别不高于 DEBUG、以及这台机器上 Agent 有权改动哪些仓库和文件。
假设指标发布失败会让这一轮失败。 会踩是因为直觉上总希望遥测失败要可见。但 publish_agent_turn_metric 的注释写明指标发布不得让一轮失败,三秒超时,加密、签名、投递三处出错都只记 warn。避法:别拿 44200 事件的存在与否当作「这一轮跑成功了」的判据,那是 turn_completed 和 stop_reason 的活。
接下来该读哪个文件:想接一个自己的消费端,从 docs/nips/NIP-AO.md 和 docs/nips/NIP-AM.md 两份契约开始,再回 crates/buzz-core/src/observer.rs 看解帧的三道检查;想搞清楚数字为什么对不上,直接读 crates/buzz-acp/src/usage.rs 里 record 的三分支注释和它下面那组测试,那批测试的命名本身就是一份边界清单。自检可以只问三句:这个数字是 turn 还是从 cumulative 重算的、这个空值是「没报」还是「报了零」、这条链路的明文最终会停在哪台机器上。成本这一侧还可以接着看 Agent 成本失控怎么防 和 token 统计怎么做,把单轮指标接进你自己的归集口径。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 多 Agent 通信平台 buzz 的 ACP 会话池与队列 和 Block 多 Agent 平台 buzz:persona 包四道工序。