☰
用Cloudflare Worker搭建统一AI网关:一次配置接入DeepSeek和聚合API
2026/10/22 11:26:14 网站建设 项目流程

如果你平时玩本地 AI 工具比较多,像 Chatbox、Cherry Studio,或者某个开源的终端客户端 Hermes,大概率会遇到同一个烦心事:一个模型一个 API 地址,一个服务一种鉴权方式,想同时用两家模型,就得在客户端里来回切配置,今天改 Base URL,明天改 API Key,周末想加一个模型直接把配置文件拉出一屏来。

我做的这个网关,就是专门把这类问题一次性收口的。核心思路很简单:用 Cloudflare Worker 搭一个统一的 OpenAI 兼容接口,对外只暴露一个地址,对内按模型名把请求分发到不同的上游,比如免费的 Agnes 聚合入口、DeepSeek 官方 API,或者其他你想接的杂七杂八的服务端。客户端只需要认一个网关地址,就能在多个模型之间切换,甚至可以让 Hermes 这类工具直接“白嫖”多个免费或低成本的模型通道,不用再手工搬配置。

这篇文章不打算讲太多泛泛的概念,直接把我从设计、编码、上线到被各种奇怪错误磨掉耐心的完整过程写出来。你能看到一个 Worker 网关是怎么一步步从开口请求路由、密钥隔离、流式转发,到接入端验证、排查问题,最终变成一套稳定可用的多模型接入方案的。如果你也想在自己本地的 AI 工具里同时喂多个模型的 API,这篇文章可以作为一份“抄作业手册”来用。

1. 动手之前,先说清楚这个东西解决什么问题

1.1 为什么会有“重复搭建网关”的需求

先说一个最直观的场景:我有一个开源的本地 AI 客户端 Hermes,它想接入大模型 API,但它只认一套 OpenAI 风格协议。可是 DeepSeek 的接口基本兼容 OpenAI,某第三方聚合入口 Agnes 的接口也声称兼容 OpenAI,但细看后会发现它们的模型命名、鉴权方式、超时行为、报错格式全都不一样。今天这个服务升级了模型名,明天那个服务改了请求头里的一个字段,我的客户端配置就得跟着改。

更麻烦的是,如果我有三个客户端,或者给同一台电脑上的多个用户用,每个人都要拿一份原始的上游密钥。而大多数密钥都是跟账号、余额绑定的,一旦真正泄露出去,不仅钱包受损失,还得花时间一家家地去轮换密钥。

我自己之前就在这个问题上栽过跟头。某个聚合服务的密钥因为存到网盘汇总文档里,被同事拿去调模型,跑了一周的批量任务才发现账单爆了。从那以后我就决定,不能再让客户端直接接触上游密钥,必须有一个中间层做隔离和统一。

这个中间层,我的选择是做成“网关”,而不是改客户端代码。原因是客户端改起来成本高,而且模型接入的活应该收敛到一处,谁需要新模型,谁去改网关配置,而不是每个人去改自己本地工具的配置文件。

1.2 网关到底放哪里:本地 vs 云端 vs Worker

做网关一般有几种放置方式,我分别评估一下:

  • 本地跑一个 Node 或 Python 代理服务,比如 FastAPI 写一个/v1/chat/completions接口,再在本地起个服务,让 Hermes 指向http://localhost:3000。这个方案很灵活,能写任意逻辑,也能接很多上游,但问题是要么你的电脑得一直开着,要么你得部署到一台云服务器上,而服务器的费用和维护本身就是一笔开销。

  • 云服务器部署通用网关项目,比如开源的一众 AI 网关项目。这类方案功能强大,有的还自带统计面板、限流、缓存等功能,但部署流程相对重,至少要有一个 Nginx、一个数据库、一个后台进程,对于“只想安静地同时吃两家模型 API”的使用场景来说,多少有点杀鸡用牛刀。

  • Cloudflare Worker,我最终选定的方案。它跑在全球分布式的边缘节点上,不需要自己管理服务器,免费档的请求配额对个人使用来说基本够用。更关键的是,Worker 用一个原生fetch就能把请求转发到任意上游 API,天然适合做“中转站”这类逻辑。

