小模型+大模型级联:简单问题用便宜的,难的再上贵的

小模型+大模型级联:简单问题用便宜的,难的再上贵的

"大多数 AI 应用的账单里,有 70% 的请求其实不配用旗舰模型——它们只是恰好走了一条不打折的通道。"

🔥 摘要:级联(Cascade)不是"用小模型替代大模型",而是给每一次请求做难度分级:先用便宜的小模型(SLM)冲一轮,只有它"没把握"时才升级到旗舰大模型(LLM)。本文讲清级联为什么省钱、三种路由策略怎么选(规则 / 分类器 / 自一致性置信度)、难度打分怎么落地,并给出一份可直接运行的 Python 级联路由器(含成本账本与超时兜底)。最后给出成本模型、7 个踩坑与 3 个练手项目。

🎯 阅读收益:① 看懂级联降本的真实杠杆点;② 拿到三种路由策略的选型对比表;③ 学会用"自一致性投票"给小模型输出打置信度;④ 复制一份可运行的级联路由器代码;⑤ 掌握升级率与漏升级率的调法;⑥ 避开最常见的 7 个坑。

⚠️ 说明:文中模型单价、成本比例、升级率均为示例量级,不代表任何厂商实际定价;各家模型价格与能力迭代极快,动手前请以官网实时报价为准,并在自己的流量上重测。
在这里插入图片描述


一、你的钱到底花在哪了

先把账算明白,再谈架构。

一个典型的 LLM 应用,单次请求成本可以拆成:

$$Cost = \frac{P_{in} \times T_{in} + P_{out} \times T_{out}}{10^6}$$

其中 $P$ 是每百万 token 单价,$T$ 是 token 数——一旦乘上日请求量,差异非常刺眼。假设一个日均 3 万次请求的知识助手,平均输入 1200 token、输出 300 token,全量走旗舰模型,按示例单价(输入 15 / 输出 60 元每百万)估算:单次 ≈ $0.036$ 元,单月 ≈ 3.2 万元。

而这 3 万次请求里,真实分布通常是这样的:

请求类型 典型占比 是否真的需要旗舰模型
格式转换、字段抽取、意图分类 30%~40% 不需要,1B~3B 模型绰绰有余
FAQ 直答、简单摘要、改写润色 25%~35% 大部分不需要
多步推理、长文档综合、代码生成 15%~25% 需要
边界模糊、高风险、用户明确要求"仔细想" 5%~10% 必须

也就是说,有六到七成的请求本来就用不到旗舰模型。级联要做的,就是把这六到七成拦下来。

这里有个容易忽略的点:降本不只是"换便宜模型",还包括减少不必要的输入 token(提示压缩、缓存命中)和减少输出 token(约束输出长度、强制 JSON Schema)。级联和这两招是叠加关系,不是互斥关系。

二、级联的三种路由策略

路由是级联系统的心脏。目前工程上主流的有三种,复杂度与收益依次递增。

mermaid diagram

策略一:规则路由(Rule-based)

最朴素,也最不该被轻视。用一组可解释的规则决定走哪条路:

  • 输入 token 数 < 300 且不含代码块 → 小模型
  • 命中"总结/翻译/改写/提取/分类"等意图词 → 小模型
  • 命中"推导/证明/一步步/写代码/对比分析" → 大模型
  • 用户显式指定模型 → 直接路由

优点:零额外开销、可解释、可审计、出问题时一句话就能改。
缺点:规则永远追不上用户表达的多样性,长尾请求容易误判。

适合:冷启动阶段,或者业务请求形态高度固定的场景(例如客服 FAQ、表单抽取)。我的建议是规则路由一定要保留,哪怕后面上了分类器,也要让规则拥有"一票否决"的最高优先级——这是你线上最后一道可解释的护栏。

策略二:分类器路由(Router Model)

