Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第 10 章:让它“能上线“ — 用 2026 全套 primitives 收尾

一句话定位:把“我电脑上能跑“的 Think agent,接上 Cloudflare 2026 Agents Week 那一整套生产 primitives —— Managed OAuth、Mesh、AI Platform、Flagship、Code Mode for MCP —— 让它真的能挂出去给人用。

想要什么

前 9 章你已经有一个 agent-coder:Think 基类、Workspace + Sandbox 工具、Artifacts 留产物、Workflows v2 跑长任务、邮件触发、GitHub PR。它在 wrangler dev 里跑得挺溜。但只要再多走半步,事情就一连串崩。

第一类是鉴权链:agent 替用户去拉一个 Access 后面的内部 wiki 或 Jira。用户授权了一次,agent 后续每次 fetch 都要带这个用户的 JWT。不能用 service account,否则 audit log 全归 bot,出问题分不清是谁的责任。第二类是网络边界:你的某个工具要查公司的 onprem Postgres,Worker 出不了公网到内网。第三类是推理治理:免费用户都打 Claude Opus,毛利负到家;同一个 prompt 重复算 1 万次,缓存能省 90%。第四类是改 prompt 不敢推:system prompt 改一个字就是线上压测,改坏了 5 万会话同时炸。第五类是反向暴露:你写好的 agent 里有一堆 tool,其他 agent(OpenCode、Claude Code、内部 Cursor)想用,得有标准接口 —— MCP

这一章一次把这五件事接完,然后这本书就到这里。

图 10-1:生产架构总览

用户请求经过 Access + Rate Limiter,Think agent 通过 Mesh / Managed OAuth / AI Platform 调下游,Flagship 控灰度,自身通过 Code Mode for MCP 暴露 主链路 用户 浏览器 / agent Cloudflare Access Managed OAuth RATE_LIMITER 滑动窗口 CoderAgent (Think) Durable Object + SQLite onConnect 校验 JWT 下游 env.AI.run("anthropic/...") AI Platform / AI Gateway env.MESH.fetch(onprem) Cloudflare Mesh + Workers VPC downstream OAuth fetch Managed OAuth (RFC 9728) 控制 / 旁路 env.FLAGSHIP.eval() 灰度 prompt / model tail consumer diagnostic → R2 / 三方 codeMcpServer(this) 把自己暴露成 MCP OpenCode / Claude Code 作为 MCP 客户端来调

主链路是同步的:Access 鉴权 → Rate Limiter → Think agent → 通过 AI Platform / Mesh / Managed OAuth 调下游;旁路是异步的:Flagship 决定走哪个 prompt、tail 把 diagnostic 落 R2、Code Mode for MCP 把这个 agent 自己暴露给其他 agent 调。

为什么

这五件事在 v1 都得自己拼:写 OAuth client、跑 cloudflared 隧道、自建一个 LLM proxy、用 LaunchDarkly、手写一个 MCP server。v2 全部是 Cloudflare 自家 binding,加几行 wrangler.jsonc 就有。

不解决会撞墙的方向也很具体:

  • Managed OAuth:不接,你的 agent 永远只能爬公开页面。用户视角的 audit、合规、scope 全没。
  • Mesh:不接,任何“Workers 出不去公网“的内网资源(staging DB、内部 API、自托管 GitLab)都要走自建 tunnel + 鉴权 + 路由,踩坑能踩半年。
  • AI Platform / AI Gateway:不接,等同放弃自动 failover、缓存、按 model 计费、按 plan 限流这些一行配置就有的能力。
  • Flagship:不接,prompt 改动 = 全量直推,出事就是 5 万会话同炸。
  • Code Mode for MCP:不暴露,agent 就是一座孤岛,公司其他 agent 调不到 —— 这恰恰是 2026 年最大的协作摩擦。

方案选择

