ICLR'25 | RouteLLM 技术拆解:四种 LLM 路由算法的输入输出、维度与代码

ICLR’25 | RouteLLM 技术拆解:四种 LLM 路由算法的输入输出、维度与代码

原文:RouteLLM: Learning to Route LLMs with Preference Data

官方实现:lm-sys/RouteLLM。本文对照论文 v4,以及代码提交 0b64fda;涉及已发布分类器的维度,另外核对了 Hugging Face 的模型配置。论文的简写与开源实现有几处区别,后面会分别说明。


1. 同一个聊天入口,有些问题值得用贵模型,有些不值得

一个聊天服务收到的请求,可能是“把这句话改得礼貌一点”,也可能是“检查这段并发代码有没有竞态条件”。

全部交给能力最强的 LLM,大语言模型,简单省心,但账单也会把简单任务按贵的价格算。全部换成便宜模型,成本降了,难题的回答质量又可能掉下去。

最自然的想法是:简单问题用便宜模型,难题用贵模型。

但问题马上来了:在答案还没生成的时候,怎么知道便宜模型够不够用?

可以让便宜模型先答,再检查,不行就升级。但这会增加一次生成和检查。RouteLLM 选择另一条路径:从历史偏好数据里学习,只看当前问题,先决定交给谁,然后让选中的回答模型生成。

本文里的 router,就是这个选择回答模型的小模块。strong / weak 分别表示当前配置里较强、较贵与较弱、较便宜的两个模型。它们是相对关系,不意味着 weak 每道题都输。

RouteLLM 讨论的是请求级的两模型路由。每个请求先得到一个分数,再选一个回答模型;这个分数怎么计算,论文比较了四种算法。

读完以后,应该能把下面这条链路完整写出来:

问题字符串
  → router 的输入表示
  → 一个 [0,1] 范围的 strong 路由分数
  → 与阈值比较
  → strong 或 weak 的模型名称
  → 被选模型生成回答文本

后面的代码都是教学实现或计算示例,重点是维度和数值如何流动,不需要下载完整模型权重。随机张量只演示计算,不代表模型已经学会路由。

2. 先拆清两个输出:router 输出选择,回答模型才输出答案

2.1 训练时看过胜负,推理时只看到问题

一条历史数据可以写成:

\[(q,m_a,m_b,y).\]

$q$ 是问题,$m_a,m_b$ 是参与比较的模型身份,$y$ 是用户选择:A 胜、平局或 B 胜。回答文本在收集偏好时当然存在,但本文讨论的 router 不把这些回答当作推理输入。

训练和上线分别做什么,可以放在一张表里:

阶段 输入 输出或监督信号
收集偏好 问题、两个模型的回答 人类或裁判给出胜负
训练 router 问题表示,部分方法还使用模型身份 用胜负监督打分函数
在线路由 新问题,外加已配置的模型对 strong 路由分数,再得到模型名称
回答生成 原始请求上下文 所选回答模型生成文本

router 不是先看两份答案再挑一份。 它要从问题本身预测:在这类请求上,多花钱调用 strong 有没有价值。

2.2 统一接口是一个标量,内部不一定都是二分类

论文把这个分数记为:

\[p(q)=P_\theta(\mathrm{win}_s\mid q).\]

可以先理解成“强模型在这个问题上胜出的预测概率”。部署时更稳妥的说法是 strong 路由分数:它的排序能力可以有效,但未必已经校准成真实频率,尤其是换了回答模型对之后。

给定阈值 $\alpha$,路由规则为:

\[R^\alpha(q)= \begin{cases} M_s,&p(q)\geq\alpha,\\ M_w,&p(q)<\alpha. \end{cases}\]

例如,预测分数为 0.7,阈值为 0.6,就选 strong;把阈值提高到 0.8,同一个问题就选 weak。

因此,阈值越高,通常调用 strong 越少。而 $\alpha=0.7$ 并不表示“70% 的流量走 strong”,流量比例取决于所有问题的分数分布。

官方的接口 Router.calculate_strong_win_rate(prompt) 返回 Python float;Router.route(...) 把它变成一个模型名,相等时走 strong。最终聊天响应由 controller 调用回答模型后返回。

2.3 四条计算路径,最终都汇聚到一个分数

下文用 $B$ 表示问题批大小,$T$ 表示分词后的长度,$N$ 表示历史比较条数,$K$ 表示模型 tier 数。tier 就是把历史能力相近的模型合成一档,默认分成 10 档。

方法 核心输入 中间表示 最终输出
SW ranking,相似度加权排名 新问题向量、全部历史比较 相似度 [N] → 权重 [N] → tier 分数 [K] 一个 strong 胜率
MF,矩阵分解 问题向量 [B,1536]、模型 ID 问题投影 [B,128],模型向量 [B,2,128] [B] 的 strong 胜率
BERT 类分类器 token ID、attention mask [B,T] 隐藏状态 [B,T,768] → 三类 logits [B,3] [B] 的 strong 分数
Causal LLM,因果语言模型分类器 包装成指令的 token ID [B,T] 隐藏维度 4096 → 五档评分 logits [B,5] [B] 的 strong 分数

logit 是尚未归一化的分数;softmax 把一组 logits 转成和为 1 的概率,sigmoid 则把单个实数压到 0 到 1。

表里 MF 的 2 是两个候选模型,BERT 的 3 是三个胜负类别,Causal 的 5 是五档评分。它们不是同一种维度。 官方在线接口一般一次接收一个问题;表里的批量形式是便于理解的展开。

3. 先把稀疏胜负变成可用监督,再谈选什么网络

