路由不是分类,是带约束的调度

先说一个被反复误用的词。很多团队上手多模型,第一件事是训练一个任务分类器:把用户输入分成"简单"和"困难",困难走大模型,简单走小模型。这套说法在 demo 里成立,在生产里几乎必然翻车,原因有三类,而每一类都指向同一个结论——路由的本质不是分类,是在一组硬约束下做调度。

第一类是目标错了。分类器只回答"这条请求属于哪一类",但生产环境真正要回答的是"在我现在能用的这批模型里,谁能用可接受的成本和可接受的时延,把这条请求做到及格"。前者是一个标签空间里的单点选择,后者是一个带预算、带 SLA、带供应商可用性约束的组合优化问题。两者在小规模下结论常常一致,一旦规模上去就分道扬镳。

第二类是忽略了能力非单调性。绝大多数人的心智模型是"模型越强越好",于是路由退化成一条按能力排序的线性规则。但现实中任务是多维的:一个中等模型在长结构化抽取上可能比旗舰模型更稳,一个小模型的多语言表现可能反超旗舰,一个被降智处理过的渠道模型在简单分类上完全不输。能力矩阵是多维且非单调的,这正是路由值得单独做一套系统的原因。

第三类是最致命的:路由决策的正确性无法被自身观测到。你永远不知道走了小模型的那条请求,如果走了大模型会不会更好。这直接导致所有"我们上路由之后省了 40% 成本"的说法都不可证伪。本文第九节专门处理这个问题,给出用 oracle 上界和 regret 曲线把路由效果变成可测量量的做法。

本文的推进顺序是工程上真实依赖的顺序:先把"到底在优化什么"定义清楚,再建分类器,然后是能力矩阵,接着把成本与时延变成硬约束,再做降级链和 cascade,最后解决怎么量化、怎么治理配置漂移。每个环节都会给出适用边界——路由系统有一半的工程量花在"什么时候不该做"上。

一、为什么 2026 年必须做路由:单模型的四个天花板

1.1 能力天花板:平均分掩盖了长尾

旗舰模型的评测分数是一条被平均掉的曲线。你在榜单上看到的是它在数百道题上的平均表现,而你的线上流量是一条完全不同的分布:大量请求是模板化的分类与抽取,少数请求是需要多步推理的复杂决策,还有更少的一批是长文档加图表加跨段落关联的复合任务。这三类请求对模型的要求方向不同,平均分会掩盖"某一类上旗舰反而输给专用小模型"的情况。

更现实的问题是失败成本的不对称。同样是结构化抽取失败,一条走错了会导致下游数据污染、触发人工回滚;而一句闲聊答得不够优雅,用户根本不会注意到。把预算平均分配给所有请求,等于用最贵的模型去解决最不重要的问题。路由要做的第一件事,是把"这条请求失败的后果有多大"显式地编码进决策。

1.2 经济天花板:单价下降不等于账单下降

过去三年模型单价一直在降,但很多团队的推理账单没有等比例下降,甚至在涨。原因几乎总是同一个:降价让调用变得更便宜,于是调用量涨得更快,尤其是模型能力提升之后,原本"人工做/规则做/不做"的场景被打开了。这类效应在企业内网里尤其明显——一个原本用正则匹配处理的意图识别,换成小模型之后准确率上去了,业务方把所有模糊场景都接了过来。

另一个常被忽略的成本项是重试与返工。一次失败的大模型调用加上一次重试,成本可能已经超过三次正确的小模型调用。如果失败率是 8%,你省下的钱要先扣掉这部分损耗才是净收益。任何成本核算只统计"首次调用的 token 价",都会系统性地高估路由收益。

1.3 时延天花板:排队与首 token 延迟

端到端时延可以粗略拆成网络与排队、TTFT(首 token 延迟)、以及按输出 token 数累积的解码时间。路由在这三段上都有影响:换到更大的模型,TTFT 和单 token 解码时间同时上升;换到更小的模型则相反。更微妙的是排队项——同一个模型在低负载下 P99 可能 800 毫秒,在峰值负载下排队就能到十几秒,而路由系统如果只看平均时延,就会持续把流量推向已经过载的渠道。

所以路由必须用分位数而不是均值作为决策依据,而且必须把渠道当前负载当成一个实时输入。一个只在离线能力表上做选择、完全不看实时拥塞的路由器,在流量高峰时会同时踩中时延和错误率两个坑。

1.4 供给天花板:限流、区域可用与渠道差异

2026 年的模型供给格局是:单一供应商不再能覆盖全部需求,同时每个供应商都有严格的速率限制与区域性配额。这意味着"多模型"从一个优化选项变成了可用性基建——你需要至少两个在能力上可互相替代的渠道,否则任何一次限流或区域性故障都会直接变成用户可见的事故。

而这恰恰是多数团队第一次认真做路由的起点:不是因为省钱,是因为上个月某家供应商在你们的主力区域连续降级了两周。由此可见,供给冗余是路由最刚性的需求,成本优化是它顺带的收益。把顺序搞反的话,很容易做出一套只能省钱、不能兜底的脆弱系统。

1.5 四个天花板里,通常是哪一个先咬你

四个天花板的触发顺序并不相同,可以用一个粗略的判据排序。供给天花板通常最先触发,因为供应商侧的限流、区域性降级、突发的容量紧张是无法预测的,而且一旦触发就是硬故障。很多团队是在被限流两次之后才下定决心做路由的,这时候架构里往往已经有大量硬编码的模型名,改造代价远比一开始就设计成别名要大。

时延天花板紧随其后。它的表现形式比较隐蔽:不是平均时延变差,而是某个时间段、某类请求、某个区域的 P99 突然拉高,而监控上看起来"总体正常"。这类问题之所以难查,正是因为它跟流量的构成有关而不是跟总量有关,因此只有把路由决策按任务族切开展示才看得见。

能力天花板是缓慢显现的。它不会哪天突然爆炸,而是随着业务功能迭代一点点侵蚀:新场景上线时默认用了旗舰模型,因为"只有旗舰能搞定",久而久之旗舰调用量翻了十倍,而其中大部分流量其实和当初一样简单。

经济天花板则常常是最后一个、也最隐蔽的。它表现为"账单涨了但说不清为什么",因为账单上涨往往来自三个方向的叠加——新功能带来的新流量、单请求输出长度增长、以及重试率上升。把这三者分开归因,是把成本优化做成体系而不是打补丁的前提。

这个顺序有一个直接的实践含义:不要等四个天花板都出现了才开始建设路由系统。先建别名抽象和决策日志(成本很低),它让前三个天花板变成可观测事件;能力矩阵和路由策略可以等数据积累够了再上。前置成本极低、收益立即兑现的一步,值得单独做。

二、先把问题定义清楚:你到底在优化哪一个量

在写任何代码之前,需要一条明确的优化目标。建议把单次请求的价值定义成一个可比较的量,而不是多个口径的混合体。最实用的形式是把"质量"折算成货币:

一次请求的净价值 = 业务收益 − (token 成本 + 时延惩罚 + 失败重试成本 + 人工介入成本)

这个式子里每一项都可测量、可归因,也都能通过 OpenTelemetry 的 GenAI 语义约定采集到统一的 span 上(gen_ai.request.model、gen_ai.usage.input_tokens、gen_ai.usage.output_tokens 都是标准字段)。有了这个统一口径,"省钱"和"提质"才第一次变成同一件事的两面——否则成本优化和效果优化会在评审会上互相打架。

具体到路由决策,通常是一个带约束的最大化问题,而不是单目标打分:

  • 硬约束:时延 P99 上限、预算上限、必须使用某区域的数据合规要求、必须满足的结构化输出契约;
  • 软目标:在满足硬约束的前提下,最大化质量分或最小化单位成功请求的成本;
  • 次级目标:质量接近时,优先选负载更低、缓存命中率更高、时延更稳的渠道。

