本文从工程实践角度,拆解 AI API 网关的核心设计——统一入口、模型路由、Provider 适配、故障转移,以及流式 SSE 代理这一高频踩坑点。
为什么需要 AI API 网关
企业在实际落地大模型应用时,往往很快就会遇到这个问题:业务代码里散落着对 DeepSeek、Qwen、GLM 等不同厂商 API 的直接调用,各家接口格式微妙不同,计费口径不统一,某个厂商出故障时需要人工介入切换——这种现状不可持续。
AI API 网关的价值,和传统 API 网关的逻辑一样:把所有下游复杂性收敛到一个层,业务侧只感知统一接口。 具体来说,它解决四个问题:
- 统一入口:业务代码只调一个地址,不感知背后用的是哪家模型
- 负载均衡与故障转移:某 Provider 不可用时自动切到备用,业务无感知
- 计费与用量监控:集中记录每次调用的 token 消耗,支持按用户/项目维度拆分成本
- 安全与鉴权:统一管理 API Key,业务侧不持有上游厂商密钥
整体架构
客户端请求
│
▼
┌─────────────────────────────────────────────┐
│ AI API 网关 │
│ │
│ ┌───────────┐ ┌───────────┐ │
│ │ 请求解析层 │──▶│ 鉴权 & 限流│ │
│ └───────────┘ └─────┬─────┘ │
│ │ │
│ ┌─────▼──────┐ │
│ │ 模型路由层 │ │
│ └─────┬──────┘ │
│ │ │
│ ┌──────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌──────────┐ ┌─────────┐ │
│ │ DeepSeek 适配│ │ Qwen 适配 │ │GLM 适配 │ │
│ └──────┬──────┘ └────┬─────┘ └────┬────┘ │
│ │ │ │ │
│ ┌──────▼─────────────▼────────────▼────┐ │
│ │ 计费层 │ │
│ └───────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
DeepSeek API Qwen API GLM API
各层职责边界清晰,下面逐层拆解。
核心层设计
1. 请求解析层:格式归一化
不同厂商的 API 格式虽然大多参考了 OpenAI 的 Chat Completions 标准,但细节上有差异。请求解析层的任务是将进来的请求归一化为网关内部统一的数据结构:
interface NormalizedRequest {
requestId: string;
model: string; // 标准模型名,如 "deepseek/deepseek-v3"
messages: Message[];
maxTokens?: number;
temperature?: number;
stream: boolean;
tools?: Tool[];
// ... 其他标准字段
}
这一层还处理一个常见的兼容性问题:部分客户端(如 Cursor IDE)会同时传 messages 和 input 两个字段,解析层需要识别并合并。
2. 两层模型抽象
这是网关设计中最关键的一个决策:对外暴露"标准模型名",对内维护"上游模型名"的映射。
对外标准模型名 内部上游模型名
───────────────── ──────────────────────────
deepseek/deepseek-v3 ──▶ deepseek-chat(DeepSeek API)
deepseek/deepseek-v3 ──▶ DeepSeek-V3(百炼 API,备用)
qwen/qwen-max ──▶ qwen-max-latest(阿里百炼)
glm/glm-4 ──▶ glm-4(智谱 API)
这样做的好处:
- 业务侧请求
deepseek/deepseek-v3,网关可以透明地把流量切到任意一个实际提供该模型的后端 - 上游模型名和版本号的变化完全不影响业务代码
- 计费时统一按标准模型名归账,不暴露内部路由细节
配置文件示例(YAML):
# standard-models.yaml(对外定价)
- alias: deepseek/deepseek-v3
display_name: DeepSeek V3
input_price_per_mtok: 2.0 # 元/百万token
output_price_per_mtok: 8.0
# provider-models/deepseek-api.yaml(上游成本)
provider: deepseek-api
models:
- upstream_name: deepseek-chat
standard_model: deepseek/deepseek-v3
priority: 1
cost_input_per_mtok: 1.0
cost_output_per_mtok: 4.0
3. 模型路由层:策略设计
路由层接收归一化请求后,需要从候选 Provider 中选出最优的一个。常见路由策略:
优先级路由(最简单,也最常用)
function selectByPriority(candidates: ProviderRoute[]): ProviderRoute {
return candidates
.filter(r => r.health === 'healthy')
.sort((a, b) => a.priority - b.priority)[0];
}
最低成本路由(适合批处理任务)
function selectByCost(candidates: ProviderRoute[]): ProviderRoute {
return candidates
.filter(r => r.health === 'healthy')
.sort((a, b) => a.costPerMtok - b.costPerMtok)[0];
}
能力过滤(请求携带工具调用时,必须过滤掉不支持 function calling 的 Provider)
function filterByCapabilities(
candidates: ProviderRoute[],
request: NormalizedRequest
): ProviderRoute[] {
if (request.tools?.length) {
return candidates.filter(r => r.capabilities.includes('tools'));
}
return candidates;
}
实际实现中,这三层逻辑会组合使用:先按能力过滤,再按优先级排序,成本路由作为可选的覆盖策略。这也是笔者在开发 TheRouter 时的核心设计思路——路由层不做业务逻辑,只做"能力过滤 + 优先级 + 健康状态"三件事,策略清晰,扩展新 Provider 时只需添加配置而无需改路由核心。
4. Provider 适配层
每个模型厂商都有独立的适配器,负责:
- 把内部统一请求格式转换为该厂商的请求格式
- 把厂商返回的响应转换为统一响应格式
- 处理该厂商特有的错误码
interface ProviderAdapter {
buildRequest(req: NormalizedRequest): ProviderRequest;
parseResponse(res: ProviderResponse): NormalizedResponse;
parseError(err: unknown): GatewayError;
}
以 DeepSeek 适配器为例,它的 API 格式和 OpenAI 高度兼容,适配器非常薄:
class DeepSeekAdapter implements ProviderAdapter {
buildRequest(req: NormalizedRequest): ProviderRequest {
return {
model: req.upstreamModel, // 替换为上游模型名
messages: req.messages,
max_tokens: req.maxTokens,
stream: req.stream,
};
}
parseResponse(res: any): NormalizedResponse {
return {
id: res.id,
model: req.standardModel, // 返回标准模型名,不暴露上游
choices: res.choices,
usage: res.usage,
};
}
}
GLM 的适配器则需要处理更多差异,例如它的工具调用格式在某些版本上与 OpenAI 不完全一致。
5. 计费层
计费层在请求完成后异步执行,记录 token 消耗:
async function recordBilling(ctx: RequestContext) {
const { requestId, userId, standardModel, usage } = ctx;
const price = await getSellingPrice(standardModel);
const cost =
(usage.promptTokens / 1_000_000) * price.inputPerMtok +
(usage.completionTokens / 1_000_000) * price.outputPerMtok;
await db.insert('request_logs', {
request_id: requestId,
user_id: userId,
model: standardModel,
prompt_tokens: usage.promptTokens,
completion_tokens: usage.completionTokens,
cost_yuan: cost,
created_at: new Date(),
});
// 异步扣减用户余额
await billingQueue.push({ userId, amount: cost });
}
故障转移设计
健康检查
每个 Provider 需要维护健康状态,用于路由决策:
interface ProviderHealth {
provider: string;
status: 'healthy' | 'degraded' | 'down';
successRate5m: number; // 最近 5 分钟成功率
p99Latency: number; // P99 延迟 ms
lastCheckedAt: Date;
}
健康检查有两种触发方式:
- 被动检测:每次请求失败时记录,成功率跌破阈值(如 80%)时标记为 degraded
- 主动探活:定期发送轻量级请求(如单 token 的补全)验证可用性
自动 Fallback
async function requestWithFallback(
req: NormalizedRequest,
routes: ProviderRoute[]
): Promise<NormalizedResponse> {
const sorted = routes
.filter(r => r.health !== 'down')
.sort((a, b) => a.priority - b.priority);
for (const route of sorted) {
try {
const adapter = getAdapter(route.provider);
const response = await adapter.call(req, { timeout: 30_000 });
recordSuccess(route.provider);
return response;
} catch (err) {
recordFailure(route.provider, err);
logger.warn(`Provider ${route.provider} failed, trying next`, { err });
// 继续尝试下一个
}
}
throw new GatewayError('all_providers_failed', 'No available provider');
}
流式 SSE 代理的技术挑战
流式响应是 AI 网关最复杂的部分之一。客户端期望实时收到 token,但网关在中间需要做的事情并不少:
问题1:背压与缓冲区
上游 SSE 流速可能远超下游消费速度。Node.js 的流式管道需要正确处理背压,否则会出现内存溢出。
// 正确做法:使用 pipeline,它会自动处理背压
import { pipeline } from 'stream/promises';
await pipeline(
upstreamResponse.body,
new BillingTransformStream(ctx), // 边流式传输边统计 token
clientResponse.raw
);
问题2:流式计费
非流式请求结束时响应体里有 usage 字段,流式请求通常只在最后一个 chunk 里有,或者根本没有(部分厂商省略了)。
解决方案:在 BillingTransformStream 里实时解析每个 SSE chunk,累加 delta 内容的估算 token 数,在流结束时以最后一个 chunk 的 usage 为准(若有),否则用估算值。
问题3:上游断流重试
上游 SSE 连接中途断开时,如果客户端已经收到了一部分内容,重试会导致内容重复。需要在内存里维护已发送的偏移量,断线重连时从断点续传(前提是上游支持)。
用 Fastify 实现核心路由(伪代码)
import Fastify from 'fastify';
const app = Fastify();
app.post('/v1/chat/completions', async (request, reply) => {
// 1. 解析与归一化
const normalized = parseRequest(request.body);
// 2. 鉴权
const user = await authenticate(request.headers.authorization);
// 3. 余额检查
await checkBalance(user.id, estimateCost(normalized));
// 4. 选路
const routes = await routerSelector.select(normalized.model, {
capabilities: normalized.tools ? ['tools'] : [],
strategy: user.routingStrategy ?? 'priority',
});
if (normalized.stream) {
// 5a. 流式响应
reply.raw.setHeader('Content-Type', 'text/event-stream');
reply.raw.setHeader('Cache-Control', 'no-cache');
await requestWithFallback(normalized, routes, {
onChunk: (chunk) => reply.raw.write(chunk),
onDone: (usage) => recordBilling({ user, normalized, usage }),
});
reply.raw.end();
} else {
// 5b. 非流式响应
const response = await requestWithFallback(normalized, routes);
await recordBilling({ user, normalized, usage: response.usage });
reply.send(response);
}
});
关键设计决策回顾
| 决策点 | 推荐方案 | 原因 |
|---|---|---|
| 模型名对外暴露 | 标准模型名(brand/model) | 解耦业务代码与路由实现 |
| Provider 适配 | 每家独立适配器 | 隔离差异,便于独立演进 |
| 故障检测 | 被动+主动混合 | 被动低开销,主动恢复快 |
| 流式代理 | pipeline + Transform Stream | 背压处理正确,不丢内容 |
| 计费时机 | 流式在流结束后异步记录 | 不阻塞响应,不影响延迟 |
小结
AI API 网关的核心价值在于把不确定性收敛:上游模型厂商的 API 变化、可用性波动、定价调整,都在网关层消化掉,业务侧获得一个稳定的统一接口。
两层模型抽象是整个设计的关键——它让路由策略、故障转移、计费逻辑都可以在不改业务代码的情况下独立演进。流式 SSE 代理则是实现上最容易踩坑的部分,背压和计费时序需要格外关注。
下一步可以扩展的方向:请求级的语义缓存(对完全相同的问题复用历史响应)、基于任务复杂度的自动模型降级,以及更细粒度的用量配额管理。这些话题下篇文章会详细展开。
作者:TheRouter 开发者,专注 AI 模型路由网关。项目主页:therouter.ai