3.1 为什么不能只收集 GPT-4 对 Mixtral 的比赛

论文最初使用 Chatbot Arena 的约 80K 场比较。Arena 是一个用户对两份匿名模型回答投票的平台。

问题是,模型多,两两组合更多。论文报告,任意模型对的比较标签平均占比不到 0.1%。只保留目标模型对,会丢掉大量数据。

于是作者根据 Arena 评分,把模型划到 10 个 tier。最前两档代表 strong 类,第三档代表 weak 类;以附录从 0 开始的编号看,就是 tier 0/1 与 tier 2。

但要区分两种训练方式:

  • BERT 等分类方法可以学习 strong 类相对 weak 类的胜负。
  • MF 的公开训练脚本保留具体模型 ID,学习多模型之间的成对比较;SW 的代码则在排名时把模型归并到 tier。

所以“分成 strong/weak 两类”不等于“所有算法都只训练两个可学习向量”。

3.2 分 tier 的动态规划,具体在优化什么

动态规划可以理解成:把一大串连续切分问题,拆成“前面已经切好了,最后一段从哪里开始”。

将 $M$ 个模型按评分排好,得到 $r_1,\ldots,r_M$。公开 compute_tiers() 的优化目标是划分连续 tier,让各组内部的方差之和尽量小。划成 $K$ 个非空组的标准递推可以写为:

\[C(i,j)=\operatorname{Var}(r_i,\ldots,r_j),\] \[D(k,j)= \min_{i=k-1,\ldots,j-1} \left[D(k-1,i)+C(i+1,j)\right].\]

$D(k,j)$ 表示前 $j$ 个模型切成 $k$ 组的最小代价,初始条件是 $D(1,j)=C(1,j)$,回溯最优切点就得到分组。

例如评分为 [1000,1010,1300,1310],切成两组时,[1000,1010] 和 [1300,1310] 的组内波动明显比从中间某一对硬拆开小。

这里对应的是源码里的各组方差之和,不能悄悄改成“按组大小加权的平方误差和”,两者的最优切点不一定相同。这一步是 SW 的初始化处理,不是每个请求都重新分 tier。

标准递推还要求将不可达状态设为无穷大。固定提交的代码把整个 DP 表初始化为零,没有完整屏蔽非法状态,特定评分分布下可能回溯出空 tier;因此不能把它视为“严格保证恰好 $K$ 个非空组”的实现。

3.3 新增数据的作用,是让 router 见过目标问题

清洗短于 16 个字符的问题,并留出约 5K 验证数据之后,论文报告约 65K 条比较、涉及 64 个模型。

作者又增加了两类数据:

数据 标签怎么来 规模与目的
$\mathcal D_{\mathrm{gold}}$ 对照标准答案,比较 strong/weak 是否答对 约 1500 道 MMLU 验证集问题,补学科选择题分布
$\mathcal D_{\mathrm{judge}}$ 用 GPT-4 对两份回答做偏好判断 约 120K 条,约 700 美元的数据收集成本

MMLU 是涵盖多个学科的选择题评测;Nectar 则提供大量问题与现成模型回答。作者利用 Nectar 中已有的 GPT-4 回答,再生成 Mixtral 回答并做裁判判断,减少重复生成费用。

这些回答只在离线构造标签时使用,上线 router 不需要重新生成一遍。

4. SW ranking:为当前问题,重新算一张“局部实力榜”

SW 是 similarity-weighted,相似度加权。它的直觉很直接:判断一道代码题该交给谁,历史代码题的胜负应该比闲聊题更有参考价值。

计算分三步:找语义相近的问题、提高这些比较的权重、重新拟合模型实力。

4.1 字符串先变成 1536 维向量,再算余弦相似度

embedding 就是把一段文字映射成数值向量,让语义相近的文字在向量空间里靠近。论文的 SW 和 MF 都调用 text-embedding-3-small;默认 MF 配置也明确使用 1536 维。

假设有 $N=65000$ 条历史比较:

历史问题向量 E                 [65000,1536]
当前问题向量 e                 [1536]
每条历史问题与当前问题的相似度   [65000]

余弦相似度只看方向:

\[c_i=\frac{E_i^\top e}{\lVert E_i\rVert_2\lVert e\rVert_2}.\]

比如两个向量 [1,0] 和 [2,0] 长度不同,但方向相同,余弦为 1;[1,0] 和 [0,1] 垂直,余弦为 0。

公开代码进一步计算:

\[\tilde c_i=\frac{c_i}{\max_j c_j}, \qquad w_i=10^{1+\tilde c_i}.\]

如果本次最高相似度为 0.8,那么相似度 [0.8,0.4,0] 会变成权重 [100,31.62,10]。这些权重不要求加起来为 1,也不是只取最近的若干邻居。

这里有一个原文与代码的区别:论文公式的分母写成历史问题 $q_i$ 对训练集的最大相似度;代码的分母是当前问题对全部历史问题的最大相似度。上式按代码解释。原实现也没有为最大值为零加保护,教学代码会显式拒绝这个不适合直接缩放的情况。

4.2 BT 模型:把“A 比 B 强多少”变成胜率

Bradley–Terry,简称 BT,是一种成对比较模型。给每个模型或 tier 一个实数分数 $\beta_m$,两者分差越大,胜率越偏向高分一方:

\[P(a>b)=\sigma(\beta_a-\beta_b), \qquad \sigma(x)=\frac{1}{1+e^{-x}}.\]

若 $y_i=1$ 表示 A 胜,$y_i=0$ 表示 B 胜,加权训练目标是:

\[\mathcal L= -\sum_iw_i \left[ y_i\log p_i+(1-y_i)\log(1-p_i) \right].\]

