LLM 路由与降级策略:不要把所有请求交给一个模型
一个模型扛不住生产环境
去年我负责的一个 AI 客服项目,所有请求都打到 GPT-4 上。运行了三个月相安无事,直到某天 OpenAI 的 API 突然开始频繁返回 429(Rate Limit)。整个客服系统在三分钟内完全不可用,用户发出的消息石沉大海。
事后复盘,问题很清楚:我们把所有鸡蛋放在了一个篮子里。简单FAQ分类、复杂多轮推理、工单摘要生成——这些对模型能力要求差异巨大的任务,全都路由到同一个模型。不仅成本高得离谱,而且一旦这个模型出问题,整个系统就瘫痪了。
这次事故之后,我花了两周时间重新设计了模型路由和降级策略。这篇文章就是那次重构的经验总结。
在深入细节之前,有必要厘清一个常见误解:模型路由和故障转移经常被混为一谈,但它们解决的是不同层面的问题。路由是在系统正常时做出「谁来做」的决策——基于任务类型、成本预算、延迟要求把请求分配给最合适的模型。故障转移是在系统异常时执行「做不了怎么办」的兜底——当首选模型不可用或输出异常时,平滑切换到备选方案。两者有交集,但设计思路完全不同。把路由做好能减少故障转移的压力,把故障转移做好能让路由系统的可用性兜底。
路由的本质:把对的请求交给对的模型
LLM 路由并不是一个新概念。它的核心思想很直接——根据请求的特征,把它分配给最合适的模型,而不是所有请求走同一个 endpoint。百度 AI Studio 的一篇架构分析提到,优秀的路由系统可以在保持 95% GPT-4 性能的同时,实现 85% 的成本降低。这个数据第一次看到的时候我也怀疑,后来在我们自己的业务上验证,确实在合理区间——大约降了 60%-70% 的成本,同时用户感知到的质量没有明显下降。
路由策略通常分四个维度:
| 维度 | 路由依据 | 适用场景 | 典型实现 |
|---|---|---|---|
| 任务路由 | 请求类型(分类/摘要/生成/代码) | 任务类型可预判断的场景 | 规则引擎 + Prompt 分类器 |
| 成本路由 | 模型单价和预算配额 | 对成本敏感的大流量场景 | 预算阈值 + 分级模型池 |
| 延迟路由 | 模型响应时间和用户 SLO | 对响应速度有硬性要求 | 延迟监控 + 自动切换 |
| 质量路由 | 模型在特定任务上的评测分数 | 对输出质量有差异化要求 | Eval 评分 + 流量分配 |
这四个维度不是互斥的。生产系统通常会把它们组合使用——先按任务分类,再在同类任务中按成本和延迟做二次路由。实际落地时,我建议从任务路由开始。原因是任务类型的判断相对简单,效果也最直观。成本路由和延迟路由需要更精细的基础设施支撑——需要实时掌握每个模型的用量、单价和响应时间分布,这些数据的采集和聚合本身就是一项工程。质量路由是最高阶的形态,它依赖于一套持续运行的评测体系,能动态反映每个模型在特定任务上的实际表现。
另一个容易被忽略的问题是:路由决策本身也需要成本。每次请求多走一次分类判断,意味着多一次 API 调用和多几十毫秒延迟。这就是为什么分类器本身应该用最轻量的模型——如果分类器的成本超过了路由节省的钱,这套系统就没有意义。
AI Gateway 是承载这些路由逻辑的中间层。Portkey、LiteLLM、Kong AI Gateway、Cloudflare AI Gateway 是目前主流的四个选择。它们的定位各有侧重:
| 工具 | 部署方式 | 模型支持 | 核心优势 | 主要短板 |
|---|---|---|---|---|
| LiteLLM | 自托管 | 100+ provider | 开源免费,OpenAI 兼容接口 | 高负载下 P99 延迟可达 28s(500 RPS) |
| Portkey | SaaS / 自托管 | 200+ 模型 | 治理能力强,语义缓存,熔断器 | Pro 版 $3K-$6K/年 |
| Kong AI Gateway | 自托管 | 依赖插件 | 与 Kong 生态无缝集成 | 需要 Kong 网关基础 |
| Cloudflare AI Gateway | SaaS | 主流 provider | 边缘部署,延迟低 | 路由灵活性有限 |
如果团队想要快速上手且不介意托管费用,Portkey 是个省心的选择。如果更在意自主可控,LiteLLM 搭配自建基础设施是更实际的路线。
案例一:任务路由——简单任务不该用最强模型
我们那个客服系统最初的设计是这样的:
// 坏做法:所有任务都走同一个模型
async function handleCustomerMessage(message: string) {
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: message }],
})
return response.choices[0].message.content
}这段代码的问题一目了然:不管用户问的是「你们的退货政策是什么」还是「请帮我分析这段合同里的三个风险点」,都交给 GPT-4 处理。前者用一个微调过的小模型甚至检索增强就能搞定,后者才需要 GPT-4 级别的推理能力。
重构后的路由逻辑按任务类型分发:
// 好做法:按任务类型路由到不同模型
type TaskType = 'faq' | 'summary' | 'reasoning' | 'extraction'
// 第一步:用轻量分类器判断任务类型
async function classifyTask(message: string): Promise<TaskType> {
const response = await openai.chat.completions.create({
model: 'gpt-4o-mini', // 分类任务用小模型
messages: [
{
role: 'system',
content: '将用户消息分类为 faq/summary/reasoning/extraction 之一,只返回类别名',
},
{ role: 'user', content: message },
],
max_tokens: 10,
})
return response.choices[0].message.content.trim() as TaskType
}
// 第二步:根据任务类型选择模型
const MODEL_MAP: Record<TaskType, string> = {
faq: 'gpt-4o-mini', // 简单问答,成本最低
summary: 'claude-3-5-haiku', // 摘要任务,速度快
reasoning: 'gpt-4', // 复杂推理,需要强模型
extraction: 'gemini-2.0-flash', // 结构化抽取,性价比高
}
async function handleCustomerMessage(message: string) {
const taskType = await classifyTask(message)
const model = MODEL_MAP[taskType]
const response = await callModel(model, message)
return response
}这套路由上线后,成本结构发生了明显变化。大约 45% 的请求被路由到 gpt-4o-mini,30% 走 claude-3-5-haiku,只有 15% 需要 gpt-4。整体月度 API 费用从 $12,000 降到了 $4,500,用户满意度评分反而上升了 0.3 分——因为简单问题的响应速度快了三倍。
分类本身的额外开销大约是每次请求多花 50ms 和 $0.0001,相比路由带来的收益可以忽略。
这里有一个容易踩的坑:分类器本身的准确性。如果分类器把复杂推理误判为 FAQ,用户得到的回答会过于简略;反过来把 FAQ 误判为推理任务,只是多花了钱,体验不会有明显损失。所以在设计分类 prompt 时,我倾向于让它偏向保守——如果拿不准,就路由到更强的模型。多花一点钱,总比让用户看到一个答非所问的回答要好。
降级链:不是 Plan B,是 Plan A 的一部分
还是那个客服系统,任务路由解决了成本问题,但单点故障的问题还没有真正解决。
TianPan.co 的一篇分析文章提出了一个值得重视的观点:简单地增加备用供应商并不等于实现了冗余,反而可能让维护成本翻倍。这个警告是对的——降级不是加个 if-else 切换模型就完事了。
先看一个典型的错误降级设计:
// 坏做法:简单的 if-else 降级,没有区分故障类型
async function callWithFallback(prompt: string) {
try {
return await openai.chat.completions.create({
model: 'gpt-4',
messages: [{ role: 'user', content: prompt }],
})
} catch (error) {
// 不管什么错误都切换到 Anthropic,太粗暴了
return await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
messages: [{ role: 'user', content: prompt }],
})
}
}这段代码有三个致命问题:
- 不区分故障类型。内容安全过滤返回的 4xx 错误不是瞬时故障,重试或切换模型都没用。
- 没有重试和退避。遇到 429 限流时,直接切换到备用模型,但可能只是需要等几秒。
- 没有熔断机制。如果 OpenAI 持续不可用,每次请求都会先等超时再切换,用户体验极差。
正确的做法需要区分故障类型、实现指数退避重试、并加入熔断器:
// 好做法:带重试、退避和熔断的降级链
interface RetryConfig {
maxAttempts: number
baseDelayMs: number
maxDelayMs: number
}
// 故障分类:不同故障类型对应不同处理策略
type FailureType =
| 'retryable' // 5xx、超时、网络错误 → 重试
| 'rate_limited' // 429 → 尊重 Retry-After 头部
| 'content_filter' // 内容安全拒绝 → 不重试,走审核流程
| 'fatal' // 参数错误等 → 直接报错
function classifyFailure(status: number): FailureType {
if (status === 429) return 'rate_limited'
if (status >= 400 && status < 500) return 'content_filter'
if (status >= 500) return 'retryable'
return 'fatal'
}
// 指数退避 + 抖动:避免重试风暴
async function sleepWithJitter(attempt: number, base: number, cap: number) {
const delay = Math.min(cap, base * Math.pow(2, attempt))
const jitter = delay * (0.5 + Math.random() * 0.5)
await new Promise(resolve => setTimeout(resolve, jitter))
}
// 熔断器:防止持续请求已不可用的供应商
class CircuitBreaker {
private state: 'closed' | 'open' | 'half_open' = 'closed'
private failureCount = 0
private lastFailureTime = 0
constructor(
private threshold: number = 5,
private cooldownMs: number = 30_000,
) {}
canExecute(): boolean {
if (this.state === 'closed') return true
if (this.state === 'open') {
if (Date.now() - this.lastFailureTime > this.cooldownMs) {
this.state = 'half_open'
return true // 放一个请求试探
}
return false
}
return true // half_open 状态允许一个请求
}
recordSuccess() {
this.failureCount = 0
this.state = 'closed'
}
recordFailure() {
this.failureCount++
this.lastFailureTime = Date.now()
if (this.failureCount >= this.threshold) {
this.state = 'open'
}
}
}熔断器的效果很直接:一个已经挂掉的供应商,每次请求只会浪费一次快速拒绝的时间,而不是一整段超时等待。
有了这些基础设施,降级链的配置可以这样写:
# 声明式降级链配置:把策略从代码中分离出来
rules:
- id: primary-chat
type: priority-based-routing
when:
task: chat
targets:
- target: openai/gpt-4
priority: 0
retry:
max_attempts: 3
on_status_codes: [429, 500, 502, 503]
timeout_ms: 15000
circuit_breaker:
threshold: 5
cooldown_ms: 30000
- target: anthropic/claude-sonnet-4-20250514
priority: 1
retry:
max_attempts: 2
on_status_codes: [429, 500, 502, 503]
timeout_ms: 20000
- target: self-hosted/qwen-2.5-72b
priority: 2
timeout_ms: 30000TrueFoundry 的工程博客强调,降级策略应该作为网关层的关注点,而不是让每个业务服务各自实现。网关能看到所有供应商的状态、持有所有 API Key、统一 API 格式,是承载这些逻辑最自然的位置。
降级的完整决策流程
下面这个流程图展示了请求从进入到最终响应的完整路径:
这个流程有几个关键设计决策值得说明:
内容过滤拒绝不走降级。这是 TrueFoundry 博客中特别强调的一点——4xx 内容安全拒绝是请求本身的问题,不是供应商的瞬时故障。切换模型重试只会浪费资源,正确做法是走审核流程或返回明确的错误提示。
熔断器半开状态只放一个探测请求。这是在「快速恢复」和「避免雪崩」之间的折中。如果探测成功,熔断器关闭,流量恢复正常;如果失败,继续保持开启状态。
缓存兜底不是万能的。当所有供应商都不可用时,返回缓存结果是最后的降级手段,但要标注清楚这是缓存数据,时效性可能不够。
案例二:故障转移的隐藏陷阱——语义漂移
技术层面的故障转移可能完全成功——HTTP 200、延迟在 SLO 范围内——但语义层面的故障转移可能在不知情的情况下悄悄失败。
这是我们在切换 Anthropic 作为 GPT-4 备选时踩过的坑。某天 OpenAI 的 API 响应变慢,流量自动切到了 Claude。从监控面板上看,一切正常:成功率 99.8%,平均延迟 1.2 秒。但客服团队的反馈是:「机器人回话的风格变了,而且有些问题开始拒绝回答了。」
问题出在三个地方:
- Prompt 的隐式耦合。我们给 GPT-4 写的 system prompt 经过了三个月的调优,很多措辞是针对 GPT-4 的行为特点精调过的。Claude 对同样的 prompt 理解方式不同,特别是拒绝策略和格式化输出。
- 评测集不可迁移。我们的自动化评测里有一条规则是「回复不能包含『I cannot』」,这在 GPT-4 上测试通过,但 Claude 有自己的拒绝表达习惯。评测失败不是 Claude 的回答质量差,而是评测规则绑定了特定模型的行为。
- 灰度流量从未覆盖长尾请求。我们做过 A/B 测试,但测试集只有 200 条精心挑选的 case。真正出问题的是那些从未出现在测试集里的用户提问。
TianPan.co 的文章把这总结得很到位:「有效的故障转移是一种持续实践,不是一个配置开关。」
修复方案包含三步:
// 坏做法:直接拿主模型的 prompt 给备选模型用
const SHARED_PROMPT = `你是一个客服助手。
请用以下格式回答:
1. 先确认问题
2. 给出解决方案
3. 如果没有解决方案,说"I cannot help with this"
...`
// 好做法:为每个模型维护独立的 prompt 适配层
interface ModelPromptAdapter {
modelId: string
adaptPrompt(basePrompt: string): string
validateOutput(output: string): boolean
}
const adapters: Record<string, ModelPromptAdapter> = {
'gpt-4': {
modelId: 'gpt-4',
adaptPrompt: (base) => base, // GPT-4 用原始 prompt
validateOutput: (output) => !output.includes('I cannot'),
},
'claude-sonnet-4-20250514': {
modelId: 'claude-sonnet-4-20250514',
adaptPrompt: (base) =>
base
.replace('I cannot help with this', '我需要将您的问题转交人工客服')
.replace(/请严格按以下/g, '请尽量参考以下'), // Claude 对强制指令更敏感
validateOutput: (output) =>
output.length > 0 && !output.includes('I cannot'),
},
}除了 prompt 适配层,我们还引入了 Shadow Traffic 机制——在正常服务的同时,把一部分真实请求异步发送给备选模型,比较两者输出差异。这样在日常运行中就能发现语义漂移,而不是等到故障发生才暴露问题。
| 验证方式 | 触发时机 | 覆盖范围 | 能发现的问题 | 局限性 |
|---|---|---|---|---|
| 离线评测集 | 模型上线前 | 200-500 条精选 case | 基础功能回归 | 无法覆盖长尾 |
| Shadow Traffic | 日常持续运行 | 真实生产请求 | 语义漂移、格式变化 | 增加 API 成本 |
| 供应商切换演练 | 定期计划执行 | 全量流量子集 | 降级链完整性 | 需要协调窗口 |
| Provider-Parity 评测 | 每次 prompt 变更后 | 每个供应商独立运行 | prompt 耦合问题 | 维护成本翻倍 |
案例三:流式响应的降级——最难处理的情况
前两个案例都假设请求是同步的——发出请求、等待响应、返回结果。但很多场景需要流式响应(Server-Sent Events),这时降级变得复杂得多。
问题在于:如果 tokens 已经开始流式传输给用户了,无法透明地切换到另一个供应商。已经发送出去的 tokens 收不回来。
我们尝试过三种方案:
// 方案 A:服务端缓冲后统一发送(丢失流式的延迟优势)
async function bufferedFallback(request: Request) {
const fullResponse = await callPrimary(request) // 等待完整响应
if (!fullResponse) {
const fallbackResponse = await callSecondary(request) // 切换
return fallbackResponse // 一次性返回
}
return fullResponse
}
// 方案 B:先探测再流式(推荐的折中方案)
async function probeThenStream(request: Request) {
// 用非流式请求先探测供应商是否可用
const probe = await callPrimary({
...request,
max_tokens: 5, // 只生成几个 token 探测
stream: false,
})
if (probe.ok) {
// 探测成功,放心地开始流式传输
return streamFromPrimary(request)
}
// 探测失败,直接用备选模型流式
return streamFromSecondary(request)
}
// 方案 C:接受中途重启(需要 UX 配合)
async function streamWithRestart(request: Request, emitter: SSEEmitter) {
const stream = await streamFromPrimary(request)
let tokensSent = 0
for await (const chunk of stream) {
if (chunk.error && tokensSent > 0) {
// 已经发送了部分 tokens,无法透明切换
emitter.send({ type: 'restart', model: 'secondary' })
const fallbackStream = await streamFromSecondary(request)
for await (const fb of fallbackStream) {
emitter.send(fb)
}
return
}
emitter.send(chunk)
tokensSent++
}
}方案 B 是我们在生产中使用的方式。额外成本是每次请求多花约 100ms 和几个 token 的探测开销,换来的是流式传输的可靠性。方案 A 太简单但失去了流式的意义,方案 C 对用户体验的侵入性太大。
调参起点与持续优化
路由和降级策略中的参数没有「正确答案」,但有合理的起点。TrueFoundry 的工程博客提供了一组经过实战验证的初始值:
| 参数 | 推荐起点 | 调优依据 |
|---|---|---|
| 重试次数 | 2-3 次后触发降级 | 错误类型、路由 SLO |
| 退避策略 | 指数退避 + 随机抖动 | 供应商 429/5xx 行为特征 |
| 单跳超时 | 健康后端的 p95-p99 延迟 | 面向用户的延迟预算 |
| 熔断器开启条件 | 错误率窗口或连续 N 次失败 | 误开容忍度 |
| 半开探测量 | 1 个请求 | 供应商恢复模式 |
| 对冲延迟 | 接近 p95 延迟值 | 成本 vs p99 延迟目标 |
对冲请求(Hedged Requests)是一个值得单独说的技巧:在主请求发出后,如果超过 p95 延迟还没返回,同时向备选供应商发送相同请求,取先返回的结果。这能有效削减尾部延迟,但代价是部分请求会产生双倍费用。需要注意的是,对于有副作用的 Tool Calling 请求,对冲可能导致重复执行——这不是一个无脑适用的策略。
监控路由效果:不是一劳永逸的配置
路由策略上线只是起点。随着模型供应商的调整、新模型的发布和业务流量的变化,路由策略需要持续优化。我关注五个核心指标:
路由分布:各类任务被分配到哪些模型、各占多少比例。如果分布异常——比如 80% 的请求都路由到了最贵的模型——说明分类器或路由规则需要调整。
降级触发率:单位时间内触发降级链的请求占比。正常情况应该低于 5%。如果这个数字持续上升,说明主模型的稳定性在恶化,或者限流阈值需要调整。
降级后质量:降级到备选模型后的输出质量。前面提到的 Shadow Traffic 在这里派上用场——通过对比主模型和备选模型的输出差异,可以量化降级带来的质量损失。
成本趋势:按天/周跟踪 API 费用。路由系统上线后成本应该呈下降趋势。如果成本突然跳升,可能是流量结构变了,也可能是某个分类规则失效导致请求都涌向了高价模型。
故障恢复时间:从供应商故障发生到系统自动切换完成的耗时。这个指标直接反映熔断器和降级链的效率。
Portkey 的工程博客有一句话我很认同:「降级不应该是隐形的——即使对用户来说它是无感的,对运维团队来说它必须完全可见。」每一次重试、路由切换和降级都应该在日志和监控面板上留下清晰的痕迹。
上线前检查清单
路由和降级策略上线前,我通常会逐项检查以下内容:
路由设计阶段
- 已识别所有任务类型,每类任务有明确的模型匹配
- 成本模型已测算,预期节省比例有数据支撑
- 延迟预算已确定,每个路由目标的超时值已配置
- 新模型的输出格式与现有 schema 校验兼容
降级实现阶段
- 故障类型已分类(可重试 vs 不可重试 vs 内容过滤)
- 重试逻辑使用指数退避 + 抖动,不会制造雷群效应
- 熔断器已配置,连续失败后能快速跳过不可用供应商
- 降级链中每个备选模型都经过 prompt 适配和输出验证
- 所有供应商都不可用时有兜底方案(缓存/降级提示/转人工)
验证与演练阶段
- 每个备选模型都跑过 Provider-Parity 评测
- Shadow Traffic 已运行至少一周,未发现显著语义漂移
- 做过至少一次计划内的供应商切换演练
- 流式响应路径的降级方案已单独验证
- 监控面板能看到每次重试、路由切换和降级事件
参考资料
- Portkey, "How to Design a Reliable Fallback System for LLM Apps Using an AI Gateway", 2025. https://portkey.ai/blog/how-to-design-a-reliable-fallback-system-for-llm-apps-using-an-ai-gateway
- TrueFoundry, "LLM Failover & Load Balancing for Provider Outages", 2026. https://www.truefoundry.com/blog/llm-failover-load-balancing-provider-outages
- TianPan.co, "那个让你的故障面成倍增加的供应商故障转移方案", 2026. https://tianpan.co/zh/blog/2026-06-01-the-provider-failover-that-multiplied-your-incident-surface
- 百度 AI Studio, "模型路由框架深度解析:架构、模式与生产级实践", 2026. https://aistudio.baidu.com/blog/detail/760981231196613
- Braintrust, "Best LLM Routers and Model Routing Platforms in 2026". https://www.braintrust.dev/articles/best-llm-routers-2026
- Requesty, "Implementing Zero-Downtime LLM Architecture: Beyond Basic Fallbacks", 2025. https://www.requesty.ai/blog/implementing-zero-downtime-llm-architecture-beyond-basic-fallbacks
- LiteLLM Documentation. https://docs.litellm.ai/
- KGateway, "How Prioritized Model Load-Balancing Helps Keep Your AI-Powered App Running", 2025. https://kgateway.dev/blog/ai-gateway-load-balancing-model-failover/