Python + FastAPI 实现大模型 API 网关:统一路由、失败重试、并发控制与成本统计
Python + FastAPI 实现大模型 API 网关:统一路由、失败重试、并发控制与成本统计
当一个应用同时接入本地模型和多个云端模型时,把
base_url、API Key、模型名、超时和重试逻辑散落在业务代码中,很快就会失控。本文使用 Python、FastAPI、HTTPX 和 Pydantic 实现一个可以运行的大模型 API 网关:客户端只认识模型别名,网关负责上游路由、密钥隔离、并发控制、谨慎重试、Token 成本统计和统一错误返回。
本文配套项目已经完成 4 组自动化测试,覆盖模型路由、429 重试、成本计算、读取超时不重试、FastAPI Lifespan、健康检查和网关鉴权。
一、完成后的效果
假设我们有两个模型来源:
local-chat → 本机 Ollama
cloud-chat → 某个 OpenAI 兼容云服务
客户端不需要知道真实地址、真实模型名和上游密钥,只调用网关:
curl.exe http://127.0.0.1:9000/v1/chat/completions `
-H "Authorization: Bearer change-me" `
-H "Content-Type: application/json" `
-d '{
"model":"local-chat",
"messages":[{"role":"user","content":"解释什么是大模型 API 网关"}],
"temperature":0,
"max_tokens":300,
"stream":false
}'
网关会把 local-chat 映射为配置中的真实模型,并将请求发送到对应上游。成功响应保持 OpenAI Chat Completions 风格,同时通过响应头返回网关信息:
X-Gateway-Trace-ID: 7c0c...
X-Gateway-Latency-MS: 823.41
X-Gateway-Cost-USD: 0.000600
还可以查询累计统计:
curl.exe http://127.0.0.1:9000/admin/stats `
-H "Authorization: Bearer change-me"
返回示例:
{
"local-chat": {
"requests": 12,
"success": 11,
"failed": 1,
"retries": 2,
"prompt_tokens": 3200,
"completion_tokens": 1400,
"cost_usd": "0.000000",
"average_latency_ms": 912.35
}
}
本地模型的价格配置为 0,所以成本为 0。云端模型价格由使用者按照实际供应商官方价格手动配置,本文不会写入可能很快过期的价格数字。
二、为什么不应该在每个业务模块里直接调用模型
一个只有单模型的 Demo,通常这样写:
client = OpenAI(api_key="...", base_url="...")
response = client.chat.completions.create(...)
当系统逐渐接入多个模型,问题会出现:
- 每个模块保存一份 API Key;
- 模型名、地址和超时散落在代码里;
- 有的地方重试 3 次,有的地方完全不重试;
- 429、502、读取超时被当成同一种错误;
- 无法统一统计 Token 和费用;
- 无法限制某个模型的并发;
- 业务方直接依赖供应商模型名,迁移成本越来越高。
API 网关的价值,是把这些横切能力集中在一层:
业务应用
↓ 统一模型别名和请求格式
大模型 API 网关
├─ 鉴权
├─ 路由
├─ 并发控制
├─ 超时与重试
├─ 错误转换
├─ Token / 成本统计
└─ Trace ID
↓
本地 Ollama / 云端 OpenAI 兼容服务
本文实现的是一个适合学习和小型内部项目的最小网关,不会把它包装成完整生产平台。
三、项目目录
020-llm-gateway/
├─ app.py
├─ providers.example.json
├─ requirements.txt
├─ test_app.py
└─ README.md
创建虚拟环境并安装依赖:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
requirements.txt:
fastapi>=0.116,<1
httpx>=0.28,<1
pydantic>=2.11,<3
uvicorn>=0.35,<1
pytest>=8,<10
建议在真实项目中生成锁文件,避免未来安装到未经验证的新版本。本文测试时使用的具体版本记录在文末。
四、用模型别名隔离真实供应商
复制配置文件:
Copy-Item providers.example.json providers.json
providers.json:
{
"providers": [
{
"alias": "local-chat",
"base_url": "http://127.0.0.1:11434/v1",
"upstream_model": "qwen3:8b",
"api_key_env": null,
"input_price_per_million": 0,
"output_price_per_million": 0,
"max_concurrency": 2,
"timeout_seconds": 120,
"retries": 1,
"backoff_base_seconds": 0.5
},
{
"alias": "cloud-chat",
"base_url": "https://api.example.com/v1",
"upstream_model": "replace-with-real-model",
"api_key_env": "CLOUD_LLM_API_KEY",
"input_price_per_million": 0,
"output_price_per_million": 0,
"max_concurrency": 8,
"timeout_seconds": 120,
"retries": 1,
"backoff_base_seconds": 0.5
}
]
}
云端地址、模型名和价格都是占位值,必须根据实际供应商官方文档修改。
模型别名带来三个好处:
- 客户端只认识
local-chat,不知道真实供应商; - 更换上游模型时,不必修改所有业务代码;
- 客户端不能提交任意模型名,模型使用范围由网关白名单控制。
五、请求模型与配置校验
使用 Pydantic 定义配置和请求:
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
class ProviderConfig(BaseModel):
alias: str = Field(pattern=r"^[a-zA-Z0-9_-]+$")
base_url: str
upstream_model: str
api_key_env: str | None = None
input_price_per_million: Decimal = Decimal("0")
output_price_per_million: Decimal = Decimal("0")
max_concurrency: int = Field(default=2, ge=1, le=1000)
timeout_seconds: float = Field(default=120, gt=0)
retries: int = Field(default=1, ge=0, le=5)
backoff_base_seconds: float = Field(default=0.5, ge=0, le=30)
@model_validator(mode="after")
def validate_base_url(self):
if not self.base_url.startswith(("http://", "https://")):
raise ValueError("base_url must start with http:// or https://")
self.base_url = self.base_url.rstrip("/")
return self
class ChatMessage(BaseModel):
role: Literal["system", "user", "assistant", "tool"]
content: str
class ChatRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
model: str
messages: list[ChatMessage] = Field(min_length=1)
temperature: float | None = Field(default=None, ge=0, le=2)
top_p: float | None = Field(default=None, gt=0, le=1)
max_tokens: int | None = Field(default=None, ge=1)
stop: str | list[str] | None = None
stream: Literal[False] = False
这里故意只支持非流式 Chat Completions,并禁止未知字段。
“OpenAI 兼容”不代表所有服务都支持完全相同的参数。如果网关不加约束,客户端可能把某个供应商专属字段传给另一个服务,最后得到难以解释的错误。
当需要支持视觉、工具调用或流式响应时,应逐项增加 Schema 和兼容测试,而不是直接允许任意 JSON 穿透。
六、为什么 HTTPX 客户端要在 Lifespan 中创建
HTTPX 官方文档提醒:不要在请求热循环中反复创建 AsyncClient,否则无法充分利用连接池。
FastAPI 推荐使用 Lifespan 管理跨请求共享并需要在关闭时释放的资源。我们在应用启动时创建一个客户端,在应用关闭时统一释放:
from contextlib import asynccontextmanager
import httpx
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
configs = load_provider_configs("providers.json")
max_connections = max(
10, sum(item.max_concurrency for item in configs) * 2
)
limits = httpx.Limits(
max_connections=max_connections,
max_keepalive_connections=max(5, max_connections // 2),
)
timeout = httpx.Timeout(
connect=10,
read=120,
write=30,
pool=10,
)
async with httpx.AsyncClient(
limits=limits,
timeout=timeout,
) as client:
app.state.gateway = Gateway(configs, client)
yield
app = FastAPI(lifespan=lifespan)
HTTPX 把超时分为四类:
connect:建立连接等待时间;read:等待响应数据块的时间;write:发送请求数据块的时间;pool:等待连接池可用连接的时间。
大模型首 Token 可能较慢,不能机械使用普通 Web API 的短读取超时。但也不应该把所有超时设为 None,否则故障请求可能长期占用连接和并发槽位。
七、并发控制不是简单限制连接数
每个模型服务的承载能力不同。一个本地 8B 模型可能只适合两路并发,而云端服务可以承载更多请求。
为每个模型别名创建独立 Semaphore:
import asyncio
from dataclasses import dataclass, field
@dataclass
class RuntimeProvider:
config: ProviderConfig
semaphore: asyncio.Semaphore = field(init=False)
def __post_init__(self):
self.semaphore = asyncio.Semaphore(
self.config.max_concurrency
)
发送请求时获取并发槽位:
async with provider.semaphore:
response = await client.post(...)
这解决的是单进程内的并发上限,不是完整分布式限流。
如果 Uvicorn 启动 4 个 Worker,每个进程都有自己的 Semaphore。配置为 8 时,整体最多可能出现约 32 路并发。多进程、多实例部署必须使用 Redis、集中式网关或供应商侧配额控制,不能把内存 Semaphore 当作全局限流器。
八、重试最容易被写错
许多示例把所有异常统一重试 3 次:
try:
...
except Exception:
retry()
对大模型接口,这可能造成重复生成和重复计费。
8.1 本文只自动重试这些情况
ConnectError:没有成功建立上游连接;ConnectTimeout:建立连接超时;- HTTP 429、502、503、504,并且没有超过配置次数。
429 按 RFC 6585 表示请求过多,响应可以提供 Retry-After。网关优先服从该字段,没有时才使用指数退避和随机抖动。
8.2 读取超时不自动重试
ReadTimeout 发生时,上游可能已经接收请求、开始生成甚至完成计费,只是客户端没有及时收到结果。此时自动重试会产生第二次调用。
所以本文返回:
{
"error": {
"code": "upstream_timeout",
"message": "upstream result is unknown; request was not retried"
}
}
这不是说读取超时永远不能重试,而是必须有可靠的幂等机制、请求状态查询或供应商明确保证后再做。
8.3 Retry-After 解析
Retry-After 可能是秒数,也可能是 HTTP 日期:
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
def parse_retry_after(value: str | None) -> float | None:
if not value:
return None
try:
return max(0.0, float(value))
except ValueError:
try:
parsed = parsedate_to_datetime(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return max(
0.0,
(parsed - datetime.now(timezone.utc)).total_seconds(),
)
except (TypeError, ValueError, OverflowError):
return None
退避计算:
def backoff(config, attempt, retry_after):
server_delay = parse_retry_after(retry_after)
if server_delay is not None:
return min(server_delay, 60.0)
exponential = config.backoff_base_seconds * (2 ** attempt)
jitter = random.uniform(0, config.backoff_base_seconds)
return min(exponential + jitter, 60.0)
设置 60 秒上限,是为了避免错误或异常大的响应头让网关任务无限等待。实际生产系统可以把任务交还队列,而不是一直占用 Web 请求。
九、核心路由代码
网关根据客户端传入的别名查找配置,再把 model 替换为真实上游模型名:
class Gateway:
def __init__(self, configs, client: httpx.AsyncClient):
self.client = client
self.providers = {
config.alias: RuntimeProvider(config)
for config in configs
}
def get_provider(self, alias: str):
provider = self.providers.get(alias)
if provider is None:
raise GatewayFailure(
404,
"unknown_model",
f"unknown model alias: {alias}",
)
return provider
async def complete(self, body: ChatRequest, trace_id: str):
provider = self.get_provider(body.model)
config = provider.config
payload = body.model_dump(exclude_none=True)
payload["model"] = config.upstream_model
url = f"{config.base_url}/chat/completions"
headers = self.upstream_headers(config, trace_id)
async with provider.semaphore:
for attempt in range(config.retries + 1):
try:
response = await self.client.post(
url,
json=payload,
headers=headers,
timeout=config.timeout_seconds,
)
except (
httpx.ConnectError,
httpx.ConnectTimeout,
) as exc:
if attempt >= config.retries:
raise GatewayFailure(
502,
"upstream_connect_failed",
str(exc),
) from exc
await asyncio.sleep(
self._backoff(config, attempt, None)
)
continue
except httpx.TimeoutException as exc:
raise GatewayFailure(
504,
"upstream_timeout",
"upstream result is unknown; "
"request was not retried",
) from exc
if (
response.status_code
in {429, 502, 503, 504}
and attempt < config.retries
):
await asyncio.sleep(
self._backoff(
config,
attempt,
response.headers.get("Retry-After"),
)
)
continue
if response.is_error:
raise GatewayFailure(
response.status_code,
"upstream_rejected",
self._safe_upstream_error(response),
retry_after=response.headers.get("Retry-After"),
)
try:
response_body = response.json()
except ValueError as exc:
raise GatewayFailure(
502,
"invalid_upstream_response",
"upstream did not return JSON",
) from exc
return response_body
raise AssertionError("retry loop exited unexpectedly")
配套源码还会记录请求数、成功数、失败数、重试数、Token、成本和平均延迟。为了让核心逻辑更容易阅读,这里省略了重复的统计加锁代码,完整文件已经通过自动化测试。
十、Token 成本如何计算
只有上游返回下面的 Usage 时,网关才计算成本:
{
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50
}
}
公式:
成本 = 输入 Token × 输入单价 ÷ 1,000,000
+ 输出 Token × 输出单价 ÷ 1,000,000
代码使用 Decimal,避免用二进制浮点数累计金额:
from decimal import Decimal
def calculate_cost(
config,
prompt_tokens: int,
completion_tokens: int,
) -> Decimal:
million = Decimal("1000000")
return (
Decimal(prompt_tokens)
* config.input_price_per_million
/ million
+ Decimal(completion_tokens)
* config.output_price_per_million
/ million
)
如果输入价格为 2 美元/百万 Token,输出价格为 8 美元/百万 Token,那么 100 输入 Token 和 50 输出 Token 的成本为:
100 × 2 / 1,000,000 + 50 × 8 / 1,000,000
= 0.0002 + 0.0004
= 0.0006 美元
这个数字只用于验证公式。真实价格必须由使用者根据实际供应商的最新官方价格配置。
如果上游没有 Usage,网关不会根据字符数伪造 Token 数,也不会给出看似精确的费用。
十一、网关鉴权与上游密钥隔离
启动网关前设置一个入口密钥:
$env:GATEWAY_API_KEY='change-me'
依赖函数检查请求头:
from fastapi import Header
def require_gateway_key(
authorization: str | None = Header(default=None),
):
expected = os.getenv("GATEWAY_API_KEY")
if not expected:
raise GatewayFailure(
503,
"gateway_not_configured",
"GATEWAY_API_KEY is missing",
)
if authorization != f"Bearer {expected}":
raise GatewayFailure(
401,
"invalid_gateway_key",
"invalid gateway API key",
)
云端上游密钥不写入 JSON,只保存环境变量名:
"api_key_env": "CLOUD_LLM_API_KEY"
运行时再读取:
api_key = os.getenv(config.api_key_env)
headers["Authorization"] = f"Bearer {api_key}"
这样业务客户端只持有网关密钥,不接触上游密钥。
这个例子仍然只是最小实现。生产环境应使用密钥管理服务、短期凭据、网关级身份体系和轮换机制,不应长期依赖进程环境变量保存所有秘密。
十二、定义接口
import uuid
from fastapi import Depends, FastAPI, Request
from fastapi.responses import JSONResponse
@app.get("/health")
async def health(request: Request):
gateway = request.app.state.gateway
return {
"status": "ok",
"models": sorted(gateway.providers),
}
@app.get(
"/v1/models",
dependencies=[Depends(require_gateway_key)],
)
async def models(request: Request):
gateway = request.app.state.gateway
return {
"object": "list",
"data": [
{
"id": alias,
"object": "model",
"owned_by": "gateway",
}
for alias in sorted(gateway.providers)
],
}
@app.post(
"/v1/chat/completions",
dependencies=[Depends(require_gateway_key)],
)
async def chat_completions(
body: ChatRequest,
request: Request,
):
trace_id = uuid.uuid4().hex
gateway = request.app.state.gateway
response_body, cost, latency_ms = await gateway.complete(
body,
trace_id,
)
headers = {
"X-Gateway-Trace-ID": trace_id,
"X-Gateway-Latency-MS": f"{latency_ms:.2f}",
}
if cost is not None:
headers["X-Gateway-Cost-USD"] = str(
cost.quantize(Decimal("0.000001"))
)
return JSONResponse(
content=response_body,
headers=headers,
)
@app.get(
"/admin/stats",
dependencies=[Depends(require_gateway_key)],
)
async def stats(request: Request):
return await request.app.state.gateway.stats()
响应正文尽量保持上游格式,把 Trace、延迟和成本放进响应头,可以减少客户端适配成本。
十三、启动与调用
先启动本地 Ollama 并准备模型,具体模型名根据机器资源选择。Ollama 官方文档说明其兼容部分 OpenAI API,默认 OpenAI 风格地址可以配置为:
http://127.0.0.1:11434/v1
然后设置网关配置:
$env:GATEWAY_CONFIG='providers.json'
$env:GATEWAY_API_KEY='change-me'
启动:
uvicorn app:app --host 127.0.0.1 --port 9000
查看健康状态:
curl.exe http://127.0.0.1:9000/health
查看可用模型别名:
curl.exe http://127.0.0.1:9000/v1/models `
-H "Authorization: Bearer change-me"
调用 Chat Completions:
curl.exe http://127.0.0.1:9000/v1/chat/completions `
-H "Authorization: Bearer change-me" `
-H "Content-Type: application/json" `
-d '{"model":"local-chat","messages":[{"role":"user","content":"你好"}],"temperature":0,"max_tokens":128,"stream":false}'
不要把 --host 改成 0.0.0.0 后直接暴露到公网。即使网关有一个静态 API Key,也不等于已经具备 TLS、账户、权限、审计、限流和防攻击能力。
十四、自动化测试
这类网关至少要测试三个高风险点。
14.1 429 只重试规定次数
使用 HTTPX MockTransport,第一次返回 429,第二次返回成功:
def handler(request: httpx.Request):
if calls == 1:
return httpx.Response(
429,
headers={"Retry-After": "0"},
)
return httpx.Response(
200,
json={
"id": "chatcmpl-test",
"choices": [],
"usage": {
"prompt_tokens": 100,
"completion_tokens": 50,
},
},
)
测试同时断言:
- 请求模型别名被替换为上游模型名;
- 总调用次数为 2;
- 网关记录一次重试;
- 成本等于 0.0006 美元。
14.2 ReadTimeout 不重试
def handler(_: httpx.Request):
raise httpx.ReadTimeout("read timed out")
即使配置 retries=3,实际调用次数也必须为 1。
14.3 API 鉴权和 Lifespan
使用 ASGITransport 直接调用 FastAPI:
/health返回模型别名;/v1/models不带密钥返回 401;- 正确密钥返回 200。
执行:
pytest -q
本文配套代码的执行结果:
.... [100%]
4 passed in 0.60s
测试环境版本:
Python 3.12
FastAPI 0.141.1
Starlette 1.3.1
HTTPX 0.28.1
Pydantic 2.13.4
pytest 9.1.1
项目依赖采用版本范围,正式部署前应生成并保存锁文件。
十五、这个版本故意没有实现什么
15.1 没有流式转发
流式转发不能简单使用普通 post()。HTTPX 官方文档说明,手动流式模式必须最终调用 Response.aclose(),否则可能泄漏连接。
增加流式功能时还要处理:
- 客户端中途断开;
- 上游响应关闭;
- SSE 格式差异;
- 首 Token 延迟;
- 流式 Usage;
- 生成中途失败后的计费和重试边界。
这些值得单独实现和测试,不适合塞进一个“看起来能跑”的代码分支。
15.2 没有自动模型降级
主模型失败后自动切换备用模型,可能改变答案质量、合规范围、上下文长度和成本。没有明确路由策略与业务授权时,网关不应该偷偷换模型。
15.3 没有持久化统计
当前统计保存在进程内:
- 重启后归零;
- 多 Worker 之间不共享;
- 不适合财务结算。
生产环境应把用量写入数据库或指标系统,并使用 Trace ID 关联请求、上游记录和业务订单。
15.4 没有完整用户体系
一个静态网关密钥只能用于本地或受控内部环境。多团队使用时需要:
- 不同调用方独立凭据;
- 模型权限;
- 每日/每月预算;
- QPS 和并发配额;
- 密钥轮换与吊销;
- 审计日志和敏感内容脱敏。
十六、生产化升级顺序
不要一开始就堆满所有功能。更稳妥的顺序是:
第一阶段:模型别名 + 密钥隔离 + 统一错误
第二阶段:超时 + 谨慎重试 + 并发控制
第三阶段:Token / 成本 / 延迟指标
第四阶段:持久化用量 + 调用方配额
第五阶段:流式转发 + 断开处理
第六阶段:多实例限流 + 高可用
第七阶段:经过验证的路由与降级策略
每增加一个能力,都要加入故障测试。大模型网关最危险的地方不是正常请求能否成功,而是超时、限流、客户端断开和上游异常时,系统是否会重复请求、泄漏连接、暴露密钥或错误计费。
十七、总结
一个可用的大模型 API 网关,不只是把请求转发到另一个 URL。
本文实现了这些核心能力:
- 用模型别名隔离真实供应商与模型名;
- 用环境变量隔离上游密钥;
- 复用一个 HTTPX
AsyncClient和连接池; - 按模型配置单进程并发上限;
- 对 429、502、503、504 和连接失败进行有限重试;
- 不自动重试结果未知的读取超时;
- 根据上游 Usage 和手动配置价格计算成本;
- 返回 Trace ID、延迟与成本响应头;
- 通过自动化测试验证关键故障路径。
如果应用只调用一个本地模型,直接连接 Ollama 完全没有问题。当模型来源、调用方和成本逐渐增多时,再引入这样一个边界清晰的网关,才能避免路由、密钥、重试和统计逻辑散落到整个项目。
真正的工程价值不是“多套一层 FastAPI”,而是把不确定的模型调用封装进可配置、可限制、可观察、可测试的系统边界。
参考资料
以下资料于 2026 年 7 月 30 日核对:
更多推荐



所有评论(0)