把 Agent 接进 buzz:Block 开源多 Agent 通信平台的三层结构
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
一个 Agent 能不能在 buzz 这张网络里说话,跟它接的是哪个模型基本无关;决定权在另外两样东西上——一把归它自己持有的私钥,和一条能让它执行 buzz 命令的工具通道。模型只决定它说得好不好。 这句话听起来像抬杠,但你把 crates/buzz-agent 这个 crate 从头读一遍就会发现,它的配置结构体里压根没有中继地址、没有私钥、没有频道,只有 provider、模型名、超时和一堆上限。身份是另一层的事。
本站另外几篇讲的是别的切面:Agent 权限台阶怎么搭 谈的是给 Agent 放权的分级顺序,模型路由策略 谈的是多模型之间怎么选怎么降级,Agent 协议生态对比 横向比的是各家协议的取向,vibe-trading 是什么 拆的则是另一个开源交易 Agent 项目。本篇只回答一件事:一个 Agent 要凑齐哪些东西,才能在 buzz 里开口。
一、先把这张网络是什么讲清楚
buzz(Block 开源的多 Agent 通信平台,仓库 https://github.com/block/buzz ,Apache-2.0,Copyright 2026 Block, Inc.)不是又一个 Agent 框架,它是一张消息网络,人和 Agent 挂在同一张网上互相喊话。仓库 README 是这样定位自己的:一个人和 Agent 一起干活的工作区,跑在一台归你自己所有的中继上。它的底座是 Nostr。
如果你没碰过去中心化协议,这里几个词一句话讲清:
- 事件(event):Nostr 里的消息单位,本质是一条带作者公钥、时间戳、类型编号和签名的 JSON 记录。仓库
crates/buzz-core/src/event.rs里的StoredEvent就是在nostr::Event外面包了一层中继侧元数据——收到时间、频道 id、是否已验签。 - 签名与 id:这两件事在代码里是分开的。同一个文件的测试把一条事件的
sig字段整个换成 128 个 0 之后,verify_id()仍然通过,verify_signature()才失败。也就是说 id 只证明内容没被改,签名才证明是谁发的。 - 中继(relay):一个 WebSocket 服务,负责接收、存储、按订阅条件分发事件。buzz 自己实现了一个,仓库根目录的
NOSTR.md里写明它原生说 NIP-29(基于中继的群组)。 - NIP:Nostr 的实现规范编号。除了公共 NIP,这个项目还写了自己的一批,放在
docs/nips/——15 份规范 md 加 2 份 fixtures json,另有一份crates/buzz-core/src/pairing/NIP-AB.md单独放在代码目录里。 - kind:事件的类型编号。
crates/buzz-core/src/kind.rs里定义了一长串,跟 Agent 直接相关的三个是KIND_AGENT_PROFILE = 10100、KIND_AGENT_OBSERVER_FRAME = 24200、KIND_AGENT_TURN_METRIC = 44200。
整个仓库 crates/ 下有 28 个 crate(自己 ls 一下就能数出来),Agent 接入这条链路上你会碰到的主要是三个:buzz-agent、buzz-acp、buzz-core。
二、agent 进程:一个跑在 stdio 上的会话服务
先看它长什么样。crates/buzz-agent/src/main.rs 全文只有六行:
fn main() {
if let Err(e) = buzz_agent::run() {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
真正的骨架在 lib.rs 的 async_main():日志写 stderr,Config::from_env() 读环境变量,Llm::new() 建 HTTP 客户端,然后一个 read_loop 按行读 stdin、一个 writer task 往 stdout 写。它不是给人用的 CLI,是一个按行收发 JSON-RPC 的常驻服务进程,父进程通过管道跟它说话。config.rs 里 PROTOCOL_VERSION = 2。
它接的方法就那么几个:session/new(参数是 cwd、mcpServers、systemPrompt)、session/prompt、session/cancel,外加一个非标准的 _goose/unstable/session/steer——把一段话塞进正在跑的那一轮,不重启这轮对话。
一轮的主循环在 agent.rs 的 RunCtx::run():检查轮数上限和取消信号 → 把队列里的 steer 消息当作 user 轮追加进历史 → 判断要不要做上下文交接 → 调 llm.complete() → 有 tool_calls 就执行、没有就结束这轮。等模型响应期间它每 30 秒发一条 keepalive 的 session update,注释写得很直白:长响应会撞上 ACP 侧的空闲超时,这条心跳是拿来重置那个计时器的。
对你意味着什么:这是个可以被任何父进程拉起来的黑盒子。你换掉外面那层,它照跑;它自己不知道网络的存在。
三、模型接入层:provider 是配出来的
config.rs 里的 Provider 枚举有五个变体:Anthropic、OpenAi、Databricks、DatabricksV2、OpenRouter。选哪个由 BUZZ_AGENT_PROVIDER 决定,而且必须显式设置——resolve_provider() 在参数为空时直接返回错误,提示语是 “config: BUZZ_AGENT_PROVIDER is required”。它不肯根据你手边有哪个 key 去猜。
模型名有两层:BUZZ_AGENT_MODEL 是通用覆盖项,优先级高于 provider 各自的 ANTHROPIC_MODEL / OPENAI_COMPAT_MODEL / DATABRICKS_MODEL / OPENROUTER_MODEL。代码注释解释了这个设计——通用项由桌面端按人格记录写入,表达明确的用户意图;provider 专属项是给 CLI 和独立部署用的默认值,可能是从 shell 里继承来的脏数据。
思考强度走 BUZZ_AGENT_THINKING_EFFORT,取值 none|minimal|low|medium|high|xhigh|max。这里有个值得抄的工程判断:校验分成了两处。纯 Anthropic provider 在启动时就拒绝 none 和 minimal(这两个不是 Anthropic 的概念);其余路线一律推迟到构造请求时再按模型能力表就近替换,理由写在 validate() 的注释里——session/set_model 能在启动之后换模型,启动时校验的结论会过期。替换发生时会打一条 warn! 日志。
另外 BUZZ_AGENT_PROMPT_CACHING 默认开,控制是否在稳定前缀(工具定义加系统提示)和滚动的对话尾部打上 Anthropic 的 cache_control 断点。注释里点了一句:Databricks 网关不会自动缓存,不打断点的话它回报的缓存命中在结构上恒为 0。
四、各组成部分与它们在仓库里的位置
| 组成部分 | 它负责什么 | 对应仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 进程入口 | 起协程运行时、把错误变成退出码 | crates/buzz-agent/src/main.rs | 几乎不用改 |
| 会话与协议层 | session/new / prompt / cancel / steer,按行 JSON-RPC | crates/buzz-agent/src/lib.rs | 自己写 harness 对接时 |
| 回合循环 | 轮数上限、取消、工具并发执行、发言提醒 | crates/buzz-agent/src/agent.rs | 排查”这一轮为什么停了” |
| 模型接入 | 各家请求体构造与响应解析、鉴权 | crates/buzz-agent/src/llm.rs | 接新模型、调思考强度 |
| 配置解析 | 所有 BUZZ_AGENT_* 环境变量与启动校验 | crates/buzz-agent/src/config.rs | 起不来的时候先看这里 |
| 工具进程管理 | 拉起 MCP 子进程、超时、重启、结果裁剪 | crates/buzz-agent/src/mcp.rs | 工具卡死、环境变量传不进去 |
| 提示与技能 | AGENTS.md 逐级拼接、技能目录扫描 | crates/buzz-agent/src/hints.rs | 想让 Agent 遵守项目约定 |
| 按需加载技能 | 内置的 load_skill 工具 | crates/buzz-agent/src/builtin.rs | 技能正文太长不想全塞进上下文 |
| 身份与网络接线 | 中继地址、私钥、owner 公钥、订阅范围 | crates/buzz-acp/src/config.rs | 让 Agent 真正上网 |
| 基础提示词 | 告诉 Agent 有哪些 buzz 命令可用 | crates/buzz-acp/src/base_prompt.md | 想知道 Agent 被教了什么 |
| 事件与类型编号 | Nostr 事件包装、kind 常量 | crates/buzz-core/src/event.rs、kind.rs | 要自己解析或产生事件 |
配置这一层除了环境变量,还有工作区里的文件。hints.rs 的做法是:从 cwd 往上找到 git 根,然后从根到 cwd 逐级读 AGENTS.md 拼起来,另外把 ~/AGENTS.md 当全局层放在最前面;总量上限 128 KiB,超了就在字符边界截断。技能目录扫三个:.agents/skills、.goose/skills、.claude/skills,扫到的只登记 frontmatter 里的 name 和 description,正文留在磁盘上,等模型调内置的 load_skill 工具时再按需读。
一个容易漏的细节:buzz 仓库根目录同时存在 AGENTS.md 和 CLAUDE.md 两份文件,但 hints.rs 这条链路只读 AGENTS.md。
五、身份层:私钥在外面,agent 进程碰不到
翻遍 buzz-agent 的 Config,没有一个字段跟中继或密钥有关。身份在 crates/buzz-acp/src/config.rs 的 CLI 参数里:
--relay-url/BUZZ_RELAY_URL,默认值ws://localhost:3000--private-key/BUZZ_PRIVATE_KEY,这一项标了hide_env_values,不进帮助输出--agent-owner/BUZZ_ACP_AGENT_OWNER,owner 的 64 位十六进制公钥,用来支撑--respond-to=owner-only这道闸
私钥就是这个 Agent 的身份本身,这是密钥自持模型的直接后果:没有服务端账号体系可以找回,丢了私钥就等于丢了这个 Agent 在网络里的存在;反过来,谁拿到这把私钥,谁就能以它的名义签出任何事件,中继只验签名不认人。
那 Agent 具体怎么”说话”?base_prompt.md 写得很明白:buzz CLI 是它的主要接口,鉴权环境变量是 BUZZ_RELAY_URL、BUZZ_PRIVATE_KEY、BUZZ_AUTH_TAG,退出码约定 0 正常、1 用户错误、2 网络、3 鉴权、4 其他,输出是结构化 JSON。命令分组覆盖 messages、channels、reactions、dms、repos、issues、pr 等。
所以一次发言在实现上是这样一条链:模型决定调一个 shell 工具 → 工具进程执行 buzz messages send ... → CLI 用私钥签出事件 → 推给中继。agent.rs 里那个”这一轮有没有尝试发言”的判断,就是照着这条链写的——工具名要以 __shell 结尾,且参数里的 command 字段命中:
cmd.contains("messages send") || cmd.contains("reactions add")
判断的是尝试而非成功,注释里给了理由:发送失败本身会返回非零退出码和错误 JSON,那是比提醒更响亮的反馈。这个护栏默认关闭,BUZZ_AGENT_REQUIRE_REPLY=1 才开,开了之后每轮最多提醒两次(MAX_REPLY_NAGS = 2)就放行,提醒文案里还明确写了”如果这一轮沉默才是对的,忽略它”。
base_prompt.md 里还有一条会影响你调试直觉的规则:同一个 Agent 身份在每个频道有各自独立的 session,它们共享核心记忆、磁盘工作区和中继,不共享对话上下文。人在 A 频道问”你在 B 频道那个活干得怎么样了”,回答的是另一个上下文里的另一份自己。
六、边界与代价
这套分层放弃了一些东西,说清楚比夸它有用。
buzz-agent 不管身份、不管订阅、不管去重。 谁的消息该进来、要不要只回 owner、重复事件怎么处理,全在 buzz-acp 那一层。你如果只读 agent 这个 crate,会得出”它跟 buzz 网络没关系”的结论,那是对的。
上下文管理是截断加交接,不是无限上下文。 truncate_history() 超预算时从最老的 User 边界开始整段丢弃;maybe_handoff() 在输入 token 逼近 BUZZ_AGENT_MAX_CONTEXT_TOKENS(默认 200000)时做交接,次数上限 BUZZ_AGENT_MAX_HANDOFFS(默认 10)。交接这条路会先让模型把已有对话总结成一段摘要再接着往下跑,那是有损压缩;交接次数用完之后就退回纯截断,被 truncate_history() 整段丢弃的部分连摘要都不留。两条路都不是”无限上下文”,只是失真的形式不同。
工具结果会被裁。 单条工具结果的文本部分默认上限 50 KiB(DEFAULT_TOOL_RESULT_TEXT_BYTES),超了从中间省略、保留头尾。注释解释得很清楚:一次 cat 大文件就能烧光上下文窗口并逼出一次有损交接。好处是稳,代价是你拿不到全文。
单轮工具调用上限 64(MAX_TOOL_CALLS_PER_TURN),超出直接截断。有个细节值得记:截断发生在发言检测之前,被丢掉的那个”发消息”调用不算数——它根本没执行过。
提醒不是强制。 发言护栏最多提醒两次,之后这轮照常结束。想要”必须回复”的硬约束,这里没有。
暴露面要如实盘。 自建中继就是自建一个 WebSocket 服务,端口、TLS、谁能连都得自己管;私钥以环境变量注入,任何能读到进程环境的人就拿到了身份;MCP 子进程虽然做了 env_clear(),但白名单里透传了 SSH_AUTH_SOCK、GIT_ASKPASS、GIT_SSH_COMMAND、GIT_CONFIG_GLOBAL 和代理变量——这意味着 Agent 通过 shell 工具能用你的 ssh agent 改本地仓库并推送。这不是设计缺陷,是显式选择(注释里论证了丢掉代理变量会让工具”看起来断网”),但你得知道这条线在。
七、上手与避坑清单
1. provider 不设就起不来。 为什么会踩:不少框架会根据你环境里有哪个 API key 自动推断 provider,形成肌肉记忆。buzz-agent 不推断,resolve_provider() 空值分支直接报错。怎么避:把 BUZZ_AGENT_PROVIDER 和对应的 key 当作一对来设;只设了 provider 没设 key 也会报错,两条错误信息不一样,照着读就行。
2. 模型名有两个来源,优先级不是你想的那个。 为什么会踩:你在 shell profile 里留了个老的 ANTHROPIC_MODEL,然后奇怪为什么改它没反应。怎么避:BUZZ_AGENT_MODEL 一旦存在就赢,排查时先看它。
3. 思考强度配了不一定按你写的执行。 为什么会踩:Anthropic 路线下写 none 或 minimal 是启动期硬错误;其余路线是运行期按模型能力表就近替换,程序不会失败、只打 warn。怎么避:起来之后翻一眼 stderr 的 warn 日志,别默认配了 max 就真是 max。
4. 上下文预算必须严格大于输出预算。 为什么会踩:把 BUZZ_AGENT_MAX_CONTEXT_TOKENS 调小做实验,忘了同步调 BUZZ_AGENT_MAX_OUTPUT_TOKENS。validate() 会直接拒绝启动,错误信息里解释了原因——上下文窗口得给回复留位置。怎么避:两个值成对改。
5. 你 export 的环境变量传不进 MCP 工具进程。 为什么会踩:spawn_one() 对子进程调了 env_clear(),只有白名单里的(PATH、HOME、TERM、LANG、TMPDIR、SSH_AUTH_SOCK、几个 GIT_* 和代理变量等)加上 session/new 里显式声明的 env 会传进去。怎么避:需要什么就写进 mcpServers 条目的 env 字段,别指望继承。
6. 项目约定要写进 AGENTS.md。 为什么会踩:从别的工具链过来的人习惯写 CLAUDE.md。怎么避:写 AGENTS.md,并且记住它是从 git 根到 cwd 逐级叠加的——父目录里的规则会跟着带下来,改上层文件会影响所有子目录。
7. 多行消息要走 stdin。 为什么会踩:base_prompt.md 里专门警告过,shell 单引号字符串会把 \n 原样保留,收到消息的人看到的是两个反斜杠字符。怎么避:用 printf 输出真实换行再管道给 --content -。
8. 私钥别落进任何会被读到的地方。 为什么会踩:CLI 参数标了 hide_env_values,容易让人以为整条链路都做了脱敏,但 systemd unit 文件、compose 文件、容器环境、进程环境快照都是明文暴露面。怎么避:按”泄露即身份丢失、且无法找回”的等级来管,跟管理 API key 是两个量级的事。
收束
这套分层的判断是:把”用哪个模型”、“进程怎么配”、“以谁的身份说话”三件事解耦到三个地方——分别是 llm.rs 加环境变量、config.rs 加工作区文件、buzz-acp 加一把私钥。好处是换模型不碰身份、换身份不碰模型;代价是任何一个问题你都得先判断它属于哪一层,否则会在错误的 crate 里找半天。
给你一份最短的自检清单:BUZZ_AGENT_PROVIDER 和配套 key 是否成对?BUZZ_AGENT_MODEL 里躺的是不是你以为的那个值?stderr 里有没有 effort 被就近替换的 warn?工具需要的环境变量是写进 mcpServers.env 了还是指望继承?私钥是从哪个文件读进环境的、那个文件谁能读?
要继续往下挖,读的顺序建议是:crates/buzz-agent/src/config.rs(把所有旋钮认全)→ agent.rs(看清一轮到底发生了什么)→ crates/buzz-acp/src/base_prompt.md(看 Agent 被教了哪些命令和禁忌)→ crates/buzz-core/src/kind.rs(想自己产生或消费事件时)。四份读完,这张网络里 Agent 那一侧的形状基本就清楚了。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 buzz 命令行怎么用:Block 开源的多 Agent 通信平台上手 和 Block 开源多 Agent 通信平台 buzz 的工具来源:内置工具与 MCP 如何合并。