让 Agent 跑在集群里:Block 开源多 Agent 通信平台 buzz 的远程调度模型
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
buzz 这个 Kubernetes 后端最反直觉的地方在于:桌面端把 Agent 推到集群里之后,就再也没有一条通向那个容器的管理通道——没有状态查询、没有 exec、没有拉日志、没有 kill。 它把这条约束写成了设计公理,然后围绕这条公理重建了整套调度模型:启动是一次调和循环,状态靠消息网络里的在线信号推断,停止靠发一条消息,资源回收靠下一次部署时的预检清扫。你如果按”控制平面 + 被管工作负载”的思路去读这份代码,几乎每一行都会觉得别扭;换成”没有控制平面,只有一次性收敛”的思路,它就顺了。
这里说的 buzz,是 Block 开源的多 Agent 通信平台(仓库 github.com/block/buzz,Apache-2.0,Copyright 2026 Block, Inc.),不是什么热度指标或蜂鸣器。仓库 README 是这样定位自己的:一个「人和 Agent 一起构建」的工作区,跑在你自己拥有的中继上;落到实现里,人和 Agent 被放进同一张消息网络里协作,网络底座用的是 Nostr。本篇只切其中一条线:远程 Agent 怎么被起在 Kubernetes 集群里。
一、先把底座讲清楚:为什么它敢不留管理通道
Nostr 是一套开放的消息协议,核心只有两样东西:事件和中继。事件是一条带签名的结构化消息;中继(relay)是负责接收、存储和转发这些事件的服务器。每条事件都由发布者的私钥签名,谁也没法冒名顶替——密钥自持的意思就是私钥握在你自己手里,没有找回流程,私钥丢了等于身份丢了。协议的扩展规范叫 NIP,编号成文;这个仓库自带了一套,docs/nips/ 下是 15 份 NIP 规范 md 加 2 份 fixtures json,另外 crates/buzz-core/src/pairing/NIP-AB.md 是第 16 份。
对远程 Agent 来说,这几件事直接决定了调度模型的形状:
一个”活着的 buzz Agent”,本质是一个 buzz-acp 进程,手里拿着一对密钥、一个 NIP-OA 的 auth 标签、一个中继地址,这三样通过环境变量交给它。NIP-OA 叫 Owner Attestation,定义的是一个可选的 auth 标签,由所有者密钥授权 Agent 密钥以自己的身份发事件——事件的作者仍然是 Agent 本身,这个标签只是授权证据。中继认的是密钥和这个标签,从来不认是谁把进程拉起来的。所以规范里写得很直白:桌面端只是众多启动器之一,一个 bash 脚本只要导出对了环境变量再 exec harness,就是一个合规启动器。
状态从哪来?从在线状态事件来。Agent 自己签名发布 kind:20001 的在线状态(online/away/offline),这是临时事件,中继带 TTL 存活。TTL 是 180 秒(PRESENCE_TTL_SECS,见 crates/buzz-pubsub/src/presence.rs)。所以”部署了没有”和”活着没有”是两个轴:前者是桌面端存的那个 backend_agent_id,只是记账;后者只认在线状态。代价也说得很清楚——Agent 被 SIGKILL 或者节点整个掉了,在线状态最多错 180 秒。规范承诺的是这个窗口有界,而不是这个数字本身。
停止不是 provider 的操作,而是往中继发一条 !shutdown 消息提到那个 Agent,harness 验证发送者是所有者之后走优雅退出:关闭 Agent 池、排空在途轮次、发 offline、关连接。
二、这条线由哪些部分组成
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 规范文档 | 协议、五条不变量、K8s 绑定策略 | docs/remote-agents.md | 想搞清某个行为为什么这么定 |
| 调和主循环 | 执行分类结果、重入、守住 600 秒截止 | crates/buzz-backend-kubernetes/src/reconcile.rs | 部署卡住、报 startup not confirmed |
| 分类器 | 把一次已验证观测映射成一个动作,纯函数 | crates/buzz-backend-kubernetes/src/classify.rs | 想知道某个 Pod 状态会被怎么处理 |
| Pod/Secret 构造 | 生成 API 对象,落地全部硬化默认值 | crates/buzz-backend-kubernetes/src/pod.rs | 调资源、换镜像、看安全上下文 |
| 预检垃圾回收 | 决定哪些 Pod 和 Secret 可以删 | crates/buzz-backend-kubernetes/src/gc.rs | 命名空间里堆了残留对象 |
| 身份与命名 | 从 nsec 派生公钥,派生全部名字与标签 | crates/buzz-backend-kubernetes/src/naming.rs | 想按标签把这个 Agent 的对象选出来 |
| 配置解析与表单 schema | 9 个字段、默认值、拒绝非法值 | crates/buzz-backend-kubernetes/src/config.rs | 填表报错、想知道有哪些旋钮 |
| 环境三层合并 | 策略默认 → 用户分层 → 权威层 | crates/buzz-backend-kubernetes/src/env.rs | 某个环境变量没按预期生效 |
桌面端和后端之间是一个零注册的插件契约:桌面端扫描可执行文件名形如 buzz-backend-<id> 的程序,一次操作起一个进程,stdin 喂一个 JSON,stdout 收一个 JSON。只有两个操作:info(超时 10 秒,返回名称、protocol_version、config_schema)和 deploy(超时 600 秒,返回 agent_id)。stdout 读取上限 1MB,stderr 上限 64KB,退出码只携带一个比特——非零就是失败,哪怕 stdout 已经解析成功也不采信。
buzz-backend-kubernetes 就是第一个符合这个契约的 provider,一个独立的 Rust crate(全仓 crates/ 下共 28 个 crate、413 个文件)。
三、deploy 不是”创建”,是一次收敛
这是整条线最值得抄走的设计。桌面端不跟踪集群状态,所以点一次 Start 就无条件发一次 deploy;那么 deploy 就不能是”创建”,只能是”收敛到至多一个活实例”。
收敛的锚点是确定性命名。provider 先从 payload 里的 private_key_nsec 自己解出公钥(AgentIdentity::from_nsec),之后所有名字、标签、比较全部用这个派生出来的公钥,绝不用调用方给的:
pub fn pod_name(&self) -> String {
format!("buzz-agent-{}", &self.pubkey_hex[..12])
}
pub fn secret_name(&self, generation: &str) -> String {
format!("buzz-agent-{}-{}", &self.pubkey_hex[..12], generation)
}
Pod 名确定,集群自己就会拒绝第二个同名 Pod——“同一个密钥在同一个命名空间里至多一个活实例”这条不变量,靠的是 apiserver,而不是分布式锁。标签只放公钥前 32 位十六进制(buzz.block.xyz/agent-pubkey),因为 Kubernetes 标签值上限 63 字符而完整公钥是 64 字符,正好多一个;截断意味着理论上会撞,所以完整公钥另放在注解 buzz.block.xyz/agent-pubkey-full 里,任何动作之前必须先比对这个注解。
比”是谁的”更严的一层是”是不是这个 provider 自己写下的”:每个对象还带 app.kubernetes.io/managed-by: buzz-backend-kubernetes 和 buzz.block.xyz/binding-version 两个标签,构成管理标记。缺这个标记的对象,即使标签和注解全对,也一律不删、不回收、不认领,直接报错给运维——reconcile.rs 里那条错误信息写的是”不是本 provider 管理的”,然后建议你换个命名空间。
循环本身在 reconcile.rs,两个常量定了节奏:轮询间隔 2 秒,截止时间 600 秒(OPERATION_DEADLINE_SECS)。每一轮观测一次,交给 classify 得到一个动作,动作只有六种:Create、Delete、AwaitDisappearance、NoOp、Observe、Report。几条容易踩的判定顺序值得单独记:
删除标记必须比阶段先判。 Kubernetes 里没有 Terminating 这个阶段——一个正在优雅删除的 Pod,整个宽限期内阶段仍然是 Running。先看阶段就会把一个正在消失的 Pod 当成”活着”,返回一个马上蒸发的 id。
“活着”的判据是容器 state.running,不是 Pod 阶段。 镜像拉不下来、配置错误、调度不上,Pod 都可能长期停在那儿而 harness 一次都没跑起来。所以 deploy 只有在 harness 容器真的启动之后才算成功,中间态一律不返回成功。
没启动过的 Pod 分三类,处理完全不同。 分类器里的 Startup 枚举写得很清楚:NeverStartedRecoverable(能自愈,比如从零扩容时的 Unschedulable、正在拉镜像)只观察,永不删除;NeverStartedProvablyBroken(被证实坏了,比如引用的 Secret 经过一次最新读确认不存在)才带栅栏删除重建;NeverStartedPullFailing 携带原因(未授权、manifest 不存在、架构不匹配),立刻报错而不是耗光 600 秒。注意”证实”两个字:kubelet 给的 reason 字符串不构成删除授权,代码里干脆让 PullFailure 这个类型压根到不了 Delete 的签名里,由编译器兜底。
每次调用只创建一次。 这条是被真实故障逼出来的。如果 harness 起来了、拒绝了自己的配置、然后退出,而循环又去删了重建,就会在 600 秒里每轮循环一次删建,每次都新写一个不可变 Secret——注释里记着实测数字:一次 600 秒的调用里生成了 107 个 Secret,每一个都比回收的年龄门槛年轻。现在的做法是设一个 created_this_call 标志,本次调用创建过的 Pod 一旦被判定要替换,直接带着退出码在带内报错,把残留留在原地,等下一次 Start 的预检回收顺手清掉。重试因此被门控在”用户重新按了一次”上,垃圾量从每轮一份变成每次点击一份。
还有一个细节值得单独拎出来:409 状态码在这里有两种完全相反的含义。删除前置条件失败是 409(reason 为 Conflict),创建撞名也是 409(reason 为 AlreadyExists)。前者要放弃删除、重新观测分类;后者要清掉自己这次的 Secret、去认领赢家。判别依据必须是 Status.reason 这个机器可读字段,只看状态码迟早会把两条路走反。
四、Pod 长什么样
pod.rs 是纯函数,没有 I/O,所以每条规范要求都能落成一个单测。几个关键点:
裸 Pod,不用 Job 也不用控制器。 理由是控制器那种”总是重启”的机制会把 !shutdown 和空闲自停终结掉的东西重新拉起来,直接违背”有意终止是终态”这条不变量。v1 只发 restartPolicy: Never;OnFailure 被双重门控着,一是 harness 需要先把”有意退出必为 0 退出码”这个约定用测试钉死,二是状态机需要先长出一行专门处理崩溃回退循环的分类——两件事没做完之前,config.rs 里干脆直接拒绝 inactivity_seconds: 0 这个组合,而不是悄悄降级成 Never。
容器不覆盖镜像的 entrypoint。 构造 Pod 时 command 和 args 都留空,单测专门断言这一点。原因在规范里:镜像的入口脚本必须以 exec 结尾。
#!/bin/bash
set -e
exec buzz-acp # exec, not a call — buzz-acp must be PID 1
写成 bash -c "setup && buzz-acp" 就是不合规的:bash 成了 PID 1,而 PID 1 对默认处理的信号有内核级豁免,SIGTERM 根本传不到 harness,Pod 会硬扛完整个宽限期然后被 SIGKILL,在线状态卡在 online 不动。terminationGracePeriodSeconds 设成 60 而不是 Kubernetes 默认的 30,也是为了这件事——不过规范自己承认,这是一个声明的预算而不是推导出来的和:优雅退出的尾巴是变量而非常量,按默认并行度算光收尾一段就能到 ~87 秒,所以在 harness 那边落地”一个共享截止时间 + 一段留给发 offline 和关连接的保留片”之前,60 秒只是个运维余量。
硬化默认值是一整套,而且都有独立断言。 这个容器跑的是被提示词驱动的代码 Agent,手里还攥着私钥,所以:automountServiceAccountToken: false(Kubernetes 默认会挂 ServiceAccount token,那是一个 Agent 根本用不到、却能被偷去调 API 的凭据)、runAsNonRoot: true 配固定的非零 UID/GID 10001、allowPrivilegeEscalation: false、capabilities 全丢、seccomp 用 RuntimeDefault,永不使用 privileged、hostPID、hostNetwork、hostPath。service_account 这个配置字段只用来选调度和 RBAC 身份,绝不能顺带把 token 挂载打开——这条也有单测盯着。工作区是一个 emptyDir,挂在 /home/agent,也就是 HOME;检出的代码和临时文件随 Pod 一起消失,Agent 的记忆是靠中继持久化的。
Secret 一次尝试一份,并且不可变。 名字带一个每次创建尝试新生成的 8 位十六进制 generation 令牌,Pod 的 envFrom 精确引用这一份,且 optional: false(可选引用会让容器在没有身份的情况下起来,把”Secret 丢了”这种 bug 变成”Agent 静默地认证不了”)。为什么不用一个固定名字的 Secret?因为两个携带不同 payload 的并发部署会都去写它,输的那次写入可能最后落盘,于是赢家的 Pod 通过 envFrom 解析到的是输家的身份。唯一命名把这条竞态关死:先写自己的 Secret,再拿确定性 Pod 名去创建,Pod 创建这一步选出赢家,payload 和 Secret 在 Pod spec 这个边界上是原子的。同一个 generation 令牌还会作为 BUZZ_MANAGED_AGENT_START_NONCE 进到环境里,被 harness 打进生命周期帧,于是日志和 Secret 共用一个关联标识。
五、回收:只碰自己写的,只信服务端的钟
gc.rs 是一个纯决策层:调用方把观测到的对象和 apiserver 的时钟递进来,它算出一份可删清单。规则很短,但两条约束很硬。
第一,只回收带管理标记且注解匹配的对象。不带标记的看起来再像也不动,测试里专门造了”标签全对但没有标记”和”有标记但公钥不是这个”两种冒充对象,断言计划为空。
第二,孤儿 Secret 有年龄门槛,而且时钟必须是服务端的。一个没有 Pod 引用的 Secret,只有在创建时间早于两倍部署截止时间(ORPHAN_SECRET_MIN_AGE_SECS,也就是 1200 秒)之后才算可回收。原因是”先建 Secret、再建 Pod”这两步对并发的回收扫描不是原子的:尝试 A 刚写完 Secret,并发的尝试 B 跑预检回收,看见一个没人引用的 Secret 就当孤儿删了,A 的 Pod 随后带着一个不存在的 Secret 被接受,卡在 CreateContainerConfigError。年龄门槛把”没人引用”变成”可证明已被放弃”。
比较年龄的两个操作数必须来自同一个钟,具体做法是从这次 list 调用的 HTTP Date 响应头取比较时刻,而不是本地 now()。理由写得很实在:这个 provider 跑在用户的笔记本上,本地时钟快过余量不是”有概率撞车”,而是每一轮都确定性地把所有在途 Secret 算成过期然后全删。所以 Date 头拿不到或者解析不了的时候,孤儿扫描整轮跳过,宁可晚点清理。终止态 Pod 的回收不看时钟,因此不受影响。
六、边界与代价
这套设计放弃的东西,规范自己列得比使用者还诚实,这里挑几条工程上真的会疼的:
改配置对正在运行的远程 Agent 不生效。 活实例是严格无操作——不改 Secret、不打补丁、不删任何东西。配置和环境的修改只在下一代生效,也就是它退出之后。本地 Agent 每次拉起都会重新解析,远程不会。规范把这个记为 v1 的取舍,“停了再起”这种显式回收动作留给了后续版本。不对称是刻意的:编辑够不到一个已经启动的 Pod,却能立刻够到一个从没启动起来的 Pod(靠创建意图指纹判定发散),因为你之所以在编辑,多半就是因为它没起来。
没有 undeploy 操作。 从桌面端删掉一个远程 Agent,会把集群里的对象孤儿化,所以 UI 上必须显式确认。残留的成本靠两条兜底:Agent 自己会因为空闲自停而退出,Pod 残骸等到同一个密钥下次部署时被预检回收扫掉,或者你手动 kubectl delete。
nsec 就在命名空间里,任何能读 Secret 或者能 exec 进 Pod 的人都能拿到。 规范把这条明明白白写成残余暴露:命名空间就是隔离单位,部署到共享命名空间等于接受它的既有 RBAC。镜像内那层收窄(把私钥从自己的环境里剥掉、以 0600 权限落成一个 keyfile 给 git helper 用)只挡住向子进程环境的意外泄漏,挡不住有权限的集群使用者。再叠上密钥自持的性质:这把私钥就是这个 Agent 的身份,泄漏了没有吊销流程,只能换身份。
日志窗口是易碎的。 当前这一代跑完的 Pod 会留在原地,它的日志是这套设计允许你拿到的唯一取证材料,而下一次部署的回收、节点丢失、命名空间删除,任何一件事都会把它抹掉。规范因此把集群侧的日志外发列成生产前置条件,而不是可选项——这个绑定负责的只是关联信息(完整公钥注解、generation 令牌、provider 版本、解析后的镜像引用),传输是集群运维自己的事。
它明确不管的事:Agent 的对话行为(那属于 harness 和各个 NIP)、恶意 provider 的隔离(provider 按设计就是要拿到私钥的)、集群自身的安全(RBAC、命名空间隔离、静态加密都是集群运维的活)、以及集群的活性(能不能调度、镜像能不能拉,只规定怎么上报,不做承诺)。还有一条现实的:v1 不发 Windows 二进制,而且截至规范记录的那个提交,发现逻辑没有剥掉 .exe/.bat/.cmd 后缀,Windows 上的 provider 能被探测到但部署不了。
跟站内几篇相邻的文章分个工:Hermes 的出口隔离讲的是单机上怎么限住 Agent 能往外连什么,pi 的容器隔离讲的是把一个 Agent 塞进容器里的边界怎么划,Agent 并发编排讲的是多个 Agent 在任务层怎么排;本篇只讲一件事——当执行体被推到集群里、而你又刻意不给自己留管理通道时,调度、身份、回收这三件事该怎么重排。想顺带看看权限面怎么收,可以接着读最小权限设计。
七、上手与避坑清单
集群凭据只走 kubeconfig,别想着往配置里塞。 配置对象会被持久化、会被渲染进 UI,所以校验器直接拒绝任何词面里含 secret、password、token、key、credential 的字段名。这是一个有误报的 lint:像 ssh_key_path 这种存路径不存凭据的字段也会被拒。踩到了要做的是改名(比如改成 identity_file),不是去放宽校验器——它的职责就是让”把密钥写进配置”这件事失败得足够早。
从图形界面启动的桌面端,PATH 是残缺的。 很多 kubeconfig 用的是 exec 凭据插件(aws eks get-token、gke-gcloud-auth-plugin 这类),它们要靠 PATH 找可执行文件;而 macOS 上从 Finder 启动的应用继承的是 launchd 的最小 PATH,provider 又继承桌面端的环境。所以 provider 必须自己把 /opt/homebrew/bin、/usr/local/bin、~/.local/bin 前置到自己的 PATH 里再建客户端,并且插件失败时要在错误里点名缺哪个二进制。你自己写 provider 的话,这是第一个会踩的坑。
命名空间不存在就建,建不了要报得可执行。 默认值是每次 info 现生成一个 buzz-agents-<6位随机>,直接当表单预填。如果 RBAC 不允许创建,规范要求错误里带上字面的 kubectl create namespace <name> 命令,并且不许回退到 default——回退到 default 意味着把带私钥的 Pod 丢进一个通常谁都能读的命名空间。
镜像必须按摘要钉死。 配置里 image 是必填,:latest 被拒绝,纯 tag 也不接受。理由不抽象:registry 的 tag 是可变指针,而这个对象是攥着私钥运行的。允许自定义镜像,但规范给了一句边界——合规的自定义镜像是”buzz-sprig 加上你的工具”,不是”你的工具替代它”,因为入口 ABI(buzz-acp 那一套)必须还在。
部署报”startup not confirmed”不等于要你去删 Pod。 600 秒是这一次 Start 同步等待的预算,包含冷启动、镜像拉取、从零扩容的调度。截止到期只是这一次调用不等了,不会触发任何清理;下一次部署会重新观测那个 Pod 走对应的分支。反过来说,遇到这个错误时正确的动作是去看错误里带的那条最新条件(容器 waiting 的 reason 或者 Pod condition),而不是手动删了重来——删掉会重置集群自动扩容器所依赖的 Pod 年龄,把一次慢冷启动变成活锁。
环境变量没生效,先确认它在第几层。 合并是三层、后写覆盖:可覆盖的行为默认(policy_env)、用户分层环境(env)、权威层。权威层不只是覆盖,而是先把自己名下的键清空再写——因为很多键是条件写入的(比如参数为空时就不写),不清空的话,低层的值会正好在高层沉默的那些场景下顶上来。另外有一个键是直接拒绝而不是覆盖:BUZZ_ACP_NO_PRESENCE。在远程场景下在线状态是唯一的存活信号,允许它被关掉,等于把”最多错 180 秒”变成”永远错”。
空闲自停是所有者策略,不是法律。 配置里 inactivity_seconds 默认 7200 秒,直接喂给 harness 的空闲退出旋钮;这个旋钮在 harness 里默认是 0(关闭),因为同一个 harness 也跑在你的笔记本上,一个 reaper 的 bug 不该有机会杀掉本地 Agent。远程侧才选择打开——一个没人看着的 Pod 是在计费的算力。至于”永不自停”这个选项,规范认可它合法,但这个绑定 v1 拒绝它,理由前面说过了。
收个尾
如果只带走一条判断:这套东西的复杂度预算是花在规范上而不是代码里的。每条保证背后的机制都刻意选了最无聊的那一个——身份在构造 payload 时就拒绝为空、密钥形状靠一个校验器、状态靠一个 Agent 本来就会发的临时事件、唯一性靠一个确定性名字加一次注解比对、生命周期靠一个复用了现有关闭通道的定时器。规范甚至写了一句:一个不小的实现,是规范有 bug 的证据。
想自己核一遍,建议的顺序是:先读 docs/remote-agents.md 的不变量与部署状态机两节,把五条不变量和那张状态表看熟;再读 crates/buzz-backend-kubernetes/src/classify.rs,它是纯函数,一眼能对上规范的每一行;然后 reconcile.rs 看这些动作怎么被执行、created_this_call 那个标志卡在哪;最后 pod.rs 和 gc.rs,前者是所有硬化默认值的落点,后者是唯一会删东西的地方。这四个文件里的测试写得比正文还密,很多都是把真实故障钉成断言,读测试往往比读实现更快明白它在防什么。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源 buzz:多 Agent 平台的 Git 仓库托管链路 和 Block 开源多 Agent 通信平台 buzz 的四层排障顺序。