ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Cloudflare Agents 子代理(Sub-Agents / Facets)架构设计与实战:从 RFC 到隔离、扇出与监督式生命周期

Cloudflare Agents 子代理(Sub-Agents / Facets)架构设计与实战:从 RFC 到隔离、扇出与监督式生命周期 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 AgentEnv { onStart() { this .sqlCREATE TABLE IF NOT EXISTS cache (q TEXT PRIMARY KEY, result TEXT); } async search(query: string): PromiseResult[] { const cached this.sqlSELECT * FROM cache WHERE q ${query}; if (cached.length) return cached; // ... fetch, cache, return } } export class MyAgent extends AgentEnv { 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-L5843abortSubAgent委托给this.dynamicAgents.abortL8361deleteSubAgent委托给this.dynamicAgents.deleteL8376。真正承载 facet 管理逻辑的DynamicAgentsInternal作为 Lifecycle capability 安装在每个 Agent 上见 packages/agents/src/dynamic-agents/dynamic-agents.tsL79-L115。SubAgentStubT类型化 RPC stubthis.subAgent(SearchAgent, main)返回的是SubAgentStubSearchAgent——一个映射类型把子类上所有用户自定义的公开方法暴露为异步 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 () Promisestring; null! as Stub[methodWithArgs] satisfies (a: string, b: number) Promiseboolean; // 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。SubAgentClassT构造器类型与env: never方差技巧SubAgentClassT使用env: never作为方差技巧。由于never可以赋值给任意类型任何AgentSomeEnv子类都能满足约束无论其Env类型参数是什么。真实的env由运行时在实例化 facet 时注入而不是由调用方传入。源码中的定义印证了这一点packages/agents/src/dynamic-agents/types.ts L106new (ctx: DurableObjectState, env: never): T;初始化命名 facet 原生 RPC 握手subAgent()会以显式命名的FacetStartupOptions.id创建或取回 facet然后通过原生 RPC 调用_cf_initAsFacet()。子代理记录 Agent 特定的父元数据并启动其组合生命周期。onStart()是惰性的——在首次访问时运行而不是在父构造期间运行。源码 packages/agents/src/index.tsL5584-L5590中的_cf_initAsFacet实现细节值得注意_cf_initAsFacet( name: string, parentPath: ReadonlyArray{ className: string; name: string } [], identityName name ): Promisevoid { 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 的约束子类同样不能与保留 tokenSub重名任何 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-chatOverseerAgent继承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-sandboxSandboxAgent继承AIChatAgent通过CustomerDatabase子代理实现数据隔离。动态 Worker isolate通过 Worker Loader只能通过一个DatabaseLoopbackWorkerEntrypoint 访问数据库后者代理回父 Agent父 Agent 再委托给子代理。形成三层隔离无网络、单一 binding、子代理边界见 experimental/gadgets-sandbox/src/server.ts 头注释 L11-L41。模式四门控访问gadgets-gatekeeperGatekeeperAgent继承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 ParentEnv, State远比直接extends AIChatAgentEnv, 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 有完整测试套件。结合仓库实际内容覆盖范围包括创建与 RPCshould create a sub-agent and call RPC methods on itL62持久化与隔离父子和不同子代理各自独立的 SQLiteL70-L97以及should keep parent and sub-agent storage fully isolatedL222并行执行多个子代理并行递增互不干扰L99中止与重启生命周期abort 后存储保留、重启可继续使用L111delete 后存储被擦除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旧 APIdeprecated新 capabilitythis.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顶层父持有唯一 alarmSDK 记录子调度的逻辑属主路径并在触发时路由回子受监督生命周期独立休眠私有可寻址性兄弟之间互不可见嵌套深度有界目前含根共四层。反向引用子代理通过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),仅供参考
返回列表