ByteNoteByteNote

字节笔记本

2026年8月28日

Cloudflare Agents SDK 这次更新了什么

API中转
¥120

Cloudflare 给 Agents SDK 补了一轮文档和运行时接口:间隔调度 scheduleEvery()、自定义路由、callable 超时、状态校验钩子,以及 MCP handler 的 options。已经在用 agents 的项目,先升到最新再按下面几条改,不用整包重写。

bash
npm i agents@latest

文档入口在 Cloudflare Agents,源码在 github.com/cloudflare/agents。更新条目也写在 Agents changelog

间隔调度:cron 做不到的秒级轮询

以前定时任务只有三种:延迟秒数、指定时间、cron。cron 最小粒度是一分钟,每 30 秒拉一次接口、每 90 秒同步一次,只能自己在回调里再 schedule() 下一轮。现在多了 scheduleEvery(),间隔按秒算,写进 SQLite,Durable Object 重启也还在。

ts
import { Agent } from "agents";

export class PollingAgent extends Agent {
  async onStart() {
    // 幂等:同样的回调名 + 间隔 + payload 不会重复建行
    await this.scheduleEvery(30, "poll", { source: "api" });
  }

  async poll(payload: { source: string }) {
    const res = await fetch("https://api.example.com/updates");
    if (!res.ok) throw new Error("poll failed");
    // 抛错只废这一轮,下一轮还按 30 秒走
  }
}

几条容易踩的:

  • 第一次执行在 intervalSeconds 之后,不是立刻跑。
  • 上一轮还没结束,下一轮会跳过,日志里能看到 Skipping interval schedule。别把慢接口塞进 10 秒间隔。
  • 取消整条间隔用 cancelSchedule(id)
  • cron 还是适合「每天 8 点」这种墙上时钟;只要相对间隔、尤其是亚分钟,用 scheduleEvery()

完整参数和 queue / Workflows 的分工写在 Schedule tasks

自定义路由:别死守 /agents/{类名}/{实例}

默认路径是 /agents/{agent}/{name}。前端要干净 URL、或者实例名来自登录态而不是 URL,用客户端的 basePath,服务端自己选实例再 fetch 过去。

ts
import { getAgentByName, routeAgentRequest } from "agents";

export default {
  async fetch(request: Request, env: Env) {
    const url = new URL(request.url);

    if (url.pathname.startsWith("/user/")) {
      const session = await getSession(request);
      const agent = await getAgentByName(env.UserAgent, session.userId);
      return agent.fetch(request);
    }

    return (
      (await routeAgentRequest(request, env)) ??
      new Response("Not found", { status: 404 })
    );
  },
};

客户端:

ts
const agent = useAgent({
  agent: "UserAgent",
  basePath: "user",
  onIdentity: (name, agentType) => {
    console.log(name, agentType);
  },
});

basePath 设了之后,URL 不再按 agent/name 拼。agent 这个参数还是要传,服务端不靠它选实例。连上之后服务端会把 name 和 agent 类型推过来,onIdentity / onIdentityChange 用来对一下你连的是不是那一个。只改前缀、结构不变,用 routeAgentRequest(request, env, { prefix: "/api/agents" }) 就够。

路由细节:Routing

Callable 超时:别让 RPC 一直转圈

浏览器、App 走 WebSocket 调 @callable() 方法,以前没超时,对端卡住就一直挂着。现在 call() 可以带毫秒超时,到点直接 reject。断线时未完成的调用也会以 Connection closed 失败。

ts
await agent.call("slowMethod", [], { timeout: 5000 });

await agent.call("generateText", [prompt], {
  timeout: 30_000,
  stream: {
    onChunk: (chunk) => append(chunk),
    onDone: (value) => console.log("done", value),
    onError: (err) => console.error(err),
  },
});

流式方法用 stream.error(message) 中途收掉,客户端走 onErrorgetCallableMethods() 能列出带 @callable() 的方法和 metadata,自己做调试页或文档时用得上。

同一 Worker 里、或者 Agent 调另一个 Agent,走 Durable Object RPC,不必加 @callable()。超时是给外部客户端这条 WebSocket 通道用的。文档:Callable methods

状态校验:setState 之前拦一把

setState() 现在是同步落盘。落盘前会跑 validateStateChange。钩子必须同步:校验、改字段可以,别在里面 await

当前文档的写法是抛错拒绝:

ts
export class CartAgent extends Agent<Env, { count: number; updatedAt: number }> {
  validateStateChange(oldState: { count: number }, newState: { count: number }) {
    if (newState.count < 0) {
      throw new Error("count cannot be negative");
    }
    return { ...newState, updatedAt: Date.now() };
  }
}

早期 changelog 里写过 return false 拒绝。升到 @latest 之后按现在文档来:抛错拒绝,返回对象可以改一版再写入。落盘并广播之后才会进 onStateChanged,那里抛错不影响已经写下的状态。

客户端乱改 count、或并发把库存改成负数,拦在这一层比事后补救干净。

MCP options:createMcpHandler 的第二参数

MCP 这条线已经走到无会话 handler。createMcpHandler 吃一个工厂函数,每个请求新建一份 server,options 用来收路由、CORS、Host、兼容旧客户端。

ts
import { McpServer } from "@modelcontextprotocol/server";
import { createMcpHandler } from "agents/mcp/server";
import { z } from "zod";

function createServer() {
  const server = new McpServer({ name: "hello-server", version: "1.0.0" });
  server.registerTool(
    "hello",
    {
      description: "Return a greeting",
      inputSchema: { name: z.string().optional() },
    },
    async ({ name }) => ({
      content: [{ type: "text", text: "Hello, " + (name ?? "World") + "!" }],
    }),
  );
  return server;
}

export default {
  fetch(request: Request, env: Env, ctx: ExecutionContext) {
    return createMcpHandler(createServer, {
      route: "/mcp",
      allowedHostnames: ["mcp.example.com"],
      corsOptions: { origin: "https://app.example.com" },
      legacy: "stateless",
    })(request, env, ctx);
  },
};

常用 option:

字段默认干什么
route/mcpWorker 只处理这条路径
allowedHostnameslocalhost / workers.dev自定义域名时显式写上
corsOptions通配 CORS也可 false 关掉响应头
legacystateless旧客户端普通 tool/prompt/resource 还能打进来;要纯新协议用 reject
responseModeauto需要纯 JSON 用 json(会丢掉结果前的 notification)
authContextExecution context propsgetMcpAuthContext()

工厂函数每次请求都跑一遍,别在模块顶层 new 一个 server 反复用。还依赖协议会话、推送 elicitation、事件回放的,暂时并一条 createLegacyMcpHandler / 旧 McpAgentMcpAgent 已经冻结。options 和迁移步骤:MCP handler APIs

升完怎么验

  1. 升级 agents 到 latest,看 lockfile 版本。
  2. 轮询从回调里再 schedule 改成 onStart 里一条 scheduleEvery,重启 Worker,确认 SQLite 里只有一行。
  3. 有自定义 URL 的,客户端加 basePath,Worker 里用 getAgentByName 加 agent.fetch。
  4. 外部 RPC 给一个合理 timeout,断线时看 pending call 会不会自己失败。
  5. 给 setState 加 validateStateChange,故意写非法值,确认被拒。
  6. MCP 服务端改工厂加 options;旧客户端先用 legacy stateless,确认 tool 列表还在再考虑 reject。

官方还补了一批带例子的文档,调度、路由、callable、MCP 这几页比以前好抄。先升包,再按自己项目用到的接口改,别一次把 Workflows、Think、Code Mode 全翻一遍。

分享: