buzz 多 Agent 通信:Block 开源平台里活递给谁看目录
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
在 Block 开源的多 Agent 通信平台 buzz 里,crates/buzz-agent/src/handoff.rs 那个叫 handoff 的东西,收件人是同一个 Agent 的下一段上下文,不是另一个 Agent。 真正的「A 干不完递给 B」发生在完全不同的一层——消息层,而且它能递给谁,不由任何一个 Rust 文件里的常量决定,由频道成员名单和一组目录事件的可见性规则决定。把这两件事混成一件,是多 Agent 协作里最常见也最难查的一类事故:你以为自己配的是「任务转交策略」,实际改的是「上下文压缩阈值」。
这篇只拆 buzz 这一个仓库里的交接链路。多 Agent 框架怎么选,站内另有 多 Agent 框架选型;任务该切多细才好转交,见 Agent 任务分解粒度;把交接写成可复用的编排脚本,见 ECC Workflow 编排。本文不重复那三篇的通用结论,只回答一个具体问题:在 buzz 这套代码里,一段活从哪儿走到哪儿,谁把关。
先约定几个协议名词,后面会反复出现。buzz 建在 Nostr 之上——一个去中心化消息协议,消息以「事件」为单位,每条事件由发送者的私钥签名,靠「中继」(relay,一台存转事件的服务器)分发;事件用一个整数 kind 标明类型。私钥由持有者自己保管,没有找回通道:丢了私钥就等于丢了这个身份,别人拿到私钥就能以你的名义签任何事件。NIP 是 Nostr 的规范编号,buzz 在 docs/nips/ 下自带了 15 份 NIP 规范 md 和 2 份 fixtures json(crates/buzz-core/src/pairing/NIP-AB.md 是第 16 份),本文引用的规则都能在那里逐条对上。
一、先把三种「交接」分开
读这个仓库最省时间的办法,是先承认它里面同时存在三种语义完全不同的交接。
第一种是上下文交接:一个会话的历史撑满了模型上下文窗口,Agent 让模型给自己写一份摘要,清空历史,带着摘要继续跑。代码在 crates/buzz-agent/src/handoff.rs。收件人是它自己。
第二种是跨会话:crates/buzz-acp/src/base_prompt.md 里写得很清楚,一个 Agent 身份在每个频道各有一个独立会话,多个会话可能同时活着;它们共享核心记忆、磁盘工作区和中继,但不共享对话上下文、推理过程和进行中的任务状态。所以人在这个频道问起「你在那边做的事」,那属于另一个会话的你——这份基础提示词要求:除非人明确让你接管,否则把执行留在原会话,只用能验证的东西(核心记忆、工作区文件、中继消息)作答。
第三种才是Agent 之间递活。它不在 buzz-agent 里,它是一条消息。同一份基础提示词规定:所有回复和委派(包括分派给其它 Agent 的任务)都发到你被 @ 的那个频道,不许换频道;完成受托工作时必须 @ 提到委派者,这被直接点名为「协作停滞的头号原因」;而仅仅表示接单、收到、确认的裸消息被禁止发送,因为它没有信息量又会再触发一次通知。
三种交接的失败症状很像——活断在半路——但排查入口完全不同。
二、handoff.rs:递给未来的自己
这一层的逻辑很紧凑,值得逐段看。
触发靠一个门限函数,它就在 handoff.rs 里,本身可单元测试:
fn token_threshold(max_context_tokens: u64, max_output_tokens: u32) -> u64 {
// Integer math: handoff threshold is 90%, i.e. window * 9 / 10.
let fractional = max_context_tokens / 10 * 9;
let output_reserved = max_context_tokens.saturating_sub(u64::from(max_output_tokens));
fractional.min(output_reserved)
}
窗口的 90% 与「窗口减去最大输出」两者取小。前者是常规压力线,后者保证输入加输出不会一起撑破窗口。上下文窗口大小由环境变量 BUZZ_AGENT_MAX_CONTEXT_TOKENS 给出(crates/buzz-agent/README.md 记的默认值是 200000),它是「你告诉它的窗口」,不是它去问模型问来的。
估算方式很有意思。handoff.rs 里 CONSERVATIVE_BYTES_PER_TOKEN 取 1,注释解释得直白:一个 token 至少占一个字节,所以把每个字节当成一个 token 是 token 数的无条件上界,永远不会低估。一个 fail-early 的预检门要的正是这种偏保守——宁可早交接,也别让下一个请求超窗。
供应商回报了上一次请求的输入 token 数时,它不直接用那个数字:历史在那次测量之后还长了(新的助手文本、工具结果、下一条用户提示),所以它拿测量值加上「测量之后新增字节」的保守估算。注释把这个坑称作旧 stale-bytes bug 的 stale-usage 表亲。
真正交接时的动作顺序是:先跑摘要,再清空历史,然后才调 _PostCompact 钩子。顺序写在注释里——钩子的目的是往新鲜的上下文里注状态,不是往正要丢弃的旧上下文里注。摘要和钩子输出被合成一条合成用户消息压进历史,前缀是 [Context Handoff],钩子那段还额外带一行标注为 untrusted 的分隔头。这个设计有两个动机:保持 _PostCompact 处于不可信位置;同时避免出现孤儿工具结果消息——OpenAI 的 Chat/Responses 要求工具输出必须跟在一次助手工具调用后面,而交接重置就是要把旧的助手轮次扔掉。
几个写死的上限都在 crates/buzz-agent/src/config.rs:HANDOFF_MAX_OUTPUT_TOKENS 是 8192,摘要系统提示词里也明写要求模型 stay under 8192 tokens;HANDOFF_ORIGINAL_TASK_MAX_BYTES 是 16 KiB,原始任务描述超过就按字符边界截断加省略号;HANDOFF_MAX_TOOL_NAMES 是 20,交接提示词里最多列 20 个工具名,多的折成 (+N more)。历史片段按从新到旧塞进预算,塞不下的丢最旧的,并在提示词里留一行 (… N older items omitted) 明示丢了多少。
交接次数有上限,环境变量 BUZZ_AGENT_MAX_HANDOFFS,README 记的默认是 10。到顶之后不再交接,退回截断历史。摘要调用失败或返回空串,同样退回截断——它不会卡住,也不会假装成功。
三、递给别的 Agent:这件事在消息层
仓库里为 Agent 之间的任务往来留了一组事件类型,就在 crates/buzz-core/src/kind.rs:
// Agent job protocol (43000–43999)
// Not using NIP-90 kinds (5000–6999) — Buzz requires auth chains (depth ≤ 3, breadth ≤ 10).
pub const KIND_JOB_REQUEST: u32 = 43001;
pub const KIND_JOB_ACCEPTED: u32 = 43002;
pub const KIND_JOB_PROGRESS: u32 = 43003;
pub const KIND_JOB_RESULT: u32 = 43004;
pub const KIND_JOB_CANCEL: u32 = 43005;
pub const KIND_JOB_ERROR: u32 = 43006;
请求、接受、进度、结果、取消、失败,六个状态各占一个 kind。注意那行注释:它没有复用通用的 NIP-90 段位,理由是 buzz 要求授权链——即每一次转包都要能沿着签名往上追到最初那个授权者,而不是谁都能凭空派活——且给了深度不超过 3、宽度不超过 10 的约束。这两个数字很值得记——它对「Agent 转包给 Agent 再转包」这件事划了明确的层数上限。docs/nips/ 下目前没有一份专讲这组 job kind 的规范,所以细节请以 kind.rs 与实际调用方为准,别按 NIP-90 的常识去套。
而日常协作里更常走的路径其实更朴素:发一条频道消息,把人或 Agent @ 进来。base_prompt.md 对这条路径的约束密度相当高:@ 后面必须写完整显示名(部分名会静默失败);不许给 @ 加粗体、斜体或反引号,那会破坏通知投递;知道对方公钥时应当同时传 --mention 参数,成功返回的 JSON 里 mention_pubkeys 来自已签名事件,那就是投递凭据,不需要再发一条命令去确认。
它还规定了一条常被忽略的分寸:只有需要对方行动时才 @,谈论某人属于叙述,叙述里不加 @。理由写得很实在——每个 @ 都发一次通知,一个没人需要行动的 @ 就是一次假警报。这在 Agent 之间尤其要紧,因为通知会真的把对面那个会话叫醒。
四、决定「能递给谁」的目录,不是那个 catalog.rs
这里有个必须点破的同名陷阱。crates/buzz-agent/src/catalog.rs 确实叫 catalog,但它做的是 Databricks 模型目录发现:调 api/2.0/serving-endpoints 或 api/ai-gateway/v2/endpoints,把可用的推理端点列出来供模型选择器展示,按名字滤掉 embedding 端点,再按创建时间新的在前排序。它决定的是「这个 Agent 能用哪个模型」,跟「能把活递给谁」毫无关系。
真正管「递给谁」的目录是中继上的一组事件,定义在 crates/buzz-core/src/kind.rs,规范在 docs/nips/NIP-AP.md:
kind:30175persona,Agent 的定义蓝图(显示名、系统提示词、模型、运行时等),d标签是明文 slug,语法^[a-z0-9][a-z0-9_-]{0,63}$,长度 1–64 字节,序列化后的 body 超过 65535 字节要拒绝。kind:30176team,一组 persona 的用户可见分组。kind:30177managed agent,按 Agent 公钥寻址的实例状态,kind.rs的文档注释明确要求它绝不携带私钥、NIP-OA 认证标签、环境变量或运行时字段——这类事件在中继上是全世界可读的。(NIP-OA 是所有者背书:Agent 事件上带一个auth标签,用来证明这把 Agent 密钥是被某个所有者公钥授权过的。)kind:30178team catalog,团队的可分享投影,内容里内嵌了成员定义的脱敏投影。
第四个才是「能递给谁」的关键件。规范解释了为什么要单独开一个 kind 而不是给 30176 打个 shared 标签:团队成员活在 30175 里,而 30175 默认只有作者自己读得到,外部读者拿到一个共享团队也水合不出成员;30178 把成员投影直接嵌进去,分享才是原子的。
访问控制规则只有一句话:作者永远可读,外部读者只有在事件带着恰好两元素的 ["shared", "true"] 标签时才可读。这条门被贴在中继的每一个读出口——历史 REQ 投递、按 id 直查、实时扇出、COUNT、HTTP 桥的 /query 与 /count、全文搜索——连 COUNT 都要绕开快速 SQL 路径改走逐事件回退,就是为了不通过计数泄露「这个 persona 存在」。
代价写在同一份规范的安全考量里,一字不含糊:分享一个团队目录,就把团队自身字段和每个成员的指令变成社区可读明文,包括那些自己的 30175 还没分享、本来仍属私有的成员。规范特意补了一句:客户端必须在分享那一刻把这件事讲明白,中继无从推断。
还有一道更硬的门,在 CLI 层:base_prompt.md 说,不带 --mention 时 CLI 会拿 @名字 去比对当前频道成员,遇到解析不出、有歧义、或者被 @ 的公钥不是频道成员,它在发送之前就停下;发送动作本身永远不会自动改变成员关系。所以一句话概括这一节:你能把活递给谁 = 频道成员名单 ∩ 目录可见性允许你看见的那部分。
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 上下文交接 | 同一会话内让模型给自己写摘要并重置历史 | crates/buzz-agent/src/handoff.rs | 长任务跑到中途,回答突然「忘了」前面的决定 |
| 交接常量与开关 | 窗口、交接次数、任务描述与工具名上限 | crates/buzz-agent/src/config.rs | 调 BUZZ_AGENT_MAX_CONTEXT_TOKENS、BUZZ_AGENT_MAX_HANDOFFS |
| 交接后钩子 | 重置后往新上下文注入状态(如待办) | docs/MCP_DRIVEN_HOOKS.md 的 _PostCompact | 交接后待办列表消失 |
| 协作规约 | @ 写法、委派落在哪个频道、完成回叫 | crates/buzz-acp/src/base_prompt.md | 委派出去的活没人回音 |
| 任务事件类型 | 请求/接受/进度/结果/取消/失败六态 | crates/buzz-core/src/kind.rs | 想在中继上追一条任务的流转 |
| 目录与可见性 | 谁的定义能被外部读到、共享的代价 | docs/nips/NIP-AP.md | 团队目录一分享,系统提示词全公开 |
| 模型目录发现 | 列出可用推理端点(与递活无关) | crates/buzz-agent/src/catalog.rs | 模型选择器里少了某个端点 |
五、边界与代价:它明确不管什么
上下文交接不是记忆。 crates/buzz-agent/README.md 说得很干脆:全部在内存里、按进程、没有 SQLite、无外部持久化。摘要写完,原始历史就没了,任何没被摘要覆盖的细节不可恢复。要长期记住的东西必须落到别处——base_prompt.md 指的是核心记忆和工作区文件,核心记忆有 65535 字节硬上限,而那份提示词建议把它按 10 KB 左右的健康基线来控,把硬上限当墙而不是预算。
摘要质量是模型的自由裁量。 交接的系统提示词只要求覆盖五件事:原始任务、已完成什么、关键决策、还剩什么、一个具体的下一步。剩下的它说了不算。所以真正关键的约束——验收标准、不能碰的文件、已经排除的方案——写在会话历史里是不安全的,得写进更稳的载体。
钩子是建议,不是权威。 docs/MCP_DRIVEN_HOOKS.md 把这条列成了「Agent 主权」:钩子默认关闭,要靠 MCP_HOOK_SERVERS 显式开;超时(BUZZ_AGENT_HOOK_TIMEOUT_MS 默认 2500 毫秒)按无异议处理,只有连续第二次超时才杀掉服务器;_Stop 的异议预算默认 3 次(BUZZ_AGENT_STOP_MAX_REJECTIONS),用完之后 Agent 照停不误。这些约束的目的就是让有 bug 的或恶意的钩子困不住 Agent——反过来说,你也别指望用钩子做强制闸门。
目录不给你运行时控制权。 NIP-AP 的安全考量明写:persona 定义的是 Agent「应该是什么」,不授予对已运行 Agent 的运行时控制;改了 persona 事件,不会自动传播到正在跑的实例。
愿景文档不是功能清单。 根目录有 8 份 VISION 开头的 md,它们是项目自述的方向。举个能当场核对的例子:VISION_AGENT.md 的架构图写着 buzz-agent 「up to 8 concurrent sessions」、并发上限默认 8,而 crates/buzz-agent/README.md 的环境变量表里 BUZZ_AGENT_MAX_SESSIONS 默认是不限。当两者打架时,以代码和 README 为准。
暴露面要自己算。 Agent 在这套体系里手握一把私钥,能以自己的身份签名发消息、开 PR、建仓库(base_prompt.md 的命令表里有 buzz repos、buzz issues、buzz pr);buzz-dev-mcp 给的是 shell 和文件编辑器,VISION_AGENT.md 自述其 shell「runs at the operator’s trust level, like bash itself」。私钥自持意味着没有找回:泄露即身份被冒用,丢失即身份消失。自建中继时,事件全都落在你自己那台机器的库里——那是明文可读的社区数据,谁能连上、谁能读,得你自己划。这块的通用判断可参考 Agent 权限太大怎么办。
六、上手与避坑清单
把 handoff 当成任务转交去配。 会踩,是因为名字太像。BUZZ_AGENT_MAX_HANDOFFS 调大只会让单个会话多压缩几轮,跟「多分几个 Agent 干」没有一点关系。避法:改任何带 handoff 字样的开关之前,先确认你要治的症状是「上下文撑爆」还是「活没人接」,前者在 handoff.rs,后者在 base_prompt.md 和消息层。
指望摘要保住验收标准。 会踩,是因为前几轮它确实记得住。摘要提示词只承诺五个要点,越细的约束越容易在重置里蒸发。避法:把不可协商的约束写进工作区文件或核心记忆,让每轮都重新读到,而不是靠会话历史续命。
交接后待办清空却没人发现。 会踩,是因为 _PostCompact 默认根本没开——MCP_HOOK_SERVERS 不设置就等于无钩子。避法:依赖交接后重注待办就显式配好允许列表,同时清楚钩子输出会被标为 untrusted 后当普通文本注入,它无权命令 Agent 做任何事。
给 @ 加粗以为更醒目。 会踩,是因为在人看来这只是排版。base_prompt.md 明说加粗、斜体、反引号会破坏通知投递,部分名字也会静默失败。避法:完整显示名、纯文本,跨频道场景补 --mention 传公钥,再看返回 JSON 里的 mention_pubkeys。
为了让同事看见团队,顺手把目录分享出去。 会踩,是因为分享按钮看起来只是分享一个分组名。实际后果是团队和每个成员的指令变成社区可读明文,包括那些成员自己从没分享过的 30175。避法:分享前逐个成员过一遍系统提示词里有没有内部流程、客户名、内网地址;kind:30178 的内容规范要求脱敏(不含环境变量、不含 respond_to 允许列表公钥、不含本地 id 与文件系统路径),但脱敏的是字段,不是你写在提示词里的正文。
用 NIP-90 的经验去读 43001。 会踩,是因为两者都叫「任务」。kind.rs 的注释已经说明它刻意不用 NIP-90 段位,且有授权链的深度与宽度约束。避法:以 kind.rs 和实际调用方为准,docs/nips/ 下目前没有对应规范可依。
把另一个频道的自己当成同一个上下文。 会踩,是因为它们共享记忆和工作区,看起来像一个人。避法:跨频道协调按「给别人交接」处理——留消息、留文件、留待办,别假设对面知道你这边刚做的决定。人机之间的交接同理,可参考 Agent 把活交回给人。
收束:三个问题做自检
看完把这三个问题问一遍,基本就不会在这套体系里把交接做砸:
第一,这段活断在哪一层?上下文撑爆是 handoff.rs,同一身份不同频道是会话隔离,接手方没动是消息层的 @ 与回叫。三层的日志和排查入口互不相通。
第二,这段活的关键约束活在哪儿?活在会话历史里就等于活不过一次交接;活在工作区文件或核心记忆里才跨得过去。
第三,这段活能递给谁?答案不在配置文件里,在频道成员名单和目录事件的可见性门里,而把目录调成可分享的那一刻,你同时把所有成员的指令变成了明文。
接着往下读的顺序建议是:crates/buzz-agent/src/handoff.rs 从头到尾一遍(连注释不到 450 行,交接的全部策略都在里面,末尾还有一组把门限数学锁死的单元测试),然后 crates/buzz-acp/src/base_prompt.md,最后 docs/nips/NIP-AP.md 的访问控制与安全考量两节。这三份读完,前面所有判断你都能自己复核一遍。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 拆解 Block 开源多 Agent 通信平台 buzz 的一轮主循环 和 Block 多 Agent 通信平台 buzz 的 ACP 会话池与队列。