1. 要做什么
上一篇做了用户管理系统,这篇换个方向——做一个 AI API 网关,部署在 Cloudflare Workers 上。
场景很常见:你做了个 AI 应用,前端需要调大模型 API。但你不能把大模型的 API Key 直接放到前端代码里(谁都能打开浏览器控制台抄走),也不想让每个客户端直连大模型(没法做访问控制和计费)。
所以中间需要一层代理:客户端拿自己的 API Key 请求你的网关,网关用服务端的大模型 Key 去调 OpenAI / Claude,把结果流式转发回来。中间顺便加上鉴权、限流、用量统计。
功能清单:
| 功能 | 说明 |
|---|---|
| API Key 鉴权 | 客户端带自己的 key,网关验证后用服务端 key 调大模型 |
| 流式代理 | SSE 流式响应透传,打字机效果 |
| 请求限流 | 固定窗口,每分钟 N 次 |
| 用量统计 | 每次请求记录 token 消耗 |
技术栈:Hono + Cloudflare Workers + KV + SSE,全是前面讲过的东西。
2. 项目结构
3. 类型定义和 Bindings
先把所有类型理清楚。网关需要四个 Cloudflare 绑定:一个环境变量存大模型 Key,三个 KV 分别做 API Key 存储、限流和用量统计。
01export type Bindings = {02// 服务端大模型 API Key03OPENAI_API_KEY: string04// 限流用的 KV05RATE_LIMIT_KV: KVNamespace06// 用量统计用的 KV07USAGE_KV: KVNamespace08// API Key 信息存储09API_KEYS_KV: KVNamespace10}1112// 存在 KV 里的 API Key 信息13export interface ApiKeyInfo {14id: string15name: string16rateLimit: number // 每分钟最大请求数17createdAt: number18}1920// 中间件往 context 里塞的变量21export type Variables = {22apiKeyId: string23apiKeyInfo: ApiKeyInfo24}2526// Hono app 的完整类型27export type AppEnv = {28Bindings: Bindings29Variables: Variables30}3132// 聊天请求体33export interface ChatRequest {34model?: string35messages: Array<{36role: 'system' | 'user' | 'assistant'37content: string38}>39stream?: boolean40temperature?: number41max_tokens?: number42}4344// 用量记录45export interface UsageRecord {46totalRequests: number47totalPromptTokens: number48totalCompletionTokens: number49lastUsedAt: number50}
对应的 wrangler.jsonc:
01{02"name": "ai-api-gateway",03"main": "src/index.ts",04"compatibility_date": "2024-12-01",05"vars": {06// 实际部署用 wrangler secret put OPENAI_API_KEY 设置07"OPENAI_API_KEY": "sk-xxx"08},09"kv_namespaces": [10{ "binding": "RATE_LIMIT_KV", "id": "your-rate-limit-kv-id" },11{ "binding": "USAGE_KV", "id": "your-usage-kv-id" },12{ "binding": "API_KEYS_KV", "id": "your-api-keys-kv-id" }13]14}
4. API Key 鉴权中间件
客户端在请求头里带 Authorization: Bearer gw-xxxx,中间件去 KV 里查这个 key 是否有效。
这里用到了 createMiddleware,它跟前面直接写 async (c, next) => {...} 效果一样,区别是 createMiddleware 可以传泛型参数,这样中间件内部访问 c.env 和 c.get() 时就有类型提示了。
01import { createMiddleware } from 'hono/factory'02import { HTTPException } from 'hono/http-exception'03import type { AppEnv, ApiKeyInfo } from '../types'0405export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {06const authHeader = c.req.header('Authorization')0708if (!authHeader?.startsWith('Bearer ')) {09throw new HTTPException(401, {10message: 'Missing or invalid Authorization header',11})12}1314const apiKey = authHeader.slice(7) // 去掉 "Bearer "1516// 从 KV 读取 key 信息17const keyInfo = await c.env.API_KEYS_KV.get<ApiKeyInfo>(18`key:${apiKey}`,19'json'20)2122if (!keyInfo) {23throw new HTTPException(401, { message: 'Invalid API key' })24}2526// 把 key 信息存到 context 里,后续中间件和路由可以用27c.set('apiKeyId', keyInfo.id)28c.set('apiKeyInfo', keyInfo)2930await next()31})
这里用 KV 存 API Key 信息,key 的格式是 key:gw-xxxx,value 是一个 JSON 对象。注册新 key 的逻辑可以单独做一个管理接口,这里不展开。
5. 限流中间件
限流是什么意思?就是限制每个 API Key 在一段时间内能请求多少次。比如每分钟最多 60 次,超了就拒绝。防止有人刷接口把你的大模型额度刷爆。
我们用 KV 做固定窗口限流。思路:以分钟为单位,每个 API Key 每分钟一个计数器。请求来了就 +1,超过阈值就返回 429。
01import { createMiddleware } from 'hono/factory'02import { HTTPException } from 'hono/http-exception'03import type { AppEnv } from '../types'0405export const rateLimitMiddleware = createMiddleware<AppEnv>(06async (c, next) => {07const apiKeyId = c.get('apiKeyId')08const apiKeyInfo = c.get('apiKeyInfo')09const limit = apiKeyInfo.rateLimit || 601011// 当前分钟的时间窗口 key12const windowKey = `rate:${apiKeyId}:${Math.floor(Date.now() / 60000)}`1314const count = parseInt(15(await c.env.RATE_LIMIT_KV.get(windowKey)) || '0'16)1718if (count >= limit) {19throw new HTTPException(429, {20message: `Rate limit exceeded. Max ${limit} requests per minute.`,21})22}2324// 计数 +1,设置 120 秒过期(确保过了这分钟后自动清理)25await c.env.RATE_LIMIT_KV.put(windowKey, String(count + 1), {26expirationTtl: 120,27})2829// 在响应头里告诉客户端限流状态30c.header('X-RateLimit-Limit', String(limit))31c.header('X-RateLimit-Remaining', String(limit - count - 1))3233await next()34}35)
几个细节:
- 时间窗口 key 用
Math.floor(Date.now() / 60000)生成,每分钟一个值 expirationTtl: 120让 key 在 2 分钟后自动过期,不用手动清理- 响应头返回限流信息,方便客户端做自适应
6. 用量记录工具函数
每次请求完成后,把 token 消耗累加到 KV。
01import type { UsageRecord } from '../types'0203export async function recordUsage(04kv: KVNamespace,05apiKeyId: string,06promptTokens: number,07completionTokens: number08) {09const key = `usage:${apiKeyId}`10const existing = await kv.get<UsageRecord>(key, 'json')1112const record: UsageRecord = {13totalRequests: (existing?.totalRequests || 0) + 1,14totalPromptTokens: (existing?.totalPromptTokens || 0) + promptTokens,15totalCompletionTokens:16(existing?.totalCompletionTokens || 0) + completionTokens,17lastUsedAt: Date.now(),18}1920await kv.put(key, JSON.stringify(record))21}2223// 按天记录,方便查看趋势24export async function recordDailyUsage(25kv: KVNamespace,26apiKeyId: string,27promptTokens: number,28completionTokens: number29) {30const today = new Date().toISOString().slice(0, 10) // "2024-12-01"31const key = `usage:${apiKeyId}:${today}`32const existing = await kv.get<UsageRecord>(key, 'json')3334const record: UsageRecord = {35totalRequests: (existing?.totalRequests || 0) + 1,36totalPromptTokens: (existing?.totalPromptTokens || 0) + promptTokens,37totalCompletionTokens:38(existing?.totalCompletionTokens || 0) + completionTokens,39lastUsedAt: Date.now(),40}4142// 每日记录保留 90 天43await kv.put(key, JSON.stringify(record), { expirationTtl: 86400 * 90 })44}
这里做了两层记录:总量和每日。总量用于计费,每日用于看趋势。
7. 流式代理:核心逻辑
这是整个网关最核心的部分。接收客户端请求,用服务端 key 调 OpenAI,把 SSE 流逐 chunk 转发。
001import { Hono } from 'hono'002import { streamSSE } from 'hono/streaming'003import { HTTPException } from 'hono/http-exception'004import type { AppEnv, ChatRequest } from '../types'005import { recordUsage, recordDailyUsage } from '../lib/usage'006007const chat = new Hono<AppEnv>()008009// 流式代理010chat.post('/v1/chat/completions', async (c) => {011const body = await c.req.json<ChatRequest>()012const model = body.model || 'gpt-4o'013const isStream = body.stream !== false // 默认流式014015// 用服务端 key 调 OpenAI016const upstream = await fetch(017'https://api.openai.com/v1/chat/completions',018{019method: 'POST',020headers: {021Authorization: `Bearer ${c.env.OPENAI_API_KEY}`,022'Content-Type': 'application/json',023},024body: JSON.stringify({025model,026messages: body.messages,027stream: isStream,028temperature: body.temperature,029max_tokens: body.max_tokens,030// 流式模式下要求返回 usage031...(isStream ? { stream_options: { include_usage: true } } : {}),032}),033}034)035036if (!upstream.ok) {037const error = await upstream.text()038throw new HTTPException(upstream.status as any, {039message: `Upstream error: ${error}`,040})041}042043// 非流式:直接转发 JSON 响应044if (!isStream) {045const result = await upstream.json<any>()046047// 记录用量048const usage = result.usage049if (usage) {050c.executionCtx.waitUntil(051Promise.all([052recordUsage(053c.env.USAGE_KV,054c.get('apiKeyId'),055usage.prompt_tokens,056usage.completion_tokens057),058recordDailyUsage(059c.env.USAGE_KV,060c.get('apiKeyId'),061usage.prompt_tokens,062usage.completion_tokens063),064])065)066}067068return c.json(result)069}070071// 流式:SSE 转发072return streamSSE(c, async (stream) => {073const reader = upstream.body!.getReader()074const decoder = new TextDecoder()075let buffer = ''076let promptTokens = 0077let completionTokens = 0078079try {080while (true) {081const { done, value } = await reader.read()082if (done) break083084buffer += decoder.decode(value, { stream: true })085const lines = buffer.split('\n')086buffer = lines.pop() || ''087088for (const line of lines) {089if (!line.startsWith('data: ')) continue090const data = line.slice(6).trim()091092if (data === '[DONE]') {093// 流结束,发送 [DONE]094await stream.writeSSE({ data: '[DONE]', event: 'message' })095096// 记录用量(不阻塞响应)097c.executionCtx.waitUntil(098Promise.all([099recordUsage(100c.env.USAGE_KV,101c.get('apiKeyId'),102promptTokens,103completionTokens104),105recordDailyUsage(106c.env.USAGE_KV,107c.get('apiKeyId'),108promptTokens,109completionTokens110),111])112)113return114}115116try {117const parsed = JSON.parse(data)118119// 提取 usage 信息(OpenAI 在最后一个 chunk 返回)120if (parsed.usage) {121promptTokens = parsed.usage.prompt_tokens || 0122completionTokens = parsed.usage.completion_tokens || 0123}124125// 原样转发给客户端126await stream.writeSSE({127data: JSON.stringify(parsed),128event: 'message',129})130} catch {131// 解析失败,跳过132}133}134}135} finally {136reader.releaseLock()137}138})139})140141export default chat
关键点:
stream_options: { include_usage: true }:让 OpenAI 在流式模式下也返回 token 用量。默认情况下,流式响应不包含 usage 信息,加了这个选项后,OpenAI 会在最后一个 chunk 里附带 token 统计c.executionCtx.waitUntil():这是 Cloudflare Workers 特有的。正常情况下,响应一返回,Worker 就结束了。waitUntil的意思是"响应先发回去,但 Worker 先别关,等这个 Promise 执行完再关"。这样用量记录就不会拖慢响应速度- buffer 拼接:SSE 数据是一行一行的,但网络传输时不一定按行断开——可能一次
read()拿到半行,也可能拿到两行半。所以要用 buffer 把碎片拼起来,按\n切分,最后没切完的留给下次
8. 用量查询接口
让客户端查看自己的 API Key 累计消耗了多少 token。
01import { Hono } from 'hono'02import type { AppEnv, UsageRecord } from '../types'0304const usage = new Hono<AppEnv>()0506// 查询总用量07usage.get('/usage', async (c) => {08const apiKeyId = c.get('apiKeyId')09const record = await c.env.USAGE_KV.get<UsageRecord>(10`usage:${apiKeyId}`,11'json'12)1314if (!record) {15return c.json({16totalRequests: 0,17totalPromptTokens: 0,18totalCompletionTokens: 0,19lastUsedAt: null,20})21}2223return c.json(record)24})2526// 查询某天的用量27usage.get('/usage/:date', async (c) => {28const apiKeyId = c.get('apiKeyId')29const date = c.req.param('date') // "2024-12-01"3031const record = await c.env.USAGE_KV.get<UsageRecord>(32`usage:${apiKeyId}:${date}`,33'json'34)3536if (!record) {37return c.json({38date,39totalRequests: 0,40totalPromptTokens: 0,41totalCompletionTokens: 0,42})43}4445return c.json({ date, ...record })46})4748// 查询最近 N 天的用量趋势49usage.get('/usage/trend/:days', async (c) => {50const apiKeyId = c.get('apiKeyId')51const days = parseInt(c.req.param('days')) || 75253const trend = []54for (let i = 0; i < days; i++) {55const date = new Date(Date.now() - i * 86400000)56.toISOString()57.slice(0, 10)58const record = await c.env.USAGE_KV.get<UsageRecord>(59`usage:${apiKeyId}:${date}`,60'json'61)62trend.push({63date,64requests: record?.totalRequests || 0,65promptTokens: record?.totalPromptTokens || 0,66completionTokens: record?.totalCompletionTokens || 0,67})68}6970return c.json({ trend: trend.reverse() })71})7273export default usage
9. 主入口
01import { Hono } from 'hono'02import { cors } from 'hono/cors'03import { logger } from 'hono/logger'04import { HTTPException } from 'hono/http-exception'05import type { AppEnv } from './types'06import { authMiddleware } from './middleware/auth'07import { rateLimitMiddleware } from './middleware/rate-limit'08import chat from './routes/chat'09import usage from './routes/usage'1011const app = new Hono<AppEnv>()1213// 全局中间件14app.use('*', logger())15app.use('*', cors())1617// 健康检查(不需要鉴权)18app.get('/health', (c) => {19return c.json({ status: 'ok', timestamp: Date.now() })20})2122// API 路由(需要鉴权 + 限流)23const api = new Hono<AppEnv>()24api.use('*', authMiddleware)25api.use('*', rateLimitMiddleware)26api.route('/', chat)27api.route('/', usage)2829app.route('/api', api)3031// 全局错误处理32app.onError((err, c) => {33if (err instanceof HTTPException) {34return c.json(35{ error: err.message },36err.status37)38}3940console.error('Unexpected error:', err)41return c.json({ error: 'Internal server error' }, 500)42})4344// 40445app.notFound((c) => {46return c.json({ error: 'Not found' }, 404)47})4849export default app
这里有个写法值得说一下:api.route('/', chat) 和 api.route('/', usage) 都挂在 / 上,不会冲突吗?不会——route('/', chat) 的意思是"把 chat 里定义的路由原样挂过来",chat 内部定义的是 /v1/chat/completions,usage 内部定义的是 /usage 和 /usage/:date。路径不同,自然不冲突。
/health 放在 api 外面,不经过鉴权和限流——这个接口是给监控系统调的,不应该需要 API Key。
最终的 API 路径:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /health | 健康检查 |
| POST | /api/v1/chat/completions | 流式/非流式代理 |
| GET | /api/usage | 查询总用量 |
| GET | /api/usage/:date | 查询某天用量 |
| GET | /api/usage/trend/:days | 查询用量趋势 |
10. 客户端调用示例
从客户端角度看,调这个网关和直接调 OpenAI 几乎一样,只是换了 URL 和 Key:
01// 流式调用02async function chatStream(messages: Array<{ role: string; content: string }>) {03const response = await fetch('https://your-gateway.workers.dev/api/v1/chat/completions', {04method: 'POST',05headers: {06'Authorization': 'Bearer gw-your-api-key',07'Content-Type': 'application/json',08},09body: JSON.stringify({10model: 'gpt-4o',11messages,12stream: true,13}),14})1516if (!response.ok) {17const error = await response.json()18throw new Error(error.error)19}2021const reader = response.body!.getReader()22const decoder = new TextDecoder()23let buffer = ''2425while (true) {26const { done, value } = await reader.read()27if (done) break2829buffer += decoder.decode(value, { stream: true })30const lines = buffer.split('\n')31buffer = lines.pop() || ''3233for (const line of lines) {34if (!line.startsWith('data: ')) continue35const data = line.slice(6).trim()36if (data === '[DONE]') return3738const parsed = JSON.parse(data)39const content = parsed.choices?.[0]?.delta?.content40if (content) {41process.stdout.write(content) // 逐字输出42}43}44}45}4647// 查看用量48async function getUsage() {49const res = await fetch('https://your-gateway.workers.dev/api/usage', {50headers: { 'Authorization': 'Bearer gw-your-api-key' },51})52return res.json()53}
11. 部署
01# 设置真正的 API Key(不要写在 wrangler.jsonc 里)02wrangler secret put OPENAI_API_KEY0304# 创建 KV namespace05wrangler kv namespace create RATE_LIMIT_KV06wrangler kv namespace create USAGE_KV07wrangler kv namespace create API_KEYS_KV0809# 把 KV id 填到 wrangler.jsonc,然后部署10wrangler deploy
注册一个 API Key(用 wrangler 手动写入 KV):
1wrangler kv key put --binding=API_KEYS_KV \2"key:gw-test-key-001" \3'{"id":"user_001","name":"测试用户","rateLimit":60,"createdAt":1700000000000}'
12. 总结
这篇做了一个能实际用的 AI API 网关:鉴权用 KV 存 API Key,限流用 KV 做计数器,流式代理用 SSE 逐 chunk 转发,用量统计用 waitUntil 异步记录。
整个项目的套路和上一篇用户系统一样——中间件管横切逻辑,路由管业务逻辑,入口文件只管组装。区别在于这篇多了流式处理和 Workers 特有的 waitUntil,这两个在做 AI 相关的后端时会经常用到。