这就是加权二元交叉熵:对高权重比赛预测错误,会被罚得更重。

将每条比赛写成一行,A 所属 tier 对应位置填 +1,B 填 -1,其他填零,就得到设计矩阵 $X\in\mathbb R^{N\times K}$:

\[z=X\beta,\qquad z\in\mathbb R^N.\]

其中 $z_i=\beta_{a_i}-\beta_{b_i}$。拟合完成后,取 strong 和 weak 对应的两项,得到当前问题下的预测胜率。

官方实现使用以 10 为底的 Elo 标度,等价写成:

\[P(s>w)= \sigma\left(\frac{\ln10}{400}(r_s-r_w)\right).\]

例如 strong 比 weak 高 400 Elo,胜率就是 $10/11\approx0.909$。论文的指数写法使用了相反的符号方向;这里统一采用代码里“分数越高越强”的约定。

平局也可以放进去:把一条平局复制成一条 A 胜、一条 B 胜。等价地,在二元交叉熵中令目标 $y_i=0.5$。这样模型不会被要求硬选一方。

4.3 用一段代码走完“相似度 → 局部排名 → 胜率”

下面用 4 条历史比较、3 个 tier、2 维文本向量,把整条链路缩小。它实现标准 BT 数学含义,优化器用于演示;官方用无正则的逻辑回归,并在设计矩阵中加入 Elo 的尺度。

# 教学实现:sw_score(E, e, a, b, y, strong, weak)
# 输入:历史向量、当前向量、双方 tier ID、A 胜标签
# 输出:当前问题的 strong 胜率 float
import torch
import torch.nn.functional as F

def sw_score(E, e, a, b, y, strong, weak):
    E, e = F.normalize(E, dim=-1), F.normalize(e, dim=-1)
    cosine = E @ e                         # [N]
    if cosine.max().item() <= 0:
        raise ValueError("示例要求最大 cosine > 0")
    weight = 10 ** (1 + cosine / cosine.max())  # [N]
    K = int(torch.cat([a, b]).max()) + 1
    X = F.one_hot(a, K).float() - F.one_hot(b, K).float()
    beta = torch.zeros(K, requires_grad=True)   # [K]
    opt = torch.optim.LBFGS([beta], max_iter=60)

    def closure():
        opt.zero_grad()
        per_row = F.binary_cross_entropy_with_logits(
            X @ beta, y, reduction="none"   # [N],允许 y=0.5
        )
        loss = (weight * per_row).sum() / weight.sum()
        loss.backward()
        return loss

    opt.step(closure)
    return torch.sigmoid(beta[strong] - beta[weak]).item()

E = torch.tensor([[1., 0.], [.8, .6], [0., 1.], [.6, .8]])
e = torch.tensor([1., 0.])
a = torch.tensor([0, 0, 1, 0])
b = torch.tensor([1, 1, 2, 2])
y = torch.tensor([1., 0., .5, 1.])
p = sw_score(E, e, a, b, y, strong=0, weak=2)
print(round(p, 4))  # 约 0.9109;优化器容差会带来末位差异

整个计算的形状为:

[N,1536] 与 [1536]
  → cosine [N]
  → weight [N]
  → 加权拟合 X [N,K]、beta [K]
  → beta_strong - beta_weak []
  → sigmoid []

有个实现细节值得单独提醒:固定提交的 SW 代码对同 tier 的 A/B 列连续赋值,后一次会覆盖前一次;标准 BT 的同 tier 分差本应为零。上面的 one-hot 相减保留了数学含义,没有复刻该行为。

SW 不需要离线训练一个神经网络,但每个新问题都要重新求解一次加权排名。 “不用训练”并不意味着推理免费,计算负担被放到了请求到来之后。

5. MF:把“问题 × 模型”的能力矩阵压到 128 维

SW 每次都查历史、重新拟合。MF 则选择提前训练一个小打分器,上线时只做投影和点积。

5.1 为什么叫矩阵分解,又为什么没有直接做 SVD

想象一张表:行是问题,列是模型,每个格子是“这个模型回答这个问题有多好”。

大部分格子没观测到。即使观测过两份回答,往往也只知道谁更好,没有绝对分数。

MF 的想法是让问题和模型各自带一个短向量,通过它们的组合预测格子的分数。可以类比推荐系统:“用户喜欢什么”和“物品有什么特点”在同一个空间里匹配。

这里没有先构造完整矩阵,再做奇异值分解 SVD。它直接学习模型向量和问题投影,用偏好比较训练。

论文写的打分函数是:

\[\delta(m,q)= w_2^\top\left[ v_m\odot(W_1^\top v_q+b) \right].\]

$\odot$ 是逐元素乘法,比如 [2,3] 与 [4,5] 相乘得到 [8,15],不是点积后的一个数。

不过,公开推理代码没有这里的偏置 $b$,并且额外对模型向量做了 L2 归一化。按实际实现,下面使用:

\[\hat v_m=\frac{v_m}{\lVert v_m\rVert_2}, \qquad z_q=Wv_q,\] \[\delta(m,q)=w^\top(\hat v_m\odot z_q).\]

问题向量和投影后的问题向量没有在这段代码里做同样的归一化,所以这个分数也不能直接叫 cosine similarity。

5.2 1536 → 128 → 1,每一个维度在表达什么

核实后的默认参数是:

参数 数值 含义
text_dim 1536 外部文本 embedding 的长度
hidden_size 128 模型向量与投影后问题向量的长度
num_models 64 可学习模型 embedding 表的行数
num_classes 1 对一个“问题、模型”组合只打一个分数

