AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-08-04

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. 项目结构

index.ts主入口,组装路由和中间件
types.ts类型定义和 Bindings
auth.tsAPI Key 鉴权
rate-limit.ts请求限流
chat.ts流式代理
usage.ts用量查询
usage.ts用量记录工具函数
wrangler.jsonc
package.json

3. 类型定义和 Bindings

先把所有类型理清楚。网关需要四个 Cloudflare 绑定:一个环境变量存大模型 Key,三个 KV 分别做 API Key 存储、限流和用量统计。

src/types.ts
01
export type Bindings = {
02
// 服务端大模型 API Key
03
OPENAI_API_KEY: string
04
// 限流用的 KV
05
RATE_LIMIT_KV: KVNamespace
06
// 用量统计用的 KV
07
USAGE_KV: KVNamespace
08
// API Key 信息存储
09
API_KEYS_KV: KVNamespace
10
}
11
12
// 存在 KV 里的 API Key 信息
13
export interface ApiKeyInfo {
14
id: string
15
name: string
16
rateLimit: number // 每分钟最大请求数
17
createdAt: number
18
}
19
20
// 中间件往 context 里塞的变量
21
export type Variables = {
22
apiKeyId: string
23
apiKeyInfo: ApiKeyInfo
24
}
25
26
// Hono app 的完整类型
27
export type AppEnv = {
28
Bindings: Bindings
29
Variables: Variables
30
}
31
32
// 聊天请求体
33
export interface ChatRequest {
34
model?: string
35
messages: Array<{
36
role: 'system' | 'user' | 'assistant'
37
content: string
38
}>
39
stream?: boolean
40
temperature?: number
41
max_tokens?: number
42
}
43
44
// 用量记录
45
export interface UsageRecord {
46
totalRequests: number
47
totalPromptTokens: number
48
totalCompletionTokens: number
49
lastUsedAt: number
50
}

对应的 wrangler.jsonc

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.envc.get() 时就有类型提示了。

src/middleware/auth.ts
01
import { createMiddleware } from 'hono/factory'
02
import { HTTPException } from 'hono/http-exception'
03
import type { AppEnv, ApiKeyInfo } from '../types'
04
05
export const authMiddleware = createMiddleware<AppEnv>(async (c, next) => {
06
const authHeader = c.req.header('Authorization')
07
08
if (!authHeader?.startsWith('Bearer ')) {
09
throw new HTTPException(401, {
10
message: 'Missing or invalid Authorization header',
11
})
12
}
13
14
const apiKey = authHeader.slice(7) // 去掉 "Bearer "
15
16
// 从 KV 读取 key 信息
17
const keyInfo = await c.env.API_KEYS_KV.get<ApiKeyInfo>(
18
`key:${apiKey}`,
19
'json'
20
)
21
22
if (!keyInfo) {
23
throw new HTTPException(401, { message: 'Invalid API key' })
24
}
25
26
// 把 key 信息存到 context 里,后续中间件和路由可以用
27
c.set('apiKeyId', keyInfo.id)
28
c.set('apiKeyInfo', keyInfo)
29
30
await next()
31
})

这里用 KV 存 API Key 信息,key 的格式是 key:gw-xxxx,value 是一个 JSON 对象。注册新 key 的逻辑可以单独做一个管理接口,这里不展开。

5. 限流中间件

限流是什么意思?就是限制每个 API Key 在一段时间内能请求多少次。比如每分钟最多 60 次,超了就拒绝。防止有人刷接口把你的大模型额度刷爆。

我们用 KV 做固定窗口限流。思路:以分钟为单位,每个 API Key 每分钟一个计数器。请求来了就 +1,超过阈值就返回 429。

