LLM 路由与降级策略:不要把所有请求交给一个模型

0阅读13分钟

一个模型扛不住生产环境

去年我负责的一个 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)
PortkeySaaS / 自托管200+ 模型治理能力强,语义缓存,熔断器Pro 版 $3K-$6K/年
Kong AI Gateway自托管依赖插件与 Kong 生态无缝集成需要 Kong 网关基础
Cloudflare AI GatewaySaaS主流 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 }],
    })
  }
}

这段代码有三个致命问题:

  1. 不区分故障类型。内容安全过滤返回的 4xx 错误不是瞬时故障,重试或切换模型都没用。
  2. 没有重试和退避。遇到 429 限流时,直接切换到备用模型,但可能只是需要等几秒。
  3. 没有熔断机制。如果 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: 30000

TrueFoundry 的工程博客强调,降级策略应该作为网关层的关注点,而不是让每个业务服务各自实现。网关能看到所有供应商的状态、持有所有 API Key、统一 API 格式,是承载这些逻辑最自然的位置。

降级的完整决策流程

下面这个流程图展示了请求从进入到最终响应的完整路径:

流程图画布 · 115%
Mermaid 流程图加载中...

这个流程有几个关键设计决策值得说明:

内容过滤拒绝不走降级。这是 TrueFoundry 博客中特别强调的一点——4xx 内容安全拒绝是请求本身的问题,不是供应商的瞬时故障。切换模型重试只会浪费资源,正确做法是走审核流程或返回明确的错误提示。

熔断器半开状态只放一个探测请求。这是在「快速恢复」和「避免雪崩」之间的折中。如果探测成功,熔断器关闭,流量恢复正常;如果失败,继续保持开启状态。

缓存兜底不是万能的。当所有供应商都不可用时,返回缓存结果是最后的降级手段,但要标注清楚这是缓存数据,时效性可能不够。

案例二:故障转移的隐藏陷阱——语义漂移

技术层面的故障转移可能完全成功——HTTP 200、延迟在 SLO 范围内——但语义层面的故障转移可能在不知情的情况下悄悄失败。

这是我们在切换 Anthropic 作为 GPT-4 备选时踩过的坑。某天 OpenAI 的 API 响应变慢,流量自动切到了 Claude。从监控面板上看,一切正常:成功率 99.8%,平均延迟 1.2 秒。但客服团队的反馈是:「机器人回话的风格变了,而且有些问题开始拒绝回答了。」

问题出在三个地方:

  1. Prompt 的隐式耦合。我们给 GPT-4 写的 system prompt 经过了三个月的调优,很多措辞是针对 GPT-4 的行为特点精调过的。Claude 对同样的 prompt 理解方式不同,特别是拒绝策略和格式化输出。
  2. 评测集不可迁移。我们的自动化评测里有一条规则是「回复不能包含『I cannot』」,这在 GPT-4 上测试通过,但 Claude 有自己的拒绝表达习惯。评测失败不是 Claude 的回答质量差,而是评测规则绑定了特定模型的行为。
  3. 灰度流量从未覆盖长尾请求。我们做过 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 已运行至少一周,未发现显著语义漂移
  • 做过至少一次计划内的供应商切换演练
  • 流式响应路径的降级方案已单独验证
  • 监控面板能看到每次重试、路由切换和降级事件

参考资料

评论 0

0 / 1000