这个定义方式有一个直接好处:它让"降级"变得自然。降级不是系统故障时的应急动作,而是优化问题在预算收紧时的最优解。当你把预算从 0.02 美元降到 0.005 美元,最优策略会自动从旗舰切到小模型,而不是需要一个独立的降级开关。反过来,"平时小模型、故障时大模型"这种设计往往是反直觉的——故障时应该先降级保可用,而不是先升级保质量,这一点后面会展开。

三、任务分类器:三条路线与各自的失效模式

3.1 规则与关键词:零成本,但只能覆盖头部

最简单的路线是规则:长度阈值、是否包含代码块、是否命中业务关键词、是否带图片附件、是否来自某个高价值客户。这类规则的优点是零推理成本、完全可解释、零冷启动,在很多场景下能覆盖 30% 到 50% 的流量,而且这部分流量的路由决策几乎不会出错。

它的天花板也很明确:只能处理"表面特征"与"难度相关"的任务。一旦难度与表面特征解耦,规则就会失效。典型反例是简短但需要多步推理的请求("这三个方案哪个更优",三个方案在附件里),以及冗长但机械的任务(一份一百万字的日志里找特定模式)。长度是最容易被误用的启发式:长不等于难,短不等于易。

实践上的建议是把规则放在路由链的第一层,但不要把它当作分类器的主体。它的真正价值是给后面的学习式分类器提供免调度的直通路径,并且在学习式分类器置信度低时充当兜底。

3.2 嵌入相似度路由:最实用的默认起点

如果你只想实现一种分类器,选嵌入相似度。它把"这条请求像不像那些已知需要用某模型的请求"变成一次向量检索,用一个小 embedding 模型(几百 MB,CPU 毫秒级)就能跑通。这类方法的优势是不需要维护标签体系——标签来自历史样本本身,即"这条请求当时是被哪个模型正确处理的"。

# kNN 风格路由:用历史请求的“正确模型”作为弱标签,
# 把新请求投到最近的若干条历史上做加权投票。
# 关键前提:向量已 L2 归一化,内积等价于余弦相似度。

import numpy as np

VECTOR_INDEX = "hnsw"          # 服务端用 HNSW,客户端持有同一份 embedding 模型
TOP_K = 12                      # 投票邻居数:太小方差大,太大抹平难度边界

def route(query: str, ctx: dict) -> str:
    q = embed_client.encode(query, normalize_embeddings=True)

    # 只在“同任务族”的候选集内检索,避免跨域相似度污染
    candidates = index.search(
        q, k=TOP_K,
        filter={"tenant_id": ctx["tenant"], "locale": ctx["locale"]},
    )

    votes = {}
    for sim, item in zip(candidates.scores, candidates.items):
        if sim < 0.55:                       # 相似度太低:交给下游回退逻辑
            continue
        w = float(sim) ** 3                   # 锐化:让最相似的邻居主导投票
        votes[item.model_id] = votes.get(item.model_id, 0.0) + w

    if not votes:
        return ALIAS_DEFAULT                   # 明确回退,而不是随机挑一个
    return max(votes.items(), key=lambda kv: kv[1])[0]

这套路线的三个关键工程细节经常被漏掉。一是必须做过滤检索:不同租户、不同语言、不同业务线的相似度不可比,混在一起投票会把一个中文客服请求路由到英文代码模型上。二是相似度阈值不能省:kNN 天然倾向于给出答案,哪怕最近邻也毫不相关;没有阈值就会出现"所有请求都被路由到历史上出现最多的模型"这种退化行为,这在分布长尾的系统里几乎必然发生。三是 embedding 模型本身是版本化的资产,换了版本就必须重建索引,历史标签的可比性随之失效——这一点在第十节还会展开。

3.3 LLM-as-a-router:把判断交给一个更聪明的模型

这条路线的逻辑很直接:既然选模型本身是一个推理任务,那就用一个便宜但足够聪明的模型来做判断。实践中通常是几百亿参数以下的小模型,加一段结构化的路由提示词,让它输出一个难度等级或能力需求标签,再由确定性代码映射到具体模型。

它的最大优点是可解释且能处理语义:你可以要求它先说出"这条请求需要跨文档推理"再给标签,从而拿到判断理由做审计。它的最大缺点也很直接:它自己是一次推理调用,时延和成本都要计入总账。如果它比目标小模型还贵,或者它带来的分类收益被它自身的开销吃掉,这条路线就不成立。经验上它更适合决策价值高、请求量中等的场景,例如合同审查、复杂工单分诊,而不是高 QPS 的分类接口。

还有一个反直觉但重要的发现:LLM 路由器的判断在小模型与大模型输出差异明显的样本上才准。如果两个模型在某类任务上表现接近,路由器再怎么"聪明"也区分不出该用谁——因为这条请求确实谁做都行,这时候路由的收益本就不存在。所以不要把力气花在提高分类准确率上,先确认"这类任务上不同模型的差距是否足够大"。这是一个被严重低估的前置判断。

3.4 训练型路由器:从偏好数据里学一个决策边界

这条路线的代表思路是用人类偏好数据训练一个轻量判别器,直接预测"更强模型是否更好"。它的思想来源是成对偏好比较:给定同一个提示的大模型与小模型输出,让标注者或强模型判断哪个更好;如果强弱模型的偏好分布存在一个稳定的分界点,就把这个分界点学成一个分类器,直接输出"升级概率"。相比规则和 kNN,它的优势是能吃进比离散标签丰富得多的连续信号。

但这类方法有三个前提必须成立,缺一个就会失败。一是必须有覆盖两个候选模型在同一批提示上的成对输出,这意味着前期要付出一笔不小的双倍调用成本。二是偏好数据必须覆盖你的真实流量分布,否则学到的是一个在通用问答上很准、在你的垂直任务上很偏的边界。三是升级概率阈值必须与成本模型联动:升级的期望收益是 P(升级正确) × Δ质量 − 额外成本 − 额外时延,概率阈值不是一个固定的 0.5,而是这个不等式的解。

训练型路由器最漂亮的副产品是它给出的连续置信度。这个概率可以自然地驱动 cascade 策略(第八节)、预算约束(第六节)和灰度发布(第十节),所以即便你先用简单的 kNN 起步,也建议从第一天起就把路由输出设计成一个带置信度的分布而不是一个硬标签——这是最容易被推迟、也最难追补的接口决策。

3.5 线上不能只有一个信号:分层与回退

最后是分层。任何成熟的路由器都是"便宜信号在前、昂贵信号在后"的级联结构:规则直通(零成本)→ 嵌入相似度(毫秒级)→ 训练型路由器(亚毫秒)→ LLM 判别(一次小模型调用)。低置信度的请求逐层上浮,而不是一上来就打最贵的那一枪。

回退路径必须显式声明而不是隐式兜底。一个常见的事故是:分类器抛异常 → 客户端库默认重试 → 重试打到默认旗舰模型 → 当晚成本曲线出现一个无法解释的尖峰。把回退写成配置里的一条显式规则,并给它单独的成本告警,比什么都重要。

3.6 多标签路由:一次请求往往同时需要多种能力

前面所有讨论都默认了一个"单标签单模型"的形态:判断一次请求属于哪一类,然后选一个模型。真实业务里这个假设经常不成立。一个典型的例子是一个"读附件里的合同然后生成修改建议"的请求,它同时需要长上下文(合同很长)、结构化输出(修改建议要落成字段)、领域准确率(法律术语不能错)和一定的推理深度。这四项需求的最优解未必来自同一个模型——很可能长上下文要换一个模型,结构化约束要换一个模型,最终生成再用第三个。