比如一批有 $B=4$ 个问题,每个问题比较 strong 与 weak:

步骤 张量形状 发生了什么
问题 embedding [4,1536] 每个问题一个外部文本向量
Linear(1536,128,bias=False) [4,128] 投影到共同空间
候选模型 ID [4,2] 每个问题配置两个模型编号
Embedding(64,128) [4,2,128] 查出两侧模型向量并归一化
问题向量增加候选轴 [4,1,128] 准备广播给两个候选
逐元素乘法 [4,2,128] 同一问题分别匹配两个模型
Linear(128,1,bias=False) [4,2,1] 每个候选得到一个分数
去掉最后一维 [4,2] 两个模型的分数
strong 分数减 weak 分数 [4] 每个问题一个分差
sigmoid [4] strong 胜率

这里的模型 ID 是模型表的行号。例如固定源码里 GPT-4 的编号是 24,Mixtral 是 36。它们不是文本 token ID,也不表示模型有 24 或 36 个参数。

官方单问题代码实际用 [1536] → [128],然后广播给 [2,128] 的两个模型向量。上表是同一计算的批量版本。

5.3 一个 3 维例子,算清楚逐元素乘法和最后打分

将隐藏维度暂时缩小为 3。设归一化后的模型向量分别为:

\[\hat v_s=(0.6,0.8,0),\qquad \hat v_w=(0,0.8,0.6).\]

设问题投影 $z_q=(2,1,-1)$,最后一层权重 $w=(1,1,1)$。

于是 strong 的分数为 $1.2+0.8+0=2$,weak 的分数为 $0+0.8-0.6=0.2$。分差是 1.8:

\[p(q)=\sigma(2-0.2)\approx0.858.\]

128 维只是在更大的空间里重复这个计算。 不需要把“矩阵分解”想成一个很神秘的额外步骤。

5.4 训练时比较两个模型,推理时只剩一次前向

公开训练脚本先丢弃平局和同模型比较,再把每条数据排列成“赢家 ID、输家 ID、问题索引”。

因此训练损失可以写成:

\[\mathcal L_{\mathrm{MF}} =-\frac1B\sum_{i=1}^B \log\sigma\left[ \delta(m_i^+,q_i)-\delta(m_i^-,q_i) \right].\]

不用两个标签:因为输入已经把赢家放在前面,目标全是 1。

下面保留实际的 1536、128、64,顺便演示一次反向传播。BCEWithLogits 直接吃分差,数值上比先 sigmoid、再取对数稳定。

# 教学批量实现,对应官方 MFModel.forward / pred_win_rate
import torch
from torch import nn
import torch.nn.functional as F

class TinyMF(nn.Module):
    def __init__(self):
        super().__init__()
        self.P = nn.Embedding(64, 128)
        self.proj = nn.Linear(1536, 128, bias=False)
        self.head = nn.Linear(128, 1, bias=False)

    def forward(self, query_embedding, model_ids):
        z = self.proj(query_embedding)            # [B,128]
        v = F.normalize(self.P(model_ids), dim=-1) # [B,2,128]
        scores = self.head(v * z[:, None, :])      # [B,2,1]
        return scores.squeeze(-1)                 # [B,2]

torch.manual_seed(0)
router = TinyMF()
query = torch.randn(4, 1536)  # 实际使用时来自文本 embedding
pair = torch.tensor([[24, 36]]).expand(4, -1)  # [4,2]
scores = router(query, pair)
logit = scores[:, 0] - scores[:, 1]             # [4]
p_strong = logit.sigmoid()                     # [4]

# 通用 A/B 顺序的教学标签;1 表示第一侧胜,0 表示第二侧胜
y = torch.tensor([1., 0., 1., 1.])
loss = F.binary_cross_entropy_with_logits(logit, y)
loss.backward()
print(scores.shape, p_strong.shape, round(loss.item(), 4))
# torch.Size([4, 2]) torch.Size([4]),最后一个是标量损失

实际赢家在前的训练中,只需把 pair 按每条比较重排,y 换成全 1。公开训练实现还会在冻结的问题 embedding 上加入高斯噪声;这个演示省去了数据加载和噪声增强。

可训练参数量为:

\[64\times128+1536\times128+128=204928.\]

约 20.5 万参数,不包含外部文本 embedding 模型。因此它不是从原始字符串开始、只靠 20 万参数就独立完成语义理解。

5.5 这套 MF 实际有多强,又有什么表达限制

把逐元素乘法改写一下:

\[\delta(m,q)= (\hat v_m\odot w)^\top Wv_q.\]

对于固定模型,它是文本 embedding 的线性打分。再固定 strong/weak 对:

\[p(q)= \sigma\left[ \left(W^\top((\hat v_s-\hat v_w)\odot w)\right)^\top v_q \right].\]

也就是说,在固定的外部问题表示上,默认 MF 对固定模型对的决策边界是线性的。

如果把所有问题堆成 $Q\in\mathbb R^{N\times1536}$,所有归一化模型向量堆成 $V\in\mathbb R^{64\times128}$,分数矩阵是:

\[S=(QW^\top)\operatorname{diag}(w)V^\top, \qquad S\in\mathbb R^{N\times64}.\]

这就是“矩阵分解”的具体含义:问题与模型通过 128 维中间表示组合。矩阵的秩可以粗看成其中有多少个独立变化的方向,这里的秩最多为 $\min(N,128,64)$,因此不超过 64。未知分数来自共享表示对稀疏比较的泛化。

6. BERT 分类器:整段问题变成三类概率,再取 strong 那一类

MF 的文本表示来自外部服务。BERT 类方法则把语义编码器和分类头一起微调。

