前前后后接过大大小小不少 AI 项目,有一个需求几乎每次都会出现:把大模型 API 的密钥藏起来,再给团队、给应用、给朋友一个小而美的统一入口。GitHub 上各种 one-api、new-api 项目火得不行,但要么得租服务器跑一堆服务,要么部署维护成本高。直到我认真用起 Cloudflare Worker,才发现这种“把中转层搬到全世界边缘节点”的方案,才是真正省心又免费的路子。这篇文章就基于我实际搭建并跑了几个月的经验,讲讲怎么用 Cloudflare Worker 干这件事,把 Gemini Pro 的能力封装成一个自带鉴权、防滥用、支持流式输出的私人 API 网关。
1. 为什么我把中转层放在了 Cloudflare Worker 上
1.1 先想清楚“私人 API”到底要解决什么问题
很多人上来就开写代码,但我建议先琢磨一下痛点。官方 API Key 直接裸奔在前端请求里,等于把自家保险柜钥匙挂在大门口——控制台里哪天突然多了几千刀账单,你都不知道是哪个环节漏出去的。我见过不少团队把 key 硬编码在小程序代码、浏览器插件、爬虫脚本里,结果要么被薅羊毛,要么被审计平台判定异常。就算你只是自己用,多端共享一个 key 时,也完全没法区分哪个设备在调用、哪个场景在消耗额度。
所以“私人 Gemini Pro API”这个需求拆开看,其实是三层:第一层是隐藏密钥,让上游 key 只存在于服务端;第二层是统一鉴权,所有请求都必须过我们自己定义的身份体系;第三层是访问控制,比如限制域名来源、限制每分钟调用频率、限制最大请求体,把滥用成本直接怼上去。Cloudflare Worker 正好把这三件事都包圆了,而且免费额度对个人项目来说完全够用。
1.2 Cloudflare Worker 和传统服务器中转的差异
如果你租过一台 2C4G 的 VPS 专门跑转发服务,应该体会过这些麻烦:系统要打补丁、Nginx 要配证书、进程要守护、半夜还得爬起来处理内存溢出。Worker 这玩意儿说白了就是一个跑在 Cloudflare 边缘节点上的函数,你不用关心操作系统、不用管负载均衡、不用买域名证书,写完代码点一下部署,全世界 300 多个节点的就近入口自动生效。
更重要的是,Worker 的免费套餐每天有 10 万次请求额度,个人项目、小团队内部工具、甚至一个几百人用的应用都很难打穿这个量级。相比之下,VPS 每月固定支出不说,还时不时被流量攻击、被扫描端口,安全加固的成本远超想象。另一个隐形优势是冷启动速度,Worker 的运行时针对边缘场景做了优化,实测冷启动基本在几十毫秒级别,和大模型动辄一两秒的响应时间相比,这个延迟完全可以忽略。这不是“能不能用”的区别,而是“省不省心”的本质差异。
1.3 方案选型时的备选项对比
我动手之前也快速过了一遍主流方案,这里把对比结果整理出来给大家当参考:
| 方案 | 部署成本 | 费用 | 鉴权控制 | 抗滥用 | 运维压力 |
|---|---|---|---|---|---|
| 裸用官方 API | 零 | 按量付费 | 无 | 无 | 无 |
| 自建 one-api 等网关 | 高,需服务器 | 服务器 + 维护 | 完善 | 中等 | 高 |
| 云函数(如 Serverless) | 中等 | 按量付费 | 需自己写 | 中等 | 低 |
| Cloudflare Worker | 极低 | 免费/极低 | 可自定义 | 强 | 极低 |
我最终选 Worker 的核心原因很简单:服务商帮我解决了网络传输、证书、防护、扩容这四件最麻烦的事,我只需要专注于业务逻辑本身。当然,Worker 也不是没有短板,比如单次执行时间限制是 30 秒(免费版 10 秒 CPU 时间),但 Gemini Pro 的接口响应普遍在几秒内,这个限制实际很少碰到。如果你真要跑长时间推理任务,可以换用 Durable Objects 或 Queue 做异步处理,那又是另一个话题了。
2. Gemini Pro API 的几个关键细节
2.1 模型版本与调用方式的理解
Gemini Pro 是 Google 推出的多模态大模型系列,语言理解、代码生成、逻辑推理能力都相当能打。在 API 层面,Google 提供了两个主要入口:一个是generateContent,用于普通的一次性问答;另一个是streamGenerateContent,用于流式输出。千万别小看这个差异,用generateContent做聊天机器人时,用户得盯着空白界面干等好几秒,然后“哗”一下全出来,交互体验非常割裂。而流式接口是按 token 逐个推送的,首字延迟通常在一秒内,体验上接近打字的节奏感。
以目前主流的gemini-2.0-flash和gemini-1.5-pro为例,前者胜在响应快、价格低,适合高频互动、分类抽取这种任务;后者胜在推理深、上下文窗口大,适合长文档分析、复杂代码生成。代码里建议把模型名做成环境变量,方便随时切换。
另外有一个很多新手踩过的坑:Google 的 API endpoint 会根据模型和版本略有差异,最稳妥的做法是在代码里把完整的v1beta路径写清楚,因为部分新模型只在新版本接口里可用。我自己生产环境里的默认配置是gemini-2.0-flash,便宜、快、稳,参数里加上temperature、maxOutputTokens就能覆盖绝大多数场景。
2.2 鉴权机制与请求格式
Gemini API 的鉴权方式是 API Key,通过 HTTP Header 传递:X-Goog-Api-Key: YOUR_API_KEY。注意这里的 Header 名和 OpenAI 的Authorization: Bearer xxx完全不一样,如果你习惯性地用 OpenAI 的 SDK 去调 Gemini,大概率会被 400 拒之门外。请求体是标准的 JSON,核心字段如下:
{ "contents": [ { "role": "user", "parts": [{ "text": "你好,请介绍一下自己" }] } ], "generationConfig": { "temperature": 0.7, "maxOutputTokens": 1024 } }返回结果里,核心内容在candidates[0].content.parts[0].text。流式接口返回的则是多行 JSON,每行一个对象,需要逐行解析再拼接。最开始我图省事直接透传流式响应,结果发现 Worker 默认会把整个 streaming 响应缓冲起来,等到全部结束才返回给前端,首字延迟直接归零报废。解决方式是对fetch返回的response.body做一层 Identity TransformStream 转发,让数据像水管一样源源不断流出去。这块细节等会儿在代码部分详细展开。
3. 动手写 Worker 代码前的准备
3.1 注册与基础环境配置
你需要准备三样东西:一个 Cloudflare 账号、一个 Google AI Studio 的 API Key,以及 Node.js 环境(非必需但有帮助)。Google AI Studio 的地址大家应该都熟,进去以后点 “Get API key” 创建一个新的密钥,创建时建议把权限范围限制在你需要的模型上,不要把全部模型权限都给同一个 key。
Cloudflare 这边,推荐用 Wrangler CLI 而不是网页编辑器来部署,因为本地调试、环境变量管理、多环境切换都方便得多。安装并登录:
npm install -g wrangler wrangler login登录后,在你的项目目录里初始化:
wrangler init gemini-proxy cd gemini-proxyWrangler 会自动生成wrangler.toml和src/index.js或src/index.ts,这里我强烈建议直接用 TypeScript,毕竟代理的逻辑会越来越复杂,类型提示能帮你少踩很多坑。然后打开wrangler.toml,把名字改成你喜欢的子域名前缀,这个前缀会直接影响你最终的 API 地址,例如my-gemini-api.yourname.workers.dev。
3.2 环境变量的设计与密钥管理
很多教程会让你直接把 Google API Key 写死在代码里,这是最典型的反面教材。一旦代码上传到 Git 仓库,或者分享给别人看,你的密钥就等于公开了,而且 Google 会不定期扫描公开仓库中的密钥并自动吊销。正确做法是利用 Cloudflare Worker 的wrangler.toml中的[vars]或者通过命令行设置环境变量。
对于开发环境,我习惯先在本地创建一个.dev.vars文件(这个文件不要提交到 Git):
YOUTUBE_API_KEY=your_google_gemini_api_key_here AUTH_TOKEN=your_strong_custom_auth_token ALLOWED_ORIGINS=https://your-app.example.com,https://admin.example.com其中YOUTUBE_API_KEY是 Gemini 的上游 Key,AUTH_TOKEN是你自己定义的鉴权 Token,用来拦截非授权请求。等到部署时,在 Cloudflare Dashboard 的后台页面或命令行中把这些变量逐一填入,线上环境就不会读到.dev.vars了。生产环境强烈建议把AUTH_TOKEN设置成一个足够长的随机字符串,可以用密码管理器生成。你也可以接 Cloudflare Secrets,原理类似,目的就一句话:别把秘密写进代码里。
4. 核心代码实现与逐段解析
4.1 Worker 主入口:请求拦截与路由
Worker 的本质是监听fetch事件并返回 Response。我一般会在主入口做三件事:检查请求方法、校验身份、路由到不同处理函数。下面是我整理过很多遍之后比较稳定的一版骨架:
const DEFAULT_MODEL = "gemini-2.0-flash"; const GEMINI_API_BASE = "https://generativelanguage.googleapis.com/v1beta"; export default { async fetch(request, env, ctx) { const url = new URL(request.url); const headers = new Headers(request.headers); headers.set("Access-Control-Allow-Origin", "*"); headers.set("Access-Control-Allow-Methods", "POST, OPTIONS"); headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (request.method === "OPTIONS") { return new Response(null, { status: 204, headers }); } // 鉴权校验:所有非预检请求都必须带对 AUTH_TOKEN const authHeader = headers.get("Authorization") || ""; const token = authHeader.replace("Bearer ", "").trim(); if (token !== env.AUTH_TOKEN) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { ...headers, "Content-Type": "application/json" }, }); } if (request.method !== "POST") { return new Response(JSON.stringify({ error: "Method Not Allowed" }), { status: 405, headers: { ...headers, "Content-Type": "application/json" }, }); } const path = url.pathname; if (path === "/v1/chat/completions" || path === "/gemini") { return handleChat(request, env, headers); } if (path === "/v1/models") { return handleModels(env, headers); } if (path === "/health") { return new Response(JSON.stringify({ status: "ok" }), { status: 200, headers: { ...headers, "Content-Type": "application/json" }, }); } return new Response(JSON.stringify({ error: "Not Found" }), { status: 404, headers: { ...headers, "Content-Type": "application/json" }, }); }, };这一段的关键在于把鉴权逻辑放在最前面,且对OPTIONS预检请求放行。如果你不做这一步,浏览器端的跨域请求会在正式请求发出前就被 CORS 策略拦住,而你排查问题时看到的会是各种晦涩的网络错误。这里对AuthorizationHeader 做了严格的比对,只有携带正确AUTH_TOKEN的请求才会继续向下走,因此即使别人扫到了你的 Worker 地址,也没有办法调用。另外建议给OPTIONS响应设置短一点的缓存时间,减少无效预检请求。
4.2 统一会话接口与 Gemini 请求映射
我对外提供的是一套 OpenAI 风格的接口,即/v1/chat/completions。这样做的用意很直白:团队里已经有很多基于 OpenAI 协议封装好的工具、脚本、甚至商业软件,只要把 base URL 改成我的 Worker 地址,原封不动就能跑通,无痛迁移。下面是把 OpenAI 风格请求转换为 Gemini 格式的核心函数:
async function handleChat(request, env, headers) { let payload; try { payload = await request.json(); } catch (e) { return new Response(JSON.stringify({ error: "Invalid JSON body" }), { status: 400, headers: { ...headers, "Content-Type": "application/json" }, }); } const model = payload.model || DEFAULT_MODEL; const messages = payload.messages || []; const temperature = payload.temperature ?? 0.7; const maxTokens = payload.max_tokens ?? 2048; const stream = payload.stream || false; // 消息映射 const contents = []; for (const msg of messages) { let role = msg.role; let text = ""; if (typeof msg.content === "string") { text = msg.content; } else if (Array.isArray(msg.content)) { // 兼容多模态内容块 text = msg.content .map((part) => { if (part.type === "text") return part.text; if (part.type === "image_url") return `[Image: ${part.image_url.url}]`; return ""; }) .join("\n"); } // Gemini 的 role 只有 user/model,需要把 assistant 映射成 model if (role === "assistant") role = "model"; if (role === "system") { // system message 以独立 user 指令方式拼在最前面 contents.unshift({ role: "user", parts: [{ text: `[System Instruction]\n${text}` }], }); continue; } contents.push({ role: role === "user" ? "user" : "model", parts: [{ text }], }); } const geminiBody = { contents, generationConfig: { temperature, maxOutputTokens: maxTokens, }, }; const geminiEndpoint = `${GEMINI_API_BASE}/models/${encodeURIComponent( model )}:${stream ? "streamGenerateContent" : "generateContent"}?alt=sse`; const upstreamResponse = await fetch(geminiEndpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Goog-Api-Key": env.YOUTUBE_API_KEY, }, body: JSON.stringify(geminiBody), }); if (!upstreamResponse.ok) { const errorText = await upstreamResponse.text(); return new Response( JSON.stringify({ error: { message: `Upstream error: ${errorText}`, type: "upstream_error", code: upstreamResponse.status, }, }), { status: upstreamResponse.status, headers: { ...headers, "Content-Type": "application/json" } } ); } if (stream) { // 流式转发,后面单独讲 return handleStreamResponse(upstreamResponse, headers); } const geminiData = await upstreamResponse.json(); const text = geminiData?.candidates?.[0]?.content?.parts?.[0]?.text || ""; const openaiStyleResponse = { id: `chatcmpl_${Date.now()}`, object: "chat.completion", created: Math.floor(Date.now() / 1000), model, choices: [ { index: 0, message: { role: "assistant", content: text, }, finish_reason: "stop", }, ], usage: geminiData?.usageMetadata || null, }; return new Response(JSON.stringify(openaiStyleResponse), { status: 200, headers: { ...headers, "Content-Type": "application/json" }, }); }这个映射函数虽然看起来很长,但每一行都有它存在的理由。系统指令(system prompt)在 Gemini 里没有独立字段,强行塞进systemInstruction有时会有兼容性隐患,我用了“塞进首条 user 消息”这种土办法,实测各家模型对它的理解都比较稳定;消息角色转换是必须做的,否则 Gemini 会返回 400 说你给了非法角色;多模态内容我暂时用占位文本处理,如果后续真需要传图片给 Gemini,你再单独扩展inline_data字段。这套兼容层的价值在于:你的前端、SDK、低代码平台都只需要认识 OpenAI 格式,剩下的脏活累活 Worker 全扛了。
4.3 流式输出的正确姿势与 TransformStream
这是整篇代码里最容易翻车的地方,也是很多教程含糊其辞的地方。直接转发 Gemini 的 SSE 流会出现一个问题:前端拿到的数据格式和 OpenAI 的流式格式大相径庭,很多基于openai-node的工具直接解析失败。所以正确的处理方式是:读取 Gemini 的流式响应,逐段解析出文本增量,再拼装成 OpenAI 风格的 chunk 写回给客户端。
async function handleStreamResponse(upstreamResponse, headers) { const encoder = new TextEncoder(); const decoder = new TextDecoder(); // 永远不要直接返回 upstreamResponse.body,必须先转换 const transformStream = new TransformStream({ start(controller) { this.buffer = ""; }, async transform(chunk, controller) { this.buffer += decoder.decode(chunk, { stream: true }); const lines = this.buffer.split("\n"); this.buffer = lines.pop() || ""; for (const line of lines) { const trimmed = line.trim(); if (!trimmed.startsWith("data:")) continue; if (trimmed === "data: [DONE]") { // 透传终止标记 controller.enqueue(encoder.encode("data: [DONE]\n\n")); continue; } const jsonStr = trimmed.replace(/^data:\s*/, ""); try { const data = JSON.parse(jsonStr); const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || ""; if (!text) continue; const chunkPayload = { id: `chatcmpl_${Date.now()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "gemini", choices: [ { index: 0, delta: { content: text }, finish_reason: null, }, ], }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunkPayload)}\n\n`)); } catch (e) { // 遇到解析失败的行直接跳过,不阻塞整个流 console.error("Failed to parse chunk:", jsonStr); } } }, flush(controller) { if (this.buffer.trim()) { try { const data = JSON.parse(this.buffer.replace(/^data:\s*/, "")); const text = data?.candidates?.[0]?.content?.parts?.[0]?.text || ""; if (text) { const chunkPayload = { id: `chatcmpl_${Date.now()}`, object: "chat.completion.chunk", created: Math.floor(Date.now() / 1000), model: "gemini", choices: [{ index: 0, delta: { content: text }, finish_reason: null }], }; controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunkPayload)}\n\n`)); } } catch (e) { // ignore trailing garbage } } controller.enqueue(encoder.encode("data: [DONE]\n\n")); }, }); return new Response(upstreamResponse.body.pipeThrough(transformStream), { status: 200, headers: { ...headers, "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); }关键点有三处:一是必须用TransformStream而不是直接透传,这样才能把 Gemini 的 SSE 格式“翻译”成 OpenAI 格式;二是解析时要处理多行数据拼接,因为网络传输会把一个完整的 SSE 事件切分成多个 chunk,直接按split("\n")处理会漏数据,我维护了一个buffer字符串来兜底;三是在flush阶段必须补发[DONE]标记,否则很多客户端会一直处于 waiting 状态,表现为“转圈圈转不完”。这套代码我在生产环境跑了两周,累计处理了几千次流式请求,还没有出现过一次流中断、乱码或格式错误。
5. 部署、配置与本地调试全流程
5.1 用 Wrangler 实现多环境变量管理
代码写完后,先别急着部署。我会先在本地把程序跑起来,验证一遍逻辑,确认无误后再推上去。Wrangler 提供了wrangler dev命令,启动后会在本地起一个服务监听端口,默认是 8787,所有远程调用都模拟线上行为。
启动前要确保.dev.vars文件已经在项目根目录,并且填入了真实可用的 Gemini API Key。这一步非常重要,没有 Key 的话本地连 401 都过不了,更别说测试功能。
验证本地没问题后,执行部署命令:
wrangler deploy部署完成后,Wrangler 会输出一个*.workers.dev的地址。这个地址默认就是公网可访问的,如果你还没有绑定自定义域名,可以先拿它做测试。但真正常态使用,我更建议绑定一个自己的域名,方便记忆和管理,也不容易被人扫到滥用地址。域名绑定操作在 Cloudflare Dashboard -> Workers -> 你的 Worker -> Settings -> Domains & Routes 里完成,配置 HTTPS 证书是自动的,Cloudflare 全托管。
5.2 用 curl 快速验证各类场景
部署完不等于完事,我习惯立刻用 curl 做一轮“冒烟测试”,确保核心链路正常。先测最简单的非流式请求:
curl -X POST https://my-gemini-api.yourname.workers.dev/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your_token_here" \ -d '{ "model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "用一句话介绍你自己"}], "max_tokens": 100 }'看到类似{"choices":[{"message":{"content":"..."}}]}的返回,说明整个链路已经通了。接着测流式:
curl -N -X POST https://my-gemini-api.yourname.workers.dev/v1/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer your_token_here" \ -d '{ "model": "gemini-2.0-flash", "messages": [{"role": "user", "content": "从1数到10,每次输出一个数字"}], "stream": true }'如果看到一串data: {...}且带[DONE]结尾,流式链路就没问题。还需要测一下鉴权是否生效,故意不带 Authorization 或者带错 Token,预期返回 401 JSON 错误。这一条测试非常关键,但很多人在部署完就忽略了——等到被人刷爆了才发现防护没生效。
5.3 自定义域名、用量限制与防护策略
如果你只是自己玩,workers.dev的域名够用。但如果你要分享给别人,或者接进公司内部系统,建议把自定义域名绑上。绑定之后 Worker 路由会把对应域名的所有请求转给这个 Worker 处理,证书自动签发,完全不需要额外操作。
千万别小看公网接口的安全防护。前面在代码里做了AUTH_TOKEN校验,这只能挡住“非授权”的请求,但挡不住那些已经拿到了 Token 的人恶意刷量。所以线上运行一段时间后,我发现一个很实际的问题:某些同事会把 Token 贴在群里,然后整个组的人共用一个 Token,调用量疯涨。针对这种情况,推荐再加一层按 IP 限流,或者干脆在 Worker 代码里加一个简单的计数器。还有一招是从业务层面做限制,比如在请求体里限制max_tokens的最大值,防止有人把单次输出拉满导致费用爆炸。层层设防,才能让“私人 API”真正私有。
6. LLM Gateway:只做一个 API 网关还不够
6.1 为什么日志和监控是刚需
跑了一个多月后,我的体会是:能通只是及格,能观察才算优秀。如果没有日志和监控,你根本不知道哪些业务在调用、每次调用的耗时和 token 消耗、有没有诡异的异常请求在试探你的接口。Cloudflare Worker 自带的日志在 Dashboard 里可以看到,但它是分散且短期的,真要追溯问题很不方便。强烈建议在上游响应返回时,把关键信息结构化地写进日志:
console.log( JSON.stringify({ ts: new Date().toISOString(), path: url.pathname, model, status: upstreamResponse.status, duration_ms: Date.now() - startTime, content_length: text.length, }) );这些日志能帮你快速回答下面几个问题:哪个模型调用最频繁?哪个时间段的流量最高?哪次请求返回了 4xx?答上来之后,你才知道要不要做模型层面的降级,要不要加更多的失败重试。
6.2 基于量级的分层告警策略
告警这件事,我推荐先定一个原则:不告警不可怕,乱告警才可怕。如果每个小错误都短信电话轰炸你,用不了一周你就会把告警渠道全静音,等到真的出大事反而没人发现。我的策略分三层:第一层,4xx 错误只记录日志,不主动告警,因为大部分 4xx 是客户端参数错误,不影响整体服务;第二层,5xx 错误或上游超时,说明服务端能力有问题,需要尽快介入,可以发一条通知到群;第三层,请求量或错误率达到某个阈值,说明可能被刷了或者模型整体不可用,除了通知还要考虑自动熔断,比如短时间内连续失败超过 20 次,就暂时停掉非必要调用,保护余额。
有人可能会问,一个“私人 API”也值得搞这么复杂的可观测性吗?我的回答是:看使用场景。如果只是自己写脚本调用,日志可有可无;但如果你把它接给了团队的工具、客户端的用户、或者跑了一些每日任务,那哪天接口挂了可能到你发现时已经过了半天,中间所有人的工作都在受影响。这时候花半小时把日志和告警补齐,收益非常可观。
6.3 多模型多密钥的扩展思路
Worker 不妨碍你继续把它当做一个统一网关来发展。Gemini 只是其中一个上游,你可以很自然地再加一个handleOpenAICompatible分支,把请求转发给 OpenAI、智谱、DeepSeek、本地 Ollama 等等。做法并不复杂:模型名用前缀区分,比如gemini/gemini-2.0-flash、openai/gpt-4o-mini、deepseek/deepseek-chat,Worker 读取前缀后动态选择上游地址和密钥即可。
多密钥管理的核心思路是不要把 Key 写死在代码里,而是在环境变量里放一组 JSON 字典。举个例子:
UPSTREAM_KEYS = { "gemini": "AIza...", "openai": "sk-...", "deepseek": "sk-..." }每次请求进来,先解析模型名前缀,找到对应的上游配置,再转发。这种“路由 + 密钥分离”的设计,能让你后续接入新模型时只改配置不动代码,真正做到低成本扩展。我接下来也准备把这条链路接进一个简单的管理面板,把 token 消耗、按用户统计、限流规则这些数据可视化出来——到那一步,这个私人 API 网关注定已经不能用一个“小工具”来形容了。
7. 常见问题与排查技巧实录
7.1 频繁踩坑的 400 错误解析
我接到过最多的求助,就是用户报api error: 400 invalid schema for function 'artifact'。这个报错信息看起来异常晦涩,很多人会以为是 Gemini 接口参数写错了,实际上它通常出现在你请求体里字段命名和主流 API 规范不一致的时候。比如把max_tokens当成 Gemini 的字段直接透传,而 Gemini 要求的是maxOutputTokens;再比如传了functions或tools字段,但格式不符合 Gemini 的 Function Calling 规范。这类 400 往往不是代码逻辑问题,而是格式翻译没做干净。
我自己的排查公式很简单:先把原始请求用curl直接打到generativelanguage.googleapis.com上,带上官方 Key,看官方接口给什么提示。如果官方接口通了,再把同样的请求体打到 Worker 上,对比差异。能快速收敛到是 Worker 的映射逻辑出了问题,还是上游本身就拒绝。这里最需要耐心,因为 Google 的报错信息经常不告诉你具体是哪个字段不合规,得自己逐字段检查。
7.2 401 鉴权失败与被误伤的合法请求
401 分两种:一种是真没带 Token,另一种是带了但 Token 不对。如果你在代码里用了headers.get("Authorization"),取出的是完整字符串,比如Bearer abc123,而你在环境变量里只填了abc123,那么比对时必然失败。很多教程没强调这个细节,导致一堆人本地测得好好的,部署到线上直接 401。我的处理方式是统一去掉Bearer前缀再比对,并且用常量时间比较法防止时序攻击,代码如下:
const authHeader = headers.get("Authorization") || ""; const token = authHeader.replace(/^Bearer\s+/i, "").trim(); if (token.length !== env.AUTH_TOKEN.length || !timingSafeEqual(token, env.AUTH_TOKEN)) { return new Response("Unauthorized", { status: 401 }); }另一个容易被忽略的场景是:带Authorization的自定义工具,比如某些低代码平台的 HTTP 插件,它们不一定让你自由设置 Header,可能会把 Token 放到 Query 参数里。为了兼容这种场景,我后来在代码里加了一个 fallback:如果 Header 里没拿到有效 Token,就再看?token=参数。当然这会带来日志泄露风险,所以只建议在内网环境或受信任客户端用这个模式,不建议默认开启。
7.3 流式响应为什么一直转圈
这个我前面说过一次,但值得再强调:绝大多数流式问题都出在返回头没有正确设置Content-Type: text/event-stream上。如果你把 JSON 当作 SSE 返回,客户端会傻等下一个数据块,表现为界面一直 loading。排查时可以先用 curl-N看原始响应,如果看到data: {...}不断滚动,说明上游和 Worker 都正常;如果看到一堆 JSON 被一次性输出,说明你的响应编码没有走流式通道。
还有一类流式问题是客户端自身没设置超时,或者设置了太短的超时。Gemini 在处理长文档时,可能几秒钟才吐第一个 token,如果客户端在 5 秒就断开连接,体验就是“偶尔成功偶尔失败”。我的建议是:客户端超时至少设置 60 秒,服务端重试次数控制在 2 次以内。另外,Cloudflare Worker 对响应头里的Connection: keep-alive有时会做一些改写,不用太纠结,重点是Cache-Control: no-cache一定要给,避免浏览器和中间层缓存你的流式内容。
8. 按实际需求调整的几点心得
搭建和运维这个服务的过程中,我不断在调整一些“看起来不起眼但影响很大”的策略,这里挑几条个人的体会。
第一,别把安全做成一把锁死所有入口的大铁锁。如果你只服务自己,严格的 IP 白名单就够了;但如果你服务的是一个 10 人团队,大家可能今天在办公室、明天在家、后天在咖啡厅,IP 白名单就是灾难。我现在用“强 Token + 可选的域名白名单 + 按 IP 限流”三级配置,平时默认只开第一级,真有需要再临时开第二级和第三级。安全手段一旦比业务本身还繁琐,就会有人绕过它,所以一定要追求“顺手”。
第二,流式接口是刚需,不是加分项。很多基于 API 的工具,比如聊天机器人、代码补全插件,都默认开启stream: true。如果你的代理不支持流式,它们要么报错要么体验极差。开发阶段可能感觉不到,但一旦真实用户接入,首字延迟和打字机效果直接决定口碑。所以从一开始就把流式做对,比后期再补要省太多事。
第三,多模型路由是一个性价比很高的演进方向。Worker 本身对上游地址没偏好,你完全可以把 Gemini 之外的大模型都接进来。团队里有人要跑 GPT,有人要用 DeepSeek,有人要试本地模型,统一入口后大家只需要改一个 model 参数。而且多个上游之间可以做故障切换——Gemini 偶尔会抽风,我可以瞬间把流量切到别的模型,用户无感知。这种能力在单点依赖某个模型时是永远体会不到的。
第四,一定要给自己留一个“逃生舱”。Worker 的代码和配置都要纳入版本管理,我在项目根目录建了一个docs/文件夹,把架构图、环境变量说明、常见问题排查方式全部写进去。万一哪一天我不在这个项目里了,接手的同事也能对照文档快速定位问题。很多人觉得“工具而已,用不着文档”,但等你同时维护三五个接口时,才发现文档是唯一的救命稻草。