处理这种复合需求有两条路线。路线一是能力打分后选 Pareto 最优的单一模型,也就是要求一个模型同时满足多项能力,实现简单,代价是可能找不到这样的模型,导致每次都在某一项上妥协。路线二是把请求拆成流水线——抽取环节用长上下文加结构化输出的模型,推理环节用推理强的模型,校验环节用便宜的小模型。路线二的收益上限更高,但复杂度也高一个量级:它引入了中间结果的传递问题(抽取阶段的错误会污染后续所有环节)和错误累积问题。

一个务实的中间形态是只拆一到两段:找出请求中"格式要求最强"的那一段(比如必须输出严格 JSON 的抽取),单独用专门优化的模型处理,其余部分保持单次调用。这个折中的收益通常能覆盖复杂度增量,因为它只切分了最确定的那一段,而最确定的切分点恰好也是最容易评测、最容易归因的那一段。

需要提醒的是,多标签路由会让评估变难:标签从一维变多维,路由器的错误率不再是单一数字,任务族之间的边界也会因为组合而变得模糊。实践上的处理办法是不追求多标签的完整表达,而是只保留"是否存在某个特殊硬需求"这类稀疏的、可以用规则直接检出的信号——比如"需要长上下文"、"需要看图"、"必须输出 JSON"、"超过两万 token"。这些信号检测准确率高、解释性强,而且它们恰恰是最容易造成"选错模型"的那些维度。

四、冷启动:从两百条样本到一个能上线的分类器

4.1 先用结果反推标签,不要先设计分类体系

冷启动最常见的错误路径是:先和业务方坐下来设计一个五层的任务分类体系,再按体系去标数据。问题是这套体系在纸面上无懈可击,落标时你会立刻发现大量样本无法归类,最终要么硬塞要么丢弃。更可行的路径是反过来:先用现有系统跑一段时间,从结果里观察哪些样本上不同模型的表现差异最大,只对这些差异明显的区域做聚类,得到一个数据驱动的任务划分。

具体做法是采样一批真实请求(几百到几千条即可起步),用两到三个候选模型各跑一遍,记录质量分与成本。这一步同时给你三样东西:任务簇、簇内模型差距、以及一份可以反复使用的回归基线。它的成本是一次性的双倍调用,通常在正式上线前就能被后来的节省覆盖掉。

这一步还有一个常被忽略的价值:它能告诉你路由这件事本身值不值得做。如果在你的流量分布上,头部和尾部模型的单位成本差只有 1.5 倍,而质量差距主要来自提示词而不是模型,那么路由的收益上限就被锁死了,这时候把精力投在提示词工程和约束解码上回报更高。做这个判断的最好时机就是冷启动,而不是上线三个月后。

4.2 影子流量与主动学习:把评测和标注绑在一起

冷启动期的标注预算极其有限,所以要把每一次标注都花在刀刃上。影子流量是最有效的手段:线上请求照常按现有策略执行,同时额外调用一个"对照模型",记录两者差异。差异大的样本自动进入待标注队列,差异小的直接跳过。这本质上是把主动学习的不确定性采样用在生产系统里。

# 影子评估 + 主动学习队列:把标注预算集中在决策边界附近
def on_request(req, primary_result):
    # 1) 只在“可能有信息量”的请求上付出对照调用的成本
    if request_value(req) < SHADOW_MIN_VALUE:      # 低价值请求不值得花对照成本
        return primary_result

    alt = call(alias=SHADOW_ALIAS, request=req)     # 通常是比 primary 更强的模型

    # 2) 用便宜的判据估计两者差距,而不是每次都人工确认
    gap = judge(alt.text, primary_result.text)

    # 3) 只有边界附近的样本才消耗人工标注预算
    if UNCERTAIN_LO <= gap <= UNCERTAIN_HI:
        labeling_queue.push(req, primary_result, alt, gap)
    # 4) 但无论是否标注,决策日志必须落盘(第十节要用)
    decision_log.append(req, alias=SHADOW_ALIAS, gap=gap)
    return primary_result

这条链路有个重要性质:它同时生产了标注数据和路由效果证据。第 4 步落盘的决策日志,加上对照调用结果,构成了一个可以离线重放的评测集。三个月后你换分类器、换模型、调阈值,都可以直接在这份日志上重放比较,而不需要重新采样。这是路由系统里投入产出比最高的一块基建,没有之一。

4.3 冷启动期最容易犯的三个错

  • 用开发集分布训练,用线上分布上线。最典型的表现是离线分类准确率 90%,线上却大量走错。根因通常不是过拟合,而是分布本身不同:线上有更长的输入、更奇怪的多轮历史、以及被上游截断过的上下文。
  • 忽略"正确"的定义。什么叫这条请求"处理得好",是格式正确就算,还是事实正确才算,还是业务验收通过才算?冷启动期不定义清楚,后面所有的量化都会失去意义(第九节会再讲)。这个定义应该是业务方的验收标准,而不是你的直觉。
  • 过早上线复杂路由器。一个用 200 条样本训练的三分类器,复杂度远超用 3000 条样本做的 kNN。样本量不足时,kNN + 规则几乎是性价比最高的组合。

五、能力矩阵:怎么维护才不至于腐化

5.1 矩阵应该有哪些维度

能力矩阵是把"选哪个"这个问题变成数据查表的前提。它容易腐化的根本原因是:只记录能力而不记录适用范围、证据强度和有效期。一个可用的矩阵至少需要下面这些维度,并且每一条都带元信息:

维度作用元信息要求
任务族矩阵的行,通常 8 到 20 个,来自 4.1 的数据驱动聚类定义、上次验证时间、样本量
质量分在该任务族上的相对表现,允许带置信区间评测集版本、评测时间、是否有显著性差异
成本画像输入/输出 token 单价、典型长度分布、缓存命中率假设价格版本、生效时间(降价要打时间戳)
时延画像TTFT 与 TPOT 的分位数,含典型输入长度下的值测量区域、测量时间、负载条件
约束能力上下文窗口、最大输出、结构化输出、工具调用、多模态、批处理支持供应商文档版本或实测验证日期
供给属性速率限制、区域可用性、模型别名稳定性、降级通道配额档位、SLA 条款、生效时间
合规属性数据保留策略、训练使用条款、区域合规等级合同版本、复核日期

5.2 从打分表到能力指纹

传统做法是维护一张人工打分表,运营同学每季度更新一次。这种表的问题不是不准,而是它的更新周期比现实慢一个数量级:模型小版本更新、价格调整、区域故障、供应商策略变更,发生的频率都是周级甚至日级。

更可持续的做法是把矩阵拆成能力指纹(capability probe)——一组自动化的、极小的探测请求,针对矩阵里的每个属性各打一票。上下文窗口用一次超长输入触发截断来验证,结构化输出用一个严格的 JSON Schema 契约来验证,多模态发一张图看能不能引用,速率限制从 429 的频率里推断。这些探测可以挂在定时任务上,产出的是带时间戳的能力快照,而不是一个静态表。

关键设计是探测与评测分离:探测验证的是"能不能"(能力边界,硬事实,容易自动化),评测衡量的是"好不好"(质量分,需要业务标注,更新更慢)。很多团队把两者混在一张表里,结果是每次想更新价格也要重跑一遍质量评测,矩阵从此停止更新。把两类数据分开存储,是让矩阵长期存活的前提。

5.3 腐化的四种形态与检测手段

矩阵腐化不会以"明显错误"的形式出现,它有四种隐蔽形态。

  • 供应商静默变更:小版本更新、区域部署配置调整、默认采样参数变化。对策是在关键任务族上保留一个金标准回归集(几十条固定样本,每次变更自动跑一遍),质量分跌幅超过阈值就告警。
  • 价格与实际用量脱节:矩阵里的 token 单价是对的,但你的实际平均输出长度在变(提示词迭代、few-shot 增加)。对策是成本画像存实测分位数而不是理论值,并且把成本归因打回业务功能上。
  • 任务分布漂移:新功能上线带来新的请求类型,矩阵里根本没有这一行。表现是路由决策分布里出现了矩阵覆盖之外的新簇。对策是对决策日志做聚类监控,出现新簇且规模超过阈值就触发矩阵补录(这也是第十节漂移治理的一部分)。
  • 过期未标记:一条三个月没被任何决策命中的能力记录,看起来和昨天验证过的一样可信。对策是给每行加 TTL,过期行在决策时直接跳过并触发复评,而不是继续用。