问题2026 选择备选
agent 代用户调 Access 后面的资源Managed OAuth for Access(/.well-known/oauth-authorization-server)自签 service account JWT(只在内部脚本用)
限流Workers Rate Limiting binding(RATE_LIMITER)SQL 手写 token bucket(只在要按 model / 工具单独算时)
内网访问Cloudflare Mesh(vpc_networks + cf1:network)Cloudflare Tunnel(Mesh 是它的超集)
推理治理AI Platform(env.AI.run("anthropic/...", { gateway: { id } }))直接调各家 SDK,自己写 cache / failover
灰度Flagship(env.FLAGSHIP + OpenFeature)wrangler versions(粒度只到流量分桶,不到 user)
自暴露Code Mode for MCP(@cloudflare/codemode/mcp)手写一个全量 tools 列表的 MCP server(token 爆)
客户端鉴权onConnect 校验 Access JWT(jose)自家 SSO JWT,签发链不变

下面按这 5 块顺序落地。

落地

1. 客户端鉴权:onConnect 校验 Access JWT

把 worker 域名挂到一个 Cloudflare Access Application 后面。Access 给每个请求加一个 Cf-Access-Jwt-Assertion header(WebSocket Upgrade 也有)。Think 继承自 Agent,直接重写 onConnect

# Terminal
npm install jose
// src/auth.ts
import { jwtVerify, createRemoteJWKSet } from "jose";

let jwks: ReturnType<typeof createRemoteJWKSet> | undefined;

export type AccessClaims = { email: string; sub: string; aud: string[] };

export async function verifyAccessJwt(
  token: string,
  teamDomain: string,   // e.g. "acme.cloudflareaccess.com"
  audience: string,     // Access App AUD tag
): Promise<AccessClaims> {
  if (!jwks) {
    jwks = createRemoteJWKSet(
      new URL(`https://${teamDomain}/cdn-cgi/access/certs`),
    );
  }
  const { payload } = await jwtVerify(token, jwks, {
    issuer: `https://${teamDomain}`,
    audience,
  });
  return payload as unknown as AccessClaims;
}
// src/agent.ts(片段:在 CoderAgent 里加 onConnect)
import type { Connection, ConnectionContext } from "agents";
import { verifyAccessJwt } from "./auth";

export class CoderAgent extends Think<Env> {
  // ...getModel / getTools / configureSession 沿用前面章节...

  async onConnect(connection: Connection, ctx: ConnectionContext) {
    const token = ctx.request.headers.get("Cf-Access-Jwt-Assertion");
    if (!token) return connection.close(4001, "Missing Access JWT");
    try {
      const claims = await verifyAccessJwt(
        token,
        this.env.ACCESS_TEAM_DOMAIN,
        this.env.ACCESS_AUD,
      );
      connection.setState({ userId: claims.sub, email: claims.email });
    } catch {
      return connection.close(4001, "Invalid Access JWT");
    }
  }
}

close(4001, ...) 是应用层 close code,4xxx 段留给业务,客户端能拿到原因。

2. 限流:Workers Rate Limiting binding

// wrangler.jsonc(增量)
{
  "unsafe": {
    "bindings": [
      {
        "name": "RATE_LIMITER",
        "type": "ratelimit",
        "namespace_id": "1001",
        "simple": { "limit": 60, "period": 60 }
      }
    ]
  }
}
// src/agent.ts(在 beforeTurn 里挡)
async beforeTurn(ctx) {
  const userId = (ctx.connection?.state as any)?.userId ?? "anon";
  const { success } = await this.env.RATE_LIMITER.limit({ key: userId });
  if (!success) {
    return { abort: { reason: "Rate limit exceeded; slow down." } };
  }
}

beforeTurn 是 Think 的钩子(@cloudflare/[email protected]),每一轮 LLM 调用前会跑;比 v1 在 onChatMessage 入口手写更干净。按 userId 分桶比按 IP 公平 —— 一家公司常常共用一个出口。

3. 内网访问:Cloudflare Mesh

