Cloudflare Agents 子代理(Sub-Agents / Facets)架构设计与实战:从 RFC 到隔离、扇出与监督式生命周期
2026/9/17 18:40:43 网站建设 项目流程

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.facetsctx.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: truethis.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 模式:

  1. 创建一个WorkerEntrypoint(例如DatabaseLoopback)代理到父 Agent;
  2. 父 Agent 通过this.subAgent()委托给子代理;
  3. 把该 WorkerEntrypoint 作为 binding 传给动态 isolate。

完整调用链为:dynamic isolate -> WorkerEntrypoint -> parent Agent -> sub-agent

备选方案与决策理由

RFC 记录了四个被否决/被采纳的备选方案,这对理解 API 形态很有价值:

  • 方案 A:独立SubAgent类 +withSubAgentsmixin(原始提案,被拒)SubAgentAgent能力几乎相同(都继承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 已移除;SubAgentClassSubAgentStub类型从主agents入口导出(见 packages/agents/src/index.ts L59-L68 的 re-export)。

测试覆盖:从运行测试到类型测试

RFC 指出子代理 API 在 packages/agents/src/tests/sub-agent.test.ts 有完整测试套件。结合仓库实际内容,覆盖范围包括:

  • 创建与 RPCshould 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.facetsctx.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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询