我举个例子帮理解这三种方案的区别:本地代理相当于你自己开了一个“私人中转站”,车(请求)都得从你门口过;云服务器代理相当于租了一间固定办公室来做中转站,位置稳定但租金一直掏;Worker 则像是把中转站直接塞进了城市交通网络的每个路口,车流到哪都能立刻找到入口。

对于个人折腾 AI 模型接入,Worker 的成本最低、灵活性最高、部署速度最快,这是我最终选择它的核心原因。你要问我它的缺点,等一下我也会提到,比如冷启动和超时限制这类细节,但总体来说是利大于弊。

1.3 这套网关实际做到的几件事

在展开代码之前,我先把这个网关的“功能清单”列在这里,后面每一节都会围绕这些功能来写:

  1. 统一对外提供/v1/chat/completions和/v1/models两个 OpenAI 风格接口,让 Hermes 这类客户端只需要配置一个 Base URL。
  2. 根据请求体里的模型名自动路由到对应上游,比如模型名带deepseek前缀就走 DeepSeek 官方 API,模型名带ag前缀就走 Agnes 聚合入口。
  3. 对客户端只要求一个网关密钥,所有上游密钥都被隐藏在工作者环境中,不会泄露到客户端。
  4. 原样兼容流式响应,SSE 格式不断流、不串格式。
  5. 把各上游五花八门的错误码统一翻译成标准的 OpenAI 风格错误,方便客户端识别。
  6. 做一层基本的请求来源校验和日志记录,便于事后审计。

这套功能如果放在传统开发里,可能需要一个 Node 服务加一套数据库,而 Worker 只需要一个 JavaScript 文件就能搞定,这也是为什么我推荐先从这个方向入手的理由。

2. 整体方案设计:模型路由、密钥管理与成本逻辑

2.1 路由策略:统一入口加模型名前缀

网关的核心是“路由”。我在最初的设计里考虑过两种路由思路:一种是完全透明转发,也就是客户端想调什么模型,网关只是把请求原样转发到某个默认上游;另一种是按模型名动态选择上游,也就是我在 Worker 内部维护一张映射表,根据请求里的模型字段来决定往哪发。

第一种思路实现起来最简单,但等于没做网关,因为没办法在同一个客户端里同时用两家模型。第二种思路才是真正的价值所在。

我的路由规则设计成“模型名前缀匹配”。举个例子,在 Hermes 配置模型时,我让它显示两个模型名:

  • deepseek-chat:命中的是 DeepSeek 官方渠道的聊天模型;
  • ag-fast:命中的是 Agnes 聚合入口里的某个快速模型。

在 Worker 里,我写一个简单的匹配函数,检查model字段有没有特定前缀:

function routeProvider(modelName, env) { if (modelName.startsWith('deepseek') || modelName === env.MODEL_DEEPSEEK) { return 'deepseek'; } if (modelName.startsWith('ag') || modelName === env.MODEL_AGNES) { return 'agnes'; } return env.DEFAULT_PROVIDER || 'deepseek'; }

如果客户端传进来的模型名无法识别,就落到默认上游。这样做的兼容性很好,因为任何客户端都只是在“选模型”,并不知道背后的路由逻辑。它只把model字段当成一个普通字符串交给网关,网关再去决定往哪转,客户端的配置成本几乎为零。

2.2 密钥分层:网关密钥与上游密钥的隔离

密钥设计是这个项目里我最在意的一块,因为它的直接动机就是避免上游密钥被滥用。整个鉴权体系分两层:

第一层是客户端到网关。客户端发起请求时,需要在Authorization: Bearer里带上一个网关自己的密钥。这个密钥我管它叫GATEWAY_KEY,它完全可以是一个自定义的随机字符串,跟上游没有任何关系。

代码里判断方式是这样的:

function checkAuth(request, env) { const auth = request.headers.get('Authorization') || ''; if (auth !== `Bearer ${env.GATEWAY_KEY}`) { return new Response( JSON.stringify({ error: { message: 'Invalid API key provided', type: 'invalid_request_error' }, }), { status: 401, headers: { 'Content-Type': 'application/json' } } ); } return null; }

