第 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 → 通过 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/...", ...) 跑特定子任务 —— LanguageModel 与 env.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 的工具(比如 runShell、commitToArtifact、openPullRequest)。最佳做法是把它们暴露成一个 MCP server,但不要把全量 tool 描述塞进 client context —— @cloudflare/codemode/mcp 的 codeMcpServer 把它折成 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应该看到一个codetool,描述里自动注入了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) => `<${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 切换前逐项打勾:
wrangler.jsonc的compatibility_date是今天,observability.enabled = true。- 所有 secret 在
--env production都wrangler secret put过(wrangler secret list --env production验证)。 onConnect校验 Access JWT,无 token 立刻close(4001)(用未登录浏览器跑一次)。RATE_LIMITERbinding 已声明,Think 的beforeTurn入口处都limit({ key: userId })过。vpc_networks的 Mesh binding 跑通一次env.MESH.fetch(onprem),看到 200。env.AI.run("anthropic/...", { gateway: { id } })在 AI Gateway dashboard 看到 token 计数与缓存命中率。- Flagship 在 dashboard 创建至少 1 个真实 flag,默认值与代码 fallback 对齐。
/mcp端点跑通:npx @modelcontextprotocol/inspector https://agents.example.com/mcp能看到search+execute两个工具。- 所有外部文本(GitHub issue、PR diff、tool output)都过
wrapUntrusted/sanitizeToolOutput。 - 危险工具(
writeFile/openPullRequest/mergePullRequest)走 HITL confirmation,默认拒绝。 - tail consumer 部署、
tail_consumers引用、R2 看到当天 events。 - 演练一次
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 端的requestcallback 里中转,不要让 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 跑得久。
延伸阅读
- Managed OAuth for Access — agent 代用户调下游
- Cloudflare Mesh — 把 worker 加进 VPC
- AI Platform — 70+ 模型一个 binding
- Flagship — 灰度 prompt / model
- Enterprise MCP & Code Mode for MCP — 把 agent 暴露给其他 agent
- Rate Limiting binding — 滑动窗口
- Cloudflare Access — 给 worker 套 Access