拆解 Block 开源多 Agent 通信平台 buzz 的一轮主循环
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
**在 Block 开源的多 Agent 通信平台 buzz 里,Agent「说话」和「干活」走的是两条完全分开的路:干活过程中吐出的助手文本、思考片段、工具状态,只会顺着本地那条 ACP 线协议流向宿主进程;要让频道里的人和其他 Agent 真正看见一句话,Agent 必须主动发起一次工具调用,把消息签名成事件推到中继上。**这个分法不是风格问题,它直接决定了一轮跑完之后有没有东西留下来——模型在上下文里洋洋洒洒写了两千字分析,只要没调那次工具,这一轮在外界看来就等于没发生。
buzz 建在 Nostr 之上。对没碰过去中心化协议的人,先把三个词按这个仓库里的定义说清楚:**事件(event)**是一条带发送者签名的 JSON 记录,是这套系统里唯一的数据单位;**中继(relay)**是一台只负责接收、校验、转发和存储事件的服务器,谁都可以自己跑一台;密钥自持指每个身份(人或 Agent)各自握着一把私钥,签名用它、身份也就是它——Agent 进程里那把私钥丢了,等于这个 Agent 的身份丢了。还有一个缩写要先摊开:NIP 是 Nostr 生态里一份编号的协议提案文档,规定某类事件用哪个 kind 编号、载荷长什么样、中继该怎么处理它——本文后面出现的 NIP-AM、NIP-AO 就是 buzz 自己写在仓库里的两份这样的规范;而 NIP-44 是 Nostr 通用的那份加密规范,两个身份用各自密钥协商出共享密钥后把内容加密,只有收发双方解得开,中继只看得到密文。仓库许可证是 Apache-2.0(Copyright 2026 Block, Inc.),crates/ 下有 28 个 crate,docs/nips/ 放着 15 份 NIP 规范 md 和 2 份 fixtures json(另有 crates/buzz-core/src/pairing/NIP-AB.md 算第 16 份)——本篇要走的这条链路,主要落在其中三个 crate 里。
站内已经有几篇讲主循环的文章,分工和这篇不一样:Vibe Trading 的 Agent 主循环 拆的是一个单机交易 Agent 从触发到下单的一轮,Vibe Trading 是什么 是那个项目的总览,Agent 用状态机还是放开自由发挥 讨论的是循环形态本身怎么选型;本篇只盯 buzz 这一个仓库,看它把「一轮」这件事在代码里切成了哪几段。
一、起点不是消息队列,是一段被拼好的 prompt
Agent 进程并不直接盯着中继上的消息流。中间隔着一层 harness(宿主进程),代码在 crates/buzz-acp/。它订阅频道事件、攒批、然后把这一批拼成一段纯文本,作为一次 prompt 交给 Agent。
拼装逻辑在 crates/buzz-acp/src/queue.rs 的 format_prompt。它按固定顺序产出若干段落:[Base]、[System]、[Agent Memory — core]、[Context]、[Thread Context] 或 [Conversation Context]、最后是触发事件本身。协议版本是个分水岭:crates/buzz-agent/src/config.rs 里 PROTOCOL_VERSION = 2,对 protocol_version >= 2 的 Agent,基础提示词、系统提示词和核心记忆改由 session/new 的系统角色下发,不再重复塞进用户消息;老版本 Agent 没有系统角色,这些段落只能每轮跟着用户消息一起走。
[Context] 这一段是给 Agent 的定位信息:Scope 是 dm、thread 还是 channel,频道显示名和 UUID,以及这一轮回复该挂在哪里。挂点由 resolve_reply_anchor 算:面向人的轮次给出线程根或触发事件 id,好让回复保持在第一层、别越挂越深;纯 Agent 之间的协作则返回 None,允许深度嵌套保留任务结构。还有一处容易忽略的细节——一批事件里的 scope 取的是最后一个事件,注释里写明了理由:混合批次(线程回复后面又跟了一条普通消息)不该被整体标成 thread。
对你的意义很直接:Agent 看到的不是实时流,是一张快照。它读到的「上下文」全是 harness 决定给它看的那几段,要调 Agent 的行为,第一个下手的地方其实在这里,而不在模型侧。
二、主循环:一轮里套着很多轮
真正的循环在 crates/buzz-agent/src/agent.rs 的 RunCtx::run。一次 session/prompt 就是一轮(turn),而一轮内部会跑很多次模型请求(代码里叫 round)。
进门先做输入约束:prompt_to_text 把内容块拼成文本,超过 MAX_PROMPT_BYTES(config.rs 里定义为 1 MiB)直接报 InvalidParams;同时把三个 per-turn 的 token 累加器和 TurnTotalState 重置回初始态。
然后进 loop,每一圈开头依次做四件事:检查 max_rounds 有没有到顶(到了返回 MaxTurnRequests)、检查取消信号、drain_steers 把中途插进来的引导消息作为 user 轮追加进历史(不重启这一轮)、最后 maybe_handoff 判断上下文压力。handoff 的实现在 crates/buzz-agent/src/handoff.rs:超阈值就用模型把历史总结成一段 [Context Handoff] 文本、清空历史重来;没超就退化成 truncate_history 按字节预算丢最老的几段。
接着组装工具清单(mcp.tools(),当会话发现了技能时再追加内置的 load_skill),发出模型请求。这次请求用的是三路 tokio::select!:取消信号、模型响应、以及一个 30 秒一跳的 keepalive——它周期性发一条 sessionUpdate: "keepalive" 的会话通知,目的是把宿主的空闲计时器摁回去,免得模型想久了被判超时。
响应回来后按顺序做三件事:记账、播报、分流。记账是把 input_tokens、output_tokens、cached_input_tokens 累加进本轮计数,并把 provider 报的总数折进 TurnTotalState;播报是把 reasoning 发成 agent_thought_chunk、把 text 发成 agent_message_chunk;分流则看有没有 tool_calls——没有就准备收尾,有就进工具执行。
这里有个防御性判断值得记一笔:如果 provider 说 stop=tool_use 却给了零个 tool_calls,代码直接返回错误而不是硬撑。这类「协议说一套给一套」的情况,宁可炸也别猜。
三、工具执行:并发跑,顺序回填
execute_calls 的文档注释把它切成三段,读代码时按这个骨架看最省事。
第一段是预检,串行:给每个调用先发一条 pending 状态通知;load_skill 这类内置工具就地执行、不走 MCP 往返;名字没注册、或者是 hook 工具(裸名以 _ 开头,对模型不可见)的,当场合成一个失败结果,连执行都不进。在这之前还有一道截断——MAX_TOOL_CALLS_PER_TURN 是 64,超了直接砍掉尾部并打 warn。
第二段是执行。可运行的调用被 spawn 进一个 JoinSet,用 Semaphore(max_parallel_tools) 限流,默认值 8(BUZZ_AGENT_MAX_PARALLEL_TOOLS),设成 1 就退化为串行。取消时的处理很克制:关掉信号量不再发新许可,但不 abort 已在飞的任务——每个任务内部自己盯着同一个取消接收端,会先给 MCP server 发一条 notifications/cancelled 再返回。之后有一个 5 秒的排空窗口,排不完才 abort_all。
第三段是回填。所有结果按原始调用顺序推进历史,跟它们实际完成的先后无关。这一点决定了并发不会污染上下文的确定性。失败的结果还会被追加一句反思提示,原文是 [Reflect] Before retrying, identify the cause and change your approach.——目的是逼模型先诊断再重试,而不是原样再撞一次。这类「工具返回结构怎么设计」的通用取舍,可以对照 Agent 并发编排 一起看。
超时另有一层:tool_timeout 默认 660 秒,超时后会杀掉那台 MCP server 的进程组并标记为死亡,靠注册表在下次调用时懒重启。代码里还专门排除了一种误杀——如果会话本来就被取消了,超时只是和快速返回赛跑赢了,这时不能连累一台健康的 server。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 事件→prompt 组装 | 攒批、拼 [Context] 等段落、算回复挂点 | crates/buzz-acp/src/queue.rs | 想改 Agent 每轮看到什么 |
| 主循环 | round 推进、取消、handoff、工具派发 | crates/buzz-agent/src/agent.rs | 排查一轮为什么不结束 |
| ACP 线协议 | JSON-RPC 分类、会话通知、stdout 写出 | crates/buzz-agent/src/wire.rs | 自己对接 harness |
| 历史与用量类型 | 历史条目计量、TurnTotalState 三态 | crates/buzz-agent/src/types.rs | 上下文压力和 token 对不上时 |
| 上下文交接 | 超阈值时总结并重置历史 | crates/buzz-agent/src/handoff.rs | 长任务丢细节时 |
| 轮次用量事件 | kind 44200 的载荷与加解密 | crates/buzz-core/src/agent_turn_metric.rs | 做成本归集 |
| 用量差值与发布 | 累计值转每轮 delta、签名发布 | crates/buzz-acp/src/usage.rs | 账目对不上时 |
| 基础提示词 | 发言纪律、工作区约定、CLI 用法 | crates/buzz-acp/src/base_prompt.md | 调 Agent 的说话行为 |
四、说话为什么必须是一次工具调用
现在回到开头那句判断,看它在代码里长什么样。
crates/buzz-agent/src/wire.rs 里 session_update 构造的是一条 JSON-RPC 通知,writer_task 把它序列化后按行写到 stdout。agent_message_chunk 就是走这条路出去的。它到达的是宿主进程和桌面端的界面,不是频道。Nostr 那边什么都没发生:没有事件、没有签名、没有中继。
要让频道里的人看见,只有一条路——调那个 shell 工具去跑 buzz messages send,由 CLI 构造一条频道消息事件(crates/buzz-core/src/kind.rs 里 KIND_STREAM_MESSAGE_V2 = 40002)、用 Agent 自己的私钥签名、发给中继。这也是为什么默认系统提示写得这么直白:
const DEFAULT_SYSTEM_PROMPT: &str =
"You are buzz-agent. Use the provided tools to act. Tool calls are your only output.";
仓库的基础提示词 crates/buzz-acp/src/base_prompt.md 把这条纪律又展开了一遍:它要求 Agent 把有价值的产出显式发出来,同时明确说明推理和工具调用对别人是不可见的;紧接着又给了反向约束——除此之外发言是可选的,沉默通常才是对的,并列出一串禁止的空回复(确认、收到、待命之类)。这两条要一起读,才不会把它理解成「多说话就对了」。
因为这个漏发是最贵的失败模式,仓库里加了一层守卫,默认是关的(BUZZ_AGENT_REQUIRE_REPLY=1 才开)。它的判定条件在 agent.rs:先要求这个工具名在注册表里、且不是 hook 工具,再看形状:
fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool {
name.ends_with("__shell")
&& arguments
.get("command")
.and_then(|v| v.as_str())
.is_some_and(|cmd| {
cmd.contains("messages send") || cmd.contains("reactions add")
})
}
几个设计取舍全写在注释里,值得照抄进自己的项目:它认的是一次尝试而不是一次成功——发送失败本身会返回非零退出码和错误 JSON,那个反馈比提醒更响;__shell 后缀里的分隔符是承重的,光判 shell 结尾会把 powershell、noshell 一起收进来;只读结构化的 command 字段,不搜整个参数序列化结果,免得一个 description 就把守卫解除了;判定放在 64 个调用截断之后,因为被砍掉的调用根本不会执行。已知会漏和会误判的两种情况,注释也自己承认了:命令在运行时拼装($CMD)或藏在包装脚本里会漏掉,而 echo "buzz messages send" 这种只是引用的文本会被误算成发言。
提醒本身的投递方式也讲究。它不是伪装成用户消息插进去的,而是走 tool result 通道,server 字段标成 buzz-agent,和 _Stop 钩子的输出共用一条路。理由在 push_hook_outputs_as_tool_results 的注释里:把钩子输出建模成工具结果而不是用户消息,恶意钩子就没法冒充用户或系统;载荷用 JSON 而不是 XML 风格标签,因为 JSON 会转义内层文本,钩子塞一个闭合标签也逃不出去。提醒次数上限 MAX_REPLY_NAGS 是 2,并且和 _Stop 的驳回共用 stop_max_rejections(默认 3)这个总预算——它是报警器,不是闸门。
五、轮次收尾:一条加密的用量记录
一轮结束后还有个尾巴。每次模型响应带回用量,RunCtx::emit_usage_update 就发一条会话累计的用量通知,载荷由 wire.rs 里的 usage_update_payload 拼出来:
let mut update = json!({
"sessionUpdate": "usage_update",
"used": accumulated_input_tokens.saturating_add(accumulated_output_tokens),
"contextLimit": 0u64,
"accumulatedInputTokens": accumulated_input_tokens,
"accumulatedOutputTokens": accumulated_output_tokens,
"accumulatedCachedInputTokens": accumulated_cached_input_tokens,
"model": model,
});
为什么一轮里要报很多次?注释说得很清楚:一轮可能是持续几十分钟、几十次往返;如果只在轮末报一次,被取消、超时或者进程被杀的那些轮,token 已经计费了却只活在栈帧里,一条记录都留不下。分轮上报把损失收敛到「最后一次在飞的请求」。
contextLimit 硬写成 0,因为 buzz-agent 不跟踪上下文窗口——这是个诚实的空值,别把它当成「无限制」读。
宿主侧 crates/buzz-acp/src/usage.rs 拿到累计值,减去上一轮已发布时冻结的基线,算出这一轮的 delta,然后由 pool.rs 发一条 kind 44200 事件。规范在 docs/nips/NIP-AM.md:一轮一条、用 NIP-44 v2 加密给主人、tags 只有一个 p(主人)和一个 agent。这里有个隐私设计值得单独说——故意不打频道 h 标签,频道 id 藏在密文里,这样中继运营方看不到每个频道的活跃频率。它和 NIP-AO 的 kind 24200 遥测是一对:后者落在临时事件区间,规范明确要求中继不得持久化,所以答不了「我的 Agent 上周烧了多少 token」这种问题,才有了这条可存的小记录。
总数字段的处理也很克制。types.rs 里 TurnTotalState 是三态:Unseen(这一轮还没见过带用量的响应)、Exact(n)(每一次都报了真实总数,n 是它们的和)、Unknown(有任何一次没报,这一轮永久废掉)。只有 Exact 才会写出 accumulatedTotalTokens 字段。规范禁止把 input + output 加起来冒充 total——那个和只配当界面上的近似显示。想把这套账落到自己的项目里,可以对照 Token 统计怎么做。
停止原因也有损耗:ACP 侧的五个值映射到 NIP-AM 的枚举时,MaxTurnRequests 和 Refusal 都落进 Unknown。你要在下游区分这两种情况,光看 44200 事件是分不出来的。
六、边界与代价:它明确不管的那些事
它不做推送。 基础提示词直说了没有推送通知,让 Agent 用 buzz messages get --channel <UUID> --since <ts> 自己轮询。别指望有个 webhook 把消息推到你脸上。
它不跟踪上下文窗口。 handoff 的触发靠两个来源:provider 上一次请求报回来的输入 token 数,以及一个字节兜底估算——handoff.rs 里 CONSERVATIVE_BYTES_PER_TOKEN 是 1,也就是 1 字节按 1 token 算的保守折法。窗口大小是配置项 BUZZ_AGENT_MAX_CONTEXT_TOKENS,默认 200000,需要运营方自己按模型调。配错了不会有人提醒你。
它对助手文本不做任何投递保证。 这是本篇最开始那句判断的代价面:模型可以在一轮里产出大量内容,只要没落成一次发送调用,外界一个字都收不到。守卫默认关,开了也只提醒两次。
跨会话上下文不共享。 基础提示词写明每个频道是同一个 Agent 身份的一个独立会话,共享核心记忆、磁盘工作区和中继,但不共享对话上下文和进行中的推理。有人在这个频道问你在另一个频道干的活,正确做法是从能核实的地方(记忆、工作区文件、中继消息)回答,而不是假装自己就是那个会话。
历史裁剪和 handoff 都有损。 truncate_history 是整段丢最老的 user 起始块;handoff 是让模型再总结一遍,细节必然掉。max_handoffs 默认 10,超了就退回裁剪。长任务跑到后半程发现 Agent「忘了前面说过什么」,多半是这里。
Agent 手里有真东西。 它持有自己的私钥(环境变量 BUZZ_PRIVATE_KEY),有 shell 工具意味着它能读写工作区、能开 PR、能建仓库和 issue。这两件事叠在一起要想清楚:私钥泄漏等于身份被接管,别人可以用这个 Agent 的名义在频道里发签名消息;而工具权限决定了它一轮里能改动到什么。自建中继同理——事件都落在你自己那台机器的存储里,端口暴露到哪就有多大面。
七、上手与避坑清单
把助手文本当成已发送。 会踩是因为本地界面确实能看到 agent_message_chunk 滚出来,看着就像发出去了。避法:判断一轮有没有「说话」,只认有没有一次 shell 调用跑了 buzz messages send;调试期把 BUZZ_AGENT_REQUIRE_REPLY=1 打开当报警。
把 require_reply 当硬约束。 会踩是因为名字听着像强制。避法:记住上限是两次提醒、且和 _Stop 共用驳回预算,之后这一轮照样结束。真需要闸门得自己写 _Stop 钩子,还得注意钩子默认是关的,要在 MCP_HOOK_SERVERS 里显式开。
命令拼装绕过守卫。 会踩是因为你为了复用把发送命令收进了变量或包装脚本,而匹配是对 command 字段做子串判断。避法:让发送命令的字面量直接出现在 command 里。
以为工具是串行的。 会踩是因为默认 max_parallel_tools 是 8,一轮里多个调用会真并发。避法:要么调成 1,要么保证同一轮里的工具不会同时写同一个文件;结果回填顺序是确定的,但副作用的先后不是。
一张截图顶爆上下文。 这不是假想——types.rs 的注释记录了真实故障:一次图片结果按 base64 长度计费到上下文压力,约 3.1M 字节,在全新上下文上就直接触发 handoff。现在图片按 16 KiB 的视觉 token 等价折算,但 truncate_history 那边仍按真实字节算(因为它管的是请求体大小)。避法:别把大图当常规返回值。
长任务被超时连坐。 会踩是因为 tool_timeout 超时不只是这一次失败,还会杀掉那台 MCP server 的进程组,同一台 server 上的其他工具要等下次调用才懒重启。避法:长任务自己切片或转后台,别指望把超时调大解决。
@提及用简称。 基础提示词写明必须用完整显示名,简称会静默失败——不报错,就是没人收到通知。避法:知道 pubkey 时用 --mention 显式传,成功返回里的 mention_pubkeys 才是投递证据。
拿 input + output 当总数报成本。 会踩是因为界面上就是这么显示的。避法:只有 TurnTotalState::Exact 的那一轮才有真实总数;其他情况按规范就该留空,硬凑出来的数字会让后面所有归集口径都歪掉。
收束
这一轮的完整形状可以压成一句话:harness 把频道事件拼成一段 prompt 交给 Agent,Agent 在模型请求和工具执行之间来回若干圈,过程中所有可见性都发给宿主,只有那次显式的发送工具调用才会变成一条签名事件落到中继上,最后附带一条加密的用量记录。
想自己核一遍,建议按这个顺序读:先 crates/buzz-agent/src/agent.rs 的 RunCtx::run 把骨架走一遍,再 crates/buzz-agent/src/wire.rs 看那些通知到底流向哪里,然后 crates/buzz-acp/src/base_prompt.md 对照发言纪律,最后 docs/nips/NIP-AM.md 收掉记账那一段。四个文件读完,本篇讲的每一句都能在原地找到出处——这也是拆开源项目最踏实的地方:所有判断都可以当场被推翻。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 通信平台 buzz 数据层怎么改表不停机 和 buzz 多 Agent 通信:Block 开源平台里活递给谁看目录。