src/middleware/rate-limit.ts
01
import { createMiddleware } from 'hono/factory'
02
import { HTTPException } from 'hono/http-exception'
03
import type { AppEnv } from '../types'
04
05
export const rateLimitMiddleware = createMiddleware<AppEnv>(
06
async (c, next) => {
07
const apiKeyId = c.get('apiKeyId')
08
const apiKeyInfo = c.get('apiKeyInfo')
09
const limit = apiKeyInfo.rateLimit || 60
10
11
// 当前分钟的时间窗口 key
12
const windowKey = `rate:${apiKeyId}:${Math.floor(Date.now() / 60000)}`
13
14
const count = parseInt(
15
(await c.env.RATE_LIMIT_KV.get(windowKey)) || '0'
16
)
17
18
if (count >= limit) {
19
throw new HTTPException(429, {
20
message: `Rate limit exceeded. Max ${limit} requests per minute.`,
21
})
22
}
23
24
// 计数 +1,设置 120 秒过期(确保过了这分钟后自动清理)
25
await c.env.RATE_LIMIT_KV.put(windowKey, String(count + 1), {
26
expirationTtl: 120,
27
})
28
29
// 在响应头里告诉客户端限流状态
30
c.header('X-RateLimit-Limit', String(limit))
31
c.header('X-RateLimit-Remaining', String(limit - count - 1))
32
33
await next()
34
}
35
)

几个细节:

  • 时间窗口 key 用 Math.floor(Date.now() / 60000) 生成,每分钟一个值
  • expirationTtl: 120 让 key 在 2 分钟后自动过期,不用手动清理
  • 响应头返回限流信息,方便客户端做自适应

6. 用量记录工具函数

每次请求完成后,把 token 消耗累加到 KV。

src/lib/usage.ts
01
import type { UsageRecord } from '../types'
02
03
export async function recordUsage(
04
kv: KVNamespace,
05
apiKeyId: string,
06
promptTokens: number,
07
completionTokens: number
08
) {
09
const key = `usage:${apiKeyId}`
10
const existing = await kv.get<UsageRecord>(key, 'json')
11
12
const record: UsageRecord = {
13
totalRequests: (existing?.totalRequests || 0) + 1,
14
totalPromptTokens: (existing?.totalPromptTokens || 0) + promptTokens,
15
totalCompletionTokens:
16
(existing?.totalCompletionTokens || 0) + completionTokens,
17
lastUsedAt: Date.now(),
18
}
19
20
await kv.put(key, JSON.stringify(record))
21
}
22
23
// 按天记录,方便查看趋势
24
export async function recordDailyUsage(
25
kv: KVNamespace,
26
apiKeyId: string,
27
promptTokens: number,
28
completionTokens: number
29
) {
30
const today = new Date().toISOString().slice(0, 10) // "2024-12-01"
31
const key = `usage:${apiKeyId}:${today}`
32
const existing = await kv.get<UsageRecord>(key, 'json')
33
34
const record: UsageRecord = {
35
totalRequests: (existing?.totalRequests || 0) + 1,
36
totalPromptTokens: (existing?.totalPromptTokens || 0) + promptTokens,
37
totalCompletionTokens:
38
(existing?.totalCompletionTokens || 0) + completionTokens,
39
lastUsedAt: Date.now(),
40
}
41
42
// 每日记录保留 90 天
43
await kv.put(key, JSON.stringify(record), { expirationTtl: 86400 * 90 })
44
}

这里做了两层记录:总量和每日。总量用于计费,每日用于看趋势。

7. 流式代理:核心逻辑

这是整个网关最核心的部分。接收客户端请求,用服务端 key 调 OpenAI,把 SSE 流逐 chunk 转发。