5.4 矩阵的运行时载体:别名与能力集

矩阵最终必须落到一个运行时可查的结构上。推荐的做法是两级抽象:上层是能力别名(业务只认识它),下层是具体模型与渠道(由配置决定)。业务代码请求"一个能读长文档并且能稳定输出 JSON 的中等成本模型",而不是"某个具体型号"。

# 能力别名 -> 能力集 -> 具体渠道。别名是路由的寻址单位,不是模型名。
CAPABILITY = {
    'fast_chat':    {'max_in': 128_000, 'structured': True,  'tier': 'cheap'},
    'deep_reason':  {'max_in': 200_000, 'structured': True,  'tier': 'frontier'},
    'vision_doc':   {'max_in': 32_000,  'vision': True,      'tier': 'mid'},
    'long_extract': {'max_in': 400_000, 'structured': True,  'tier': 'mid'},
}

# 一个别名可绑定多个渠道:能力相同,供给与价格不同,由路由实时挑选
ALIAS_BINDING = {
    'fast_chat':   ['vendor-a/mini-2', 'vendor-b/flash-2', 'vendor-c/turbo'],
    'deep_reason': ['vendor-a/frontier', 'vendor-b/reasoner'],
}

def candidates(alias, ctx):
    """查能力集 -> 过滤硬约束 -> 再交给策略层按质量/成本/负载打分排序。"""
    cap = CAPABILITY[alias]
    for c in ALIAS_BINDING[alias]:
        if c.max_input_tokens < ctx.tokens:      # 上下文窗口不够,直接出局
            continue
        if not ctx.allowed_region(c.region):       # 合规硬约束
            continue
        yield c

这层抽象带来三个直接收益:换供应商时业务代码零改动;同一个能力可以有多个渠道做故障转移;成本与时延的策略层和具体模型解耦,可以独立演进。它的代价是一次额外的解析开销,以及"业务方不知道自己实际用了哪个模型"带来的排查难度——后者靠决策日志里的完整链路来补,第十节会讲。

5.5 人工覆盖:必要,但必须带失效期

再完善的自动流程也会遇到需要立刻人工干预的时刻:某个模型出了临时故障需要临时封禁、某次业务活动需要把某个别名整体切到最高配置、某家供应商出了安全问题需要立刻下架。能力矩阵必须支持这种人工覆盖,否则唯一的选择就是改代码发版本,而那需要几小时到几天。

但人工覆盖是路由系统腐化的头号来源,因为它绕过了所有自动校验。所以覆盖项必须遵守三条纪律:每一条覆盖都要有负责人和原因(否则没人知道为什么这个模型被封了,也没人会去解封);每一条覆盖都要有过期时间(临时封禁在故障恢复后应该自动失效,而不是永远留在配置里);每一条覆盖都要在决策日志里可追溯(否则出了问题无法区分是策略决策还是人工干预)。

还有一条经验:人工覆盖的次数本身是一个健康指标。如果某个别名每个月都被人工干预好几次,说明它的自动决策策略和现实差距很大,应该回头修策略而不是继续加覆盖项。一个覆盖项数量持续增长的矩阵,是在用运维手段掩盖建模问题。

六、把成本与时延预算变成硬约束

前面说过,降级在优化框架里是自然结果。要让这件事真正发生,预算必须是一个一等公民,而不是散落在各个业务方配置里的注释。预算至少有三层。

def resolve_plan(request, budgets, matrix):
    # 1) 先按硬约束过滤:上下文窗口、合规、速率限制、实时负载
    pool = [c for c in matrix.candidates(request)
            if c.region_allowed(request)
            and not c.rate_limited(now)
            and c.load_p95() < budgets.max_queue_ms]

    # 2) 在候选里按“净价值”排序,而不是按单价排序
    def net_value(c):
        expected_cost = estimate_cost(c, request)         # 含缓存命中与典型长度
        p_success   = matrix.success_rate(c, request.task_family)
        retry_cost  = (1 - p_success) * expected_cost * RETRY_FACTOR
        latency_penalty = c.p99_ms(request) / budgets.ms_value_ratio
        return (p_success * request.value) - (expected_cost + retry_cost + latency_penalty)

    ordered = sorted(pool, key=net_value, reverse=True)

    # 3) 预算不够时,不是硬切,而是从次优解里挑“质量损失最小”的那个
    for c in ordered:
        est = estimate_cost(c, request)
        if est <= budgets.per_request:
            return Plan(model=c, mode='primary', est_cost=est)
    return Plan(model=ordered[-1], mode='degraded',
                est_cost=estimate_cost(ordered[-1], request))

这段代码里最值得注意的是排序函数用的是净价值而不是单价。用单价排序的系统会系统性地偏爱便宜的模型,而忽略成功率差异带来的重试成本;用净价值排序则会在"便宜但只有六成成功率"的渠道和"贵一点但九成五成功率"的渠道之间做出正确选择。这一个改动通常就能把路由的实际收益拉回到设计预期附近。

6.1 预算的三层:请求级、会话级、日/月级

请求级预算是最容易落地的:单次调用允许花多少钱、直接映射到 max_tokens 与模型选择。适合无状态接口。会话级预算处理多轮与 Agent 场景:一个任务可以调用二十次模型,总预算是固定的,此时路由器要做的是分配——把预算集中到关键步骤(比如先花 60% 在检索与规划,再留 40% 在最终生成),而不是每步平均。这套思路和测试时计算扩展里的预算分配是同一个问题。

日/月级预算是组织约束,它需要配额分配和超额处置策略。实践中的一个反模式是:把日预算直接按请求量线性摊给各业务方,结果低价值流量和高价值流量拿到同样的额度。更好的做法是给每个业务方独立的每日额度,额度用不完可以结转,让预算自然流向真正在用的地方。

6.2 超预算时,先砍什么

预算耗尽时不要简单地拒绝服务,也不要简单地降级到最便宜的模型。正确的顺序是先砍输出长度,再砍推理深度,最后才换模型。理由是:输出长度对质量的边际损失通常最平缓(用户少看一句话的损失,小于答案错一句话的损失),推理深度(思考步数、采样次数)在中高价值请求上损失明显,而换模型是最粗暴的手段。

还有一个常被忽略的预算项是上下文预算。长对话场景里,输入 token 的增长是二次的:每一轮都把完整历史重发一遍。如果不做上下文裁剪或前缀缓存复用,你会看到账单随轮次非线性增长,而此时"换更便宜的模型"完全无效。输入侧的成本优化和模型路由是两件事,但必须放在同一个预算看板里看。

6.3 把业务价值换算成预算:唯一必须产品参与的一步

前面所有的预算参数,最终都要落到一个问题上:一次正确的回答值多少钱。这不是技术问题,但它决定了整个路由系统的优化方向,绕不过去。如果答案是"一次客服应答的价值大约等于一次人工处理成本的百分之一",那预算上限就非常明确,路由策略会自然地偏向低成本方案;如果一个漏判的代价是百万级的合同损失,那预算会宽到几乎不构成约束,策略会偏向质量优先。

实操中比精确数值更实用的是分档:把请求按业务价值分成三到五档,每档给一个预算区间和时延区间。分档的好处是它足够粗糙因而稳定,可以由产品同学直接拍定,也足够有用——路由系统只需要在档位之间做选择,而不需要处理连续的价值信号。绝大多数团队应该从这里开始,而不是试图建立一个精确的价值回归模型,那个模型需要的数据量和维护成本通常远超收益。