6.1 论文写单 sigmoid,发布模型实际是三分类 XLM-R

论文用 BERT-base 描述,并写成:

\[p(q)=\sigma(Wh_{\mathrm{CLS}}+b).\]

$h_{\mathrm{CLS}}$ 是用来概括整段输入的特殊位置向量。这个表达解释了“文本表示接分类头”的想法,但发布的默认 checkpoint 是 XLMRobertaForSequenceClassification,底座为 xlm-roberta-base。

核实配置后,它的隐藏维度是 768,输出三个类别:

类别下标 含义 路由时怎么处理
0 strong 一侧胜 计入 strong 分数
1 平局 计入 weak-or-tie
2 weak 一侧胜 计入 weak-or-tie

配置文件只写了通用的 LABEL_0/1/2,上面的语义来自 router 源码的概率合并逻辑。

6.2 [B,T] → [B,T,768] → [B,3] → [B]

以 $B=4,T=128$ 为例:

input_ids / attention_mask       [4,128]
XLM-R 隐藏状态                  [4,128,768]
取首个 <s> 位置                 [4,768]
Dropout → Linear(768,768) → tanh [4,768]
Dropout → Linear(768,3)          [4,3]
沿类别维 softmax                [4,3]
取第 0 类                      [4]

input_ids 是整数词表索引;attention_mask 通常以 1 表示有效 token、0 表示补齐位置。它不会把输入变成两份候选回答。

XLM-R 的 <s> 在这里承担类似 BERT [CLS] 的作用。发布 tokenizer 的默认最大长度是 512,通常包括起止 token;配置里的位置 embedding 数 514 不是“可以直接塞 514 个正文 token”。

6.3 平局不拆半,softmax 也不沿 token 维做

若 logits 为 [2,1,0]:

\[(p_s,p_t,p_w)= \frac{(e^2,e^1,e^0)}{e^2+e^1+e^0} \approx(0.665,0.245,0.090).\]

代码取:

\[p(q)=1-(p_t+p_w)=p_s.\]

如果阈值是 0.7,这次就走 weak。虽然三类里 strong 的概率最大,它仍然没有达到路由阈值。三分类 argmax 和路由决策是两回事。

三分类的训练目标是一个整数类标,形状 [B],配合三类交叉熵;不需要像 BERT 预训练那样随机遮掉词。

# 计算示例:对应 BERTRouter.calculate_strong_win_rate 的聚合
import torch
import torch.nn.functional as F

logits = torch.tensor([[2., 1., 0.]], requires_grad=True) # [1,3]
prob = logits.softmax(dim=-1)  # 在“类别”上归一化
p_strong = prob[:, 0]          # [1]
label = torch.tensor([0])      # [1],这条训练样本 strong 胜
loss = F.cross_entropy(logits, label)
print(round(p_strong.item(), 4), round(loss.item(), 4))
# 0.6652 0.4076

把三类 softmax 写成单 sigmoid 也可以,但对应的是:

\[p_s=\sigma\left(z_s-\log(e^{z_t}+e^{z_w})\right).\]

这显然不是简单地对一个线性头的输出再接 sigmoid,所以讲实际网络结构时不能只照抄论文的简式。

7. Causal LLM:不另接三分类头,而是学会输出评分 token

再往上加容量,论文用 Llama 3 8B 来做 router。它仍然只负责判断,不在这一步生成用户最终需要的答案。

7.1 问题先包装成指令,输出被压缩成五档评分

默认发布模型把问题放进类似下面的模板,前面再加评分 system instruction:

[Question]
用户的问题

Prediction:

它在原始词表后追加五个 token:[[1]] 到 [[5]]。每个是一个新增 token,不是让模型逐字符生成五六个符号。

评分越高,代表弱模型越可能处理好。代码把 1/2/3 分归到 strong 一侧,4/5 分归到 weak 一侧。

这里的五档评分不是五种胜负类别,中间的 [[3]] 也不能直接称为“平局类”。论文把方法概括成标签 token 分类,公开 checkpoint 的实际接口更具体。

7.2 4096 维隐藏状态,为什么最后会看到 128261 维

发布配置的隐藏维度为 4096,原词表大小为 128256,新增 5 项后为 128261。

以 $B=1,T=128$ 为例,普通完整前向的维度是:

步骤 形状
包装后的 input_ids [1,128]
Llama 隐藏状态 [1,128,4096]
LM head 投影到词表 [1,128,128261]
某一个生成步骤的 scores [1,128261]
取五个新增评分位置 [1,5]
五类 softmax,再合并 [1]

128261 是词表大小,4096 是每个 token 的特征维度。两者通过 LM head 的权重矩阵 [128261,4096] 连接,不能混为一谈。

官方代码最多贪心生成 6 个新 token,找其中第一个评分 token,然后取生成该评分 token 的那个步骤对应的分数。所以不保证一定直接读取 scores[0]:前面可能还生成了格式相关 token。

7.3 只在五个标签上做 softmax,再把前 3 档相加

设选中生成步骤的五个评分 logits 是 $a_1,\ldots,a_5$:

\[u_i=\frac{e^{a_i}}{\sum_{j=1}^5e^{a_j}}, \qquad p(q)=u_1+u_2+u_3=1-u_4-u_5.\]

这里是在评分 token 集合内部重新归一化。如果先对整个词表做 softmax,再只取五个概率而不归一化,算出来会不同。

下面假设已经定位到评分步骤,用一个极简 logits 演示后半段:

# 计算示例:对应 CausalLLMClassifier 中评分步骤的概率聚合
import torch