src/routes/chat.ts
001
import { Hono } from 'hono'
002
import { streamSSE } from 'hono/streaming'
003
import { HTTPException } from 'hono/http-exception'
004
import type { AppEnv, ChatRequest } from '../types'
005
import { recordUsage, recordDailyUsage } from '../lib/usage'
006
007
const chat = new Hono<AppEnv>()
008
009
// 流式代理
010
chat.post('/v1/chat/completions', async (c) => {
011
const body = await c.req.json<ChatRequest>()
012
const model = body.model || 'gpt-4o'
013
const isStream = body.stream !== false // 默认流式
014
015
// 用服务端 key 调 OpenAI
016
const upstream = await fetch(
017
'https://api.openai.com/v1/chat/completions',
018
{
019
method: 'POST',
020
headers: {
021
Authorization: `Bearer ${c.env.OPENAI_API_KEY}`,
022
'Content-Type': 'application/json',
023
},
024
body: JSON.stringify({
025
model,
026
messages: body.messages,
027
stream: isStream,
028
temperature: body.temperature,
029
max_tokens: body.max_tokens,
030
// 流式模式下要求返回 usage
031
...(isStream ? { stream_options: { include_usage: true } } : {}),
032
}),
033
}
034
)
035
036
if (!upstream.ok) {
037
const error = await upstream.text()
038
throw new HTTPException(upstream.status as any, {
039
message: `Upstream error: ${error}`,
040
})
041
}
042
043
// 非流式:直接转发 JSON 响应
044
if (!isStream) {
045
const result = await upstream.json<any>()
046
047
// 记录用量
048
const usage = result.usage
049
if (usage) {
050
c.executionCtx.waitUntil(
051
Promise.all([
052
recordUsage(
053
c.env.USAGE_KV,
054
c.get('apiKeyId'),
055
usage.prompt_tokens,
056
usage.completion_tokens
057
),
058
recordDailyUsage(
059
c.env.USAGE_KV,
060
c.get('apiKeyId'),
061
usage.prompt_tokens,
062
usage.completion_tokens
063
),
064
])
065
)
066
}
067
068
return c.json(result)
069
}
070
071
// 流式:SSE 转发
072
return streamSSE(c, async (stream) => {
073
const reader = upstream.body!.getReader()
074
const decoder = new TextDecoder()
075
let buffer = ''
076
let promptTokens = 0
077
let completionTokens = 0
078
079
try {
080
while (true) {
081
const { done, value } = await reader.read()
082
if (done) break
083
084
buffer += decoder.decode(value, { stream: true })
085
const lines = buffer.split('\n')
086
buffer = lines.pop() || ''
087
088
for (const line of lines) {
089
if (!line.startsWith('data: ')) continue
090
const data = line.slice(6).trim()
091
092
if (data === '[DONE]') {
093
// 流结束,发送 [DONE]
094
await stream.writeSSE({ data: '[DONE]', event: 'message' })
095
096
// 记录用量(不阻塞响应)
097
c.executionCtx.waitUntil(
098
Promise.all([
099
recordUsage(
100
c.env.USAGE_KV,
101
c.get('apiKeyId'),
102
promptTokens,
103
completionTokens
104
),
105
recordDailyUsage(
106
c.env.USAGE_KV,
107
c.get('apiKeyId'),
108
promptTokens,
109
completionTokens
110
),
111
])
112
)
113
return
114
}
115
116
try {
117
const parsed = JSON.parse(data)
118
119
// 提取 usage 信息(OpenAI 在最后一个 chunk 返回)
120
if (parsed.usage) {
121
promptTokens = parsed.usage.prompt_tokens || 0
122
completionTokens = parsed.usage.completion_tokens || 0
123
}
124
125
// 原样转发给客户端
126
await stream.writeSSE({
127
data: JSON.stringify(parsed),
128
event: 'message',
129
})
130
} catch {
131
// 解析失败,跳过
132
}
133
}
134
}
135
} finally {
136
reader.releaseLock()
137
}
138
})
139
})
140
141
export 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。

src/routes/usage.ts
01
import { Hono } from 'hono'
02
import type { AppEnv, UsageRecord } from '../types'
03
04
const usage = new Hono<AppEnv>()
05
06
// 查询总用量
07
usage.get('/usage', async (c) => {
08
const apiKeyId = c.get('apiKeyId')
09
const record = await c.env.USAGE_KV.get<UsageRecord>(
10
`usage:${apiKeyId}`,
11
'json'
12
)
13
14
if (!record) {
15
return c.json({
16
totalRequests: 0,
17
totalPromptTokens: 0,
18
totalCompletionTokens: 0,
19
lastUsedAt: null,
20
})
21
}
22
23
return c.json(record)
24
})
25
26
// 查询某天的用量
27
usage.get('/usage/:date', async (c) => {
28
const apiKeyId = c.get('apiKeyId')
29
const date = c.req.param('date') // "2024-12-01"
30
31
const record = await c.env.USAGE_KV.get<UsageRecord>(
32
`usage:${apiKeyId}:${date}`,
33
'json'
34
)
35
36
if (!record) {
37
return c.json({
38
date,
39
totalRequests: 0,
40
totalPromptTokens: 0,
41
totalCompletionTokens: 0,
42
})
43
}
44
45
return c.json({ date, ...record })
46
})
47
48
// 查询最近 N 天的用量趋势
49
usage.get('/usage/trend/:days', async (c) => {
50
const apiKeyId = c.get('apiKeyId')
51
const days = parseInt(c.req.param('days')) || 7
52
53
const trend = []
54
for (let i = 0; i < days; i++) {
55
const date = new Date(Date.now() - i * 86400000)
56
.toISOString()
57
.slice(0, 10)
58
const record = await c.env.USAGE_KV.get<UsageRecord>(
59
`usage:${apiKeyId}:${date}`,
60
'json'
61
)
62
trend.push({
63
date,
64
requests: record?.totalRequests || 0,
65
promptTokens: record?.totalPromptTokens || 0,
66
completionTokens: record?.totalCompletionTokens || 0,
67
})
68
}
69
70
return c.json({ trend: trend.reverse() })
71
})
72
73
export default usage