第二件必须产品参与的事是明确降级的可见性要求。同样是"答案变短了",在闲聊场景里无所谓的降级,在法律文书场景里可能是不可接受的。这个要求只能由业务方给出。它的实现成本很低——响应里带一个标记,UI 上决定要不要展示——但如果没提前说清楚,往往会在上线后以"用户投诉降级答案太短"的形式爆回来。

第三件事是给"不确定"定价。当路由系统的置信度不足以判断该不该升级时,系统的默认行为应该是什么?保守做法是升级(保质量、牺牲成本),激进做法是放行(保成本、牺牲质量)。这个选择没有技术上的正确答案,取决于业务对两类错误的相对容忍度,需要显式声明并配置,而不是留给实现者的默认分支——后者通常恰好是最保守的那个,于是系统在没有产品决策的情况下自动选择了高成本路径。

七、降级链与故障转移:顺序决定成败

7.1 降级不是"换一个更差的模型"

降级链最常见的错误设计是一条按能力递减排列的模型列表,出问题就顺着往下走。这个设计在实验室里成立,在生产里会以三种方式失效:

  • 语义不等价:从"能读 40 万 token"降到"能读 12 万 token",用户得到的是一个被静默截断的答案,这比明确报错更糟。降级的每一步都必须语义等价,否则它是欺骗而不是降级。
  • 延迟叠加:一串三层降级全部失败,总时延是三者之和加上重试退避,完全可能突破客户端超时。降级链必须有独立的整体时限,而不是每层各算各的。
  • 级联雪崩:主渠道抖动 → 全量流量涌入备渠道 → 备渠道被打挂。降级链本身会成为故障放大器。

更稳的设计是按故障类型分别设计路径,而不是一条通用链:

# 降级路径按“请求形态 + 故障类型”分别声明,而不是一条通用瀑布。
PATHS = {
    # 首选语义等价替换:换渠道、换区域,能力不变
    'soft': [('channel', 'next_best_channel'), ('region', 'other_region')],
    # 能力收缩:必须同时收紧任务范围,避免静默截断
    'shrink': [('reduce_scope', 'drop_optional_refs'),
               ('reduce_output', 'cap_tokens_to_512')],
    # 最后才降级到规则/模板,这类答案必须在 UI 上明确标记
    'terminal': [('deterministic', 'rule_based_extract'),
                 ('user_visible', 'ask_user_clarify')],
}

HARD_DEADLINE = {'total_ms': 3000, 'per_hop_ms': 700}

def run(req, node):
    path = PATHS[node.kind]
    for hop in path:
        if elapsed() > HARD_DEADLINE['total_ms']:
            break                      # 整体时限优先于“按计划走完降级链”
        try:
            return invoke(hop, req)    # 每个 hop 自身也有超时与取消
        except Retryable as e:
            observe(hop, e)            # 失败计入该 hop 的健康度
    return Terminal(user_visible_fallback(req))

7.2 故障转移:区分可重试与不可重试

故障转移的第一条纪律是:先判断错误类别,再决定动作。可以直接归成三类,每类的处置完全不同。

  • 可重试且幂等:429 限流、连接超时、5xx。处置是换渠道 + 指数退避,注意退避要在渠道维度而不是全局维度,否则一个渠道的限流会拖慢全部流量。
  • 不可重试:上下文超限、内容策略拒绝、参数非法。处置是立即走降级,绝不重试——这类错误重试一百次也是同样的结果,而每一次重试都在烧钱。
  • 可重试但不幂等:工具调用、写操作、带外部副作用的 Agent 步骤。处置是换供应商执行且必须携带幂等键,否则一次"超时其实成功了"的调用会造成重复副作用。

第三类是最容易被忽略的。跨供应商故障转移意味着同一次业务操作可能由两个不同的模型执行,它们的工具调用约定、参数风格、输出格式都不一样。解决办法是把工具调用的协议固定在网关层,让所有候选模型都必须遵守同一份契约,而不是相信每个模型原生就兼容你的工具定义。这也是统一网关(LiteLLM、Portkey、Envoy AI Gateway 这类)真正的价值所在——它们的核心贡献不是省几行代码,而是给跨供应商转移提供了语义对齐的公共层。

7.3 顺序设计的四条原则

  1. 等价优先于降级:任何一步降级之前,先把所有能力等价的渠道试完。换渠道不影响质量,不该被当成降级。
  2. 缓存优先于模型:语义缓存或结果缓存命中是一次零成本零时延的"最优模型"。它应该排在链的最前面。前提是缓存的相似度阈值足够保守,并且要能区分"完全相同"与"仅仅相似"——错误的缓存命中在某些任务上比降级更危险。
  3. 快失败优于慢失败:健康度检查与熔断应该基于滑动窗口的错误率和 P99 时延,熔断恢复用半开探测而不是固定冷却时间。固定冷却期在流量波动时要么过早恢复(反复打挂)要么恢复太慢(长时间不可用)。
  4. 降级必须可见:任何一次降级都要在响应里带标记并落进决策日志。这条看起来是产品洁癖,实际上是保命的——当用户投诉"今天答案质量变差"时,你唯一能回答"是降级了、持续了多久、影响了谁"的方式就是这条链路。

7.4 演练:降级链只有在真的断掉时才验证得了

降级链有一个众所周知的特性:它在一切正常时完全是死代码,因此它的正确性无法通过正常使用来验证。而它恰恰是系统在最难堪的时刻唯一的依靠。更糟的是,很多链上的分支在写完代码之后一次都没被执行过,等到真正需要时才发现某个降级模型的超时设置与主模型一致(于是它也超时了),或者某个降级路径依赖的缓存根本没在冷路径上被填充过。

所以降级链必须有定期强制演练,而且要逐跳演练而不是整链演练。逐跳的意思是:单独让某一跳返回错误,验证后面每一跳的行为是否符合预期——是走了下一条路径,还是错误地重试了,还是静默地把错误原样返回给了用户。整链演练只能验证"全部失败时会怎样",无法定位"第二跳失败时会怎样"。

演练需要人为注入的故障类型至少覆盖四类:连接层失败(连接超时、连接被拒)、服务层失败(返回错误状态码)、行为层失败(返回成功但内容为空或格式错误)、以及慢响应(不返回,只是超出时延)。最后一类最容易被漏掉,也最容易造成生产事故——熔断器通常只统计错误率,对"变慢但不报错"的情况不敏感,而慢响应恰恰是最容易拖垮整个系统的一类。

演练的结论应该沉淀成一张表:每一跳的预期行为、实测行为、以及差异。这个表的存在还有一个副作用——它把"降级链"从一段代码变成了一份可以被评审的规格说明。实践中,新人看不懂降级逻辑是这类系统最常见的交接障碍,而有了这张表,代码和规格是一一对应的。

八、Cascade:小模型先试再升级

8.1 机制:两阶段加一个判定器

Cascade 策略的思想非常朴素:先用便宜模型快速试,如果它做到了,就到此为止;如果没做到,再带着前面的结果升级到强模型。这个机制在学术上被证明对大量分布是有效的——因为很多任务的难度分布是双峰的:大部分请求对任何模型都是简单的,剩下的一小部分需要真正的能力。级联把两种情况的开销分开了。