V0 = 128256
step_scores = torch.zeros(1, V0 + 5)  # [1,128261]
step_scores[:, V0:] = torch.tensor([[0., 1., 2., 3., 4.]])
rating_logits = step_scores[:, V0:]  # [1,5]
rating_prob = rating_logits.softmax(dim=-1)
p_strong = rating_prob[:, :3].sum(-1)  # [1]
print(round(p_strong.item(), 4))       # 0.1295

在完整推理接口中,这里的 step_scores 来自 out.scores[k],其中 $k$ 是首个评分 token 的生成步骤;找不到评分 token 时,外层 router 的默认分数是 1,倾向走 strong。

代码里的 score_threshold=4 是把五档评分切成两组,不是前面 $p(q)\geq\alpha$ 的概率阈值。一个切评分类别,一个切最终路由分数。

7.4 训练时谁预测谁:标签位置要向前错一格

因果语言模型在位置 $t-1$ 预测位置 $t$ 的 token。因此训练序列如果是:

system instruction + 问题 + Prediction: + [[2]]

预测 [[2]] 的是它前一个位置的输出,不能拿 [[2]] 自己位置上的隐藏状态去预测自己。

一个教学实现可以把无关位置的标签置为 -100,只监督评分 token:

# 教学函数:score_token_loss(logits, input_ids, score_pos)
# logits [B,T,V];input_ids [B,T];score_pos [B] 为评分 token 下标
# 返回:完整词表交叉熵的标量损失
import torch
import torch.nn.functional as F

def score_token_loss(logits, input_ids, score_pos):
    B, T, V = logits.shape
    rows = torch.arange(B, device=input_ids.device)
    labels = torch.full_like(input_ids, -100)
    labels[rows, score_pos] = input_ids[rows, score_pos]
    return F.cross_entropy(
        logits[:, :-1, :].reshape(-1, V),  # 前一个位置的预测
        labels[:, 1:].reshape(-1),        # 后一个位置的真实 token
        ignore_index=-100,
    )

注意两个不同的“mask”:attention_mask 控制哪些输入位置有效,labels=-100 控制哪些位置计算损失。

上面演示的是常见的评分 token 监督方式。RouteLLM 公开入口把分类器训练交给 llmforge,没有展示完整的底层训练循环;因此不能把这段教学 mask、完整词表损失的细节,写成已核实的 checkpoint 历史训练配置。能直接核实的是发布模型与推理时的五档聚合方式。

8. 四种方法究竟在何时计算,平局又怎么处理

把训练与推理放到一起,算法差异会更清楚:

方法 离线阶段 在线阶段 平局处理
SW 存比较、存 embedding、构造 tier 当前问题算相似度,再拟合加权 BT 拆成一胜一负,等价二元目标 0.5
MF 学模型向量、问题投影和打分层 embedding → 两侧分数差 → sigmoid 公开训练脚本丢弃
BERT/XLM-R 微调文本编码器与三类分类头 三类 softmax → 取 strong 胜概率 保留独立 tie 类,决策时并入 weak-or-tie
Causal LLM 微调指令式评分模型 找评分 token → 五档 softmax → 聚合 推理接口没有独立 tie token,不能套用三类映射

统一接口不代表统一标签空间。 这是复现时最容易被一句“都是预测 strong 胜率”掩盖掉的差异。

训练资源也不是一个数量级。论文报告 MF 约 10 epochs、batch size 64,用 8GB GPU;BERT 约 2000 steps、最大长度 512,用 2 张 L4;Causal 约 2000 steps、最大长度 2048,用 8 张 A100 80GB。论文报告值与公开脚本示例要分开看,例如 MF 示例脚本设的是 100 epochs,不能把它当成论文实验配置。

9. 分数算出来之后,还要校准阈值,才能控制账单

9.1 想让 30% 的请求走 strong,就取分数的 70% 分位数

假设在一批有代表性的请求上得到分数 $p_1,\ldots,p_n$。希望 strong 调用比例为 $\rho$,可先选:

\[\alpha=\operatorname{Quantile}_{1-\rho}(p_1,\ldots,p_n).\]

这一步不需要答案标签,也能按分数分布控制近似流量比例;但要判断质量是否达标,仍然需要回答质量评估。

例如:

# 计算示例:calibrate_threshold(scores, target_fraction)
import numpy as np

scores = np.array([.05, .1, .2, .4, .7, .9])
alpha = np.quantile(scores, 1 - 1/3)
use_strong = scores >= alpha
print(round(alpha, 2), round(use_strong.mean(), 3))
# 0.5 0.333

分数大量相同时,>= 会让实际比例跳变;流量分布变化后,原阈值也不再保证同样比例。不是抄一个论文阈值就能永久省钱。

9.2 胜率不等于质量增益,调用比例也不等于真实费用

假设 A 类问题上 strong 以 0.9 的概率略好一点,B 类问题上 strong 以 0.6 的概率好很多。如果业务计的是质量提升幅度,B 未必比 A 更不值得花钱。

因此 RouteLLM 的分数是一个有用的代理目标,不直接等于每个请求的经济价值。更一般的决策可以写成:

\[\Delta U(q)= \mathbb E[U(M_s(q))-U(M_w(q))\mid q],\] \[\text{选择 strong,当 } \Delta U(q)>\lambda\,[C_s(q)-C_w(q)].\]

$U$ 是业务质量,$C$ 是费用,$\lambda$ 是对额外费用的惩罚。这是理解原方法边界的扩展表达,不是论文声称实现了逐请求效用最优。

同理,若每次 strong/weak 的费用近似固定,总成本可以粗写为:

\[C_{\mathrm{avg}}\approx \rho C_s+(1-\rho)C_w+C_{\mathrm{router}}.\]