训练(或微调)一个轻量打分器,输入用户 query,输出"难度分"或"该走哪个模型"的标签。常见做法:

  • 用一个 0.5B~3B 的小模型做二分类/多分类微调;
  • 或者直接用 embedding + 轻量 MLP 头,输入 query 向量,输出难度分;
  • 训练标签来自历史日志:把"小模型答对、大模型也答对"的样本标为 0(可降级),把"小模型答错、大模型答对"的标为 1(必须升级)。

优点:能捕捉规则覆盖不到的语义难度信号,准确率通常显著高于纯规则。
缺点:需要标注数据和维护成本;分类器本身也会漂移,业务请求分布一变,判定就失真。

关键技巧:不要直接让分类器输出 0/1 硬标签,而是输出概率,然后你去调阈值。因为"漏升级"(本该上大模型却给了小模型,答错了)和"过度升级"(简单题也上大模型,多花钱)的代价是不对称的——绝大多数业务里,漏升级的代价比多花几分钱高得多。

策略三:自一致性路由(Self-Consistency Routing)

这是我最推荐作为主策略的一种:让小模型先答,然后判断它"答得稳不稳"。

具体做法(后面代码里会实现):

  1. 小模型用温度 $T > 0$(例如 0.8)采样 $k$ 次(通常 $k=3$);
  2. 对 $k$ 个答案做归一化后比对,统计一致率;
  3. 一致率高 → 说明模型对这个问题是"有把握"的,直接返回;
  4. 一致率低 → 说明模型在犹豫,升级到大模型。

为什么有效:模型采样结果的分歧度,和它答对的概率存在显著相关性。模型"拿不准"的时候,多次采样会给出五花八门的答案;"拿得准"的时候,几次采样高度收敛。这个信号是免费的——你已经花了那一次推理,只是多花两次采样,成本仍然远低于直接上大模型。

代价:小模型侧耗时变成约 3 倍(但可以并行发起),且需要任务是可判定的(答案能比较)。对于开放式写作类任务,改为让小模型自己输出一个 0~100 的置信度分,或者用第二个小模型做"评审"(LLM-as-judge),也能达到类似效果。

三种策略横向对比

维度 规则路由 分类器路由 自一致性路由
额外推理开销 无 一次小模型/向量打分 $k-1$ 次小模型采样
准确率 中 中高 高(对可判定任务)
可解释性 强 弱 中(可以给"分歧原因")
维护成本 中(规则会膨胀) 高(需重训) 低(无需训练)
冷启动难度 低 高(需日志积累) 低
推荐用法 一票否决护栏 主策略(有数据后) 主策略(冷启动)

工程上成熟的形态通常是三层叠加:规则先做硬性拦截(例如涉法涉医必须大模型)→ 分类器给一个先验难度分 → 小模型自一致性校验做最终闸门。

三、架构与数据流

一次请求的完整生命周期:统一入口(OpenAI 兼容协议,业务方无感知)→ 规则闸门(命中即路由,零额外推理)→ 小模型执行(顺带取 logprobs,那是免费的置信度信号)→ 置信度判定(自一致性投票 / logprobs 均值 / 评审打分,三选一或组合)→ 升级或返回(低于阈值调大模型,并写入升级原因)→ 账本与复盘(记录模型、token、耗时、成本、是否升级)。

最后一步最容易被砍掉,但它决定了这套系统是"一次性降本"还是"持续降本"。

四、一个可直接运行的级联路由器

下面这份代码是完整可跑的,只依赖 httpx,对接任何 OpenAI 兼容端点(vLLM、One-API、New API、官方 API 均可)。

# -*- coding: utf-8 -*-
"""cascade_router.py —— 小模型优先、不达标再升级到大模型
依赖: pip install httpx   环境变量: OPENAI_BASE_URL / OPENAI_API_KEY"""
import os, re, time, json, asyncio
from collections import Counter
import httpx