async def cascade(req, budgets):
    # 阶段一:便宜模型 + 收紧的输出上限(cascade 的收益来源之一)
    draft = await call(alias='fast_chat',
                       request=req,
                       max_tokens=budgets.draft_tokens,   # 通常是完整预算的 20~40%
                       reasoning_effort='low')

    # 判定器:这是整个策略的成败所在,三种实现可靠性递减、成本递增
    verdict = verify(draft, req)      # 1) 确定性判据:schema 校验 / 规则 / 引用校验
                                     # 2) 自检:让同一模型回答“此题是否已被完整回答”
                                     # 3) 独立小判别器:另一次小模型调用做二分类

    if verdict.ok and verdict.confidence >= budgeted_threshold(req):
        return Outcome(text=draft.text, tier=0, cost=budgets.draft_cost)

    # 阶段二:升级,并携带阶段一结果作为上下文(这一步会额外花 prefill,
    # 是 cascade 最常被忽略的隐性成本)
    final = await call(alias='deep_reason',
                       request=req,
                       context=verdict.carry_over(draft))
    return Outcome(text=final.text, tier=1, cost=budgets.draft_cost + final.cost)

这个结构里有一处极其关键、也极易被忽略的细节:阶段二的输入包含阶段一的输出。这意味着你要为"每个省下来的请求少付一次强模型的 prefill"、为"每个升级的请求多付一次长上下文的 prefill"这两项同时记账。如果草稿很长而升级率又高,cascade 的成本可能比直接调用强模型还贵。控制草稿长度上限因此不是可选项。

8.2 什么时候有效,什么时候无效

条件为什么建议
难度分布双峰且简单峰很宽大多数请求一次命中,只有长尾升级最理想场景,升级率通常能压到两成以内
难度分布连续无明显峰几乎所有请求都擦着阈值,升级率趋近百分之百收益归零甚至为负,改用直接路由
判定器比任务本身还难判定器要么误判放行(质量塌陷)要么误判升级(成本归零)先把判定器做好做简单,不要靠调阈值掩盖
时延预算极紧阶段一 + 判定 + 阶段二是串行的,P99 显著变差把 cascade 用在非实时的批处理或离线任务上
输出长度不可控草稿太长会让升级的 prefill 成本吃掉全部节省强制 max_tokens 上限,并对草稿做截断式交接
强模型存在供应商风险cascade 的最大卖点就是强模型只在少数请求上被调用,抗风险更好这其实是 cascade 最被低估的收益

表里第二行值得展开:升级率是判断 cascade 是否值得做的单一最重要指标。如果在离线重放里升级率超过 50%,那么这个策略在数学上几乎不可能赚钱——你为所有请求付了两次模型成本(阶段一必然发生),却只对不到一半的请求省下了强模型调用。把它当作上线前的一个门禁指标,比看平均成本更可靠,因为平均成本会被"简单峰"的比例掩盖。

8.3 陷阱清单

  • 假阳性升级:判定器过于敏感,升级率飙到 80%,所有收益消失。表现是成本不降反升。诊断方法是把升级率做成日常看板的一等指标,并对判定器做混淆矩阵监控。
  • 假阴性放行:判定器漏放,问题被交付到下游,且下游没有任何信号能告诉你出错了。这是 cascade 最危险的一种失效,因为它不会报警,只会在用户投诉里出现。唯一的防线是把判定器的漏检率纳入回归测试,每次提示词或模型变更都重跑。
  • 选择偏差评估:只在"小模型已经失败"的样本上评估升级收益,会系统性地高估效果。评估必须跑在全量真实分布上。
  • 路由抖动:cascade 的判定结果依赖随机采样,同一条请求两次可能走不同层级。这会让你的 A/B 实验变成噪声,因为"同一个模型"不再意味着"同一批输出"。要么固定采样种子,要么在实验设计里承认这一点。
  • 把 cascade 当成降级链:两者方向相反。cascade 是日常的成本优化(便宜优先),降级链是异常时的可用性兜底(可用优先)。混在一起会导致正常流量在故障时被错误地降级。

8.4 与强化学习、测试时计算的边界

Cascade 的思路和测试时计算扩展、以及在线强化学习解决的是相邻但不同的问题,边界值得划清楚,因为实践中经常被混为一谈。测试时计算扩展是在单个模型内部通过增加思考步数、并行采样、验证器重排来换取质量,它的成本单位是"同一个模型的更多 token"。Cascade是在模型之间做选择,成本单位是"更便宜的模型的少量 token 加上偶尔的昂贵模型调用"。两者可以叠加,但优化目标不同:前者调节的是质量-算力的曲线,后者调节的是质量-货币的曲线。

与在线强化学习做路由的边界更微妙。把路由本身当成一个待优化的策略,用线上反馈(用户是否接受、任务是否成功)去训练它,理论上是最优的,实践中却极少成功。原因有三个:反馈信号延迟且噪声大,一个路由决策的好坏可能要几天后才能从业务指标里体现;动作空间是离散的且后果不可逆,选错一次的成本立即发生而收益在未来;探索成本太高,线上随机探索意味着主动给一小部分用户较差的服务,这在很多业务里不可接受。

因此更现实的做法是用离线的成对偏好数据训练路由器,在线只做受约束的贪心选择,把探索完全限制在离线环境里。这本质上是一个 contextual bandit 的离线求解:训练阶段可以用全候选的日志(IPS 之类的加权校正来处理分布不一致),线上阶段只执行策略不做探索。这与前面 3.4 节讲的训练型路由器是同一套方法,只是把学习目标和数据来源说得更明确。

一个值得注意的新方向是利用路由器自身的置信度做请求级的推理预算分配:路由概率高说明任务容易,就给更少的思考步数;路由概率低说明任务难,就给更多的思考步数。这把"选模型"和"选算力"两个决策合并成了一个,可以看成是级联思想在单模型内部的推广。它的前提仍然是那个老问题——路由器要足够可信,否则预算分配会把算力加到本来就不该加的请求上。

九、量化路由效果:怎么才能不靠拍脑袋

回到开头那个问题:路由的正确性无法被自身观测到。要量化它,必须引入一个外部参照系。最实用的参照系是 oracle——对每一条评测样本,你都离线跑过所有候选模型,那么"这条请求最好的结果"是已知的。路由的价值,就是它离这个上界有多近。

import numpy as np

def evaluate_router(log, candidates):
    # log 里每条样本有:每个候选模型的质量分 q[m]、成本 c[m]、时延 t[m]
    # oracle = 成本达标约束下质量最高的那个候选
    gain, regret, cost = [], [], []

    for row in log:
        feasible = {m: r for m, r in row.runs.items() if r.cost <= row.budget}
        if not feasible:
            continue
        best = max(feasible.values(), key=lambda r: r.quality)
        chosen = row.decision

        # 质量 regret:相对上界掉了多少
        regret.append(best.quality - chosen.quality)
        # 成本节省:相对“全用最强模型”省了多少(必须扣掉重试)
        gain.append((row.strongest_cost - chosen.total_cost) / row.strongest_cost)
        cost.append(chosen.total_cost)

    return {
        'quality_regret_mean': float(np.mean(regret)),      # 越小越好
        'quality_regret_p95':  float(np.percentile(regret, 95)),
        'cost_saving_vs_best': float(np.mean(gain)),
        'abs_spend_per_success': float(np.sum(cost) / len(log)),  # 比均值更诚实
    }

这组指标里有几个容易混淆的地方需要说清。路由准确率是最容易被滥用、也最没有信息量的指标。它只在"你真的选到了最优模型"时才成立,但多模型场景里经常存在多个并列最优;更糟的是它和收益没有单调关系——准确率从 80% 提到 95%,成本可能一点没降,因为剩下的 5% 恰好是收益最大的那些请求。所以regret 才是主指标,它直接对应"我少赚/少做对了多少"。

9.1 用 oracle 定义上界,也用它定义对照组

oracle 的价值不止于算 regret。实践中最有用的三个对照组是:全用最强模型(质量上界、成本上界)、全用最便宜模型(成本下界、质量下界)、当前线上策略(基线)。你的路由器要证明的是自己离上界足够近,同时比现状省得多。三条线画在一张图上,收益一目了然,而且这张图对非技术的业务方也完全可读。