公司 onprem 还有一个 Postgres 在 10.0.1.50,agent 想直接 env.MESH.fetch(...) 不绕公网。Mesh 是 GA(2026 Agents Week),用法是声明一个 vpc_networks binding。

// wrangler.jsonc(增量)
{
  "vpc_networks": [
    { "binding": "MESH",    "network_id": "cf1:network", "remote": true },
    { "binding": "AWS_VPC", "tunnel_id":  "350fd307-...", "remote": true }
  ]
}

cf1:network 是保留关键字,代表当前账户的 Mesh 网络。AWS / GCP 旧的 Tunnel 也能并存。

// src/tools/internal-db.ts
import { tool } from "ai";
import { z } from "zod";
import type { Env } from "../agent";

export const queryStaging = (env: Env) => tool({
  description: "Query the staging Postgres for read-only checks.",
  inputSchema: z.object({ sql: z.string() }),
  execute: async ({ sql }) => {
    const r = await (env as any).MESH.fetch("http://10.0.1.50:5432/query", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ sql }),
    });
    return r.text();
  },
});

这条路径不出 Cloudflare 边缘,也没有公网中间跳。

4. 推理治理:AI Platform + AI Gateway + 按 plan 切 model

env.AI 现在不只跑 Workers AI 自家模型,70+ 个第三方模型用同一个 binding(provider 前缀切)。AI Gateway 自带 cache、自动 failover、按账单聚合 —— 第三参数挂上 gateway 就行。

调用模式直接用 env.AI.run:

// src/inference.ts
import type { Env } from "./agent";

export async function ask(env: Env, plan: "free" | "pro" | "enterprise", prompt: string) {
  const modelId =
    plan === "free"      ? "@cf/moonshotai/kimi-k2.5"
    : plan === "pro"     ? "anthropic/claude-haiku-4-5"
    :                      "anthropic/claude-opus-4-6";
  return env.AI.run(modelId, { prompt }, { gateway: { id: "agent-coder" } });
}

如果你要把 model 接进 Think 的 getModel()(返一个 AI SDK LanguageModel),目前最稳的写法是继续用 createWorkersAI 走自家模型作为兜底,把“按 plan 选第三方模型“放到 beforeStep 里调 env.AI.run("anthropic/...", ...) 跑特定子任务 —— LanguageModelenv.AI.run 直接互操作的官方 helper 在 2026-04 还在迁移中 // 待验证。

// src/agent.ts(getSystemPrompt 由 plan 决定哪个 model 实际跑子任务)
async beforeStep(ctx) {
  const plan = (ctx.session.get("plan") ?? "free") as "free" | "pro" | "enterprise";
  this.session.set("currentModel", plan);
}

AI Gateway 给你的:重复 prompt 命中缓存、provider 故障自动切下一家、token 用量按 gateway id 聚合在 dashboard 看;并且对长流式响应做了缓冲 —— agent 中断重连还能拿到剩下的 token,不会重新计费。

5. 灰度:Flagship

System prompt 改了一个字,你想先放给 5% 的 enterprise plan 用户。Flagship 是 Cloudflare 2026 自家的 feature flag(closed beta),OpenFeature 兼容,binding 评估是子毫秒。

// wrangler.jsonc(增量)
{
  "flagship": [
    { "binding": "FLAGSHIP", "app_id": "<APP_ID>" }
  ]
}
// src/agent.ts(getSystemPrompt 走灰度)
async getSystemPrompt() {
  const userId = (this.session.get("userId") ?? "anon") as string;
  const plan   = (this.session.get("plan")   ?? "free") as string;
  const variant = await this.env.FLAGSHIP.getStringValue(
    "system-prompt-variant",
    "v1",
    { targetingKey: userId, plan },
  );
  return variant === "v2"
    ? "你是一个有帮助的中文技术助手,回答简洁、准确。优先给可运行代码。"
    : "你是一个有帮助的中文技术助手,回答简洁、准确。";
}

