Cloudflare Agents 动态 Agent 实战:用 Supervisor + Facet 运行用户提交代码
【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents
导读
本文讲解 Cloudflare Agents 项目(examples/next/dynamic-agents)中"动态 agent"(facets)模式:一个Supervisoragent 存储用户提交的 JavaScript 源码,通过 Worker Loader 在运行时把其中导出的 Durable Object 类(Sandbox)挂载为自己的 facet 来运行。每个 gadget 拥有独立的 isolate 与独立的 SQLite 数据库,且没有任何 wrangler binding——这种"受监督的动态代码"正是 facet 这一运行时原语的设计用途。读完本文你将掌握:facet 能做什么、Supervisor 的完整 callable API、如何实现"换代码不换数据"的版本升级、如何监督中止与删除 gadget,以及何时该用、何时不该用这种模式。
facet:为"无法绑定的代码"提供持久化存储
什么是 facet
在 Cloudflare Agents 中,动态 agent(dynamic agents / facets)是位于父 agent 之下、受其监督的子 Durable Object,基于运行时提供的 facet 原语构建。每个子对象运行在自己的 isolate(独立 JS 堆)中,拥有自己的 SQLite 数据库,但它不是独立寻址的顶层对象——它存在于父 Durable Object 内部,由父对象创建、可达、中止或删除。
Supervisoragent 的整体结构如下(摘自 README.md):
Supervisor (Agent, one DO) ├─ gadgets table: name → source code, version ├─ facet "gadget:counter" ← Sandbox class from user code v3 │ own SQLite (hits table) — invisible to the supervisor └─ facet "gadget:notes" ← Sandbox class from user code v1 own SQLite关键点在于:supervisor 与 gadget 的数据库是物理隔离的。Supervisor 的 SQLite 里只有一张gadgets注册表(name → 源码、版本号),而每个 gadget 的数据(如示例中的hits表)全部落在它自己 facet 的 SQLite 里,对 supervisor 完全不可见。示例代码中的supervisorTables()方法(src/index.ts)专门查询 supervisor 自身的表,测试用例据此断言hits表不在supervisor 的数据库里:
// examples/next/dynamic-agents/src/tests/dynamic-agents.test.ts expect(await supervisor.supervisorTables()).not.toContain("hits");为什么 facet 是唯一解
文档明确列出 facet 在这里能做的三件独有的事:
- 为无绑定代码提供持久化存储(Durable storage for unbound code):你无法给 AI 生成或用户提交的代码分配一个 Durable Object namespace binding。facet 是这类代码获得持久、隔离存储的唯一途径——且存储由 supervisor 掌控。
- 在稳定状态之上做代码升级(Code upgrades over stable state):
updateGadgetCode写入新源码并 abort 该 facet;下一次调用就会在同一份存储之上加载新类。也就是说invokeGadget返回的仍然是原来的命中计数,只不过由新代码来服务。 - 受监督的生命周期(Supervised lifecycle):
abortGadget能立即停止一个行为异常的 gadget(存储保留);deleteGadget彻底清空它;supervisor 决定代码拥有什么能力(如globalOutbound: null——禁止网络访问)。
从 docs/agents/sub-agents.md 可以补充 facet 原语本身的特性:每个 facet 有独立 isolate、独立 SQLite;父对象可以传递性地 abort 子对象(存储保留)、delete(存储清除),也可以用不同的类在相同存储上重启——这就是"稳定状态上的代码升级";facet 的寻址是私有的,只能通过父对象访问,同级之间互不可见。
何时不该用 facet
文档特别强调了两条边界:
- 对于静态已知的 Agent 子类,同样的监督能力通过
this.dynamicAgents获得,参见 examples/agents-as-tools/README.md; - 对于大量相互独立的 peer(聊天、会话),不要用 facet,参见 examples/next/chats 以及 docs/agents/sub-agents.md 中的决策规则。
决策规则原文可概括为:facet 是"代码或生命周期由父对象监督、且必须活在父对象内部"的子对象;而按名字寻址的独立 peer 应该用各自的顶层 Durable Object。
运行与测试
安装与启动(来自 README.md):
pnpm install pnpm run startstart脚本在 package.json 中定义为vite dev,会同时启动 Worker 运行时和 React 前端(编辑器 UI)。部署与类型生成脚本还包括:
"deploy": "vite build && wrangler deploy", "typecheck": "tsc --noEmit", "types": "wrangler types env.d.ts --include-runtime false", "test": "vitest --run --config src/tests/vitest.config.ts"运行测试:
pnpm run test测试通过@cloudflare/vitest-pool-workers在真实 Worker 池中运行,5 个用例覆盖"运行用户代码并持久化 / 读取与更新源码 / 同存储升级 / abort 保留存储 / delete 清空存储"五条关键路径(src/tests/dynamic-agents.test.ts)。
架构与配置
wrangler 配置
examples/next/dynamic-agents/wrangler.jsonc 是该示例的核心配置:
{ "name": "next-dynamic-agents", "main": "src/index.ts", "compatibility_date": "2026-06-11", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "run_worker_first": ["/agents/*"] }, "durable_objects": { "bindings": [ { "name": "Supervisor", "class_name": "Supervisor" } ] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["Supervisor"] } ], "worker_loaders": [ { "binding": "LOADER" } ], "observability": { "enabled": true } }要点说明:
Supervisor是唯一一个静态绑定的 Durable Object,对应src/index.ts中导出的Supervisor类;new_sqlite_classes迁移为其分配 SQLite 存储;worker_loaders绑定LOADER是动态加载用户代码的关键——Worker Loader 允许在运行时从一个模块 map 实例化一个 Worker,而无需任何静态绑定;run_worker_first: ["/agents/*"]让 agent 请求优先由 Worker 处理,其余走 SPA 静态资源。
测试用 src/tests/wrangler.jsonc 额外开启了 vitest-pool-workers 需要的 Node.js 兼容标志(enable_nodejs_fs_module、enable_nodejs_process_v2等),并复用同一个LOADER绑定。
客户端连接
env.d.ts(由wrangler types生成)给出了类型化的环境绑定:Supervisor: DurableObjectNamespace和LOADER: WorkerLoader。前端 src/client.tsx 通过useAgent({ agent: "supervisor", name: instanceName })连接到一个以随机 UUID 命名实例的 Supervisor,用supervisor.call(...)远程调用其 callable 方法,并内嵌一个代码编辑器(默认填充DEFAULT_GADGET_CODE),提供 Create / Deploy new version / Abort / Delete / Invoke 五个操作按钮。
Supervisor 实现剖析
Supervisor类的完整实现位于 src/index.ts,整体结构如下。
存储与辅助方法
onStart()中创建gadgets表(name 主键、code、version)。辅助方法包括:
#facets():从this.ctx.facets取 facet 控制句柄,若当前运行时没有 facets 会抛出"更新 compatibility_date"的错误提示;#gadget(name):按名字查注册表并返回{name, code, version};#facetName(name):返回gadget:${name}。注释明确说明:"facet 名是存储键——每个 gadget 一个、永不随版本变化",这是"同一存储上换类升级"能够成立的关键。
六个 callable API
| 方法 | 作用 | 底层关键调用 |
|---|---|---|
createGadget(name, code?) | 创建 gadget,默认填DEFAULT_GADGET_CODE,版本置 1;ON CONFLICT DO NOTHING保证幂等 | 写入gadgets表 |
updateGadgetCode(name, code) | 升级源码,version +1,然后abort对应 facet;若未运行则捕获异常静默 | #facets().abort |
invokeGadget(name, path?) | 加载源码为动态 Worker,把导出的Sandbox类挂载为 facet,转发一次请求 | Worker Loader +#facets().get |
abortGadget(name, reason?) | 立即停止异常的 gadget,存储保留 | #facets().abort |
deleteGadget(name) | 完全拆除:先删 facet(存储清除),再删代码行 | #facets().delete+ SQL DELETE |
getGadget(name)/listGadgets()/supervisorTables() | 查询当前源码 / 列出所有 gadget / 查看 supervisor 自己的表 | 只读 SQL |
所有方法都标了@callable(),供前端supervisor.call(...)以及测试中await supervisor.createGadget(...)这类 RPC 风格调用直接使用。
关键调用链:invokeGadget
invokeGadget是整条链路的核心(src/index.ts):
const worker = this.env.LOADER.get( `gadget:${this.name}:${name}:v${row.version}`, // loader 按 id 缓存 () => ({ compatibilityDate: "2026-06-11", mainModule: "gadget.js", modules: { "gadget.js": row.code }, // 用户代码作为模块 globalOutbound: null // 无网络:supervisor 决定能力边界 }) ); const fetcher = this.#facets().get<Fetcher>(this.#facetName(name), () => ({ class: worker.getDurableObjectClass("Sandbox") })); const response = await fetcher.fetch(new Request(`https://gadget${path}`)); return response.json();三个设计要点:
- Loader 缓存 id 包含版本号:
gadget:${name}:v${version}使每个代码修订版成为独立的缓存 Worker,升级后不会被旧缓存污染; globalOutbound: null:动态代码完全没有网络能力,能力边界由 supervisor 决定(示例中强调null——no network);getDurableObjectClass("Sandbox"):要求用户代码必须具名导出Sandbox类,这是约定的契约,shared.ts中的默认代码注释明确说明这一要求。
升级的微妙之处
updateGadgetCode中 abort 套在 try/catch 里,注释解释了原因:"Not running — the next invoke simply loads the new code."(若 facet 此刻未在运行,则下一次 invoke 自然加载新代码)。也就是说升级路径有两种:运行中→abort 强制重载;空闲→惰性生效。测试用例验证了"新代码、同一份存储、计数从 2 继续到 3"的行为:
await supervisor.updateGadgetCode("upgradeable", V2_CODE); // ... 新代码,同一 facet 存储:计数从 2 继续 const result = (await supervisor.invokeGadget("upgradeable", "/x")) as GadgetResult; expect(result).toMatchObject({ version: "v2", hits: 3 });默认 Gadget:一个带私有 SQLite 的计数器
src/shared.ts 中的DEFAULT_GADGET_CODE是最小的可运行示例,它被定义为前端编辑器与createGadget的默认填充代码。其核心逻辑:
- 继承
cloudflare:workers的DurableObject; - 具名导出
Sandbox(契约要求); - 在
fetch中通过this.ctx.storage.sql建hits表、对每个 path 做 upsert 计数、返回{version, path, hits}的 JSON。
它的存储完全位于 facet 自己的 SQLite(this.ctx.storage.sql),与 supervisor 的表零交集。测试中的V2_CODE与默认代码唯一区别只是version字段从"v1"改为"v2",恰好用来验证"换代码不换数据"。
前端演示界面
src/client.tsx 提供了一个完整的管理台:
- 左侧边栏:新建 gadget 输入框 + gadget 列表(带
v{version}徽章); - 中间主区:代码编辑器(
Textarea,等宽字体)+ 工具栏(Deploy new version / Abort / Delete); - 右侧面板:invoke 路径输入框 + 操作日志(每次调用以
{at, label, body}形式记录,最多保留 50 条); - 编辑器底部提示明确写出玩法:"Deploying aborts the facet and loads the new class over the SAME storage — bump the 'version' string in the code, hit Deploy, then Invoke: the hit counter keeps counting."
INSTANCE_KEY会持久化一个随机实例名,刷新页面后仍连接到同一个 Supervisor 实例,从而继续查看同一批 gadget 的存储。
五个测试用例:行为的完整契约
src/tests/dynamic-agents.test.ts 用五个用例固化了下述行为,可作为读者验证自己实现的验收清单:
- 运行用户代码并拥有自己的持久 SQLite:两次 invoke 同一 path,hits 从 1 变 2;且
supervisorTables()不含hits; - 返回当前源码:
getGadget能取回刚升级的代码与版本号(v2); - 同存储升级:先打到 2 次,升级到 v2 后 hits 变为 3,且
version字段变为"v2"; - abort 保留存储:invoke 一次后 abort,再次 invoke 返回 hits = 2(计数延续);
- delete 清空存储:删除后列表为空,重新 create 同名 gadget,hits 从 1 重新开始。
测试通过getAgentByName(env.Supervisor, uniqueName())为每个用例创建独立命名的 supervisor 实例,保证用例间互不干扰;vitest 配置(src/tests/vitest.config.ts)设置了 15 秒超时和 3 次重试。
小结与决策建议
回顾本文核心结论:
- facet 是动态代码唯一的持久化方案:AI 生成或用户提交的代码没有 wrangler binding,只有挂载为 facet 才能获得受监督的持久存储;
- 升级不丢数据:
updateGadgetCode+ abort 让新类在旧存储上重启,是 facet 的"超能力"所在; - 监督是关键:abort 保留存储、delete 清空存储、
globalOutbound: null关闭网络,全部由 supervisor 决策。
最后再回到文档给出的边界:静态已知子类用this.dynamicAgents(参考 examples/agents-as-tools);大量独立 peer 用顶层 DO(参考 examples/next/chats 与 docs/agents/sub-agents.md 的决策规则)。想快速体验完整链路,执行pnpm install && pnpm run start后,在浏览器中创建 gadget、修改代码里的version字符串、Deploy 再 Invoke,即可亲眼看到命中计数在新版本代码下继续增长。
【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考