第二层是网关到上游。Worker 会根据路由结果,把请求头里的Authorization替换成对应上游的密钥,这个密钥存储在工作者的wrangler.toml或 Dashboard 的vars与secrets中,客户端永远拿不到。

有人可能会问:直接把网关密钥分享给多个客户端,是不是也等于共享了漏洞?理论上确实存在这个问题。所以我在 Worker 里预留了一个按请求来源过滤的逻辑,后面会提到。至少相比直接把上游密钥散播出去,这种分层设计已经大大缩小了风险面。

2.3 为什么不建议直接在 Hermes 里配多个上游

有人肯定会想:既然 Hermes 本身就支持配置多个 OpenAI 兼容 API,为什么还要多此一举加一个 Worker?

我试过直接在 Hermes 里配置多个上游,体验是这样的:每加一个新的模型通道,我都要重新填一遍 Base URL、API Key、模型列表;如果某个上游临时维护,客户端的报错信息五花八门,有的直接显示超时,有的显示 502,有的显示 JSON 解析错误;不同的上游对max_tokens、temperature的边界要求还不一样,导致同一段提示词在模型 A 上是正常输出,在模型 B 上直接报错。

用网关把这些差异在外面“抹平”之后,Hermes 的配置文件就非常干净了,它只需要维护一个网关地址和一个网关密钥。后面接入新模型,我只需要在 Worker 里加一个路由分支,客户端完全不用动。

这就是我认为网关的价值所在:不是多转发一层请求那么简单,而是把复杂度集中到一处处理,让终端工具保持干净。

3. Worker 核心实现:一个 OpenAI 兼容代理的完整骨架

3.1 路由与鉴权:入口处先做两件事

接下来进入实际代码环节。先说 Worker 的基本入口。Cloudflare Worker 采用 ES Modules 格式,一个默认导出对象就可以覆盖所有 HTTP 请求。