getStringValue / getBooleanValue / getNumberValue / getObjectValue 全有,带 *Details 变体能拿到 { value, variant, reason }。规则像 (plan == "enterprise" AND region == "us") OR email.endsWith("@cloudflare.com") 这类组合都能在 dashboard 配,不用上线。

灰度 model 是同样的套路:返一个 flag 决定 getModel() 里走哪个 modelId。不要wrangler versions 灰度 prompt —— versions 灰度的是整个 worker 二进制,粒度太粗。

6. 把自己暴露成 MCP server

公司里别的 agent(OpenCode、Claude Code、内部 Cursor)想调你 agent-coder 的工具(比如 runShellcommitToArtifactopenPullRequest)。最佳做法是把它们暴露成一个 MCP server,但不要把全量 tool 描述塞进 client context —— @cloudflare/codemode/mcpcodeMcpServer 把它折成 search + execute 两个工具,client 写 JS 探索和调用,token 节省 99%。

// src/mcp.ts
import { codeMcpServer } from "@cloudflare/codemode/mcp";
import { DynamicWorkerExecutor } from "@cloudflare/codemode";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpHandler } from "agents/mcp";
import { z } from "zod";
import type { Env } from "./agent";

// 返回一个真正的 Worker fetch handler:(request, env, ctx) => Promise<Response>
export async function buildMcpServer(env: Env) {
  // 1. 先写一个普通的 McpServer,把 agent-coder 的工具登记上
  const upstream = new McpServer({ name: "agent-coder", version: "1.0.0" });
  upstream.tool(
    "runShell",
    "在 sandbox 里跑命令",
    { cmd: z.string() } as any,
    async (args: any) => ({
      content: [{ type: "text" as const, text: `(stub: would run ${args.cmd})` }],
    }),
  );

  // 2. codeMcpServer 把工具集折成 search + execute 两个工具
  // ⚠️ 这一步是 async,返回 Promise<McpServer>(2026 SDK 改的)
  const folded = await codeMcpServer({
    server: upstream,
    executor: new DynamicWorkerExecutor({ loader: (env as any).LOADER }),
  });

  // 3. 必须用 createMcpHandler 把 McpServer 包成 Worker fetch handler。
  // McpServer 自身**没有 .fetch 方法**,(server as any).fetch(req, env) 运行时会抛
  // "TypeError: server.fetch is not a function"
  return createMcpHandler(folded);
}
// src/index.ts(给 worker 加一个 /mcp 端点)
import { buildMcpServer } from "./mcp";
import { routeAgentRequest } from "agents";
import type { Env } from "./agent";

export default {
  // ⚠️ 必须把第三个参数 ctx: ExecutionContext 显式拿出来 ——
  // createMcpHandler 内部 buildAuthContext 会读 ctx.props,
  // 不传或传 undefined 直接抛 "Cannot read properties of undefined (reading 'props')"
  async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(req.url);
    if (url.pathname === "/mcp") {
      const handler = await buildMcpServer(env);
      return handler(req, env, ctx);
    }
    return (await routeAgentRequest(req, env)) ??
      new Response("Not found", { status: 404 });
  },
};

实测 curl POST /mcp {"jsonrpc":"2.0","method":"initialize",...} 应该看到 serverInfo:{name:"codemode"}。再 tools/list 应该看到一个 code tool,描述里自动注入了 codemode.runShell(input: {cmd: string}): Promise<unknown> 的 TypeScript 签名 —— 这就是 Code Mode for MCP 的精髓:N 个工具 → 一个 code(code: string) 工具。

// wrangler.jsonc(增量)
{ "worker_loaders": [{ "binding": "LOADER" }] }

OpenCode / Claude Code 在它们自己的 MCP client 配置里加一行:

{ "mcpServers": { "agent-coder": { "url": "https://agents.example.com/mcp" } } }