还要单独画一条成本-质量帕累托曲线:通过逐步调高升级概率阈值,让路由器在一条曲线上滑动,输出"成本 vs 质量"的连续权衡。这条曲线的存在是整个路由论证的核心——它证明你不是在某一点上碰运气,而是在一个连续空间里选择了合适的工作点。业务方真正想讨论的是"这个点",而不是"这个路由器"。

9.2 一个可操作的评估流程

  1. 构建评测集:从决策日志采样,覆盖全部任务族,并且刻意超采样边界样本(路由器决策置信度低的那部分)。均匀采样会让指标好看但没有区分度。
  2. 跑全候选:每条样本用每个候选模型跑一遍,记录质量、成本、时延、成功率。这就是 oracle 数据的来源。成本是一次性的,值得花。
  3. 分任务族报告:不要只看总体。路由的收益在不同任务族之间差异巨大,总体指标会掩盖"某个族上你在持续做负收益决策"这个事实。
  4. 画帕累托曲线并选点:和业务方一起在曲线上选工作点,然后把它固化成配置里的阈值。
  5. 上线后持续监控:指标包含单位成功请求成本(而不是单位请求成本)、regret 的滑动平均、升级率、各任务族流量分布偏移。任一项越过阈值就回到第 1 步。

9.3 离线指标为什么会骗你

  • 评测集分布不等于线上分布。最常见的是评测集里长尾被低估了,而长尾恰恰是路由决策最不确定的地方。
  • 忽略输出长度差异。同样"答对了",一个模型输出 50 字另一个输出 800 字,成本差 16 倍。质量评估必须把长度作为约束或者做长度归一化。
  • 忽略失败的成本。失败率从 3% 涨到 6%,可能让整体业务指标明显恶化,但只看"平均质量分"几乎看不出来。
  • 忽略时延的尾部。平均时延改善但 P99 恶化,在交互式产品里是净损失。
  • 忽略评测本身的不确定性。LLM-as-judge 有位置偏差和长度偏好,同一批输出换一次评判就会得到不同结论。因此所有质量指标都要带置信区间,两个方案差距不显著时不要下结论——这类"看起来有提升"的改动是最常见的伪收益来源。

9.4 离线评估与在线评估怎么配比

离线评估和在线评估都不是对方的替代品,各自的失效方式也不同。离线评估的主要问题是样本选择偏差:评测集是在某个时刻、某种流量构成下采样得到的,而线上的流量构成一直在变。一个在三月的数据上评测出来的最优阈值,到了十月可能因为新功能上线而完全不适配——不是因为路由变差了,而是因为它优化的对象换了。

在线评估的主要问题是噪声大、周期长、有代价。业务指标(转化率、投诉率、工单解决率)的变化通常需要相当样本量和相当时间才能显现,而你在灰度期间同时还在改别的东西,指标变化根本无法归因。这导致很多团队上线一个 A/B 测试,跑两周看不到显著差异,就草率地判定"路由没有价值"。

因此一个健康的配比是:离线负责快节奏迭代,在线负责最终确认。离线用全候选日志做重放评估,可以一天内跑几十组阈值组合,几秒钟出结果,用于日常调参;在线用影子流量做无风险验证(不做决策只记录对照),持续运行,成本可控且能提供分布是否偏移的信号;只有真正的工作点变更才需要走完整的 A/B。

还有一条关于实验设计的经验值得单独说:路由系统会让 A/B 实验变得更难做。因为请求会被路由到不同模型,"同一个实验组"的请求实际经过了不同处理,效应被稀释了。缓解办法是按路由决策分层——先看"小模型处理的这部分请求上,两组有没有差异",再看整体差异。这样既能解释实验现象,也能顺便回答"路由在这类请求上到底做得对不对"。把 A/B 的分层维度和路由的决策维度对齐,是一个能同时提升实验质量和路由质量的设计。

十、多模型并存的版本管理与配置漂移

10.1 别在代码里写模型名

多模型系统里最贵的技术债,是散落在代码、配置、提示词、文档、前端选项里的模型名。它们会以各种形式存在:硬编码的字符串、某个变量的默认值、某个环境变量、某段提示词里顺带提到的"请用高推理强度"、甚至某个同事本地的开发配置。你不可能在升级模型时把它们全部找到。

# 路由配置是数据,不散落在代码里;配置本身要可版本化、可回滚、可审计。
@dataclass
class RoutingPolicy:
    version: str                       # 语义化版本,改动必升
    alias_budgets: dict                 # 别名 -> 每日美元额度
    cascade: dict                       # 别名 -> {threshold, draft_tokens}
    capability_overrides: dict          # 人工覆盖,优先级高于自动指纹
    fallback_order: list                # 与故障类型绑定,不是一条通用瀑布
    ab_buckets: dict                    # 灰度分桶,显式声明

    def fingerprint(self) -> str:
        # 配置指纹用于:变更检测、灰度一致性校验、回滚判定
        return sha256(canonical_json(self).encode()).hexdigest()[:16]

配置即代码带来的收益不只是可回滚,更是可比较。指纹相同意味着两个实例的行为一致,这在灰度发布和配置漂移检测里都是刚需:影子实例、回放系统、线上实例三者必须共享同一个指纹,否则你对比出来的差异全是噪声。

10.2 路由决策日志是唯一的事后依据

决策日志要记录的比"选了哪个模型"多得多。最小字段集是:请求指纹(脱敏后的输入摘要,用于去重与重放)、当时的路由决策与置信度、候选池及各候选的关键属性(当时的质量估计、成本、时延、负载、是否被硬约束过滤掉)、最终调用的模型与实际用量与真实时延、命中的降级层级、以及策略版本指纹。

有了它才能回答那些事后一定会被问到的问题:上周那批变差的答案是怎么来的?某个租户的降级率为什么是别人的四倍?换分类器之后到底变好了还是把问题挪到了另一个任务族?没有决策日志,前面第九节的所有量化都只是上线前的一次性结论,无法持续验证。它在合规审计(需要证明某条数据没有被送到不允许的区域)上同样是硬要求。

还要注意一个实践细节:决策日志要能在不重新调用模型的前提下重放。存全部候选的输出体积很大,通常做法是存决策与候选属性(便宜),只在需要精确定位时才回填输出(昂贵)。这个分层能让你在"要不要为了重放多存 10 倍存储"这个问题上有一个理性答案。

10.3 漂移检测与发布节奏

配置漂移有四个可观测的信号,建议全部做成看板:

  • 决策分布偏移:各别名被选中的比例、各任务族的流量分布。用 KL 散度或简单的分桶占比变化对比上一个时间窗。这个信号最早预警——通常比质量下降早一到两周。
  • 实际用量 vs 矩阵预测:每个别名的实际 token 用量与成本画像的偏离度。偏离持续扩大,说明矩阵的价格或长度分布过期了。
  • 质量漂移:金标准回归集在每次变更后自动跑,配合线上抽样的 judge 打分趋势。
  • 降级与升级率:这两个率是最灵敏的健康指标。升级率突然上升通常意味着分类器失准或上游模型退化;降级率上升意味着供给出问题或容量不足。

发布节奏上,模型小版本更新的影响面远大于大多数人的预期。它可能在不改变接口、不发公告的情况下改变风格、改变工具调用格式、甚至改变对特定语言的处理方式。所以"升级模型"和"升级路由策略"应该走同一条发布流程:先在影子实例上用固定回归集跑分,再灰度一个小比例流量,最后全量。这套流程和发布普通代码没有本质区别,只是影子实例跑的是真实流量的镜像,这一点特别适合用统一网关的路由能力实现——网关同时是发布通道和观测点。

还有一条容易被忽略但很重要的纪律:不要让路由策略在多个层次上都能被修改。很多系统既有代码里的默认路由,又有服务端的动态配置,又有某个运营后台的开关。这三层同时存在时,谁生效取决于优先级规则,而优先级规则本身没有测试覆盖。一次上线事故往往是"后台有人改了一个开关"。收敛到单一配置源(第十节第一段代码里的那个),是消除这类事故的根本手段。