真实 API 账单还受输入输出长度、缓存、不同端点定价影响。论文用 strong 调用比例作主要成本代理,是因为当时两者费用差距很大,不意味着线上永远只数请求条数就够。

10. 看实验之前,先把 PGR、APGR 和 CPT 算明白

10.1 “恢复 50% 差距”不等于“达到 strong 的 50%”

PGR 是 performance gap recovered,恢复的性能差距比例,越高越好:

\[\operatorname{PGR}= \frac{r_{\mathrm{router}}-r_w}{r_s-r_w}.\]

如果 weak 得 70 分,strong 得 90 分,router 得 80 分,那么:

\[\operatorname{PGR}=\frac{80-70}{90-70}=50\%.\]

router 的绝对分数已经是 strong 的 $80/90\approx88.9\%$,但只恢复了两者差距的一半。

这个比例要求 $r_s>r_w$ 才方便解释;它也不是数学上必定处于 0 到 1。若错误选择导致更差表现,可以小于 0;若能利用互补性超过全 strong,也可以大于 1。

APGR 在不同 strong 调用比例下,对 PGR 曲线求面积,越高越好:

\[\operatorname{APGR}= \int_0^1\operatorname{PGR}(c)\,dc.\]

$c$ 是 strong 调用比例。论文用 10 个调用比例点的 PGR 均值做近似。理想化随机路由的期望 PGR 约等于调用比例,所以面积约为 0.5;有限样本结果会有波动。

CPT 是 call-performance threshold,表示达到指定 PGR 所需的最小 strong 调用比例,越低越好。例如 CPT(50%) 是恢复一半性能差距需要调用多少 strong。

下面这张原图先看右侧:横轴是 strong 调用比例,绿色虚线标出 CPT(50%),蓝色阴影对应 APGR 的面积;再看左侧和中间,相同调用比例下曲线越高,路由越有效。

路由质量与成本指标

右侧绿线大约在 37% 的调用比例处达到一半性能差距,说明把贵模型用在更合适的问题上,能比随机分流更有效。中间带 (A) 的曲线表示加入增强数据,局部提升来自训练分布改善,不是单纯调整阈值。

10.2 同样的数据增强,在聊天、学科题和数学题上效果不同

先看 MT Bench,开放式对话评测,由模型裁判打分。下图左边只有 Arena 数据,右边加入裁判增强数据,横轴都是 GPT-4 调用比例。

聊天路由与数据增强

左侧 BERT 红线在不少区间甚至低于随机;右侧增强之后,MF、SW、BERT 都明显高于随机。更大的编码器不天然等于更好的 router,数据不足时反而容易学不好。

原文 MT Bench 的关键数字如下,CPT 越低越好,APGR 越高越好:

方法 训练数据 CPT(50%) CPT(80%) APGR
随机 — 49.03% 78.08% 0.500
MF Arena 25.32% 74.26% 0.580
MF Arena + judge 13.40% 31.31% 0.802
SW Arena + judge 23.21% 36.04% 0.759
BERT Arena + judge 19.58% 34.02% 0.751
Causal LLM Arena + judge 31.50% 48.75% 0.679

其中 CPT(50%) 对应的 MT Bench 分数约为 8.8,strong 的分数约为 9.3,所以“95% 的 GPT-4 分数”与“50% 的差距恢复”可以同时成立。

再看 MMLU。下面左边只有 Arena,右边补充约 1500 道 MMLU 验证集问题构造的标签。

学科题路由分布变化

左侧几条线基本贴着随机走;右侧才拉开差距。MF 的 CPT(50%) 从 45.00% 降到 35.46%,但仍不像聊天任务那样只需 13.40%。不能把某个数据集上的省钱比例直接搬到另一个业务。

最后看 GSM8K,小学数学应用题评测。左边只有 Arena,右边加入 judge 数据。

数学题路由与数据增强

左侧几种方法大多低于随机;右侧 Causal LLM 的紫线表现最好。它的 CPT(50%) 为 33.64%,CPT(80%) 为 63.26%,APGR 为 0.622。MF 在这里的 APGR 为 0.565。

这说明不同方法的容量和偏置会影响不同任务,但更一致的现象是:router 有没有见过目标请求分布,比“它是不是更大的模型”更基础。

10.3 “3.66 倍省钱”需要把比较对象说完整

论文成本分析里的 3.66 倍,可以从 MT Bench 上的数字直接看出:

\[\frac{49.03\%}{13.40\%}\approx3.66.\]

它比较的是:达到同样的 PGR=50% 时,随机路由与最佳 router 的 strong 调用比例。在 strong 费用占主导的假设下,换算为约 3.66 倍的成本改善。

因此不能把这个数字直接写成“相对全量调用 GPT-4,所有场景费用统一除以 3.66”。论文当时的费用估计约为 GPT-4 每百万 token 24.7 美元、Mixtral 0.24 美元,这些是历史实验假设,不是当前 API 报价。

11. 换一对模型仍然能用,但迁移的是查询排序能力

论文还测试了两组未在原训练数据里出现的回答模型:

  • Claude 3 Opus / Claude 3 Sonnet。
  • Llama 3.1 70B / Llama 3.1 8B。

不重新训练 router,仅替换最终选择的回答模型,仍然观察到有效的质量与成本权衡。

这个结果在代码里如何发生,需要讲清楚。

Controller 负责配置实际回答出口,例如新的 strong / weak API;MF 和 SW 内部则仍可以按原 GPT-4/Mixtral 对计算分数:

新问题
  → 原内部模型对的路由分数
  → 与阈值比较
  → 新 strong / 新 weak 出口