export default { async fetch(request, env, ctx) { // 处理 CORS 预检请求 if (request.method === 'OPTIONS') { return new Response(null, { status: 204, headers: corsHeaders(), }); } // 第一件事:鉴权 const authError = checkAuth(request, env); if (authError) { return authError; // 返回 401 } const url = new URL(request.url); // 第二件事:路由 if (url.pathname === '/v1/models') { return handleModels(env); } if (url.pathname === '/v1/chat/completions') { return handleChat(request, env, ctx); } return new Response('Not Found', { status: 404 }); }, };

这里的env是 Cloudflare Worker 注入的环境变量对象,里面放着网关密钥和上游密钥等配置。ctx是 ExecutionContext,可以用来做后台任务,比如等待日志写入,不过大部分时候我们用不到它。

CORS 这块很重要,因为 Hermes 这类本地客户端本质上是浏览器技术栈,发请求时会先发一个OPTIONS预检请求,所以我要在入口处统一放行OPTIONS。

3.2 请求改写:把“别名”翻译成上游真实参数

用户通过客户端发一个请求到达网关,请求体的结构像这样:

{ "model": "deepseek-chat", "messages": [{ "role": "user", "content": "你好" }], "stream": true }

Worker 拿到这个请求体之后,要做几件事:读取 JSON、读取model字段、根据路由决定上游地址、替换鉴权头、可能还要改写部分参数。

具体代码如下:

async function handleChat(request, env, ctx) { let body; try { body = await request.json(); } catch (e) { return jsonResponse({ error: { message: 'Invalid JSON body', type: 'invalid_request_error' } }, 400); } const model = body.model || ''; const provider = routeProvider(model, env); let upstreamUrl; let upstreamKey; const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${upstreamKey}`, }; if (provider === 'deepseek') { upstreamUrl = 'https://api.deepseek.com/v1/chat/completions'; } else if (provider === 'agnes') { upstreamUrl = env.AGNES_BASE_URL + '/v1/chat/completions'; // Agnes 的特殊之处是可能还要附加一个渠道 ID 头或不同的认证字段 headers['X-Channel-ID'] = env.AGNES_CHANNEL_ID || ''; } else { upstreamUrl = env.FALLBACK_BASE_URL; } // 实际请求上游 const upstreamResponse = await fetch(upstreamUrl, { method: 'POST', headers: headers, body: JSON.stringify(body), }); return handleUpstreamResponse(upstreamResponse, request); }

这里有一处容易踩坑的地方:直接转发body可能会把客户端传入的temperature、max_tokens这类参数原样送到上游。大部分情况下这没问题,但有些上游对参数边界要求严格,比如max_tokens超出上限会在 400 错误,而不是自动截断。所以我的做法是在转发前做一次参数清洗:

if (typeof body.max_tokens !== 'undefined') { const limit = provider === 'deepseek' ? 8192 : 4096; body.max_tokens = Math.min(Number(body.max_tokens), limit); }

这样做的好处是,客户端即使疯狂调大生成长度,也不会被上游拒绝,网关相当于做了一个“安全带”般的参数约束。

3.3 SSE 流式转发:最容易翻车,也最关键

大模型接口最常用的就是流式响应,也就是 SSE(Server-Sent Events)。客户端在请求里带上"stream": true之后,上游就会返回一段持续不断的data:文本流。

Worker 转发流式响应的方式是把上游的响应体直接“接通”给客户端,不做缓存式读取。代码如下:

function handleUpstreamResponse(upstreamResponse, request) { const responseHeaders = { 'Content-Type': upstreamResponse.headers.get('Content-Type') || 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', ...corsHeaders(), }; // 构造返回给客户端的 Response,直接透传 body stream return new Response(upstreamResponse.body, { status: upstreamResponse.status, headers: responseHeaders, }); }

如果只是转发成功响应,这段代码已经够用。但有一个问题:如果上游在处理过程中返回了一个非 200 的状态码,它的响应体可能不是 SSE 格式,而是 JSON。如果我也把它当成 SSE 传回给客户端,客户端就会因为解析不到data:行而报错。

所以更好的写法是:先看状态码,如果是非 2xx,就转成 JSON 错误格式返回;如果是 2xx,再透传流。

if (!upstreamResponse.ok) { const errorText = await upstreamResponse.text(); return jsonResponse({ error: { message: `Upstream error: ${errorText}`, type: 'upstream_error' } }, upstreamResponse.status); }

另外还有一个需要特别注意的点:如果把Content-Type改成text/event-stream,但上游实际返回的可能是application/json,客户端就会卡住。我的建议是优先透传上游自己的Content-Type,不要自己强行改。客户端其实能处理text/event-stream和application/json两种类型。

3.4 错误码规范化:让客户端只认一套规则

不同上游的报错格式差异很大。DeepSeek 一般会返回 OpenAI 风格的错误对象,结构是:

{ "error": { "message": "...", "type": "...", "code": null } }

但 Agnes 聚合入口就不一定了,它可能返回一个纯文本的 500,或者一套完全不同的 JSON。如果客户端解析不了,体验就会很糟糕。

我的做法是统一包一个normalizeError函数,把各种异常咬成标准的 OpenAI 错误结构:

function normalizeError(status, rawText) { let message = rawText; let type = 'api_error'; try { const parsed = JSON.parse(rawText); if (parsed.error) { message = parsed.error.message || parsed.error; type = parsed.error.type || type; } else if (parsed.message) { message = parsed.message; } } catch (e) { // 保持原文 } return { error: { message: message.slice(0, 500), // 防止日志被疯狂刷屏 type, }, }; }

这样,Hermes 拿到的所有错误信息都是可解析的 JSON,能直接显示在聊天界面中,而不是弹出一个不可读的 HTML 报错页。

4. 多个上游的适配细节:DeepSeek 与 Agnes 的差异

4.1 模型名映射表

虽然路由用“模型名前缀”来判断,但在实际落地时,因为各家模型的上下文窗口、计费方式、擅长领域都不同,我更建议维护一张更完整的模型映射表。

我这里做了两层设计:第一层是“路由层”,根据前缀判断往哪发;第二层是“展示层”,也就是/v1/models接口返回什么模型列表,让客户端能自动识别。

/v1/models的响应大概是这样的:

async function handleModels(env) { const models = [ { id: 'deepseek-chat', object: 'model', owned_by: 'deepseek', }, { id: 'ag-fast', object: 'model', owned_by: 'agnes', }, { id: 'ag-reason', object: 'model', owned_by: 'agnes', }, ]; return jsonResponse({ object: 'list', data: models }); }

当 Hermes 调用GET /v1/models时,它就能自动拉出这两个模型的列表,用户在界面里直接选即可,非常省事。注意这里我没有返回供应商真实模型名,而是用了网关自己的别名。为什么这样做?因为上游改名的频率比我们想象中大得多,如果让客户端直接看到真实模型名,一旦上游改名,客户端缓存就会失效,还得手动清配置。用别名隔离一层,上游的变动只影响 Worker 内部映射表。

4.2 上游参数差异:温度、max_tokens、system

当你真正开始适配多家上游时,很快会发现,所谓“OpenAI 兼容”更像是“大家长得比较像”,但细节总有偏差。

DeepSeek 官方 API 对max_tokens的上限一般能到 8192(新一代上下文模型可能更高),对temperature的范围是 0 到 2 之间。它对system消息是完全支持的。

但 Agnes 聚合入口有可能对接的是多家中转模型,有的后端模型不支持system消息,接受之后直接忽略;有的要求temperature必须是整数;有的则对max_tokens有严格上限,超了直接报invalid request。

我此前遇到过一个典型的坑:我在一个聚合入口传了"temperature": 0.7,它要求传整数0或1,结果我在毫不知情的情况下总是拿到 400 错误。后来查文档才知道它的参数规则跟标准不一样。

因此,适配 Agnes 时我在 Worker 里做了一层“规则微调”。比如,如果检测到provider === 'agnes'且temperature是小数,我会选择把它改写成Math.round(temperature),或者在网关环境变量里加一个开关来控制是否向下取整。类似的处理逻辑不复杂,但确实能让你少踩很多坑。

4.3 上游返回结构差异与补充

除了入参,出参结构其实也是五花八门。OpenAI 标准的响应里有一个choices数组,每个 choice 里有message或delta。DeepSeek 基本遵从这个结构,但也会多返回一个usage字段,里面包含prompt_cache_hit_tokens这类额外信息。

Agnes 作为聚合入口,返回的结构可能是直接被它的顶层代码包了一层,比如:

{ "data": { "choices": [...], "usage": {...} } }

如果客户端拿到choices在顶层找不到,就相当于白请求了一遍。所以我在转发层对非流式响应做了轻量级的“修正”:检查响应 JSON 里有没有data.choices,如果有,就把整个data对象提升到最外层。这个处理其实是自己根据常见聚合服务反推出来的通用做法,不是某个官方接口文档写死的规则,每家都可能不同,所以要留一个条件判断,如果提升后会导致重复内容再回滚。

async function fixUpstreamJson(response) { const text = await response.text(); try { const json = JSON.parse(text); if (json.data && Array.isArray(json.data.choices) && !json.choices) { json.choices = json.data.choices; json.usage = json.data.usage || {}; } return new Response(JSON.stringify(json), { status: response.status, headers: { 'Content-Type': 'application/json' }, }); } catch (e) { return new Response(text, { status: response.status }); } }

这些怪癖如果不经历一次,很可能你调试半天也不知道问题出在哪。

5. 让 Hermes 吃上这套接口:接入方法与验证

5.1 在 Hermes 里填网关地址

Hermes 是我这边在用的一个本地 AI 客户端工具,它本身支持自定义 OpenAI 兼容 API。接入前,我先在wrangler.toml里配置环境变量,然后发布 Worker。

name = "ai-gateway" main = "src/index.js" compatibility_date = "2025-01-01" [vars] GATEWAY_KEY = "gw_live_xxxxxxxxxxxx" DEFAULT_PROVIDER = "deepseek" MODEL_DEEPSEEK = "deepseek-chat" MODEL_AGNES = "ag-fast" AGNES_BASE_URL = "https://your-agnes-endpoint.example.com" AGNES_CHANNEL_ID = "ch_xxxx" # 注意:真正的上游密钥不要写在 toml 里,用 wrangler secret put

发布之后,在 Hermes 的 API 设置里这样填:

  • API Base URL:https://ai-gateway.your-subdomain.workers.dev/v1
  • API Key:gw_live_xxxxxxxxxxxx
  • Model 列表:手动添加deepseek-chat和ag-fast

填完之后,Hermes 会自动请求/v1/models来验证密钥有效性。如果能看到模型列表,说明前面打通了。

5.2 用 curl 做三连验证

在把 Hermes 接进来之前,我建议先用curl做三连验证,这样能把问题范围缩小到 Worker 还是客户端。

第一遍,验证网关本身是否在线:

curl https://ai-gateway.your-subdomain.workers.dev/v1/models -H "Authorization: Bearer gw_live_xxxxxxxxxxxx"

第二遍,验证 DeepSeek 路由,发一个非流式请求:

curl https://ai-gateway.your-subdomain.workers.dev/v1/chat/completions \ -H "Authorization: Bearer gw_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", "messages": [{"role": "user", "content": "你好,输出一句话"}], "stream": false }'

第三遍,验证 Agnes 路由和流式输出:

curl https://ai-gateway.your-subdomain.workers.dev/v1/chat/completions \ -H "Authorization: Bearer gw_live_xxxxxxxxxxxx" \ -H "Content-Type: application/json" \ -d '{ "model": "ag-fast", "messages": [{"role": "user", "content": "你好,输出一句话"}], "stream": true }'

如果第三遍能看到一行行data:前缀的输出,且以data: [DONE]结尾,就说明流式链路完全正常。如果在这里失败,那问题就出在 Worker 层,跟 Hermes 无关。

5.3 请求路径选择演示

为了演示“同一个网关,两个不同上游”的实际效果,我在 Worker 里加了一个小日志字段,每次请求会在响应头里带上X-Gateway-Provider,标注这次实际被路由到哪家。

responseHeaders['X-Gateway-Provider'] = provider;

这样一来,在 Hermes 的开发者面板或curl -i里就能看到每次请求是走的 DeepSeek 还是 Agnes。有一次我发现,明明客户端选的是ag-fast,响应头却显示provider: deepseek,排查后发现是我在路由函数里把前缀判断写反了,ag被识别成deepseek。这个调试头后来救了我不少时间。你也可以在自己网关里加上类似字段,排查问题会很直观。

6. 我实际踩过的坑与排查速查表

6.1 典型坑位清单

这里把我的翻车记录整理成一张表,方便你直接对照排查。

现象根因解决办法
客户端提示 Invalid API keyAuthorization头带了空格或大小写差异用严格比较时对字符串做 trim,且检查 Bearer 后面只有一个空格
上游返回 401,但网关密钥正确网关没有替换请求头里的 Auth,把网关密钥传给了上游确认转发前重新设置headers.Authorization
客户端报 SSE 解析失败上游非 200 时仍透传了 JSON 响应给流式请求在handleUpstreamResponse里先判断upstreamResponse.ok
响应内容全部拿到,但最后报错上游返回的Content-Type不是 SSE,但客户端仍按 SSE 解析直接透传上游原始Content-Type,不强制改
请求偶尔超时Worker 与上游之间地域链路波动为 fetch 设置超时 AbortSignal,并对超时错误做降级处理
无法调用/v1/modelsWorker 路由里没加这个路径在入口处增加/v1/models分支
同一模型名称有时走 A 有时走 B路由函数匹配逻辑有漏洞开头用startsWith,并且先用最长前缀匹配

6.2 排查思路:从客户端到 Worker 再到上游的三层分段法

遇到问题不要慌,先确定问题在哪一层,实验的方法也很简单。

第一层,客户端到 Worker。先不调用任何模型,直接请求/v1/models看鉴权是否通过。如果返回 401,问题在密钥或者请求头。如果返回 200,说明网关本身可用。

第二层,Worker 到上游。我临时在 Worker 里加一个调试端点,比如/debug/providers,它返回当前环境变量里所有非敏感配置,包括上游 URL 和模型映射,便于确认配置真的被发布到了线上。

第三层,直接绕过网关,用curl打上游原始地址。如果上游原始地址也失败,那就是上游的问题,与网关无关。我之前遇到过本地上游账号欠费后,网关反复报 401,一度怀疑是 Worker 代码问题,后来直连上游才发现是对方账户状态不对。

这套“三层分段”的思路看起来简单,但比瞎猜代码高出很多效率。我建议你在开始改造代码前,先用这个方式把故障边界画出来。

6.3 防止“白嫖”被反薅:配额与日志

标题里有个词叫“白嫖”,说的是尽量用免费或低成本的模型通道。但白嫖是一回事,被白嫖是另一回事。如果你的网关密钥被泄漏出去,别人就能借用你的上游额度,尤其是聚合入口的免费通道,额度往往是共享的,很容易被刷爆。

所以我做了三个加固动作:

  • 第一,网关密钥尽量长且随机,至少 32 位以上,不要用123456这种过于友好的值。
  • 第二,在 Worker 里加一层来源域名限制。如果客户端从浏览器发起,可以把允许的域名列表写在环境变量里。但 Hermes 这样的本地客户端会带Origin头,所以这里要灵活处理。
  • 第三,给每个上游请求加上轻量化日志。Cloudflare Worker 有ctx.waitUntil(),我可以在返回响应的同时,异步把请求的关键信息(模型名、路由、状态码、耗时)发送到一个日志端点。这样做不会阻塞主响应,但留下了审计痕迹。

日志发送代码大概是这样的:

ctx.waitUntil( fetch(env.LOG_WEBHOOK, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ts: Date.now(), model: body.model, provider, status: upstreamResponse.status, duration: Date.now() - startTime, }), }).catch(() => {}) );

注意不要记录完整请求体或完整响应体,因为里面包含用户敏感输入。只记录元信息即可,这是给自己看的审计摘要,不是给监控系统堆数据。

7. 一些不是官方文档会提醒你的经验

7.1 超时与网络稳定性

Cloudflare Worker 的免费额度很香,但它和上游服务之间的连接不是完全不受限制的。我自己在跑长任务时,遇到过 Worker 主动断开连接的情况,尤其当上游响应时间特别长时,比如复杂的推理任务跑了一两分钟。

后来我养成一个习惯:对于特别长的生成任务,不要把流式响应的超时责任完全丢给 Worker。要么在请求里设置合理的超时信号,要么在上游侧选择更快的模型通道。如果你确实需要用慢速推理模型,建议用 Workers 的 Queue 或者把任务拆成异步轮询模式,不要让客户端一直挂着同一个连接。

7.2 别过度设计:先跑通再优化

最后这一点我特别想强调。我第一版搭这个网关时,给自己规划了非常宏大的架构:多租户、请求计数、速率限制、缓存。结果呢?光调试速率限制就把我卡了两天,因为 Cloudflare Worker 的免费环境里没有现成的持久化计数器,每次都靠 Durable Objects,复杂度和排错成本一下就上去了。

后来我把这些功能全部删掉,只保留最基本的“路由加鉴权加流式转发”,半小时就上线了。一个月用下来,发现我真正需要的其实就是基础功能。那些高级功能等出现真实需求时再加也不迟,而且到时候你会更清楚需要加在什么位置。

7.3 几条低调但好用的配置建议

  • AGNES_BASE_URL别写在代码里,放在环境变量中,方便在不改代码的情况下切换验证地址。
  • 客户端Connection: keep-alive头不必特殊处理,Cloudflare 节点会处理。
  • 给网关的域名加一个自定义域,比如ai.example.com,而不是直接用*.workers.dev。自定义域走的线路更稳,某些网络环境下的兼容性也更好。
  • 如果要支持多个团队成员共用,建议每个团队成员的 Hermes 配置里都填同一个网关地址,但网关密钥不要写在聊天记录里,单独发给个人并定期轮换。

我在实际使用中还有一个比较深的体会:网关这类东西,从一开始就该把“密钥与业务逻辑分离”这个原则贯彻到底。我第一版把所有配置都写在代码里,后来有一次临时要换上游密钥,还得重新打包发布;改成环境变量之后,在后台点一下就能更新,这才是真正能长期用下去的状态。

如果你也正在为多个模型 API 的统一接入头疼,按照上面的思路搭一个 Worker 网关,大概率一下午就能跑通。等跑通了再回头看,你会发现其实最难的不是代码,而是想清楚“哪些事情应该由网关负责,哪些事情应该交给客户端和上游”。这个边界划清楚了,后面的路就顺了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询