Cloudflare Agents 子代理(Sub-Agents / Facets)架构设计与实战:从 RFC 到隔离、扇出与监督式生命周期
【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents
子代理是 Cloudflare Agents SDK 在Agent基类中内置的一等抽象:任何Agent都可以作为顶层 Durable Object(通过 wrangler 绑定)挂载,也可以作为子 facet(通过this.subAgent())被同一父 Agent 监督。本文以仓库中的设计文档 design/rfc-sub-agents.md 为骨架,结合 SDK 源码(packages/agents/src/index.ts、packages/agents/src/dynamic-agents/dynamic-agents.ts)、完整测试套件(packages/agents/src/tests/sub-agent.test.ts)以及四个experimental/gadgets-*实战示例,系统讲解子代理解决的问题、subAgent/abortSubAgent/deleteSubAgent三方法 API、类型系统、初始化与校验流程,并给出可直接运行的实战代码。读完你将掌握如何在 Cloudflare Agents 上实现数据隔离、多租户/多房间、并行扇出与结构化访问控制。
问题背景:为什么单个 Agent 不够用
一个 Agent 就是一个 Durable Object,自带一个 SQLite 数据库。这对简单场景没问题,但真实应用往往需要内部结构。RFC 列出了四个必须由"子 Durable Object"原语解决的典型诉求:
- 隔离(Isolation):代码沙箱 Agent 需要一个 LLM 无法直接访问的数据库。如果审批队列和客户数据放在同一个 SQLite 里,就没有结构性约束——LLM 可以通过写 SQL 绕过审批。你需要一个独立的存储边界。
- 多实例(Multiplicity):聊天应用需要很多房间,每个房间有自己的消息历史和 LLM 上下文。把所有房间塞进一张带
room_id的表虽然能用,但房间之间没有隔离、没有独立生命周期,父 Agent 会退化成管理每个房间状态的"上帝对象"。 - 并行工作(Parallel work):分析 Agent 想把一个问题分发给三个专业人设,每个人设独立调用 LLM、拥有独立的 system prompt 和历史。串行太慢;在单个 Agent 里并行则意味着共享可变状态、人设之间没有隔离。
- 有界上下文(Bounded context):门卫 Agent 需要强制所有数据库变更都走审批队列。如果数据库和 Agent 同体,这种强制只是一种约定("别直接调
this.sql")。你要的是结构性的强制——Agent 除了通过类型化接口之外,字面上没有任何路径能触达数据。
这些诉求的共同点是同一个原语:与父 Agent 同置(colocated)的子 Durable Object,各自拥有独立的 SQLite,通过类型化 RPC 调用。workerd 运行时提供了底层积木(ctx.facets、ctx.exports),而 Agents SDK 需要为它提供一等抽象——这就是 RFC 的由来。
设计核心:子代理管理直接内建于Agent基类
无独立SubAgent类,行为随实例化方式自适应
子代理管理直接构建在Agent基类之中,没有单独的SubAgent类。任何Agent都可以被挂载为:
- 顶层 Durable Object(通过 wrangler 绑定),或
- 子 facet(通过
this.subAgent())。
行为根据实例化方式自适应。RFC 中给出的最小 API 示意如下(原文摘录):
import { Agent } from "agents"; export class SearchAgent extends Agent<Env> { onStart() { this .sql`CREATE TABLE IF NOT EXISTS cache (q TEXT PRIMARY KEY, result TEXT)`; } async search(query: string): Promise<Result[]> { const cached = this.sql`SELECT * FROM cache WHERE q = ${query}`; if (cached.length) return cached; // ... fetch, cache, return } } export class MyAgent extends Agent<Env> { async doStuff() { const searcher = await this.subAgent(SearchAgent, "main"); const results = await searcher.search("hello"); } }三个管理方法
Agent上共有三个子代理管理方法:
| 方法 | 语义 |
|---|---|
subAgent(cls, name) | 获取或创建指定名称的子 facet,返回类型化 RPC stub。子类必须继承Agent并从 worker 入口导出。首次调用触发子onStart(),后续调用返回既有实例 |
abortSubAgent(name, reason?) | 强制停止运行中的子代理。挂起的 RPC 调用会收到以 reason 为内容的错误。传递性地中止子代理自己的子代理。下一次subAgent()调用时子代理会重新启动 |
deleteSubAgent(name) | 先中止子代理,再永久擦除其存储,传递性地删除其子代理,不可逆 |
父子都用Agent。子 Agent 自己也可以调用this.subAgent()创建嵌套 facet,形成多级树。
从源码看,三个方法在 packages/agents/src/index.ts 中的实现均为薄封装:subAgent(cls, name)委托给this.dynamicAgents.get(cls, name)(L5838-L5843),abortSubAgent委托给this.dynamicAgents.abort(L8361),deleteSubAgent委托给this.dynamicAgents.delete(L8376)。真正承载 facet 管理逻辑的DynamicAgentsInternal作为 Lifecycle capability 安装在每个 Agent 上,见 packages/agents/src/dynamic-agents/dynamic-agents.ts(L79-L115)。
SubAgentStub<T>:类型化 RPC stub
this.subAgent(SearchAgent, "main")返回的是SubAgentStub<SearchAgent>——一个映射类型,把子类上所有用户自定义的公开方法暴露为异步 RPC 调用,同时隐藏Agent/Server/DurableObject的内部方法。
排除机制使用keyof Agent:凡是在Agent基类上定义的方法都会从 stub 中隐藏。这意味着以后给Agent新增方法会自动被排除,无需维护手工黑名单;只有子类自定义的方法会被暴露。RFC 强调这是零样板设计,也避免了"白名单注册"式 API 的维护成本。
仓库中的类型级测试 packages/agents/src/tests-d/sub-agent-stub.test-d.ts 精确验证了这一行为:
// 同步方法被 Promise 包装 null! as Stub["syncMethod"] satisfies () => Promise<string>; null! as Stub["methodWithArgs"] satisfies (a: string, b: number) => Promise<boolean>; // Agent/Server/DurableObject 内部方法全部被排除 // @ts-expect-error fetch 被排除 null! as Stub["fetch"]; // @ts-expect-error sql 被排除 null! as Stub["sql"]; // @ts-expect-error onStart / onConnect / onMessage ... 被排除 null! as Stub["onStart"];该测试还验证了一个容易被忽略的细节:SubAgentStub只排除keyof Agent。如果一个中间子类(如AIChatAgent)新增了方法,这些方法会保留在 stub 上——这正是设计意图(L112-L134)。
SubAgentClass<T>:构造器类型与env: never方差技巧
SubAgentClass<T>使用env: never作为方差技巧。由于never可以赋值给任意类型,任何Agent<SomeEnv>子类都能满足约束,无论其Env类型参数是什么。真实的env由运行时在实例化 facet 时注入,而不是由调用方传入。
源码中的定义印证了这一点(packages/agents/src/dynamic-agents/types.ts L106):
new (ctx: DurableObjectState, env: never): T;初始化:命名 facet + 原生 RPC 握手
subAgent()会以显式命名的FacetStartupOptions.id创建或取回 facet,然后通过原生 RPC 调用_cf_initAsFacet()。子代理记录 Agent 特定的父元数据并启动其组合生命周期。onStart()是惰性的——在首次访问时运行,而不是在父构造期间运行。
源码 packages/agents/src/index.ts(L5584-L5590)中的_cf_initAsFacet实现细节值得注意:
_cf_initAsFacet( name: string, parentPath: ReadonlyArray<{ className: string; name: string }> = [], identityName = name ): Promise<void> { return this._dynamicAgents.init(name, parentPath, identityName); }该方法的 JSDoc 说明了设计演进:初始化完全在子 agent 自己的 isolate 内运行,因此所有存储写入和onStart()I/O 都由子 DO 拥有。这取代了早期"在父 DO 中构造 Request 再stub.fetch()到子 DO"的握手方式——后者由于原生 I/O 绑定在父对象上,会触发 "Cannot perform I/O on behalf of a different Durable Object" 错误。同时,_isFacet会在onStart()运行之前被抢先设置,让依赖该标志的代码(例如跳过父持有 alarm 的调度守卫)在首次onStart()期间就能看到它。
facet 的逻辑名与路由 id 分开持久化:老式 facet 直接以逻辑名作为ctx.id.name,新式 facet 可以使用路径作用域路由 id 同时保留this.name。
校验:类名与ctx.exports匹配
创建 facet 前,类名会与ctx.exports核对。如果类未从 worker 入口导出,会抛出清晰错误(RFC 原文):
Sub-agent class "Foo" not found in worker exports. Make sure the class is exported from your worker entry point and the export name matches the class name.这能捕获两种常见错误:忘记export类,或使用export { Foo as Bar }(后者会破坏cls.name查找)。测试套件中对应了三个用例:"should throw descriptive error for non-exported sub-agent class"、"should throw descriptive error when the root class is exported under a different name than its declaration"、以及针对代码压缩场景的 "should hint at minification when the root class name looks minified"(见 packages/agents/src/tests/sub-agent.test.ts L169-L211)。
接线:无需 wrangler 配置
子代理不需要wrangler.jsonc条目——没有 binding,没有 migrations。它们通过ctx.facets实例化,通过ctx.exports引用。唯一要求是:类必须以其原始名字从 worker 入口导出。
补充一点来自运行时文档 docs/agents/sub-agents.md 的约束:子类同样不能与保留 token"Sub"重名(任何 kebab-case 后等于"sub"的类都会被拒绝,因为它会与/sub/URL 分隔符冲突);如果子类同时被绑为顶层 Durable Object,才需要在new_sqlite_classes中登记。此外,父类必须被绑定为durable_objects.bindings中的命名空间,且打包器必须保留类名(若 esbuild 未开启keepNames: true,this.constructor.name会变成_a之类的短 id,导致查找失败)。
四个实战模式(experimental/gadgets-*)
RFC 用四个experimental/gadgets-*示例演示了 API 的四种典型用途。以下结合示例源码逐一展开。
模式一:扇出 / 扇入(gadgets-subagents)
示例 experimental/gadgets-subagents/src/server.ts 中,CoordinatorAgent(继承AIChatAgent)并行 spawn 三个PerspectiveAgent子代理,每个人设(技术专家、商业分析师、唱反调者)拥有不同的 system prompt,独立调用 LLM,结果通过Promise.all()汇聚后合成。每个子代理把自己的分析历史持久化在自己的 SQLite中。
核心扇出代码(L246-L272,已精简注释):
const results = await Promise.all( perspectiveIds.map(async (pid) => { const agent = await this.subAgent(PerspectiveAgent, pid); const analysis = await agent.analyze(pid, question); // 把每个结果也写入 coordinator 自己的存储 this.sql` INSERT INTO perspective_results (id, round_id, perspective_id, name, analysis) VALUES (${resultId}, ${roundId}, ${pid}, ${perspective.name}, ${analysis}) `; return { perspectiveId: pid, name: perspective.name, analysis, ... }; }) );子代理PerspectiveAgent.analyze内部独立调用 LLM 并写入自己的analyses表(L134-L155),它的getHistory()只读自己的存储。注意subAgent的幂等性:同一个(PerspectiveAgent, pid)组合第二次调用时返回既有实例,因此历史可以跨多次分析累积,而三个 facet 之间互不可见。
模式二:多房间聊天(gadgets-chat)
OverseerAgent(继承Agent)管理一个房间注册表,每个房间是一个ChatRoom子代理,拥有自己的消息历史和 LLM 上下文。父代理把 WebSocket 消息代理到活动房间,并负责子代理与客户端之间的流中继。删除房间时调用this.deleteSubAgent()——子代理及其存储被永久移除。
源码中的调用形态(experimental/gadgets-chat/src/server.ts):
- 创建/取回:
const room = await this.subAgent(ChatRoom,room-${roomId}); - 删除:
await this.deleteSubAgent(ChatRoom,room-${roomId});
这正是 RFC 中"Multiplicity"诉求的落地方案:每个房间独立生命周期、独立上下文,父 Agent 只负责路由,不再成为上帝对象。
模式三:隔离数据库(gadgets-sandbox)
SandboxAgent(继承AIChatAgent)通过CustomerDatabase子代理实现数据隔离。动态 Worker isolate(通过 Worker Loader)只能通过一个DatabaseLoopbackWorkerEntrypoint 访问数据库,后者代理回父 Agent,父 Agent 再委托给子代理。形成三层隔离:无网络、单一 binding、子代理边界(见 experimental/gadgets-sandbox/src/server.ts 头注释 L11-L41)。
模式四:门控访问(gadgets-gatekeeper)
GatekeeperAgent(继承AIChatAgent)使用 LLM 无法直接访问的CustomerDatabase子代理。所有变更都走审批队列。子代理边界使这种约束成为结构性强制——Agent 除了子代理的 RPC 方法之外没有其他路径触达数据,这正是 RFC 开头 "Bounded context" 诉求的直接实现。
伴随模式:Loopback(回环代理)
当动态 Worker isolate(来自env.LOADER)需要回调子代理时,它们无法持有子代理 stub——只能拥有ServiceStubbinding。此时使用 Loopback 模式:
- 创建一个
WorkerEntrypoint(例如DatabaseLoopback)代理到父 Agent; - 父 Agent 通过
this.subAgent()委托给子代理; - 把该 WorkerEntrypoint 作为 binding 传给动态 isolate。
完整调用链为:dynamic isolate -> WorkerEntrypoint -> parent Agent -> sub-agent。
备选方案与决策理由
RFC 记录了四个被否决/被采纳的备选方案,这对理解 API 形态很有价值:
- 方案 A:独立
SubAgent类 +withSubAgentsmixin(原始提案,被拒)。SubAgent与Agent能力几乎相同(都继承Server、都有this.sql),两套类令人困惑;mixin 写法const Parent = withSubAgents(AIChatAgent); export class MyAgent extends Parent<Env, State>远比直接extends AIChatAgent<Env, State>笨拙;对experimentalcompat flag 的担忧也被高估了——不调用subAgent()的用户不受影响(且当时需要 flag 的ctx.facets/ctx.exports后来已从experimental中毕业)。 - 方案 B:不带
experimental/前缀的独立入口(被拒)。会暗示 API 已稳定。RFC 写作时 SDK 仍在围绕ctx.facets/ctx.exports演进,把方法直接放在Agent上,让稳定性信号来自方法上的@experimentalJSDoc 标签而非 import 路径。 - 方案 C:直接用
DurableObject而非继承Server(被拒)。更轻量,但this.sql对大多数要存数据的子代理确实有用;set-name 初始化模式已存在于Server;子代理作为普通Agent可免费获得完整 Agent 特性(调度、状态同步、可调用方法等);父子一致性降低认知负担;未用到的特性在真正调用前零运行时成本。 - 方案 D:用白名单而非
keyof Agent排除(被拒)。当前"排除Agent上的一切、暴露其余"是零样板且随Agent新方法自动适配的。
结论(The decision):已接受。subAgent/abortSubAgent/deleteSubAgent三个管理方法内建于Agent基类;独立的SubAgent类和withSubAgentsmixin 已移除;SubAgentClass与SubAgentStub类型从主agents入口导出(见 packages/agents/src/index.ts L59-L68 的 re-export)。
测试覆盖:从运行测试到类型测试
RFC 指出子代理 API 在 packages/agents/src/tests/sub-agent.test.ts 有完整测试套件。结合仓库实际内容,覆盖范围包括:
- 创建与 RPC:
should create a sub-agent and call RPC methods on it(L62) - 持久化与隔离:父子和不同子代理各自独立的 SQLite(L70-L97),以及
should keep parent and sub-agent storage fully isolated(L222) - 并行执行:多个子代理并行递增互不干扰(L99)
- 中止与重启生命周期:abort 后存储保留、重启可继续使用(L111);delete 后存储被擦除(L132)
- 命名传播:
this.name等于 facet 名(L149-L167) - 导出错误守卫:非导出类 / 改名导出 / 压缩类名(L169-L211)
- 同名不同类:允许不同类使用相同名字(L212)
- 嵌套子代理:子代理再生孙代理、嵌套存储隔离、同名嵌套隔离(L314 起的
describe("nested sub-agents")) - 流式回调:通过
RpcTarget向子代理传回调并接收分块,支持多流与单块流(L249-L313)
类型级测试 packages/agents/src/tests-d/sub-agent-stub.test-d.ts 验证SubAgentStub正确暴露用户方法(含同步方法 Promise 化)并隐藏Agent内部(fetch/alarm/sql/onStart/broadcast/subAgent等全部被@ts-expect-error断言为不可访问)。
命名演进:从 sub-agent 到 dynamic agents
值得注意:RFC 之后,该能力在文档中演进为 "dynamic agents (facets)"(见 docs/agents/sub-agents.md 的命名说明)。subAgent()/hasSubAgent()/listSubAgents()/abortSubAgent()/deleteSubAgent()仍然可用,但已被标记为 deprecated,统一委托给同一this.dynamicAgentscapability:
| 旧 API(deprecated) | 新 capability |
|---|---|
this.subAgent(Cls, name) | this.dynamicAgents.get(Cls, name) |
this.abortSubAgent(Cls, name) | this.dynamicAgents.abort(Cls, name) |
this.deleteSubAgent(Cls, name) | this.dynamicAgents.delete(Cls, name) |
this.hasSubAgent(Cls, name) | this.dynamicAgents.has(Cls, name) |
this.listSubAgents(Cls?) | this.dynamicAgents.list(Cls?) |
能力层补充了若干 RFC 时期未提及的运行时语义:
- facet 语义(docs/agents/sub-agents.md 的 "Facet semantics"):独立 isolate 但同机(整个树共享父对象的物理放置,不会散落边缘);自己的 SQLite;无独立 alarm(顶层父持有唯一 alarm,SDK 记录子调度的逻辑属主路径并在触发时路由回子);受监督生命周期;独立休眠;私有可寻址性(兄弟之间互不可见);嵌套深度有界(目前含根共四层)。
- 反向引用:子代理通过
this.parentPath(根优先的祖先链)和this.parentAgent(ParentClass)(类型化 stub)反向调用父级;顶层无父时parentPath === []。 onBeforeSubAgent钩子:父可在/sub/请求唤醒子代理前做门禁/改写/短路,返回void(放行)、Request(改写后转发)或Response(短路响应)。- 客户端寻址:前端可用
useAgent({ agent, name, sub: [{ agent, name }] })直达某个子代理,对应 URL 形如/agents/supervisor/{userId}/sub/job-runner/{runId}。
开放问题与未解难题
RFC 如实记录了设计边界,这些内容对使用者同样重要。
开放问题:
- 从
experimental毕业:方法已标记@experimental。底层 workerd 原语(ctx.facets、ctx.exports)已从 experimental compat flag 毕业,SDK API 本身的毕业只取决于足够的真实使用量。 - 父子状态同步:子代理不参与父的
setState()广播。子数据变化后父必须显式重新同步——gadgets 示例的做法是在子代理 RPC 之后调用this.setState()。"子通知父变更"的响应式模式值得探索。 - 跨机器子代理:facet 是同置的。未来可通过标准 DO stub 支持远程子代理,但 API 与失败模式会非常不同。
- 发现与内省:父目前无法列出活跃子代理或查询其健康状态,没有
listSubAgents()或getSubAgentStatus(name),父必须在自己的存储里跟踪子代理。(注:后续版本已在dynamicAgentscapability 上补上了has()/list()与父侧 SQLite registry,见 docs/agents/sub-agents.md。) - 资源上限:父可 spawn 多少子代理、嵌套多深、整棵树消耗多少存储,均无 SDK 层上限;workerd 可能施加自身限制,但 SDK 不暴露也不强制。
未解难题:
- 编排(Orchestration):没有框架级子代理协调支持,扇出/扇入、错误处理、结果合成全部由父负责,gadgets 示例是硬编码模式。
- 追踪与可观测性:
父调用子 → 子调 LLM → LLM 触发工具 → 工具再调子没有连通 trace,每个子代理都是不透明的 RPC 调用;agents/observability模块对子代理树无感知,需要跨 facet 调用的 trace ID 传播。 - 错误传播与韧性:子代理失败没有重试、没有熔断器、没有结构化错误类型。重试设计(design/retries.md)覆盖重试原语,但尚未接入子代理调用。
实践要点速查
- 子代理 = 同置的 child Durable Object,独立 isolate + 独立 SQLite,类型化 RPC 可达,父可监督生命周期;不需要任何 wrangler 配置,只需类从 worker 入口以原名导出。
subAgent(Cls, name)幂等且惰性:首次触发子onStart(),之后复用既有实例;abortSubAgent只终止运行(存储保留、下次自动重启),deleteSubAgent永久擦除(不可逆)。- 嵌套合法:子代理可再
subAgent(),嵌套深度有界(含根四层);facet 无独立 alarm,调度由顶层父代为持有并路由。 - 需要反向调用时用
parentAgent(ParentClass)与parentPath;动态 isolate 需要回调子代理时走 Loopback 模式(dynamic isolate → WorkerEntrypoint → parent Agent → sub-agent)。 - 在 RFC 基础之上,新代码建议直接使用
this.dynamicAgents.get/abort/delete/has/list,旧subAgent*方法仍可用但已弃用。
【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考