Electric Agents 实体协作模式:用 blackboard 共享状态构建多 Agent 协作系统
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
多个 Agent(实体)如何围绕同一份数据集协同工作,而不是各自闭门造车?在 Electric Agents 的实体设计体系中,blackboard(黑板 / 共享状态)模式给出了标准答案:把读写操作追加到一条**所有参与者都连接的持久流(durable stream)**上,由父实体创建共享状态,子实体连接进来,借助自动生成的 CRUD 工具完成协作。本指南以 blackboard 模式文档 为骨架,结合 agents-runtime 运行时源码 与 测试用例 的验证,完整讲解共享状态的 schema 设计、自定义 worker 注册、父/子实体 handler 写法,以及模式专属的审查清单与反模式。读完你将能够独立实现辩论、Wiki 协作、同行评审、交易大厅等"多实体共写一份数据"的实体系统。
blackboard 模式是什么:多个实体通过共享数据结构协调
blackboard(shared state)模式的核心定义是:
多个实体通过一个共享的数据结构进行协调——所有的读取和写入都被追加到一条持久的流(durable stream)上,每个参与者都连接到这条流。父实体负责创建共享状态;子实体连接进来,并使用自动生成的 CRUD 工具进行协作。
这与我们熟悉的"黑板架构"一致:黑板本身是一块共享的、可被所有人读写的存储区域,专家(这里的子实体)在上面不断添加自己的贡献,父实体(或某个阅读者实体)负责汇总、审查与综合。在 Electric Agents 的实现里,这块"黑板"不是内存对象,而是持久化的事件流——写入的每一行都是流上的一条事件,任何参与者都可以回放、连接并响应。
在 designing-entities 技能 归纳的七种协调模式(single-agent、manager-worker、pipeline、map-reduce、dispatcher、blackboard、reactive-observers)中,blackboard 是一种可以叠加在其他模式之上的协作模式:manager-worker或map-reduce负责定义"如何派生子实体",而 blackboard 定义的是"子实体之间通过哪条数据通道共享成果"。因此它特别适合贡献是"加法式、累积式"的工作负载。
为什么共享状态工作流必须使用自定义 worker 类型
Electric Agents 服务器内置的worker(即 SKILL.md 中描述的 built-in agent 类型)是一个最小权限沙箱:它只接受{ systemPrompt, tools }两个 spawn 参数,不会收到ctx.electricTools,也不认识sharedState。也就是说,直接 spawn 内置"worker"并把sharedState塞进参数里是无效的——该参数会被静默忽略。
因此,blackboard 工作流有两条实现路径,二选一:
- 在应用中注册自定义 worker 实体类型:该类型在
ctx.args中接收sharedState,调用ctx.observe(db(args.sharedState.id, args.sharedState.schema)),并向自己的 Agent 暴露共享集合的 CRUD 工具。playground 的 worker 是一个现成模板——把它的写法复制到你的应用中,用唯一的类型名(例如"blackboard-worker")注册即可。文档中标注的权威示例位于examples/durable-agents-playground/src/workers/worker.ts(配套示例debate.ts、wiki.ts、peer-review.ts、trading-floor.ts位于examples/durable-agents-playground/src/blackboard/,该 playground 目录属于文档体系中的 canonical material,当前仓库树中未包含),它接受sharedState/sharedStateToolMode/builtinTools三个 spawn 参数。 - 改用
ctx.send+ctx.observe协调:父实体写入自己的状态,子实体观察父实体,子实体再把结果通过ctx.send发回。这种方案的带宽更低,但放弃了共享集合 CRUD 工具的便利性。
下文所有示例均假设你选择了方案 1——即已在应用中注册了自定义的blackboard-worker类型。
适用场景:何时该选择 blackboard 模式
当开发者的描述符合以下特征时,blackboard 模式就是正确的选择(触发词表完整定义见 pattern-triggers.md):
- 多个 worker 需要读取并向同一份数据集贡献内容(研究结论、辩论论点、订单、Wiki 章节);
- 贡献是加法式或累积式的——每个 worker 往里加东西,而不覆盖别人的成果;
- 父实体(或某个阅读者实体)负责从共享状态中聚合 / 审查 / 综合最终结果;
- 可以叠加在
manager-worker或map-reduce之上——前者定义派生关系,后者定义共享数据通道。
在 pattern-triggers.md 的触发词表 中,命中 "shared knowledge base"、"collaborative writing"、"debate"、"wiki"、"shared state"、"collective intelligence"、"workers updating the same board" 等短语的描述,都指向 blackboard。当"manager-worker + blackboard"叠加出现时(例如"多个 worker 研究主题并把结论写到共享板上,manager 读取后产出最终报告"),需要进一步澄清一个关键问题:父实体是等所有 worker 完成后才综合,还是在结论落到黑板上时实时响应?前者用wake: { on: "runFinished" }等待完成,后者则需要在观察共享状态时配置wake: { on: "change" }。
模式所需状态:父状态与共享 schema 的定义
blackboard 模式涉及两类状态定义:父实体自己的状态,以及由父实体创建、父子共享的 schema。
父实体状态(用于记录当前阶段的机器状态):
state: { status: { schema: z.object({ key: z.literal("current"), value: z.enum(["idle", "active", "done"]), }), primaryKey: "key", }, // 如果要动态派生 worker,还需要 children 追踪 }共享 schema(必须从一个共享模块导出,父实体和子实体都从这个模块导入同一个 schema 对象):
// shared-schema.ts import { z } from 'zod/v4' export const debateSchema = { arguments: { schema: z.object({ key: z.string(), side: z.enum(['pro', 'con']), round: z.number(), text: z.string(), }), type: 'shared:argument', primaryKey: 'key', }, } as const这里有几个值得注意的约定:
type: 'shared:argument':共享集合的type遵循"shared:<name>"命名约定。这与 源码中的SharedStateCollectionSchema类型 一致——每个共享集合有一个schema(Zod 或其他 Standard Schema 校验器)、一个type(持久流中使用的事件类型字符串)和一个primaryKey(行的字符串主键字段)。shared:前缀用于在持久流中区分共享状态事件与实体本地状态事件。zod/v4:Electric Agents 运行时使用 Zod v4 的校验对象(StandardSchemaV1兼容)。后文的反模式中会强调:不要把 schema 序列化成 JSON 字面量传过去——必须传递导入的对象本身。- schema 必须唯一:
SharedStateSchemaMap本质上是"集合名 → 集合 schema 定义"的映射(见 types.ts 的注释示例)。父子两边若各自声明一份看起来一样的 schema 对象,运行时并不会把它们当成同一个 schema,会引发静默写入失败或 CRUD 工具形状漂移。
父实体 handler 骨架:创建共享状态并派生 worker
父实体的 handler 完整骨架如下(节选自 blackboard 模式文档):
import { debateSchema } from "./shared-schema" import { db } from '@electric-ax/agents-runtime' async handler(ctx, wake) { const sharedId = `debate-${ctx.entityUrl}` if (ctx.firstWake) { ctx.mkdb(sharedId, debateSchema) ctx.db.actions.status_insert({ row: { key: "current", value: "idle" } }) } const shared = await ctx.observe(db(sharedId, debateSchema), { wake: { on: "change", debounceMs: 500 }, // optional — 如果父实体需要响应更新 }) const startDebateTool: AgentTool = { execute: async (_id, { topic }) => { const pro = await ctx.spawn( "blackboard-worker", // 应用中注册的自定义类型 — 不是内置的 "worker" "pro", { systemPrompt: "Argue FOR the proposition...", sharedState: { id: sharedId, schema: debateSchema }, }, { initialMessage: `Topic: ${topic}`, wake: { on: "runFinished", includeResponse: true } } ) // ... 类似地派生 con ... return { content: [{ type: "text", text: "Debate started." }], details: {} } }, // ... } // 后续需要读取共享状态: // const args = shared.arguments.toArray ctx.useAgent({ /* ... */ }) await ctx.agent.run() }逐段解读这段骨架:
sharedId = \debate-${ctx.entityUrl}``:共享状态的 ID 通常由实体 URL 派生,保证每个父实体实例拥有独立、可预测的共享流。if (ctx.firstWake) { ctx.mkdb(...) }:mkdb只能在首次唤醒时调用。它负责初始化共享状态的底层流;再次调用会报错。这在源码中有直接佐证——setup-context.ts 的buildSharedStateHandle中,当以create模式调用而该 ID 已存在句柄时,会抛出[agent-runtime] shared DB "<id>" already exists — use observe(db("<id>", schema)) to get a handle的错误。ctx.observe(db(sharedId, debateSchema), { wake: { on: "change", debounceMs: 500 } }):每次唤醒都要调用(句柄不会跨唤醒持久)。配置wake: { on: "change" }后,共享状态发生变化会再次唤醒父实体;debounceMs用于合并高频更新。- spawn 自定义类型:子实体必须是应用注册的自定义 worker 类型(如
"blackboard-worker"),spawn 参数中携带sharedState: { id, schema }。内置的worker不支持这些参数。
子 worker handler 骨架:连接共享状态并贡献数据
子 worker 的 handler 完整骨架如下:
import { debateSchema } from "./shared-schema" // 同一个 schema 对象 import { db } from '@electric-ax/agents-runtime' async handler(ctx) { const args = ctx.args as { sharedState: { id: string; schema: typeof debateSchema } } const shared = await ctx.observe(db(args.sharedState.id, args.sharedState.schema)) // Worker 通过 shared.<collection>.insert / .update 写入 // 当 sharedStateToolMode: "full" 时,自动生成的 CRUD 工具 // (write_arguments、read_arguments 等)也会暴露给它的 LLM ctx.useAgent({ systemPrompt: args.systemPrompt, model: "claude-sonnet-4-5-20250929", tools: [...ctx.electricTools], }) await ctx.agent.run() }关键点:
- 从
ctx.args取{ id, schema }:按惯例放在args.sharedState.id和args.sharedState.schema。这是父子之间唯一的"握手协议"——父实体 spawn 时传入,子实体在 handler 中解包。 ctx.observe(db(...))每次唤醒都调用:子 worker 不调用mkdb(共享状态已由父实体创建),只以connect模式观察。- 写入方式:代码里可以直接
shared.arguments.insert(...);当 spawn 参数设置sharedStateToolMode: "full"时,运行时还会把每个共享集合的 CRUD 工具(write_arguments、read_arguments等)注入到tools,交给 LLM 自主调用。这正是"自动生成的 CRUD 工具"一词的含义。 ctx.electricTools展开到 tools:这与 设计实体的通用规范 一致——注意内置worker拿不到electricTools,而自定义 worker 可以。
运行时原理:mkdb 与 observe 的底层实现
blackboard 模式的两个核心 API——ctx.mkdb与ctx.observe(db(...))——在 setup-context.ts 中有完整实现,理解它们有助于把握模式的不变量。
ctx.mkdb(id, schema)是"创建模式"(create)下的buildSharedStateHandle。它把共享状态注册进唤醒会话的 manifest(kind: "shared-state",key 形如shared-state:<id>),并从 schema 提取每个集合的type与primaryKey(setup-context.ts#L395-L409)。如果同一 ID 已存在句柄而以create模式再次调用,会直接抛错——这正是"mkdb只能firstWake调用一次"的运行时保证。
ctx.observe(db(...))则是"连接模式"(connect)。观察到的实体/共享状态句柄会被缓存(observeHandleCache),并对已观察的实体注册唤醒(setup-context.ts#L1043-L1056 附近的 spawn 逻辑中,observe: false才能关闭对派生实体的自动观察)。此外,延迟 wiring 的观察句柄会在preloadDeferredObservedHandles中统一preload()(setup-context.ts#L1313-L1319),保证读取前数据已就绪。
写操作的语义:共享集合的代理类型StateCollectionProxy(types.ts#L481-L491)定义了insert/update/delete(返回EntityTransaction,可 fire-and-forget 或await tx.isPersisted.promise等待持久化)以及get/toArray(读取委托给底层 TanStack DB 集合)。
测试用例的完整验证:agents-runtime 的 runtime-dsl.test.ts 用 D1–D7 一组用例系统验证了共享状态语义,对应快照在 runtime-dsl.test.ts.snap 中:
| 用例 | 验证点 | 源码位置 |
|---|---|---|
| D1 | mkdb产生带 manifest 条目(shared-state:<id>)的实体历史 | runtime-dsl.test.ts#L1942-L1949 |
| D2 | 即使没有任何写入,共享状态流也已经存在 | runtime-dsl.test.ts#L1951-L1959 |
| D3 | 对共享状态的写入同时反映在实体历史与共享状态历史中 | runtime-dsl.test.ts#L1960-L1977 |
| D4 | 第二个实体可以连接既有共享状态并读取之前写入的行 | runtime-dsl.test.ts#L1979-L1993 |
| D5 | 共享状态的 update / delete 事件跨唤醒保持持久 | runtime-dsl.test.ts#L1995-L1999 |
| D6 | 多集合共享状态在 writer 与 reader 实体之间保持一致 | runtime-dsl.test.ts#L2011 附近 |
| D7 | 多个实体可以向同一共享集合贡献持久行 | runtime-dsl.test.ts#L2053 附近 |
这些测试从行为层面证实了 blackboard 模式的三个关键承诺:共享流先于写入存在(D2)、写入跨实体可见且持久(D3–D5)、天然支持多写入者(D7)。
不变量:blackboard 模式的正确姿势
把上面的分析收敛为五条必须遵守的不变量:
mkdb只在ctx.firstWake中调用:它初始化底层流;再次调用是 no-op 或直接报错(如setup-context.ts中"already exists"错误所示)。observe(db(...))每次唤醒都调用(父实体和子实体都是如此):观察句柄不跨唤醒持久,必须在每次 handler 运行中重新获取。- 父实体和子实体使用同一个 schema 对象:从共享模块导入,禁止重复声明。schema 不一致会导致静默写入失败或 CRUD 工具形状漂移。
- 子实体通过
ctx.args接收{ id, schema }:按惯例放在args.sharedState.id与args.sharedState.schema。 - 写入是最终一致的:不要假设一次写入能立即被其他实体看到;需要响应更新时,使用
wake: { on: "change" }。
模式专属审查清单
designing-entities 技能 的第 4 阶段要求机械式套用通用审查清单 + 模式专属清单。blackboard 模式文档 给出了 BB1–BB7 七条模式专属规则(也可对照 review-checklist.md 中的 W2 条——spawn 参数中不得出现sharedState/sharedStateToolMode/builtinTools,需要时只能 spawn 自定义 worker 类型):
| # | 规则 | 原因 |
|---|---|---|
| BB1 | 共享 schema 在共享模块中声明,父实体和子实体都导入它 | 防止写入者与读取者之间的 schema 漂移 |
| BB2 | mkdb位于if (ctx.firstWake) { ... }内 | 每次唤醒都调用会报错 |
| BB3 | observe(db(...))在firstWake守卫之外、每次唤醒都调用 | 句柄必须每次唤醒重新获取 |
| BB4 | Worker 通过ctx.args接收{ id, schema },并调用ctx.observe(db(args.sharedState.id, args.sharedState.schema)) | 标准的 worker 契约 |
| BB5 | 若父实体要响应更新(而非等待子实体完成),配置observe(db(...), { wake: { on: "change", debounceMs } }) | 否则父实体永远不会因共享状态变化而唤醒 |
| BB6 | 共享集合type遵循"shared:<name>"约定 | 在持久流中区分共享状态事件与实体本地状态事件 |
| BB7 | 被派生的 worker 是应用注册的自定义类型(非内置worker),或者改用ctx.send+ctx.observe协调 | 内置 worker 是最小权限沙箱,不支持sharedState参数 |
反模式:需要避免的常见错误
- 在 worker 文件里重复声明 schema:两个看起来一模一样、但相互独立的对象字面量并不是同一个 schema。必须从共享模块导入。
- 每次唤醒都调用
mkdb:根据运行时不同会报错或覆盖数据;只能在firstWake调用。 - 假设写入立即可见:共享写入是异步的;需要响应时使用 wake-on-change。
- 把 schema 作为 JSON 字面量传递:运行时需要的是 Zod(或等效 Standard Schema)对象——传递导入的对象,而不是
JSON.stringify(schema)的产物。
与其他协调模式的组合使用
blackboard 是"可叠加"的协作模式,典型组合包括:
- manager-worker + blackboard:父实体按固定角色派生多个专家 worker,worker 的结论统一写入共享集合,父实体读取聚合。若父实体需要等全部完成再综合,用
wake: { on: "runFinished" };若需要实时响应新结论,则对共享状态配置wake: { on: "change" }。 - map-reduce + blackboard:动态分块派生 worker,每个块把结果写到共享集合,reduce 阶段从集合中读取全部结果进行合并。
判别方法见 pattern-triggers.md 的消歧流程:先问"是否派生/协调其他实体",再问"并行还是串行"、"固定角色还是动态类型"、"多个实体是否读写同一份数据集"——最后一条命中即指向 blackboard,且它通常作为次级模式叠加在某种派生模式之上。在设计时,把"定义 handler 形状"的模式作为主模式,把 blackboard 作为额外的状态与唤醒需求来声明。
从模式文档到运行时实现,blackboard 的设计环环相扣:mkdb保证共享流只创建一次,observe(db(...))保证每个参与者都能在每次唤醒时重新连接,"shared:<name>"的 type 约定让共享事件在持久流中可辨识,而 D1–D7 测试则从行为上锁定了这套语义。按照本文的 schema 组织、父/子 handler 骨架与审查清单,你就可以在自己的 Electric Agents 应用中搭建出稳定、可扩展的多实体共享状态协作系统。
【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考