BASE = os.getenv("OPENAI_BASE_URL", "http://localhost:8000/v1")
KEY = os.getenv("OPENAI_API_KEY", "sk-placeholder")
SMALL = os.getenv("SMALL_MODEL", "Qwen/Qwen2.5-3B-Instruct")
LARGE = os.getenv("LARGE_MODEL", "Qwen/Qwen2.5-72B-Instruct")
PRICE = {SMALL: {"in": 0.6, "out": 1.8}, LARGE: {"in": 4.0, "out": 12.0}}  # 元/百万token

FORCE_LARGE = [r"推导", r"证明", r"一步步", r"逐步推理", r"写代码", r"对比分析"]
FORCE_SMALL = [r"^翻译[::]", r"^改写[::]", r"^提取", r"^总结为?\d*字"]
MAX_SMALL_INPUT, CONF_THRESHOLD, SAMPLES = 1500, 0.66, 3

_c = None
def client():
    global _c
    if _c is None:
        _c = httpx.AsyncClient(base_url=BASE,
                               headers={"Authorization": f"Bearer {KEY}"}, timeout=60.0)
    return _c

async def chat(model, messages, temperature=0.7, max_tokens=512):
    r = await client().post("/chat/completions", json={
        "model": model, "messages": messages,
        "temperature": temperature, "max_tokens": max_tokens})
    r.raise_for_status()
    d = r.json()
    return d["choices"][0]["message"]["content"], d.get("usage", {})

def cost(model, usage):
    p = PRICE.get(model, {"in": 0, "out": 0})
    return (p["in"] * usage.get("prompt_tokens", 0)
            + p["out"] * usage.get("completion_tokens", 0)) / 1_000_000

def norm(s):                       # 归一化:去空白/标点/大小写
    return re.sub(r"[\s\W_]+", "", s).lower()

def rule_route(q):                 # 'large' / 'small' / None(交给置信度判定)
    if any(re.search(p, q) for p in FORCE_LARGE) or len(q) > MAX_SMALL_INPUT:
        return "large"
    return "small" if any(re.search(p, q) for p in FORCE_SMALL) else None

async def self_consistency(messages, k=SAMPLES):
    """采样 k 次投票 -> (最一致答案, 置信度, 累计用量)"""
    res = await asyncio.gather(*[chat(SMALL, messages, temperature=0.9) for _ in range(k)])
    ans = [norm(a) for a, _ in res]
    usage = {"prompt_tokens": sum(u.get("prompt_tokens", 0) for _, u in res),
             "completion_tokens": sum(u.get("completion_tokens", 0) for _, u in res)}
    top, cnt = Counter(ans).most_common(1)[0]
    return res[ans.index(top)][0], cnt / k, usage

async def route(query, system="你是一个严谨的助手,回答简洁准确。"):
    messages = [{"role": "system", "content": system}, {"role": "user", "content": query}]
    led = {"small_calls": 0, "large_calls": 0, "cost": 0.0,
           "upgraded": False, "reason": "small_confident"}
    t0 = time.time()
    forced = rule_route(query)
    if forced == "large":
        ans, u = await chat(LARGE, messages, temperature=0.3)
        led.update(large_calls=1, upgraded=True, reason="rule_forced_large", cost=cost(LARGE, u))
    elif forced == "small":
        ans, u = await chat(SMALL, messages, temperature=0.3)
        led.update(small_calls=1, reason="rule_forced_small", cost=cost(SMALL, u))
    else:
        ans, conf, u = await self_consistency(messages)
        led.update(small_calls=SAMPLES, confidence=round(conf, 3), cost=cost(SMALL, u))
        if conf < CONF_THRESHOLD:                          # 没把握 -> 升级
            ans, u2 = await chat(LARGE, messages, temperature=0.3)
            led.update(large_calls=1, upgraded=True, reason=f"low_confidence_{conf:.2f}",
                       cost=led["cost"] + cost(LARGE, u2))
    led["latency_s"] = round(time.time() - t0, 2)
    return ans, led