9. 主入口

src/index.ts
01
import { Hono } from 'hono'
02
import { cors } from 'hono/cors'
03
import { logger } from 'hono/logger'
04
import { HTTPException } from 'hono/http-exception'
05
import type { AppEnv } from './types'
06
import { authMiddleware } from './middleware/auth'
07
import { rateLimitMiddleware } from './middleware/rate-limit'
08
import chat from './routes/chat'
09
import usage from './routes/usage'
10
11
const app = new Hono<AppEnv>()
12
13
// 全局中间件
14
app.use('*', logger())
15
app.use('*', cors())
16
17
// 健康检查(不需要鉴权)
18
app.get('/health', (c) => {
19
return c.json({ status: 'ok', timestamp: Date.now() })
20
})
21
22
// API 路由(需要鉴权 + 限流)
23
const api = new Hono<AppEnv>()
24
api.use('*', authMiddleware)
25
api.use('*', rateLimitMiddleware)
26
api.route('/', chat)
27
api.route('/', usage)
28
29
app.route('/api', api)
30
31
// 全局错误处理
32
app.onError((err, c) => {
33
if (err instanceof HTTPException) {
34
return c.json(
35
{ error: err.message },
36
err.status
37
)
38
}
39
40
console.error('Unexpected error:', err)
41
return c.json({ error: 'Internal server error' }, 500)
42
})
43
44
// 404
45
app.notFound((c) => {
46
return c.json({ error: 'Not found' }, 404)
47
})
48
49
export 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:

client.ts
01
// 流式调用
02
async function chatStream(messages: Array<{ role: string; content: string }>) {
03
const response = await fetch('https://your-gateway.workers.dev/api/v1/chat/completions', {
04
method: 'POST',
05
headers: {
06
'Authorization': 'Bearer gw-your-api-key',
07
'Content-Type': 'application/json',
08
},
09
body: JSON.stringify({
10
model: 'gpt-4o',
11
messages,
12
stream: true,
13
}),
14
})
15
16
if (!response.ok) {
17
const error = await response.json()
18
throw new Error(error.error)
19
}
20
21
const reader = response.body!.getReader()
22
const decoder = new TextDecoder()
23
let buffer = ''
24
25
while (true) {
26
const { done, value } = await reader.read()
27
if (done) break
28
29
buffer += decoder.decode(value, { stream: true })
30
const lines = buffer.split('\n')
31
buffer = lines.pop() || ''
32
33
for (const line of lines) {
34
if (!line.startsWith('data: ')) continue
35
const data = line.slice(6).trim()
36
if (data === '[DONE]') return
37
38
const parsed = JSON.parse(data)
39
const content = parsed.choices?.[0]?.delta?.content
40
if (content) {
41
process.stdout.write(content) // 逐字输出
42
}
43
}
44
}
45
}
46
47
// 查看用量
48
async function getUsage() {
49
const res = await fetch('https://your-gateway.workers.dev/api/usage', {
50
headers: { 'Authorization': 'Bearer gw-your-api-key' },
51
})
52
return res.json()
53
}

11. 部署

terminal
01
# 设置真正的 API Key(不要写在 wrangler.jsonc 里)
02
wrangler secret put OPENAI_API_KEY
03
04
# 创建 KV namespace
05
wrangler kv namespace create RATE_LIMIT_KV
06
wrangler kv namespace create USAGE_KV
07
wrangler kv namespace create API_KEYS_KV
08
09
# 把 KV id 填到 wrangler.jsonc,然后部署
10
wrangler deploy

注册一个 API Key(用 wrangler 手动写入 KV):

terminal
1
wrangler 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 相关的后端时会经常用到。