下游 agent 看到的不是 N 个工具,而是 search(写 JS 查可用工具)+ execute(写 JS 调它们)。结合上一节的 Managed OAuth,client 第一次请求会被 WWW-Authenticate 引导走 OAuth 流程,拿到 user-scoped JWT,后续请求直接带 —— 整条链路不用 service token。

7. prompt injection 防御(沿用思路,稍微换 helper)

外部文本(GitHub issue、PR diff、tool stdout)进 prompt 前必过两关:包裹(让 LLM 区分指令与数据)+ 净化(去掉伪造的 role 标签 + 截断)。

// src/safety.ts
const TAG_RE = /<\/?(system|assistant|user|tool|user-input|untrusted)>/gi;

export const wrapUntrusted = (label: string, text: string) =>
  `<untrusted source="${label}">\n${text.replace(TAG_RE, (m) => `&lt;${m.slice(1)}`)}\n</untrusted>`;

export const sanitizeToolOutput = (text: string, max = 8000) =>
  text.replace(TAG_RE, "").slice(0, max);

在 Think 的 afterToolCall 钩子里强制走一遍:tool 返的不是裸 stdout,是 <untrusted source="tool:runShell">...</untrusted>,这样 LLM 不会把里面的 “ignore previous instructions” 当指令。

最深的一道:任何不可逆动作(写文件、推代码、合 PR)永远 HITL。第 4 章已经讲过模式,这里不重复。

8. 上线 checklist

prod 切换前逐项打勾:

  1. wrangler.jsonccompatibility_date 是今天,observability.enabled = true
  2. 所有 secret 在 --env productionwrangler secret put 过(wrangler secret list --env production 验证)。
  3. onConnect 校验 Access JWT,无 token 立刻 close(4001)(用未登录浏览器跑一次)。
  4. RATE_LIMITER binding 已声明,Think 的 beforeTurn 入口处都 limit({ key: userId }) 过。
  5. vpc_networks 的 Mesh binding 跑通一次 env.MESH.fetch(onprem),看到 200。
  6. env.AI.run("anthropic/...", { gateway: { id } }) 在 AI Gateway dashboard 看到 token 计数与缓存命中率。
  7. Flagship 在 dashboard 创建至少 1 个真实 flag,默认值与代码 fallback 对齐。
  8. /mcp 端点跑通:npx @modelcontextprotocol/inspector https://agents.example.com/mcp 能看到 search + execute 两个工具。
  9. 所有外部文本(GitHub issue、PR diff、tool output)都过 wrapUntrusted / sanitizeToolOutput
  10. 危险工具(writeFile / openPullRequest / mergePullRequest)走 HITL confirmation,默认拒绝。
  11. tail consumer 部署、tail_consumers 引用、R2 看到当天 events。
  12. 演练一次 npx wrangler rollback --env production,确认回滚链路通。

验证

# Terminal

# 1. 没带 Access JWT 的 WS 连接立刻被踢
curl -i 'https://agents.example.com/agents/coder-agent/test' \
  -H 'upgrade: websocket' -H 'connection: upgrade' \
  -H 'sec-websocket-key: dGhlIHNhbXBsZSBub25jZQ==' -H 'sec-websocket-version: 13'
# 期望:101 之后立刻 close,code=4001

# 2. 限流
for i in $(seq 1 70); do
  curl -s -o /dev/null -w '%{http_code}\n' \
    -H 'cf-access-jwt-assertion: <valid-token>' \
    https://agents.example.com/api/chat -X POST -d '{"messages":[]}'
done
# 期望:前 60 个 200,后面是 429

# 3. Mesh
curl -H 'cf-access-jwt-assertion: <valid-token>' \
  https://agents.example.com/api/chat -X POST \
  -d '{"messages":[{"role":"user","parts":[{"type":"text","text":"查一下 staging 库 users 表行数"}]}]}'
# 期望:LLM 调 queryStaging 工具,日志里看到 env.MESH.fetch 200

