LLM网关与多供应商路由#
一句话答案#
LLM 网关是所有模型调用的统一出口:对上暴露一套 OpenAI 兼容接口,对下管多家供应商和自部署模型。它做四件核心的事——按供应商/模型的 RPM + TPM 双维限流(Redis Lua 原子扣两个桶,TPM 发前预估预扣、回来按真实 usage 回补)、熔断与降级(只对”对面坏了”计数,首 token 超时算故障,切到能力相当的备用模型)、重试与背压(可重试错误指数退避、遵守 Retry-After,自己限不住时向调用方回 429)、计量与成本归因(按上游返回的 usage 记账到租户/应用/模型)。
核心要点
单次调用的超时、重试、finish_reason 处理见 [LLM API调用与流式输出](/topics/llm-serving/LLM API调用与流式输出);租户级配额分档与预算止损见 多租户隔离与配额治理。本篇讲网关这一层内部的机制。
1. 为什么要统一网关#
业务直接调 SDK 时,问题会在规模上来之后集中出现:
| 问题 | 没有网关时 | 网关怎么做 |
|---|---|---|
| 供应商配额 | 每个服务、每个副本各自调用,供应商看到的是 N 倍速率,429 频发 | 全局共享一本限流账(Redis) |
| 单点故障 | 某家挂了 = 全站挂;每次调用都要等满超时才失败 | 熔断快速失败 + 切备用模型 |
| 切换成本 | 换模型要改代码、改配置、重新发版 | 模型名映射到出口,改路由配置即可 |
| 成本不可见 | 账单只按 API Key 汇总,说不清哪个业务花了多少 | 每次调用按租户/应用/功能打标签记账 |
| 密钥与审计 | Key 散落在各服务 | Key 只在网关,业务用虚拟 Key |
flowchart LR
A[业务服务] -->|OpenAI 兼容请求 + 虚拟 Key| G[鉴权 / 租户配额]
G --> R[路由:模型名 → 部署列表]
R --> B{熔断器允许?}
B -->|否| FB[下一个 fallback 部署]
B -->|是| T[双令牌桶 RPM+TPM 预扣]
T --> P[供应商适配 / 流式转发]
P --> S[按真实 usage 结算 + 记账]
顺序有讲究:熔断检查和令牌桶都放在占用本地并发位之前。熔断器处于打开状态时,先占一个并发位再拒绝,等于把快速失败省下的资源又还了回去;等令牌时占着本地并发位也会饿死其他模型的请求。
2. 限流:RPM + TPM 双维令牌桶#
供应商的配额本来就是两维:每分钟请求数(RPM)和每分钟 token 数(TPM)。只限一维都会出问题——只限 RPM,一条 30K 输入的长请求和一条 500 token 的短请求同价,TPM 照样撞线;只限 TPM,大量短请求能打满 RPM。算法选型(令牌桶 vs 滑动窗口)见 限流方案设计,这里讲 LLM 场景特有的三点:
- 两个桶要原子地一起扣:要么都扣,要么都不扣。先扣了 RPM、发现 TPM 不够再退回,并发下那一格很容易漏账,表现是”桶没满却老在等”,不报错,很难查;
- TPM 要预估:发请求前不知道会消耗多少 token,只能估:
输入 token(含工具 schema、system)+ 预计输出(max_tokens或按历史均值取一个值); - 回来再回补:响应的 usage 回来后按
预扣 − 实际退还或补扣;真实消耗超过预估时允许桶扣成负数,后面的请求自然等待,账最终是平的。
-- KEYS[1]=rpm 桶, KEYS[2]=tpm 桶;ARGV: rpm_cap, rpm_rate, tpm_cap, tpm_rate, need
-- rate 单位:每秒补充的令牌数;redis.call('TIME') 需 Redis 5+(默认按效果复制)
local t = redis.call('TIME')
local now = tonumber(t[1]) + tonumber(t[2]) / 1e6
local function level(key, cap, rate)
local v = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(v[1]) or cap
local ts = tonumber(v[2]) or now
return math.min(cap, tokens + (now - ts) * rate)
end
local rpm_cap, rpm_rate = tonumber(ARGV[1]), tonumber(ARGV[2])
local tpm_cap, tpm_rate = tonumber(ARGV[3]), tonumber(ARGV[4])
local need = math.min(tonumber(ARGV[5]), tpm_cap) -- 单次需求不能超过桶容量,否则永远等不到
local rpm = level(KEYS[1], rpm_cap, rpm_rate)
local tpm = level(KEYS[2], tpm_cap, tpm_rate)
if rpm >= 1 and tpm >= need then -- 两个桶都够才一起扣
redis.call('HSET', KEYS[1], 'tokens', rpm - 1, 'ts', now)
redis.call('HSET', KEYS[2], 'tokens', tpm - need, 'ts', now)
redis.call('EXPIRE', KEYS[1], 120); redis.call('EXPIRE', KEYS[2], 120)
return {1, 0}
end
local wait = 0 -- 都不扣,只告诉调用方要等多久
if rpm < 1 then wait = math.max(wait, (1 - rpm) / rpm_rate) end
if tpm < need then wait = math.max(wait, (need - tpm) / tpm_rate) end
return {0, math.ceil(wait * 1000)} -- Lua 数字返回会被截成整数,用毫秒lua结算脚本同理:先按时间补充,再把 reserved − actual 加回 TPM 桶,上限封顶在容量、下限不设(允许为负)。工程细节:
- 桶的 key 按
供应商/模型(或部署)划分,和供应商配额的粒度一致;租户级配额是另一层,叠在前面; - Redis 不可用或脚本超时:退回进程内桶,限额按副本数分摊,同时打指标告警,不要让限流组件拖垮主链路;
- 等待有上限:排队超过上限就向调用方返回 429 +
Retry-After,或者按业务选择放行并记录; - 为什么要 Lua:读余量、判断、写入如果分成多条命令,并发下有竞态,Lua 在 Redis 里原子执行,见 Redis事务与Lua脚本。
3. 熔断:难点在”什么算故障”#
熔断器三态(closed → open → half-open)的通用原理见 熔断降级原理。LLM 场景的关键是只对”对面坏了”计数:
| 信号 | 是否计入熔断 | 理由 |
|---|---|---|
| 5xx、连接失败、连接重置 | 计 | 对面真的出问题了 |
| 首 token 超时(TTFT 超过阈值) | 计,并且真掐断连接 | 供应商过载时常表现为”连接建立了但迟迟不出字”;不掐断只是换个地方等满总超时 |
| 429 | 不计 | 说明我们发得太快,对面是好的;计进去会把自己的限流放大成服务不可用,降速是限流器的事 |
| 总超时(流一直在吐只是长) | 不计 | 长回答本来就慢,计进去会在长回答多时误熔断 |
| 4xx(参数错、上下文超长) | 不计 | 请求本身的问题,换谁都一样 |
判定方式可以是”连续 N 次失败”或”滑动窗口内错误率超过阈值且样本数足够”,后者在流量大时更稳。熔断器按 供应商/模型 分别维护,一家的某个模型挂了不影响别的。半开探测遇到 429 时要特殊处理:既不能当成功清零(等于用一次限流把前面的故障记录洗掉),也不能什么都不做(状态会卡在半开),应该退回 open 状态、等下一次探测。
import asyncio, time
# counts_as_failure / is_rate_limited / FirstTokenTimeout 按上面的表格实现,此处省略
class LLMBreaker:
def __init__(self, threshold=5, recovery_sec=30):
self.fails, self.threshold, self.recovery = 0, threshold, recovery_sec
self.opened_at = None
def allow(self) -> bool:
if self.opened_at is None:
return True
return time.monotonic() - self.opened_at >= self.recovery # 到时间放行探测(简化:未限制只放一个)
def record(self, exc: Exception | None):
if exc is None:
self.fails, self.opened_at = 0, None
elif counts_as_failure(exc): # 5xx / 连接错误 / 首 token 超时
self.fails += 1
if self.fails >= self.threshold:
self.opened_at = time.monotonic()
elif is_rate_limited(exc) and self.opened_at is not None:
self.opened_at = time.monotonic() # 半开探测撞 429:不清零也不加计数,退回 open 等下次探测
async def first_chunk(stream, ttft_sec: float):
it = stream.__aiter__()
task = asyncio.ensure_future(it.__anext__())
done, _ = await asyncio.wait({task}, timeout=ttft_sec)
if not done:
task.cancel()
await stream.aclose() # 真正关掉上游连接,否则连接还挂着
raise FirstTokenTimeout()
return task.result()python4. 降级与 fallback:切过去之前先确认”能用”#
- fallback 链:主模型熔断或重试耗尽后,按配置的链切到备用部署(同模型的另一个区域/另一家托管 → 同档位的其他模型 → 更小的模型);
- 能力门:备用模型要具备业务必需的能力。Agent 场景最关键的是能不能调用工具,其次是上下文长度、结构化输出。不支持 prompt caching 只是变贵变慢,不支持工具调用则等于切了不如不切。能力信息来自模型元数据表时要注意”查不到”和”明确不支持”是两回事,查不到应放行并告警,否则会把正在用的模型误判为不可用;
- 流式中途失败不能透明切换:首 token 之后连接断开,用户已经看到半截回答,网关无法无缝换一家续写。fallback 只对首 token 之前的失败成立,之后只能向上报错,由应用决定重试整轮还是提示用户;
- 切换的代价:换模型后前缀缓存全部失效(首轮更贵更慢),输出风格和格式可能变化,所以切换事件要打日志、带上”少了哪些能力”,并在评测里覆盖备用模型。
5. 重试与幂等#
- 可重试:429(速率类)、5xx、连接错误、首 token 超时;不可重试:400(参数/上下文超限)、401/403、内容过滤。
- 指数退避 + 抖动:
sleep = min(cap, base * 2**attempt) * random.uniform(0.5, 1);响应里有Retry-After就以它为准。 - 只在一层重试:SDK 自带重试(如 OpenAI Python SDK 默认会自动重试,次数以官方文档为准)、网关重试、业务重试叠在一起会变成乘法,3 层各 3 次就是 27 次请求。约定只在网关重试,SDK 设
max_retries=0; - 重试预算:限制重试请求占总请求的比例(例如一个时间窗内重试不超过一定比例),供应商故障时避免重试风暴把自己的配额也耗光;
- 每次重试重新计时:首 token 预算按每次尝试单独计算,否则前几次失败的耗时会算到最后一次头上;每次尝试的超时也要收到本轮请求的总截止时间以内;
- 幂等:LLM 调用天然不幂等,重试会重复计费。网关用调用方传入的 request_id / Idempotency-Key 在短时间窗口内去重(相同 key 返回同一结果或拒绝);已经开始流式输出的请求不重试;有副作用的工具调用在工具侧做幂等,见 [Function Calling与工具编排](/topics/ai-agent/Function Calling与工具编排)。
6. 429 与背压#
网关同时处在两个方向上:
- 对上游(供应商):收到 429 时读
Retry-After和剩余额度类响应头(如 OpenAI 的x-ratelimit-remaining-tokens,各家头名不同),主动降低该出口的并发或拉开请求间隔(类似 AIMD:出错快速降、恢复慢慢升)。还要区分”速率超限”和”额度/余额用尽”——后者重试没有意义,应该直接告警并切出口; - 对下游(调用方):自己的令牌桶等待超过上限、或租户配额用完时,向调用方返回 429 + Retry-After,把压力推回去,而不是无限排队。队列有长度上限,超过就拒;交互式请求和批量任务分优先级,批量任务可以等、交互式请求优先。
7. 计量与成本归因#
- 以上游 usage 为准:输入、输出、命中缓存的输入、推理 token 分别记,计价时按各自单价;不要用网关自己的 tokenizer 估算值记账(估算只用于预扣)。有的网关库在上游没返回 usage 时会自己估一份补上,计费场景要确认拿到的是上游那份;
- 流式要开 usage 回传:OpenAI 兼容接口加
stream_options: {"include_usage": true},usage 在最后一帧;客户端中途断开时,网关要么继续把上游流读完拿到 usage,要么取消上游并按已转发内容估算,两种选择要写清楚; - 归因维度:租户、应用、功能点、用户、模型、供应商。调用方通过请求头或 metadata 传标签,网关写入异步日志(Kafka → ClickHouse 等),不阻塞主链路;
- 价格表带版本:模型价格会变,按”模型名 + 生效时间”查价,历史账单不受新价格影响;
- 告警与止损:按预算设软限告警和硬限拒绝,分档策略见 多租户隔离与配额治理;调用链路追踪见 Agent可观测性与质量保障。
8. 开源方案#
| 方案 | 形态 | 特点 |
|---|---|---|
| LiteLLM | Python SDK + Proxy 服务 | 上百家供应商统一成 OpenAI 格式;Router 支持多部署负载均衡、fallback、重试、冷却、按部署的 RPM/TPM;Proxy 提供虚拟 Key、预算、花费统计;迭代很快(2026-09 仍在按周发版),升级前要回归 |
| One API / New API | Go 服务 + 管理后台 | 渠道(上游 Key)管理、令牌额度分发、OpenAI 兼容转发,国内使用多;One API 更新已明显放缓(仓库最近一次提交在 2026-01),New API 是在它基础上继续开发、目前仍活跃的分支 |
| Higress / Kong 等 AI 网关 | 基于通用 API 网关的插件 | 在已有网关上加 AI 代理、token 限流、缓存等插件,适合已有网关体系的团队 |
选型判据:通用的部分用现成的,有业务口径的部分自己写。“哪个模型名对应哪个 base_url 和 Key”是通用问题,用 LiteLLM Router 这类组件即可;“什么算故障""TPM 预扣要不要算工具 schema""降级时必须保留哪些能力”带有业务口径,现成实现的默认值未必符合,需要自己定义甚至自研。
面试回答(2分钟版)
统一网关解决的是模型调用规模化之后的几个问题:多副本各自调用会让供应商看到 N 倍速率,一家挂了全站挂,换模型要发版,成本说不清谁花的。网关对上暴露 OpenAI 兼容接口,对下管多家供应商。限流上,供应商配额是 RPM 和 TPM 两维,我用 Redis Lua 同时扣两个桶,要么都扣要么都不扣;TPM 发之前按输入加工具 schema 加预计输出预扣,回来按真实 usage 回补,超了允许扣成负数。熔断的难点是什么算故障:5xx、连接失败、首 token 超时算,而且首 token 超时要真的关掉连接;429 不算,因为那是我们发太快,应该降速而不是熔断;总超时也不算,长回答本来就慢。熔断或重试耗尽后按 fallback 链切备用模型,切之前过能力门,Agent 场景至少要能调工具;流式出了首 token 之后断掉没法透明切换。重试只在网关一层做,指数退避加抖动,遵守 Retry-After,再加重试预算防风暴;幂等靠 request_id 去重。自己的桶等太久就给调用方回 429 加 Retry-After。计量按上游 usage 分输入、输出、缓存命中分别记,按租户和功能打标签。选型上一种常见取舍是:寻址、多部署负载均衡这类通用部分交给 LiteLLM Router 之类的现成组件,断路器和跨副本的 RPM+TPM 双令牌桶自己写,因为”什么算故障""预扣怎么算”这些判据带有业务口径。结合项目时可以讲:哪些用了现成组件、哪些自研、切换前用什么数据验证没有引入额外延迟或改写请求。
追问与易错
追问方向:
- “为什么 RPM 和 TPM 必须在一个 Lua 脚本里扣?” → 分两步扣时,先扣 RPM 再发现 TPM 不够,需要退回 RPM,并发下这个”扣了又退”很容易漏账或多扣;一个脚本里先判断两个桶都够再一起扣,Redis 单线程执行保证原子。
- “TPM 预估不准怎么办?” → 预估只用来发请求前占额度,响应回来按 usage 回补;估少了允许桶变负,后续请求自然等待,总账是对的。要避免的是预估恒为 0 之类的失效——那样预扣形同虚设、并发时照样撞供应商的 TPM 线。
- “429 为什么不计入熔断?” → 429 表示对面健康、是我们发得太快;计进熔断会在流量高峰时把一个正常的供应商熔断掉,把”限流”放大成”不可用”。正确处理是降速和退避。
- “首 token 超时和总超时为什么区别对待?” → 首 token 迟迟不来通常说明对面排队或过载,是故障信号;首 token 之后流一直在吐只是回答长,不该判故障。首 token 超时还要主动关连接,否则连接和资源仍被占着。
- “流式输出到一半上游断了,网关能自动切备用模型吗?” → 不能透明切换,用户已经收到半截内容,换模型续写会出现不连贯;只能把错误传给上层,由应用决定整轮重试或提示用户。fallback 只对首 token 之前的失败有效。
- “网关、SDK、业务都有重试会怎样?” → 次数相乘,一次故障放大成几十次请求,还会重复计费;约定只在一层(通常是网关)重试,SDK 的自动重试关掉。
- “Redis 挂了限流怎么办?” → 退回进程内令牌桶,限额按副本数分摊,并通过指标告警;限流组件的故障不能让主链路不可用,但要知道这段时间的限额是不精确的。
- “怎么把成本归到具体业务功能?” → 调用方在请求头或 metadata 里带租户、应用、功能标签,网关用上游返回的 usage 按版本化价格表计价,异步写入分析库,按标签聚合出报表。
易错点:
- ❌ “用网关自己的 tokenizer 估算 token 记账” → 估算只用于预扣,记账必须用上游返回的 usage,否则和供应商账单对不上。
- ❌ “所有错误都熔断、都重试” → 4xx 重试和熔断都没意义,429 应该降速而不是熔断。
- ❌ “配了 fallback 就高可用了” → 备用模型可能不支持工具调用或上下文不够长,切过去照样失败;流式中途失败也接不住。