所以“能换模型对”不等于 MF 自动给任何新模型生成了一个能力向量。显式改内部打分模型时,MF 仍要求名称存在于模型 ID 表里,SW 则要求模型已存在于对应的 tier 映射中。

迁移成功,说明原分数对“哪些问题更需要强模型”的排序有一定共性。它不能证明原来 0.8 的分数,换到新模型对后仍然是准确的 80% 胜率。

11.1 用训练集相似度诊断分布差异

论文给了一个数据分布诊断指标:对每个测试问题,找训练集中最相似的问题,再对这些最大相似度取平均:

\[\mathcal S(B,D)= \frac1n\sum_{i=1}^n \max_{1\leq j\leq m} \cos(b_i,d_j).\]

$B$ 和 $D$ 分别是测试问题集与训练问题集,$b_i,d_j$ 是文本向量。一次性写成矩阵是:

归一化测试向量 [n,1536]
  × 归一化训练向量转置 [1536,m]
  → 相似度 [n,m]
  → 每行 max [n]
  → mean []

比如 100 道测试题,65000 条训练比较,会得到 [100,65000] 的相似度矩阵。大数据时应分块计算,保留每行最大值即可,不需要把整个矩阵一直放在内存中。

原文中,Arena 与 MMLU 的这个指标为 0.4823,加入 gold 数据后变为 0.5678。它与路由表现改善相关,可以用来发现覆盖不足;它不是回答质量指标,也不能单独证明没有数据泄漏。

论文另用 embedding 相似度阈值 0.95 检查潜在污染,并移除命中的评测样本。加入的 MMLU 数据来自验证集,不应混成测试题。

12. 真正落地时,最后还要对齐三件事

12.1 router 自己也要花时间和钱

下面是原文测得的吞吐和成本。先对比每秒请求数,再看每百万请求的 router 成本;这些是特定硬件与实现下的测量,不是单请求延迟承诺。

路由器吞吐与成本

SW 约 2.9 requests/s,MF 约 155.16,BERT 约 69.62,Causal LLM 约 42.46。SW 的每百万请求成本约为 39.26 美元,MF 为 3.32 美元。

SW 没有离线神经网络训练,却在每次请求重新拟合,在线开销反而最大。MF 的小前向很便宜,但要把外部 embedding 的调用也算进去。这里的吞吐还受批处理、服务并发等影响,不能直接取倒数就当端到端延迟。

12.2 多轮对话中,路由看到的上下文可能比回答模型少

固定提交的 controller 用:

# routellm/controller.py
# class Controller
# def _get_routed_model_for_completion(...)
prompt = messages[-1]["content"]

它直接取最后一条消息的 content,没有把整段聊天历史拼给 router,也没有搜索“最后一条 user 消息”。选好模型后,完整 messages 仍会传给回答生成接口。

所以对“那刚才第二种方案呢?”这种请求,router 可能只看到这一小句,而回答模型能看到前文。理解输入输出时,这个边界比多记一个网络层名更重要。

12.3 先在自己的请求分布上确认,再决定用哪个 router

最值得先试的通常是 MF:实现简单,计算链路短,官方也推荐。随后应在自己的代表性问题上比较不同 strong 调用比例下的质量,而不是仅检查分类准确率。

只有当需要更强的文本表示,并且有足够贴近业务的偏好数据时,更大分类器的训练与部署开销才更容易合理化。新模型对、长上下文、不同语言或多轮对话,都需要重新确认分数排序与阈值。

13. 总结

RouteLLM 最容易被一句“训练一个模型,判断问题难不难”带过去。但沿着实际计算往下走,四种方法解决的是同一个接口问题,内部机制差得很大:

  • SW 用问题相似度改变历史比赛的权重,在线重算一张局部排行榜。
  • MF 把 1536 维问题向量投影到 128 维,与模型向量逐元素组合,最后用两个分数的差预测胜负。
  • 发布的 BERT 类模型是 XLM-R 三分类,softmax 后取 strong 胜出那一类。
  • Causal LLM 用五个新增评分 token 表达判断,在五档内部归一化后再聚合。

训练标签、模型分数、路由选择、最终答案,是四个不同的东西。 把它们的输入输出和维度分开,算法就没那么绕了。

从实验看,真正值得记住的也不只是 MT Bench 上的 13.40% strong 调用比例,而是同一个 router 换到学科题和数学题,效果就会明显变化。路由不是脱离数据分布的一套固定规则:它是在用历史比较,预测当前问题上多花钱有没有价值。

参考资料与代码核对

以下源码链接固定到本文核对的提交,方便后续对应实现;Hugging Face 链接也固定到配置版本。

  1. 论文 v4:方法、实验与附录。
  2. 四种 router、阈值比较与默认维度。
  3. SW 的 BT 拟合、平局处理与 tier 动态规划。
  4. MF 推理模型与模型 ID 表、MF 训练脚本。
  5. BERT/XLM-R 发布配置。
  6. Causal LLM 推理与评分聚合、发布模型配置。
  7. Controller:选择模型与保留完整 messages、公开分类器训练入口。

顺带一提,模型路由是在服务时决定“这次用哪个模型”,AutoML 则把类似的取舍往前移,研究模型架构、压缩和配置该怎么自动寻找。我们把后一个方向的积累整理进了《动手学 AutoML:从 NAS 到大语言模型优化实战》,其中包括 NAS 搜索、LLM 剪枝量化与模型融合,也有从零动手的实现。书里没有 RouteLLM 这套路由工程,关注的是另一侧的模型优化问题。

动手学AutoML书籍封面

Flag Counter