如果你平时玩本地 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 这套网关实际做到的几件事
在展开代码之前,我先把这个网关的“功能清单”列在这里,后面每一节都会围绕这些功能来写:
- 统一对外提供
/v1/chat/completions和/v1/models两个 OpenAI 风格接口,让 Hermes 这类客户端只需要配置一个 Base URL。 - 根据请求体里的模型名自动路由到对应上游,比如模型名带
deepseek前缀就走 DeepSeek 官方 API,模型名带ag前缀就走 Agnes 聚合入口。 - 对客户端只要求一个网关密钥,所有上游密钥都被隐藏在工作者环境中,不会泄露到客户端。
- 原样兼容流式响应,SSE 格式不断流、不串格式。
- 把各上游五花八门的错误码统一翻译成标准的 OpenAI 风格错误,方便客户端识别。
- 做一层基本的请求来源校验和日志记录,便于事后审计。
这套功能如果放在传统开发里,可能需要一个 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 key | Authorization头带了空格或大小写差异 | 用严格比较时对字符串做 trim,且检查 Bearer 后面只有一个空格 |
| 上游返回 401,但网关密钥正确 | 网关没有替换请求头里的 Auth,把网关密钥传给了上游 | 确认转发前重新设置headers.Authorization |
| 客户端报 SSE 解析失败 | 上游非 200 时仍透传了 JSON 响应给流式请求 | 在handleUpstreamResponse里先判断upstreamResponse.ok |
| 响应内容全部拿到,但最后报错 | 上游返回的Content-Type不是 SSE,但客户端仍按 SSE 解析 | 直接透传上游原始Content-Type,不强制改 |
| 请求偶尔超时 | Worker 与上游之间地域链路波动 | 为 fetch 设置超时 AbortSignal,并对超时错误做降级处理 |
无法调用/v1/models | Worker 路由里没加这个路径 | 在入口处增加/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 网关,大概率一下午就能跑通。等跑通了再回头看,你会发现其实最难的不是代码,而是想清楚“哪些事情应该由网关负责,哪些事情应该交给客户端和上游”。这个边界划清楚了,后面的路就顺了。