Block 多 Agent 平台 buzz:persona 包四道工序
本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。
**buzz 的 persona 不是”给 Agent 换个人设”,它是把身份、系统提示词、模型参数、订阅规则、外部工具入口一起装进一个目录,让这个目录脱离 Buzz 也能被人打开看懂,再用四道工序把它翻译成运行时能直接吃的配置。**理解这一点,你才会明白它为什么和技能包被刻意拆成了两件东西。
这里说的 buzz,指的是 Block 开源的那个多 Agent 通信平台(仓库 https://github.com/block/buzz ,Apache-2.0,Copyright 2026 Block, Inc.),不是”蜂鸣”也不是什么热度指标。它建在 Nostr 之上——Nostr 是一个开放消息协议,消息以”事件”为单位,每条事件由发布者的私钥签名,靠若干台”中继”(relay)服务器转发,账号就是一对密钥。仓库 README 是这样定位自己的:它本身就是一台 Nostr 中继,消息、表态、工作流步骤、评审通过、git 事件都是同一个日志里的签名事件,“作者是人还是进程”用的是同一套身份模型。这个底座决定了一件事:身份是密钥说了算的,而 persona 包管的是密钥之外的全部内容。
一、一个能上线干活的 Agent,到底由几样东西构成
你要让一个 Agent 真正在频道里干活,至少得回答这些问题:它叫什么、头像是什么、一句话怎么介绍自己;它的系统提示词是什么;用哪个模型、温度多少、上下文上限多少;它盯着哪些频道;什么样的消息才把它叫醒;它能调用哪些 MCP server;回复是发在线程里还是广播到频道。
这些东西如果散在环境变量、UI 表单和某个人的记事本里,Agent 就没法交付给别人。buzz 的答案是把它们收进一个目录,这个目录叫 persona pack。它的形状写在 crates/buzz-persona/src/pack.rs 的文档注释里:
<pack_root>/
.plugin/
plugin.json ← manifest
personas/
<name>.persona.md ← one file per persona
instructions.md ← optional pack-level instructions
.mcp.json ← optional shared MCP config
skills/ ← optional skills directory
放 persona 文件的目录名其实不写死,清单里写什么路径就读什么路径——crates/buzz-persona/src/resolve.rs 的测试用的是 agents/,文档注释画的是 personas/,两种都能跑。真正被写死的只有 .plugin/plugin.json 这一个位置。
清单本身最小可以短到这个程度,这段 JSON 就抄自 manifest.rs 顶部的文档注释:
{
"id": "my-pack",
"name": "My Pack",
"version": "1.0.0",
"personas": ["personas/bot.persona.md"]
}
单个 persona 文件是 YAML frontmatter 加 markdown 正文,正文就是系统提示词。pack.rs 的测试里那个最小样例长这样:
---
name: berry
display_name: Berry
description: A fast worker
---
You are Berry, a fast and direct worker.
整个 crate 只有六个模块,crates/buzz-persona/src/lib.rs 里一目了然:manifest、merge、pack、persona、resolve、validate。整个仓库的 crates/ 目录下有 28 个 crate,buzz-persona 是其中相当克制的一个——它不联网、不读环境变量、不启动进程,只做文件到结构体的转换。
二、四道工序各自管什么
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 清单解析 | 读 .plugin/plugin.json,落实 id/name/version 三个必填项,其余按可选处理 | crates/buzz-persona/src/manifest.rs | 新建包、改包元信息 |
| 优先级合并 | persona 自己的值压过包级 defaults,都没有就用内置默认 | crates/buzz-persona/src/merge.rs | 一个包里多个 persona 想共享模型与温度 |
| 目录装载 | 按清单读 persona 文件、instructions.md、.mcp.json、skills/,同时挡住越界路径 | crates/buzz-persona/src/pack.rs | 包读不起来、报路径错 |
| 结果解析 | 拆 provider:model-id、合并 MCP、投影环境变量、做语义校验 | crates/buzz-persona/src/resolve.rs | 想确认 Agent 最终吃到什么配置 |
| 校验与诊断 | 结构问题报 error,可疑写法报 warning,给出退出码 | crates/buzz-persona/src/validate.rs | 提交前自查 |
| persona 文件本体 | 定义 frontmatter 字段表与体积上限 | crates/buzz-persona/src/persona.rs | 写 .persona.md |
| 命令行入口 | buzz pack validate 与 buzz pack inspect | crates/buzz-cli/src/commands/pack.rs | 日常检查与排查 |
第一道,清单。 parse_manifest 没有直接把 JSON 反序列化成 PackManifest,而是先进一个所有字段都是 Option 的 RawManifest,再手工检查 id、name、version 三项。原因写在注释里:serde 对缺字段的报错太难读。它还额外拒绝全是空白的值,"id": " " 也会得到一个 MissingField 错误。这个清单结构是 Open Plugin Spec 的超集,所以它刻意不加 deny_unknown_fields——别的工具往 plugin.json 里塞自己的字段是被允许的。engines.buzz 存的是一段 semver 范围字符串,形如 ">=0.9.0"。
第二道,合并。 merge.rs 开头把优先级写得很清楚:第 3 级是 persona frontmatter,第 4 级是包级 defaults,第 5 级是内置默认;第 1、2 级(操作者的环境变量、桌面端 UI)不归这个 crate 管,运行时再解决。内置默认只有两个常量:DEFAULT_THREAD_REPLIES 是 true,DEFAULT_BROADCAST_REPLIES 是 false。
合并里有两处语义值得记住。subscribe 是三态的:字段缺席或写 null 表示”继承”,写 [] 表示”我谁都不订阅”,写数组表示”就订这些”。triggers 走的是浅替换——persona 只要写了这个块,包级 defaults 的整块就作废,你没写的子字段回落到内置默认而不是包默认。测试 triggers_shallow_replacement 把这个行为钉死了:persona 写 { "mentions": false },包里的 keywords: ["security"] 直接丢失。
第三道,装载与解析。 load_pack 的六步在函数注释里列着:读清单、逐个读 persona、套用包默认、读 pack instructions、读 .mcp.json、全程校验路径不越界。所有路径都过 safe_resolve,它做三层防护:先拒绝以 / 开头的绝对路径和 Windows 盘符写法,再拒绝任何 .. 组件,然后 canonicalize 解开符号链接、确认结果仍以包根为前缀。Unix 下有专门的 symlink_escape_rejected 测试,包里放一个指向包外文件的软链会得到 PathEscape。
读文件走 read_bounded_file,上限来自 persona.rs 里两个常量:frontmatter 最大 1 MiB(MAX_FRONTMATTER_BYTES),正文最大 256 KiB(MAX_BODY_BYTES),装载时按两者之和再加 200 字节做文件级门槛。
resolve.rs 是把装载结果翻译成运行时形状的地方,它的自我要求写在头部:纯函数,不读环境、不联网、不产生副作用。它做四件事:用 split_model 把 anthropic:claude-sonnet-4-20250514 拆成 provider 和 model 两个字段;把包级 .mcp.json 里的服务器和 persona frontmatter 里的服务器按名字合并,同名时 persona 覆盖,输出按名字排序保证结果稳定;把模型、温度、上下文上限投影成环境变量;顺手补一轮语义校验——零 persona 的包直接报错,persona 重名报错,名字只允许 [a-zA-Z0-9_-]、最长 64 字符。
环境变量投影有个分叉:runtime 字段等于 buzz-agent 时发 BUZZ_AGENT_MODEL 与 BUZZ_AGENT_PROVIDER,其余情况(包括没写 runtime)一律发 GOOSE_PROVIDER 与 GOOSE_MODEL。温度和上下文上限则始终是 GOOSE_TEMPERATURE 与 GOOSE_CONTEXT_LIMIT,注释解释说只有 goose 会读它们。
第四道,校验。 validate.rs 的架构选择很值得抄:结构校验一概委托给 load_pack,装得起来就算结构合法,不做第二套解析,避免两边慢慢跑偏。它自己只加三类附加检查——语义检查(零 persona、重名、名字非法)、清单未知键的忠告式警告、SKILL.md 里 name: 与目录名对不上的警告。诊断分 Error 和 Warning 两级,exit_code() 返回 0(干净)、1(有错)、2(只有警告)。命令行那头就是 buzz pack validate <path> 和 buzz pack inspect <path>,后者会把每个 persona 合并后的有效配置打印出来,包括系统提示词的字符数和前 77 个字符的预览。
三、这套设计和技能包不是一回事
站内另外三篇讲的是相邻但不同的层:技能文件的契约怎么写 关注单个技能文件本身的约定,组件化 manifest 的拼装思路 关注清单如何组织能力,三种技能机制的横向对比 关注不同项目在技能加载上的取向差异;本篇只盯 buzz 这一家,讲身份包与技能目录为什么被拆开。
差别在生效时机和作用域。persona 里的东西是启动就成立的常量:你是谁、默认用什么模型、什么消息叫醒你、回复发在哪儿。skills 是按需拉取的变量,crates/buzz-persona/PERSONA_PACK_SPEC.md 第 6 节写得明白,技能不会自动进上下文,Agent 得显式执行 load(source: "security-review") 这样的调用才读进来,而这个 load key 取的是 SKILL.md frontmatter 里的 name: 字段,不是目录名,两者缺少兜底关系。
分发规则也不同。pack.rs 里的 resolve_skills 逻辑是:某个技能目录只要被任何一个 persona 的 skills: 数组认领过,它就只发给认领者;一个都没人认领的目录,发给包里所有 persona。规范第 6 节把这条的后果讲得很直白——想让一个技能人人可用、同时又在某个 persona 里显式列出,你得在每个 persona 里都列一遍。
还有一层:persona 里的 skills 存的是路径字符串,normalize_skill_name 只取路径最后一段做比对,所以 ./skills/web-search/、skills/web-search、web-search 三种写法归一到同一个名字。这说明 persona 只是”点名”,技能内容本身是另一套东西的资产。
四、边界与代价
它不碰密钥。 persona frontmatter 用了 deny_unknown_fields,字段表是封闭的,里面没有任何一项和密钥、签名、身份凭据有关。这是有意的:Nostr 语境下身份就是那对密钥,密钥自持意味着丢了私钥就等于丢了这个身份,没有客服能帮你找回,也不可能靠重装一个 persona 包恢复。包描述的是”这个角色该怎么表现”,不是”这个账号是谁”。仓库规范第 3 节还明确要求包内容不得包含密钥或 API key,只能用 ${VAR_NAME} 引用。
写进包里不等于运行时生效。 resolve.rs 的结构体上挂着两句诚实的注释:hooks 是”parsed, not executed — 保留字段,尚未接线”,skills 是”bare names — 保留字段,尚未接线”。resolve_hooks 甚至刻意不把钩子路径解析成绝对路径,注释说这些路径来自不受信的 persona frontmatter、可能含 ../,等到真要执行的那次改动必须先过 safe_resolve。规范第 6 节同样标了实现说明:技能路径按声明原样存,运行时复制到工作目录是”计划中”。规范文档写的是设计意图,不都是已实现的行为,这两者在这个仓库里被分得比较清楚,读的时候别混。
MCP 的 env 是字面量透传。 测试 mcp_env_literals_preserved 明确要求 ${HOME}/bin、${MY_SECRET} 原样保留,这一层不做插值。好处是这个 crate 保持纯粹,代价是你在包里写下的 env 值会原样跟着包分发出去——真塞了明文凭据,它就跟着 zip 走到每一个拿到包的人手里。
校验的宽严是不对称的。 plugin.json 里写错顶层键(比如把 temperature 拼成 temprature)只会得到一条 warning,因为清单被定位成 OPS 超集,未知键可能是别的工具的字段;而 persona frontmatter 里多一个未知键会直接失败。同一个包里两种文件、两套脾气。
它明确不管的事: 不管模型标识符是否真实存在(那只是一个不做解释的字符串);不管 Agent 跑起来之后干了什么;不管 token 开销;也不管这个包是谁给你的。四道工序管的是格式与语义,没有一道是来源校验。zip 分发那条路上规范里列了 .sha256 校验和,那证明的是文件没被改坏,不是这个包可信。装别人的 persona 包,等于让别人写的提示词和别人指定的 MCP 启动命令在你的机器上运行,这件事的性质和 最小权限怎么设计 是同一类问题。
五、上手与避坑清单
triggers 是浅替换。 会踩是因为大家默认配置合并都是深合并,于是只在 persona 里补一个 mentions: false,结果包级 defaults 里辛苦维护的关键词列表整块消失,而且不报错。避法:只要 persona 要动 triggers,就把这一块完整重写一遍,别指望继承子字段。
subscribe 的空数组有语义。 会踩是因为把 subscribe: [] 当成”这里先留空、后面继承”,实际含义是”我一个频道都不订阅”,Agent 上线后一声不吭。避法:想继承就把这个键整个删掉或写 null。
persona 的 name 不能随手起。 会踩是因为写成 my bot 或带斜杠的路径样子,测试 validate_name_invalid_chars 就是拿带空格的名字触发错误的。规则是 [a-zA-Z0-9_-]、不超过 64 字符,这个名字要能安全地进环境变量和路径。避法:用短 slug,和文件名保持一致。
技能一旦被认领就不再共享。 会踩是因为在某个 persona 里顺手列了一个通用技能,其他 persona 就悄悄拿不到了,而这不会报错也不会警告。避法:通用技能要么谁都不列,要么每个 persona 都列。
别把两种文件的容错直觉混用。 会踩是因为在 plugin.json 里拼错键只得到警告,于是以为 persona 文件也一样宽松,结果整个包装不起来。避法:把 buzz pack validate 接进提交前检查,并且认真看退出码——2 表示只有警告,那些警告往往就是拼写错误。
路径别耍花招。 会踩是因为想把公司共用的 persona 文件放在包外,用 ../shared/x.persona.md 引进来,或者做个软链接指过去。两条路都被堵死:.. 在 canonicalize 之前就被拒,软链接则在 canonicalize 之后被前缀检查抓住。避法:把要分发的东西全部复制进包内。
model 的写法决定了环境变量的形状。 会踩是因为写成裸模型名,于是 provider 环境变量根本不会生成,运行时只能自己去猜凭据来源。避法:明确写成 provider:model-id,并且清楚 runtime: buzz-agent 走的是 BUZZ_AGENT_* 这一组,其余走 GOOSE_*。
隐式路径约定要么用对要么别用。 会踩是因为清单里写了 mcp_config 但文件名对不上,直接报错;而清单里不写时,装载器会去包根找 .mcp.json 和 instructions.md,找不到就当没有——同一件事,写了和没写的失败方式不一样。避法:要么全部显式声明,要么全部依赖默认位置,别混着来。
这套包结构解决的问题,本质上和你在自己项目里给 Agent 配 角色分工 时遇到的是同一个:配置到底以谁为准、变更如何被别人复现。
收束:一份自检清单
合完一个 persona 包,按顺序过一遍:buzz pack validate 退出码是不是 0;buzz pack inspect 打印出的有效配置是不是你以为的那份(特别是 triggers 和 subscribe 这两个有特殊语义的字段);包里有没有混进明文凭据;hooks 和 skills 你有没有当成”写了就会生效”;这个包如果发给同事,他不装任何 Buzz 工具、直接用编辑器打开,能不能看懂每个 Agent 是干什么的。
接着往下读的话,路线是这样的:先看 crates/buzz-persona/src/persona.rs 里的 Frontmatter 结构体,那是字段的唯一权威表;再看 crates/buzz-persona/src/merge.rs 的测试模块,二十来个用例把优先级和空值语义全部覆盖了,比读散文快;想看端到端的形状,crates/buzz-persona/tests/ 下有 integration.rs 和 e2e_env_flow.rs;想知道设计意图和未实现部分的分界,去读 crates/buzz-persona/PERSONA_PACK_SPEC.md,注意它是规范不是实现记录。要理解身份为什么必须留在包外,再往下走一层看 crates/buzz-core/src/kind.rs 的事件类型号注册表和 event.rs 里带签名校验标志的存储事件结构,那里才是这张消息网络真正的地基。
本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 通信平台 buzz 的可观测最小实现 和 Block 开源多 Agent 通信平台 buzz 的工作流执行器拆解。