10.4 网关层的职责边界:哪些该统一,哪些必须留在业务侧

做多模型时很容易走向另一个极端:把所有事情都塞进网关,最后得到一个谁都不敢改的黑盒。可行的划分是按"是否与业务语义相关"来切。应该放在网关层的是那些与业务无关、且必须跨供应商保持一致的机制:统一鉴权与密钥管理、请求重试与退避、限流、语义缓存、遥测埋点、结构化输出的契约校验、以及跨渠道的等价替换。这些东西在业务侧各写一遍,既不可能一致,也没法集中观测。

必须留在业务侧的是所有需要业务判断的决策:这条请求值多少钱、失败的后果有多大、降级后的答案能不能接受、两个模型的质量差距在这类任务上是否显著。把这些放进网关意味着网关需要理解你的业务语义,最终它会变成一个既不懂业务又难维护的庞然大物。网关应该输出信号(成本、时延、成功率、当前负载、候选可用性),由业务侧的策略层做决策。

关于网关选型有一点务实的建议:优先选那些你已经需要的那一类。如果你需要成本归因,就选遥测和用量聚合做得好的;如果你需要跨供应商故障转移,就选重试与降级语义定义清晰的;如果你在自建推理集群和外部 API 之间混用,就选两边都能接入的。过度追求"功能最全"的网关,往往在某个具体维度上都不够好,最后靠绕开来补,反而失去了统一层的意义。

还有一个在自建场景下特别重要的边界问题:自建集群的模型要不要纳入路由。答案通常是肯定的,而且收益常被低估——自建集群的成本结构和外部 API 完全不同(边际成本低但有闲置风险),它天然适合承接"量大、难度分布偏简单、可容忍排队"的流量,而把"量大但难"的流量留给外部 API。这个分工能同时提高自建集群的利用率和外部 API 的性价比,是多供应商体系里最直接的收益之一。

10.5 供应商下线与废弃迁移:一条必然发生的运维事件

无论你用几家供应商,模型下线或型号废弃都是必然事件而不是意外。绝大多数供应商会给出一个提前通知窗口,但在实践中,有几种情况会让这个窗口变得非常紧:区域性的紧急下线、合规原因导致的强制下架、以及大版本发布时把旧的小版本直接标为不可用。

这件事对路由系统的要求比看上去更高。困难点不在于换一个模型名,而在于能力矩阵里那一行是空的。你没有一个已经下线的模型的评测数据,也没有替代模型的同任务族评测——因为它昨天可能还不在候选列表里。所以真正有效的准备不是"提前准备好替代模型",而是常态化维护一个比实际使用列表更宽的候选池:每一条正在使用的别名,都至少有两个经过评测的候选渠道,其中一个可以是当前不用的。

这个"更宽的候选池"还有一个不太明显的好处:它让能力矩阵持续保持新鲜。如果某个备用渠道三个月没被选中,它的评测数据就已经过期了,一旦需要启用时才发现它不达标,等于没有备份。所以候选池里的条目需要有独立的评测刷新节奏,而不是跟着实际使用频率走。

迁移过程本身应该走和普通变更一样的流程:影子实例对比 → 小比例灰度 → 观察决策分布与质量指标 → 全量。特殊之处在于要让新旧两个模型并行存在一段时间,而不是原地替换。并行的价值在于:当新模型出现质量问题时,你能立刻回切;如果已经原地替换且状态不可回滚,这次迁移就变成了一次不可逆的下注。

最后一条纪律关于别名不要与具体模型版本绑定。如果你的业务代码里写的是 deep_reason_v3 而不是 deep_reason,那么每次模型升级都变成了一次跨团队的全量代码改造,而这种改造通常会被无限期推迟,最后变成一次仓促上线。把版本变更收敛到配置层,是让迁移成本从"周"降到"小时"的唯一办法。

十一、什么时候不该上路由

路由系统的复杂度不低,以下几种情况明确不建议做,把预算花在别的地方回报更高:

  • 流量小或者任务单一。每天几百个请求、或者全部是同一类任务,路由的收益无法覆盖建设与维护成本。先做能力评测和提示词优化。
  • 时延预算紧到只够一次调用。cascade 和分层路由都会增加串行段,此时唯一正确的做法是固定一个模型并把工程资源投入到缓存与推理优化上。
  • 团队没有评测能力。路由系统的价值 90% 来自评测能力。如果你没法回答"哪个模型在什么任务上更好",那么无论路由器多智能,它都只是在优化一个你说不清的目标。这个前提不满足时,做路由就是做一个不可证伪的系统。
  • 只有一个供应商的可用权限。没有等价渠道时,"路由"退化成"选一个模型",故障转移无从谈起。这时更值得做的是把统一网关的抽象层先建起来,为将来接入第二家做准备。

十二、一条能走通的落地顺序

  1. 先做单模型基线:固定一个模型,把提示词、缓存、重试策略优化到位,测出质量、单位成功成本、P99 时延。没有基线就没有参照系。
  2. 先建决策日志和归因:在还没有路由的时候就把 gen_ai.* 这类字段埋全,把成本归因到业务功能上。这一步不需要任何模型决策逻辑,但它是后面一切量化的前提。
  3. 加能力别名和双渠道:把业务代码从模型名上解耦,引入第二家供应商。此时解决的是可用性,顺带验证你的网关抽象是否成立。
  4. 做一次全候选离线评测:拿到 oracle 数据,画出成本-质量帕累托曲线。这一步会告诉你路由的天花板有多高,以及哪些任务族根本不值得路由。
  5. 上线规则 + kNN 版本:先上最简单、最可解释、零冷启动成本的版本。它的目标不是最优,而是开始产生决策日志,让后续所有工作都有数据基础。
  6. 按任务族逐个引入学习式路由:只对评测显示"模型差距大、样本量足够"的族上线更复杂的路由器。跳过其他族,别做全站统一。
  7. 对符合条件的任务族开 cascade:以升级率为门禁指标,超过阈值就关掉。同步把时延影响纳入非实时任务的筛选。
  8. 把质量回归和配置治理常态化:金标准回归集每次变更自动跑,配置单一源、指纹校验、能力矩阵 TTL 与复评流水线。做到这一步,路由才从"一个功能"变成"一个系统"。

如果只能做一件事,我会选第 2 步。绝大多数团队跳过它直接开始训练路由器,然后陷入"效果说不清"的困境,最后靠感觉继续迭代。归因和日志是唯一一个"现在做永远不亏、以后补代价极高"的基础设施。

结语

模型路由做得好不好,最终不体现在分类器的准确率上,而体现在三个朴素的数字上:单位成功请求的成本、P99 时延、以及相对 oracle 的质量 regret。这三个数字能被持续测量、能在业务方面前被解释、能随模型供给的变化重新计算——做到这三点,路由就是一个真正的工程系统;做不到,它就只是一个包着 if-else 的成本幻觉。

2026 年的模型供给比任何时候都更丰富,也让"选哪个"从一个偶尔发生的技术决策,变成了每个请求都要回答的常态问题。变化的是选项,方法论其实很稳定:把问题定义成带约束的优化,用能力矩阵把事实沉淀下来,用预算和降级链把不确定性框住,最后用 regret 而不是感觉来验收。选项越多,这套方法论的价值就越大。

如果要用一句话概括这套系统的本质:它不是让某个模型变得更聪明,而是让"用哪个模型"这件事从个人的临场判断变成系统的一个可回归、可审计、可解释的决策过程。前者你只能靠招人解决,后者可以靠工程解决——而在一个每天都有新模型、每天都有新价格、每天都有新限流的世界里,后者的复利要高得多。