# 4. MCP server 暴露
npx @modelcontextprotocol/inspector https://agents.example.com/mcp
# 期望:左侧列出 search + execute 两个工具

AI Gateway dashboard 上能看到按模型、按 gateway id 聚合的 token 数与缓存命中率;Flagship dashboard 上能看到 system-prompt-variant flag 的实时评估比例;Cloudflare Access 的 audit log 上能看到每个 user 的每次工具调用。

边界与坑

  • RATE_LIMITER.limit() 不抛错,只返 { success }。忘了判返回值就等于没限。
  • env.MESH.fetch 只能解析 Mesh 内部地址(私有 IP 或 *.mesh 主机名)。访问公网域名要走普通 fetch
  • AI Gateway 的 cache 默认按 prompt+model+params 完整匹配。你想让两个略不同的 prompt 命中同一份缓存,需要在 gateway 配置里调 normalize 规则。
  • Flagship 是 closed beta(2026-04),production 用之前确认你的账户被加了白名单;否则代码会拿不到 binding 报错。
  • Code Mode for MCP 里 executor 跑在 Dynamic Worker 沙盒里,默认 globalOutbound: null。client 写的 JS 想 fetch 外网,要在 host 端的 request callback 里中转,不要让 sandbox 直出。
  • 不要给 MCP server 暴露 runShell 这种没 HITL 的工具 —— 调用方的 LLM 写 JS 在你的容器里跑,等于你给它 root。

全书完结语

到这里,我们用 Cloudflare 2026 全套 primitives 走完了一条 agent 流水线:

第 1 章选 Think 而不是普通 Worker;第 2 章用 AI Platform 一行切到 Anthropic;第 3 章用 Think 的 Session 让 agent 记得住;第 4 章 createWorkspaceTools + tool() 让它会做事,顺便定下“危险动作必 HITL“;第 5 章把 prompt 工程沉淀成 Skills;第 6 章接上 Sandboxes(GA);第 7 章用 Artifacts 把容器里的产物变成可版本化的 git 仓;第 8 章 Workflows v2 + DO Facets sub-agent 让长任务能恢复、能单步重试;第 9 章接 GitHub App + Email Service,agent 真的能改代码、能回邮件;这一章用 Managed OAuth + Mesh + AI Platform + Flagship + Code Mode for MCP 把它送上线。

你现在手上的 agent-coder,从架构上不输任何商业 coding agent。剩下的差距是产品 sense、prompt 调优、和你愿意给它投多少时间。

接下来你可以做什么?这本书是骨架,几个明显的下一步:

  • 换模型:getModel() 是一个钩子。Gemini、DeepSeek、Qwen,新旗舰一出你只改一行 modelId(provider 前缀走 AI Platform)。
  • 加 Skills:第 5 章给了模式;公司里所有“重复出现的 review checklist““特定语言的常见 bug 模式”“部署前的检查项”,都沉淀到 skills/ 目录。
  • 接更多周边武器:语音(@cloudflare/voice)、Email Routing、Browser Run 的 Live View / HITL handoff、Registrar API、Agent Memory(私测) —— 这些不在主线,但每一个都能让 agent 多一个真实交互通道。附录 C 给了它们的速览。
  • 把 sub-agent 也包成独立 MCP server:第 8 章的 PlannerAgent / EditorAgent / VerifierAgent,各自包一份 codeMcpServer,部署成独立 worker,你的 agent 就从一个工具变成一个生态位。

附录 A 是这一路用到的所有 API 速查、附录 B 是依赖与版本快照、附录 C 是 2026 还没塞进主线的其它 agent 武器。常翻就行。

agent 这个领域 2026 年还在飞速变。但你现在有一个真实跑过的项目、知道每个抽象为什么存在、踩过哪些坑 —— 比任何“最新框架“都重要。剩下的就是接着写,接着改,接着 ship。

祝你的 agent 跑得久。

延伸阅读