if __name__ == "__main__":
    async def demo():
        total = 0.0
        for q in ["把下面这段话翻译成英文:今天天气不错,适合出门散步。",
                  "水池甲管 6 小时注满、乙管 4 小时注满,两管同开多久注满?请一步步推导。",
                  "帮我提取这段文本里的联系人姓名和手机号。"]:
            ans, led = await route(q)
            total += led["cost"]
            print(json.dumps(led, ensure_ascii=False), "|", ans[:60].replace("\n", " "))
        print(f"总成本: {total:.6f} 元")
    asyncio.run(demo())

运行:

pip install httpx
export OPENAI_BASE_URL="http://localhost:8000/v1"
export OPENAI_API_KEY="sk-placeholder"
python cascade_router.py

这段代码已经包含了级联的所有关键要素:规则闸门、自一致性投票、阈值升级、成本账本、升级原因留痕。你只要把 PRICE 换成真实单价、把 FORCE_LARGE / FORCE_SMALL 换成你业务的关键词表,就能直接上灰度。

五、成本能降多少:把账算清楚

级联到底能省多少,取决于一个数字:升级率 $r$(走大模型的请求占比)。

设全量走大模型的单请求平均成本为 $C_L$,全量走小模型为 $C_S$,自一致性带来的额外采样倍率为 $k$,则级联后的单请求成本约为:

$$C_{cascade} \approx k \cdot C_S \cdot (1-r) + (k \cdot C_S + C_L) \cdot r
= k \cdot C_S + r \cdot C_L$$

也就是说,成本 = 小模型采样开销(固定支出)+ 升级率 × 大模型单价(浮动支出)。这个公式极其有用,它告诉我们两件事:

  1. 升级率 $r$ 是唯一的浮动杠杆,每降 10 个点,成本就线性下降;
  2. 小模型的采样开销是"底座",如果 $k$ 太大(比如采样 5 次),底座会抬高,抵消升级率下降的收益。所以 $k=3$ 通常是性价比拐点。

带入前文示例量级($C_L{in}=15$、$C_L=60$;$C_S{in}=0.6$、$C_S=1.8$,输入 1200 / 输出 300 token):

策略 相对成本 说明
全量旗舰大模型 100% 基线,质量最好但最贵
全量小模型 约 4% 便宜,但复杂问题质量不可接受
级联(升级率 30%) 约 40% 保守阈值,质量接近全量旗舰
级联(升级率 18%) 约 25% 调优后的常见区间
级联 + 前缀缓存 + 提示压缩 约 13%~18% 三招叠加,缓存命中率高时更明显

在这里插入图片描述

注意这里的"质量"是有代价的。升级率从 30% 压到 18%,意味着有 12% 的请求从"大模型答"变成"小模型答",其中一部分会答错。所以调阈值时一定要盯着质量指标一起看,而不是只看账单。

六、质量护栏:别为了省钱把体验搞砸

级联最怕的不是多花钱,是漏升级——一个本来该上大模型的问题被小模型糊弄过去了,用户拿到一个一本正经的错误答案。这类事故比多花几块钱严重得多。

我的做法是加三道护栏:① 非对称阈值——漏升级代价远高于过度升级,阈值要偏向"宁可多升级",自一致性阈值一开始定 0.66(3 次采样 2 次一致),宁可多花也别错;② 高风险领域强制升级——医疗、法律、金融、人身安全相关一律走大模型,并在提示词里加"不确定时明确说不确定",这条是硬规则,不看置信度;③ 用户反馈闭环——前端放一个"这答案不对"按钮,点一次就把该请求连同上下文存进 bad case 库并强制升级重答,既是兜底,也是调优分类器最好的标注数据源。

评估效果时不要只看成本,至少同时盯这四个指标:

指标 定义 健康区间(参考)
升级率 $r$ 走大模型的请求占比 15%~35%,视业务难度
漏升级率 小模型答错且未升级的比例 越低越好,建议 < 2%
过度升级率 小模型本来能答对却升级的比例 可接受 10%~25%
P95 延迟 包含升级路径的端到端耗时 不超过全量大模型的 1.2 倍

"过度升级率高"不是坏事,它是你为漏升级买的安全垫。真正要压制的是漏升级率。

七、什么时候不该用级联

四类场景我不建议上:① 请求形态单一且都很简单(95% 都是"提取手机号")→ 直接全用小模型;② 对延迟极度敏感的实时交互(语音字幕、输入法联想)→ 升级路径串行,尾部延迟翻倍;③ 输出不可比较的开放式创作 → 投票机制失效;④ 日均请求量太小(< 5000 次)→ 省的钱不够维护这套系统。

八、生产踩坑清单

  1. 阈值拍脑袋定死,上线后再也不看。请求分布会随季节、活动、产品改版漂移,必须把升级率做成仪表盘指标,月度复盘。
  2. 忘了给升级路径做超时兜底。级联是串行的,必须给大模型调用设独立超时(如 8 秒),超时就用小模型的最佳答案返回,并记录 fallback_timeout。
  3. 自一致性采样用了 temperature=0。温度为 0 时多次采样结果几乎一致,投票率恒等于 1,置信度信号直接失效——这是个非常隐蔽、会让你"以为级联很有效"的坑。采样温度必须 > 0(0.7~1.0)。
  4. 归一化函数写得太粗糙。只去空格会把"答案是 3"和"答案是 3。"判为不一致,升级率虚高;归一化过头又会误判不同答案为一致。归一化规则要针对任务单独设计并回归测试。
  5. 成本账本只统计 token,没统计失败重试。重试、流式中断、超时重发都产生真实费用。账本要挂在实际发出的请求上,否则成本模型系统性偏低。
  6. 大小模型部署在同一实例抢显存。级联意味着两个模型都常驻,小模型应单独部署(甚至可用 CPU 或消费级卡),大模型独占推理卡。
  7. 提示词没做区分。小模型需要更强的输出格式约束(JSON Schema、少样本示例、更短指令),大模型才适合开放式指令。同一份提示词下,小模型表现会被系统性低估。

九、练手项目

  1. 搭一个最小级联网关并压测。用 vLLM 起一个 3B 和一个 32B(或 API 版),套用本文代码,构造 300 条混合难度 query,测出不同阈值(0.5 / 0.66 / 0.9)下的升级率、准确率与总成本,画出"成本-准确率"帕累托前沿曲线。
  2. 训练一个难度分类器。跑两周级联日志,用"小模型答对/答错"自动打标,训一个 0.5B 二分类器,看能否把升级率再降 5~10 个点且漏升级率不升,并对比它与规则路由的差异。
  3. 做一次"漏升级"专项复盘。捞出所有被用户点"答案不对"的请求,人工分类:规则没拦住?置信度误判?还是小模型确实答不出来?输出根因分布图,针对性补规则。

结语

级联的本质不是"用小模型省钱",而是给每一次请求匹配它真正需要的算力——绝大多数团队的做法恰恰相反:为了省事,把所有请求都扔给最贵的模型,然后祈祷账单能扛住。

动手顺序建议:先上规则路由 + 账本(零成本,先看清请求分布)→ 再上自一致性校验(把难度判断自动化)→ 最后用日志训分类器(把升级率一点点压下来),全程盯着漏升级率,别让省钱变成事故。

最后提醒:所有成本数字都要在你自己的流量上重算一遍。本文公式是通用的,数字是示例的。你真正需要的不是抄一个阈值,而是建立"升级率—质量—成本"三者的量化关系,按自己的业务容忍度去调它。

posted @ 2026-08-30 20:53  橘和柠  阅读(10)  评论(0)    收藏  举报