本文只讲方法:如何把 Cloudflare Workers AI 的免费额度包装成一个带鉴权、带限额的 OpenAI 兼容网关。所有密钥、域名请替换成你自己的,文中用
<...>标注占位,代码可直接复制部署。
上一篇文章介绍了自建 OpenRouter 智能网关——那是转发付费模型、按用量计费的方案。今天这条路线完全不同:直接用 Cloudflare 自己托管的模型库 Workers AI,每天有 10,000 neurons 免费额度(所有模型共享,每天 00:00 UTC 重置,Free 计划超限直接失败、不扣钱)。
换句话说:每月 30 万 neurons 的推理额度,一分钱不花。个人使用、小群分享完全够——前提是做好两件事:鉴权(防止陌生人刷爆)和限额(防止朋友多了被刷爆)。
网关解决的三件事#
- 鉴权防盗刷:客户端只持有一个自定义
ACCESS_KEY,网关校验通过后才转发给 Workers AI。真实凭据永远不下发。 - KV 每日限额:全局日限额 + 单 IP 日限额,按 UTC 日期计数,超限返回 429。给"只信得过的小群"再加一道保险。
- OpenAI 兼容:
/v1/chat/completions(非流式 + SSE 流式)+/v1/models,Cherry Studio、Chatbox、OpenWebUI 等客户端填上 Base URL 就能用。
架构概览#
客户端(Cherry Studio 等 OpenAI 兼容客户端)
│ Authorization: Bearer <ACCESS_KEY>
▼
Cloudflare Worker(free-ai-gateway)
├─ 请求路径:鉴权 → KV 限额检查 → 模型映射 → env.AI.run()
├─ KV(QUOTA_KV):quota:day:{日期} / quota:ip:{日期}:{IP} / quota:model:{日期}:{模型}
└─ Workers AI 绑定:env.AI(Cloudflare 托管模型库)
│
▼
Workers AI(每日 10,000 neurons 免费额度)关键点:网关本身也跑在 Cloudflare 上,调用 Workers AI 走的是内部绑定(env.AI.run()),不产生额外网络费用;只有 neurons 消耗计入每日 10,000 免费额度。
免费额度到底怎么算#
Cloudflare 用 neuron 作为推理计算单位(官方定价页):
- Workers Free 计划:每天 10,000 neurons 免费,超限直接失败,不会扣钱。
- Workers Paid 计划:每天同样 10,000 neurons 免费,超出部分 $0.011 / 1,000 neurons。
不同模型消耗不同:小模型(如 llama-3.1-8b)单次推理可能只要几个 neurons,大模型(如 llama-3.3-70b)单次可能要几十到几百。所以"每天能跑多少次"取决于你用什么模型、聊多长。给网关加请求次数限额,是防止单个人把额度刷完的第一道闸。
从零部署#
前置:本机已装 Node.js(v22+)与 npm。
1. 初始化并安装 wrangler
mkdir free-ai-gateway && cd free-ai-gateway
npm init -y
npm install -D wrangler2. 登录 Cloudflare
npx wrangler login会弹出浏览器 OAuth 授权页,手动点 Allow 即可(wrangler login 会等回调,不要在前台让它超时被杀)。
3. 创建 KV 命名空间
npx wrangler kv namespace create QUOTA_KVwrangler 4.x 语法是
kv namespace(带空格)。成功后把输出的id = "xxx"填到wrangler.toml。
4. 设置访问密钥(交互式输入,不会落盘到代码)
npx wrangler secret put ACCESS_KEY # 自定义访问密钥,生成一段足够长的随机串5. 编写 wrangler.toml
name = "free-ai-gateway"
main = "src/index.js"
compatibility_date = "2025-10-01"
# 自定义域名路由(custom_domain=true 会在 Cloudflare 自动创建 DNS 记录)
routes = [
{ pattern = "<你的网关子域>.<你的域名>", custom_domain = true },
]
# KV 命名空间绑定(id 来自第 3 步的输出)
[[kv_namespaces]]
binding = "QUOTA_KV"
id = "<你的 KV 命名空间 ID>"
# Workers AI 绑定:env.AI.run(model, input)
[ai]
binding = "AI"
[vars]
DEFAULT_MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast"
DAILY_LIMIT = "1000" # 全局每日请求上限(次/天)
IP_DAILY_LIMIT = "200" # 单 IP 每日请求上限(次/天)6. 部署
npx wrangler deploy部署输出应包含你的自定义域名。绑定自定义域名后,workers.dev 默认地址会自动停用。
7. 部署后立即验证
# ① 模型列表(公开)
curl https://<你的网关地址>/v1/models
# ② 无鉴权 → 401
curl https://<你的网关地址>/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model":"llama-3.3-70b","messages":[{"role":"user","content":"hi"}]}'
# ③ 对话(非流式)
curl https://<你的网关地址>/v1/chat/completions \
-H "Authorization: Bearer <你的 ACCESS_KEY>" -H "Content-Type: application/json" \
-d '{"model":"llama-3.3-70b","messages":[{"role":"user","content":"你好"}]}'
# ④ 流式
curl -N https://<你的网关地址>/v1/chat/completions \
-H "Authorization: Bearer <你的 ACCESS_KEY>" -H "Content-Type: application/json" \
-d '{"model":"llama-3.3-70b","messages":[{"role":"user","content":"hi"}],"stream":true}'
# ⑤ 今日用量(需鉴权)
curl -H "Authorization: Bearer <你的 ACCESS_KEY>" https://<你的网关地址>/stats完整 Worker 代码#
把下面内容保存为 src/index.js。它不含任何密钥——ACCESS_KEY 来自 wrangler secret,运行时以环境变量注入。
/**
* FreeAI 网关 —— Cloudflare Workers AI 免费额度网关
* 功能:鉴权防盗刷 / KV 每日限额 / OpenAI 兼容(非流式 + SSE 流式)/ 模型映射
*
* KV 键设计(QUOTA_KV,键按 UTC 日期分片,自动过期 3 天):
* quota:day:{date} 当日全局请求数
* quota:ip:{date}:{ip} 当日单 IP 请求数
* quota:model:{date}:{m} 当日各模型请求数(/stats 展示用)
*/
const MODEL_MAP = {
"llama-3.3-70b": "@cf/meta/llama-3.3-70b-instruct-fp8-fast",
"llama-3.1-8b": "@cf/meta/llama-3.1-8b-instruct-fp8",
"qwen-30b": "@cf/qwen/qwen3-30b-a3b-fp8",
"glm-4.7-flash": "@cf/zai-org/glm-4.7-flash",
"mistral-small": "@cf/mistralai/mistral-small-3.1-24b-instruct",
"kimi-k2.6": "@cf/moonshotai/kimi-k2.6",
"deepseek-v4-flash": "@cf/deepseek-ai/deepseek-v4-flash-0731",
};
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, Accept",
"Access-Control-Max-Age": "86400",
};
const KV_TTL_SECONDS = 3 * 24 * 3600;
function jsonResponse(status, payload) {
return new Response(JSON.stringify(payload), {
status,
headers: { ...CORS_HEADERS, "Content-Type": "application/json" },
});
}
function todayKey() {
return new Date().toISOString().slice(0, 10);
}
async function kvIncr(kv, key, ttl) {
const raw = await kv.get(key);
const next = (parseInt(raw || "0", 10) || 0) + 1;
await kv.put(key, String(next), { expirationTtl: ttl });
return next;
}
async function checkQuota(env, ip, modelId) {
const date = todayKey();
const dailyLimit = parseInt(env.DAILY_LIMIT, 10) || 1000;
const ipLimit = parseInt(env.IP_DAILY_LIMIT, 10) || 200;
const [globalCount, ipCount] = await Promise.all([
kvIncr(env.QUOTA_KV, `quota:day:${date}`, KV_TTL_SECONDS),
kvIncr(env.QUOTA_KV, `quota:ip:${date}:${ip}`, KV_TTL_SECONDS),
]);
kvIncr(env.QUOTA_KV, `quota:model:${date}:${modelId}`, KV_TTL_SECONDS).catch(() => {});
if (globalCount > dailyLimit) {
return jsonResponse(429, { error: { message: `Daily global limit reached (${dailyLimit}/day)`, code: 429 } });
}
if (ipCount > ipLimit) {
return jsonResponse(429, { error: { message: `Daily per-IP limit reached (${ipLimit}/day)`, code: 429 } });
}
return null;
}
function resolveModel(body, env) {
const raw = typeof body.model === "string" ? body.model.trim() : "";
if (MODEL_MAP[raw]) return MODEL_MAP[raw];
if (/^@cf\/[A-Za-z0-9._/-]{1,100}$/.test(raw)) return raw;
return env.DEFAULT_MODEL || "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
}
/** 兼容提取文本:llama 返回 {response:"文本"},glm 返回嵌套 OpenAI JSON */
function extractContent(response) {
if (typeof response === "string") {
try {
const parsed = JSON.parse(response);
const inner = extractContent(parsed);
if (inner != null) return inner;
} catch {}
return response;
}
if (response && typeof response === "object") {
if (Array.isArray(response.choices) && response.choices.length) {
const msg = response.choices[0].message ?? response.choices[0].delta ?? null;
if (msg && typeof msg.content === "string") return msg.content;
}
if (typeof response.response === "string") return extractContent(response.response);
return null;
}
return response == null ? null : String(response);
}
function toOpenAICompletion(out, modelName) {
let content = typeof out === "string" ? out : extractContent(out);
if (content == null) content = JSON.stringify(out);
return {
id: "chatcmpl-" + Date.now(),
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: modelName,
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
};
}
/** Workers AI 流式(SSE 事件)→ OpenAI SSE 格式 */
function streamToOpenAI(stream, modelName, id) {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let buffer = "";
const sendChunk = (obj) => encoder.encode(`data: ${JSON.stringify(obj)}\n\n`);
return new ReadableStream({
async start(controller) {
const reader = stream.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const event of events) {
const dataLine = event.trim().split("\n").find((l) => l.startsWith("data:"));
if (!dataLine) continue;
const payload = dataLine.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
let chunk;
try { chunk = JSON.parse(payload); } catch { continue; }
const delta = extractContent(chunk.response ?? chunk);
if (!delta) continue;
controller.enqueue(sendChunk({
id, object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000), model: modelName,
choices: [{ index: 0, delta: { content: delta }, finish_reason: null }],
}));
}
}
} catch (err) {
console.error("[stream] read failed:", err.message);
} finally {
controller.enqueue(sendChunk({
id, object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000), model: modelName,
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
}));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
}
},
});
}
export default {
async fetch(request, env) {
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
const url = new URL(request.url);
// 模型列表(公开,客户端启动探测用)
if (request.method === "GET" && (url.pathname === "/v1/models" || url.pathname === "/models")) {
const ids = [...new Set([...Object.keys(MODEL_MAP), ...Object.values(MODEL_MAP)])];
return jsonResponse(200, { object: "list", data: ids.map((id) => ({ id, object: "model", owned_by: "free-ai-gateway" })) });
}
// 以下全部需要鉴权
const authHeader = request.headers.get("Authorization") || "";
if (authHeader !== `Bearer ${env.ACCESS_KEY}`) {
return jsonResponse(401, { error: { message: "Unauthorized: invalid access key", code: 401 } });
}
// 今日用量(运维用)
if (request.method === "GET" && url.pathname === "/stats") {
const date = todayKey();
const globalRaw = await env.QUOTA_KV.get(`quota:day:${date}`).catch(() => null);
const modelList = await env.QUOTA_KV.list({ prefix: `quota:model:${date}:` }).catch(() => ({ keys: [] }));
const byModel = {};
for (const k of modelList.keys) {
const m = k.name.slice(`quota:model:${date}:`.length);
byModel[m] = parseInt(await env.QUOTA_KV.get(k.name).catch(() => "0"), 10) || 0;
}
return jsonResponse(200, {
date, globalCount: parseInt(globalRaw || "0", 10) || 0,
globalLimit: parseInt(env.DAILY_LIMIT, 10) || 1000,
ipLimit: parseInt(env.IP_DAILY_LIMIT, 10) || 200,
byModel,
});
}
// 主接口:/v1/chat/completions
if (request.method !== "POST" || !url.pathname.endsWith("/chat/completions")) {
return jsonResponse(404, { error: { message: "Not Found", code: 404 } });
}
let body;
try { body = await request.json(); } catch {
return jsonResponse(400, { error: { message: "Invalid JSON body", code: 400 } });
}
const messages = Array.isArray(body.messages) ? body.messages : [];
if (messages.length === 0) {
return jsonResponse(400, { error: { message: "messages is required", code: 400 } });
}
const modelId = resolveModel(body, env);
const clientModel = typeof body.model === "string" && body.model.trim() ? body.model.trim() : modelId;
const clientIp = request.headers.get("CF-Connecting-IP") || "unknown";
const quotaRes = await checkQuota(env, clientIp, modelId);
if (quotaRes) return quotaRes;
const isStream = body.stream === true;
const id = "chatcmpl-" + Date.now();
if (isStream) {
try {
const aiStream = await env.AI.run(modelId, { messages, stream: true });
return new Response(streamToOpenAI(aiStream, clientModel, id), {
status: 200,
headers: { ...CORS_HEADERS, "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
});
} catch (err) {
console.warn("[stream] fallback to non-stream:", err.message);
try {
const out = await env.AI.run(modelId, { messages });
const completion = toOpenAICompletion(out, clientModel);
const encoder = new TextEncoder();
const body2 =
`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: completion.created, model: clientModel, choices: [{ index: 0, delta: { content: completion.choices[0].message.content }, finish_reason: null }] })}\n\n` +
`data: ${JSON.stringify({ id, object: "chat.completion.chunk", created: completion.created, model: clientModel, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n` +
"data: [DONE]\n\n";
return new Response(encoder.encode(body2), {
status: 200,
headers: { ...CORS_HEADERS, "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
});
} catch (err2) {
return jsonResponse(502, { error: { message: `AI upstream failed: ${err2.message}`, code: 502 } });
}
}
}
try {
const out = await env.AI.run(modelId, { messages });
return jsonResponse(200, toOpenAICompletion(out, clientModel));
} catch (err) {
return jsonResponse(502, { error: { message: `AI upstream failed: ${err.message}`, code: 502 } });
}
},
};可调参数(wrangler.toml → [vars])#
| 变量 | 默认 | 说明 |
|---|---|---|
DEFAULT_MODEL | @cf/meta/llama-3.3-70b-instruct-fp8-fast | 默认模型(客户端未指定/无法识别时兜底) |
DAILY_LIMIT | 1000 | 全局每日请求上限(次/天),按 UTC 日期计数 |
IP_DAILY_LIMIT | 200 | 单 IP 每日请求上限(次/天) |
调参后重新
npx wrangler deploy生效。比如朋友很多怕被刷爆,把DAILY_LIMIT调小(如 300)。
模型怎么选#
网关内置了几个友好名映射(客户端填 llama-3.3-70b 即可),也可以直接填 @cf/... 原始 ID。实测注意:只有 Free 计划可用的模型才能跑(kimi-k2.6、deepseek-v4-flash 等会返回 5035 需要升级付费计划):
| 友好名 | Workers AI 模型 ID | 适合 | 约 100 字回复的 neurons 消耗 |
|---|---|---|---|
llama-3.3-70b(默认) | @cf/meta/llama-3.3-70b-instruct-fp8-fast | 通用均衡,免费额度主力 | ~15 |
llama-3.1-8b | @cf/meta/llama-3.1-8b-instruct-fp8 | 轻量快,最省 neurons | ~1.1 |
qwen-30b | @cf/qwen/qwen3-30b-a3b-fp8 | 中文高效 MoE | ~15 |
qwen-coder-32b | @cf/qwen/qwen2.5-coder-32b-instruct | 代码 | — |
glm-4.7-flash | @cf/zai-org/glm-4.7-flash | 中文,带回显 reasoning | ~26 |
mistral-small | @cf/mistralai/mistral-small-3.1-24b-instruct | 英文 | ~11 |
llama-vision | @cf/meta/llama-3.2-11b-vision-instruct | 看图对话(多模态) | ~8-37 |
📷 视觉模型:
llama-vision支持 OpenAI 标准的图片输入格式(content数组里带image_url,data URL 或 http URL 均可),Cherry Studio 等客户端直接传图就能用。首次使用需在 Cloudflare 账号级同意一次模型许可协议(实测 2026-08-21:logo 图 8 neurons/次,真实照片约 37 neurons/次)。 完整的账号模型目录可以在 Cloudflare 控制台 Workers AI 页面查看,也可以在网关里直接填任意@cf/...ID 实测——Free 计划不可用的模型会返回 5035 错误。
客户端怎么用#
任何 OpenAI 兼容客户端(Cherry Studio、Chatbox、OpenWebUI、各种 SDK)都能直接接,只需三步:
- Base URL:
https://<你的网关地址>/v1 - API Key:
<你的 ACCESS_KEY>(就是第 4 步自己设的那个随机串,不是 Cloudflare 的凭据) - 模型 ID:填
llama-3.3-70b或上表其他友好名,换模型直接改这里
运维接口#
| 接口 | 方法 | 说明 |
|---|---|---|
/ | GET | 首页(使用说明 HTML,公开) |
/v1/models | GET | 模型列表(公开,客户端启动探测用) |
/v1/chat/completions | POST | 主代理接口(OpenAI 兼容,支持 stream) |
/stats | GET | 今日用量(需鉴权:全局计数/限额/各模型计数) |
# 查看今日用量
curl -H "Authorization: Bearer <你的 ACCESS_KEY>" https://<你的网关地址>/stats故障排查#
| 症状 | 排查步骤 |
|---|---|
| 域名打不开 | ① npx wrangler deployments 看部署是否成功;② CF 面板 Workers → 域名绑定是否在;③ DNS 记录是否自动创建(子域);④ 等 1–5 分钟 DNS 生效 |
| 401 鉴权失败 | ACCESS_KEY 是否被换过?CF 面板 Settings → Variables and Secrets 里核对(secret 不回显,重设一次即可) |
| 一直 429 | 今日额度用完(/stats 看计数):全局 DAILY_LIMIT 或单 IP IP_DAILY_LIMIT。等 UTC 次日 00:00 重置,或调大限额 |
| 模型调用报错 | 模型 ID 是否在账号模型目录内?用 /v1/models 列出的 ID;换个模型试试 |
| 限额计数不准 | KV 读有边缘缓存 + 读改写非原子,限额是"软闸",极端并发下可能略微超限;真正的硬上限是 CF 侧 10,000 neurons |
| 想看实时日志 | wrangler tail 可能受本机网络限制连不上,改用 CF 面板 Workers → Logs |
踩坑记录#
wrangler kv:namespace create在 wrangler 4.x 已改为wrangler kv namespace create(去掉冒号)。- KV
get的cacheTtl最小 30 秒,不能传 0(报 400 Invalid cache_ttl)。 - Workers AI 流式响应是 SSE 格式(
data: {...}\n\n事件),不是裸 JSON 流。llama 类 chunk 是{"response":"纯文本"},glm 类直接是 OpenAI 风格嵌套——代码里的extractContent()统一兼容两种。 - glm-4.7-flash 非流式返回嵌套 OpenAI JSON 字符串(
out.response是完整 chat.completion JSON),必须解析提取,不能当纯文本直接返回。 - Free 计划不是所有模型都能跑:
kimi-k2.6、deepseek-v4-flash等前沿模型返回 5035(需 Workers Paid 计划)。部署后先实测一遍模型列表再分享。 - 部署后 1–2 分钟内新旧版本并存(Cloudflare 渐进式发布),刚部署完立刻测试可能打到旧版本。
- 本机 curl 访问外网若 IPv6 不通,加
-4强制 IPv4。
和 OR 网关怎么选#
| FreeAI 网关(本文) | OR 网关(上篇) | |
|---|---|---|
| 上游 | Cloudflare Workers AI(自家模型库) | OpenRouter(聚合多家模型) |
| 成本 | 每天 10,000 neurons 免费,超限即停 | 按 OpenRouter 用量计费 |
| 模型 | 开源模型(Llama/Qwen/GLM 等) | 全 OpenRouter 模型(含闭源) |
| 适合 | 翻译、文案、摘要、日常对话等轻量任务 | 需要推理/代码的复杂任务、长会话 |
结语#
这套网关本质上是"鉴权 + 限额 + 转发“三件事,跑在 Cloudflare 免费额度里。10,000 neurons/天对个人完全够用,给小群分享时记得设好 DAILY_LIMIT 和 IP_DAILY_LIMIT 两道闸。模型能力上别期待它能打过 DeepSeek 这类闭源旗舰——但日常翻译、写文案、摘要这类轻量任务,白嫖 Cloudflare 的 GPU 不香吗?








