Block 开源多 Agent 通信平台 buzz:engram 把记忆做成事件之后,检索边界在哪

2026-08-05

本文基于 buzz 仓库 commit 8342dfc(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/block/buzz 最新代码与文档为准。

engram 能精确取回你早就知道名字的那一条,取不回你说不清名字的那一类——这不是实现没跟上,是它选择的寻址方式在设计阶段就把检索能力换掉了。

先把对象说清楚:这里说的 buzz 是 Block 开源的多 Agent 通信平台(仓库 github.com/block/buzz,Apache-2.0,Copyright 2026 Block, Inc.),不是什么热度指标,也不是蜂鸣器。它的定位是让人和 Agent 待在同一张消息网络里协作,仓库 README 把自己描述为一个人和 Agent 共处同一批房间、跑在你自己拥有的中继上的可自托管工作区——这是项目自己的说法,我在下面只谈代码里能核到的部分。整个仓库 crates/ 下有 28 个 crate,本文只碰其中三个:buzz-core(协议原语)、buzz-acp(Agent 会话接入)、buzz-cli(命令行)。

engram 是它给 Agent 准备的持久记忆。规范在 docs/nips/NIP-AE.md(仓库 docs/nips/ 下有 15 份 NIP 规范 md 加 2 份 fixtures json),实现的纯逻辑部分在 crates/buzz-core/src/engram.rs

站内已有的三篇是另一个层面的东西:Agent 记忆分层 讲的是不依赖具体框架的记忆分层方法论,RAG 检索调优 讲向量召回怎么调,vibe-trading 是什么 拆的是另一个开源项目。这篇只做一件事:把 buzz 这一个实现的检索边界量出来。

一、它要解决的问题:跨会话的身份,不落在本机磁盘上

Agent 的痛点很俗:这一轮会话里你教会它的规矩,下一轮它不知道。常规解法是往本地写一个 memory 文件,或者塞进一个向量库。buzz 的选择是第三条路——记忆本身就是网络里的一条事件,和聊天消息躺在同一套设施上。

这里有几个协议概念要先落地,读者不必事先懂去中心化那一套:

  • 事件(event):Nostr 协议里所有内容的统一载体,一个带 kind(类型编号)、created_at(秒级时间戳)、tags(标签数组)、content、作者公钥和签名的 JSON 对象。
  • 事件签名:作者用私钥对事件做 Schnorr 签名,任何人拿公钥就能验;改一个字节,签名立刻不成立。所以事件的作者身份不靠服务器背书。
  • 中继(relay):存事件、按过滤器把事件吐出来的服务器。它是仓库加转发,不是信任根。buzz 自带一个,在 crates/buzz-relay
  • 密钥自持:身份就是一对密钥,没有注册流程,也没有”忘记密码”。丢了私钥就是丢了这个身份——engram 里丢的不只是身份,连历史记忆都再也解不开(下面第五节细说)。
  • 可寻址事件(addressable):NIP-01 定义的一类事件,中继只需保留 (kind, pubkey, d) 三元组下最新的那一条,旧的可以丢。

engram 用的类型编号是 30174,在 crates/buzz-core/src/kind.rs 里写成常量 KIND_AGENT_ENGRAM,落在可寻址区间。内容用 NIP-44 v2 加密(NIP-44 是 Nostr 的加密消息规范),密钥是 Agent 与它的 owner 之间的会话密钥 K_c。这个密钥对称——conversation_key(seckey_a, pubkey_o)conversation_key(seckey_o, pubkey_a) 算出同一个值,NIP-AE 开头就明说了这个后果:owner 永远能读到 Agent 记住的一切。

记忆的作用域是 (pubkey_a, pubkey_o) 这一对。同一个 Agent 服务两个 owner,就是两份互不相干的记忆。

二、寻址方式决定检索方式:d 标签是摘要,不是文件名

engram 里每条记忆有个 slug 作为名字,语法写在 NIP-AE.md 的 Slugs 一节,engram.rs 里的 validate_slug 逐字节实现了它:

^mem/[a-z0-9][a-z0-9_-]{0,63}(/[a-z0-9][a-z0-9_-]{0,63})*$

保留字 core 是唯一例外,它是 Agent 的身份面(CORE_SLUG 常量)。其余都得以 mem/ 开头,每段首字节必须是小写字母或数字、段长不超过 64 字节、整条不超过 SLUG_MAX_LEN 也就是 255 字节。大写、点号、空格全部拒绝。normalize_slug 会给你少打的 mem/ 前缀补上,但补完还要再过一遍校验。

关键在于 slug 不会以任何明文形式出现在事件里。事件的 d 标签是这么来的(NIP-AE.md 的 Addressing 一节):

K_c = nip44_conversation_key(seckey_a, pubkey_o)
d   = lower_hex(HMAC-SHA256(K_c, utf8("agent-memory/v1/d-tag") || 0x00 || utf8(slug)))

engram.rs 里的 d_tag 就是这三行,域前缀写成常量 D_TAG_DOMAIN,中间那个 0x00 字节是把域前缀和 slug 分开、防止拼接歧义。规范还有一句硬要求:实现不得把 slug 或它的任何明文形式放进标签。

于是中继看到的只是 64 个小写十六进制字符。这一步就把检索能力定死了:

你必须先知道 slug 的完整字符串,才能算出 d,才能查到那条事件。 反推不回去,前缀也没用——mem/notes/2026-05-12mem/notes/2026-05-13d 值毫无相似性,HMAC 就是干这个的。所以”把 mem/notes/ 下面的都列出来”这种请求,在协议层没有对应的查询。

crates/buzz-cli/src/commands/mem.rs 里的 fetch_head 就长成这样,一个精确到 d 的过滤器:

let filter = serde_json::json!({
    "kinds": [KIND_AGENT_ENGRAM],
    "authors": [agent.to_hex()],
    "#d": [d],
    "#p": [owner.to_hex()],
    "limit": 16,
});

limit 是 16 而不是 1,因为可寻址事件的”只留最新”是中继的应有行为而非保证,某些中继会把旧版本一起吐出来。所以拿回来一批之后还要在本地做一次收敛:逐条 verify() 验签、validate_and_decrypt 解密并复核信封、然后 select_headcreated_at 最大者取胜、相同秒数比事件 id 小者胜。这就是 NIP-AE 的 Head selection 规则,纯粹的 last-write-wins。

写入侧配套一条单调时钟规则,monotonic_created_at 实现:新事件的时间戳取 max(now, 前一个 head 的时间戳 + 1)。理由规范里写了——NIP-44 用随机 nonce,同一秒内的 id 大小不可预测,靠时间戳单调递增才能保证新写的一定压过旧的。

三、四块拼图分别在哪

组成部分它负责什么仓库位置你什么时候会碰到它
规范本体定义 slug 语法、d 推导、信封形状、head 选择五条规则、写入与列举流程,附带四组可复现的测试向量docs/nips/NIP-AE.md自己写第二套实现、或怀疑某条记忆为什么被判无效时
协议原语无 I/O 的纯逻辑:validate_slugd_tagBody 编解码、build_eventvalidate_and_decryptselect_headextract_refscrates/buzz-core/src/engram.rs想把 engram 接到自己的传输层上;模块开头就写明它不碰中继也不碰文件系统
会话注入新会话诞生时同步取一次 core,渲染成一段提示词;取不到时决定是给引导语还是什么都不给crates/buzz-acp/src/engram_fetch.rs排查”Agent 为什么没带上人格设定”;调用点在 crates/buzz-acp/src/pool.rs
人工操作面buzz mem ls / get / hash / set / patch / rm 六个子命令,含并发编辑防护crates/buzz-cli/src/commands/mem.rs手动播种记忆、备份、或者 owner 侧抢救一个失联 Agent 的记忆

另外两处不在这条主链上但会影响你观察到的行为:中继的入库校验在 crates/buzz-relay/src/handlers/ingest.rsvalidate_engram_envelope,读取授权在 crates/buzz-relay/src/handlers/req.rsengram_filters_authorized

四、读路径的三种形态,各有各的上限

第一种是自动注入。 engram_fetch.rsbuild_core_section 只做一件极窄的事:会话新建时查一次 core,命中就渲染成 [Agent Memory — core] 加上 profile 正文。它的三态设计值得抄——

  • 中继明确返回空集合,才算”确实没有”,这时给出 ONBOARDING_NUDGE,提示 Agent 用 buzz mem set core "…" 建一条。
  • 拿回了候选但一条都解不开,返回 Err,调用方什么都不注入
  • 传输失败、解析失败,同样什么都不注入。

模块注释把理由写得很直白:不能把中继故障误当成”没有 core”,否则 Agent 会以为自己是张白纸,转头用一份新 profile 覆盖掉真实存在、只是暂时读不到的记忆。这是记忆系统里最实用的一条工程判断——读失败和读到空,必须走不同分支pool.rs 那边还包了 3 秒的 CORE_FETCH_TIMEOUT,超时也是不注入,宁可少一段提示也不让会话创建被卡住。同一处注释写明没有会话中途刷新:只有会话失效重建才会重新取。运维侧留了 --no-memory / BUZZ_ACP_NO_MEMORY 开关整体跳过。

第二种是精确取。 buzz mem get <slug> 走上面那个 fetch_head。命中返回原始字节、不加尾换行,好和 buzz mem set foo - 对接;缺失和墓碑(valuenull)都算 not found。

第三种是枚举。 cmd_ls 不带 #d,只按作者加 #p 捞一批回来,本地解密后按 d 分组、每组选 head、丢掉墓碑、丢掉 core,输出 {slug, event_id, created_at} 三元组。它请求的 limit 是 5000。

crates/buzz-db/src/event.rsDEFAULT_MAX_PAGE_LIMIT1_000crates/buzz-relay/src/nip11.rs 把这个同一个常量作为 NIP-11 的 max_limit 对外公布(NIP-11 是中继的自述文档规范:客户端访问中继地址就能拿到一份 JSON,里面写着这台中继支持哪些 NIP、单次查询最多返回多少条这类限额,所以这个上限不是藏起来的),请求里超过它的 limit 会被夹到这个值。也就是说对着 buzz 自带中继跑 mem ls,你实际最多拿到 1000 条事件——不是 1000 条记忆。同一个 slug 的历史版本、墓碑事件都占额度。NIP-AE 的 Listing 一节自己也承认这是 best-effort:Nostr 没有协议级分页,被上限静默截断的结果集会少报,实现应该把它当快照而不是完整枚举。

引用图是第四种,但它是非规范的。 记忆正文里可以用双方括号包一个 slug 当引用,extract_refs 按首次出现顺序去重提取。这个扫描纯粹是字面子串匹配:没有转义机制、不认 markdown 上下文、没括号的裸 slug 不算引用、括号里语法不合法的直接丢掉。规范给出的用法是从 core.profile 出发做可达性遍历,不可达的叫 orphan,并且明确要求客户端把 orphan 摊给用户看、不许自动删。这是一张你自己手写出来的图,不是系统推断出来的关联。

五、边界与代价:它明确不管的事

没有语义检索,一点都没有。 content 是 NIP-44 密文,d 是 HMAC,中继手里没有任何可做相似度或全文匹配的素材。req.rs 里的 engram_filters_authorized 把这条封得更死:能匹配到 30174 的过滤器,必须 authors 全等于自己、或者 #p 全等于自己,否则订阅被直接 closed,理由字符串写着 agent-engram 读取需要 authors 或 #p 等于自己。这个门被刻意放在 NIP-50 搜索分支之前执行(NIP-50 是 Nostr 的全文搜索扩展,允许过滤器里带一个 search 字段让中继在自己的索引里做关键词匹配,这条路径绕过了按作者和标签取事件的常规通道),注释说明了原因:否则一个已认证成员可以拿 {"search":"...","kinds":[30174]} 去捞全局存储的敏感事件。所以”我记得提过预算的事但忘了存在哪”这类需求,engram 给不了答案。真要模糊召回,得在 engram 之外自己建索引层,而这层的召回质量问题就回到 RAG 检索调优 那一套里去了。

单条有硬上限。 NIP44_PLAINTEXT_MAX 是 65,535 字节,卡的是序列化后的 body 字节数,超了 build_event 直接返回 BodyTooLarge,不给你先加密再失败。cmd_set 从标准输入读的时候也按这个数加一字节设上界,防止上游疯了把内存打满。长文档得自己切片,切完的组织关系还是靠你手写的引用。

没有版本链。 last-write-wins 的另一面是历史不可追。规范的安全考虑一节写得很清楚:持有 seckey_a 的人能重写或墓碑任何一条记录,还能对每一个已知的 owner 公钥推出 K_c,解开这些对下所有过去和未来的记录;在遵守可寻址替换语义的中继上,重写不留协议层痕迹;归档型中继能看出”发生过重写”,但没法自己判断哪一版是权威版。这个 NIP 不提供权威版本链机制。

owner 没有写权限。 只有 seckey_a 能签这些事件。owner 能读全部、能通过 buzz mem ls --agent <pubkey> 做抢救式读取(CLI 身份此时被当作 owner),但改不了。想让 owner 指挥 Agent 的记忆,规范说这是带外交互,协议不管。

记忆污染不管。 加密保的是机密性,不是 Agent 记下来的东西是真的。规范原话把准入控制归给实现者。这块的现实后果可以看 Agent 记忆污染与遗忘

中继迁移不自动。 持久性挂在 Agent 的写中继集合上,规范建议下线任何中继前先把当前 head 重发到新集合,并明说自己不定义自动迁移机制——换中继而不搬 head,那些没同时存在于保留中继上的记忆就没了。

元数据是明文的。 这条在自建部署时必须算进去。密文之外,(作者公钥, kind:30174, #p=owner公钥, created_at, 事件大小) 全都落在中继的数据库里。运营这台中继的人看不到你记了什么,但看得到这个账号在用 Agent 记忆、它的 owner 是谁、写入节奏是什么样、有多少条、每条多大。规范把这个性质总结为”化名而非匿名”。你自建中继就等于自己承担这份日志,同时暴露一个对外的中继端口。

六、上手与避坑清单

1. BUZZ_PRIVATE_KEY 就是全部记忆的总钥匙,别放进任何会被打包的东西里。 为什么会踩:mem.rs 模块注释写明默认拿调用方的 BUZZ_PRIVATE_KEY 当 Agent 的 nsec,用起来像个普通环境变量,很容易顺手写进 CI 变量或者 .env 后提交。但拿到它的人不只是能冒充这个 Agent 发消息——能重写、能墓碑、能对任意 owner 公钥推 K_c 解开全部历史。怎么避:让它只存在于运行时注入的进程环境里,别落盘、别进日志、别进镜像层;轮换时记住旧密钥能解开的历史无法追回。owner 侧的 pubkey 要么靠 --owner 显式传,要么靠 BUZZ_AUTH_TAG 里的 NIP-OA 附证解析——docs/nips/NIP-OA.md 定义的这个附证是一个 auth 标签,owner 用自己的私钥签一段声明,授权某个 Agent 公钥以自己的身份发事件;它刻意不走 NIP-26 的委托语义,因为那套语义会把事件在语义上归给委托方,而这里要保留的恰恰是”这条是 Agent 自己写的”这个作者归属。

2. 想改一条已有记忆,用 mem patch 而不是 mem set 为什么会踩:set 是整条覆盖,两个并发写手都读了旧值再各写一遍,后到的那个会把前一个的编辑整段吞掉,而 LWW 不会报错。怎么避:buzz mem hash <slug> 拿到当前值的 sha256,编辑,然后 buzz mem patch <slug> --base-hash <hex> 把 diff 喂进去。值变了就报冲突让你重新生成补丁。--base-hash 是必填的,除非你显式写 --no-base-hash 认领风险。--dry-run 会把补丁和结果哈希回显但不落地。

3. 补丁必须在它声称的行号上原样对得上。 为什么会踩:底层的 diffy 在上下文匹配失败时会滑动补丁去别处找匹配位置,落地到一个你没预期的地方。mem.rsverify_hunks_at_declared_position 专门把这个行为掐掉了:每个 hunk 的前像行必须在它 @@ -N,M @@ 声明的那个行号上逐字节相符,不允许偏移、不允许模糊。怎么避:编辑前先 mem get 拿最新值再生成 diff,别拿旧副本改;无上下文的纯插入补丁会被拒(diff -u 默认带上下文,手写才会撞上)。另外多文件补丁也会被拒——一个 slug 是一个虚拟文件,--- 头出现两次以上直接报错。

4. 管道里的 mem set <slug> - 可能写进空值。 为什么会踩:上游某一步挂了、关掉 stdout,set 读到零字节,一次覆盖就把这条记忆清空了。怎么避:默认行为已经帮你拦了——从 stdin 读到空且没带 --allow-empty 会直接拒绝,并提示要真删就用 buzz mem rm。别为了让脚本”跑通”而习惯性挂上 --allow-empty

5. 别指望 mem rm core 为什么会踩:core 的墓碑在 NIP-AE 里没有定义语义,规范只给记忆条目定义了墓碑。怎么避:cmd_rm 直接拒绝并告诉你改用 buzz mem set core '' 覆盖成空 profile。同样地,core 不出现在 mem ls 的输出里,这是规范规定的列举行为,不是命令坏了。

6. mem ls 空输出不等于没有记忆。 为什么会踩:枚举被中继的 limit 夹过、被解密失败静默跳过(parse_eventscmd_ls 都是遇到坏事件就 continue,一条烂数据不该拖垮整个列举)、或者你查的 owner 对不上。任何一种都表现为”少了几条”或者干净的空列表。怎么避:把它当采样看待;关键 slug 用 mem get 精确验一遍;重要的 slug 清单自己在 core.profile 里用引用维护,别指望从中继反查回来。

7. 换中继之前先把 head 搬过去。 为什么会踩:记忆的持久性挂在 Agent 的写中继集合上,没有自动迁移。怎么避:下线旧中继之前,把当前每一条 head 重新发布到新集合,确认新集合上读得到再切。

8. slug 命名一次定终身,起名的时候当成主键起。 为什么会踩:改名等于换 d,等于新开一条记忆,旧的变成孤儿,而且你没法用前缀把它们捞出来对照。怎么避:命名规则提前定好并写进 core.profile,只用小写字母数字和 _ -,层级用 /;日期这类天然有序的东西放在段里(规范测试向量用的就是 mem/notes/2026-05-12 这种形状)。

收束:判断它适不适合你的四个问题

engram 的取舍其实很干净:它用一个 HMAC 换来了”中继运营者看不到你的记忆名字”,代价是任何形式的模糊检索都得由你在协议之外自己搭。这套设计适合”数量可控、名字稳定、需要跨会话确定性复现”的记忆——身份设定、长期规矩、结构化笔记索引。不适合”海量、名字随内容长出来、需要靠语义捞”的那类。

落地前先自问四条:

  1. 你的记忆条目能不能用一套人能记住的命名规则枚举出来?不能,就先建命名规则,别先建记忆。
  2. 有没有哪条记忆会超过 65,535 字节?会的话切片方案和引用维护成本算进去了吗?
  3. Agent 私钥的存放和轮换流程定了吗?记住轮换不能挽回已泄漏密钥解开的历史。
  4. 中继部署在谁手里,它的数据库里那份元数据你能不能接受?

想继续往下读,路线是这样的:先 docs/nips/NIP-AE.md 从头到尾一遍,尤其 Head selection 的五条规则和 Security considerations 一节;再 crates/buzz-core/src/engram.rs,它的单元测试直接钉住了规范里的测试向量,看测试比看实现快;然后 crates/buzz-acp/src/engram_fetch.rs 看三态注入怎么做的;最后 crates/buzz-cli/src/commands/mem.rs 看那些护栏各自防的是什么事故。要理解为什么中继侧不肯让你搜,crates/buzz-relay/src/handlers/req.rsengram_filters_authorized 的那段注释比任何二手解释都清楚。

本篇属于一个把开源多 Agent 通信平台 buzz逐层拆开讲的系列,整体地图见 buzz 是什么:Block 开源的多 Agent 通信平台全景图;沿着这条线往下,还可以看 Block 开源多 Agent 通信平台 buzz 的两层隔离:租户在中继之上,频道在租户之下Block 开源 buzz 多 Agent 通信平台:中继内部三层转发

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。