一个客服问答接口,绝大多数流量是"我的订单到哪了",少部分是三百字的投诉。全走最强模型,账单像开了水龙头;全走小模型,投诉那一类答得一塌糊涂。多模型不是"多接几个 API",而是在请求进入模型之前,先决定这次该花多少钱。
成本不是事后看账单能控制的,它由四个变量在请求发生的那一刻就锁定了:模型单价、输入长度、输出长度、调用次数。
1. 为什么需要多模型
| 动因 | 表现 | 工程含义 |
|---|---|---|
| 能力梯度 | 简单分类小模型就够,复杂推理要强模型 | 按难度分流,别一律顶配 |
| 成本梯度 | 单价可差一个数量级 | 分流即省钱,量级按各家价目核对 |
| 延迟差异 | 小模型首 token 明显更快 | 交互场景优先小模型 |
| 可用性冗余 | 单一供应商限流或故障 | 主备切换,接口契约要统一 |
| 合规与地域 | 数据不能出境、需本地部署 | 按租户/地域路由到不同部署 |
最后一条常被忽略:某些租户的请求必须走境内节点,这不是成本问题,是路由的硬约束,优先级高于一切成本优化。
2. 路由的四种依据
| 依据 | 判定手段 | 优点 | 风险 |
|---|---|---|---|
| 任务类型 | 意图分类(小模型或规则) | 稳定、可解释 | 分类错则整条链路错 |
| 输入长度/复杂度 | token 数、实体数、是否含代码 | 零成本、即时 | 长度≠难度 |
| 置信度/自评 | 小模型输出概率或自评分数 | 动态 | 自评本身要花一次调用 |
| 成本预算 | 剩余额度、租户等级 | 直接控钱 | 额度耗尽时的降级要设计 |
def route(req, tenant):
if tenant.region == "cn": # 硬约束优先
return "model-cn-small" if req.tokens < 2000 else "model-cn-large"
if req.tokens > 8000 or req.has_code:
return "model-large"
if req.intent in {"refund_policy", "complaint"}:
return "model-large"
return "model-small"
反模式:用"让模型自评难度"来做路由判据。自评要调用一次模型,如果用小模型自评、再由大模型作答,你付了两次钱;用大模型自评,等于每次都先付大模型的钱。自评只在"答错代价极高"的场景成立。
3. 级联策略与成本结构
级联是"小模型先答,不合格再升级"。它的成本不是两段相加,而是加权:
总成本 ≈ c1 + (1 - h) · c2
其中 c1 = 小模型单次成本,c2 = 大模型单次成本,h = 小模型命中率(一次通过率)
| h(命中率) | c2 = 5·c1 时总成本 | c2 = 20·c1 时总成本 |
|---|---|---|
| 0.5 | 3.5·c1 | 10.5·c1 |
| 0.7 | 2.2·c1 | 6.7·c1 |
| 0.9 | 1.4·c1 | 2.9·c1 |
读法:升级代价越大,命中率的价值越高。h 从 0.7 提到 0.9,在 20 倍单价差下成本降一半以上;若 h 只有 0.5,级联几乎等于全走大模型还多付一次小模型的钱。
def cascade(prompt, checker):
out = call("model-small", prompt)
ok, reason = checker(out) # 规则校验优先,别用模型判
if ok:
return out, {"tier": 1, "reason": None}
escalated = call("model-large", prompt + "\n\n上次回答的问题:" + reason)
return escalated, {"tier": 2, "reason": reason}
命中率靠可判定的检查器提升:格式校验、必填字段、引用是否命中检索片段、是否触发拒答词表。这些判定便宜且确定;用模型判"答得好不好"会把省下的钱又花回去。
4. 成本的可控项
| 可控项 | 手段 | 效果量级 | 副作用 |
|---|---|---|---|
| 输入长度 | 检索片段去重、历史压缩 | 直接线性 | 信息损失 |
| 输出上限 | max_tokens 硬限 |
防跑飞 | 长回答被截断 |
| 缓存命中 | 精确/语义/前缀缓存 | 命中即省全额 | 一致性风险 |
| 批处理 | 离线任务合并提交 | 通常有折扣 | 延迟上升 |
| 减少重试 | 首次就带足上下文与示例 | 少一次即省一半 | 提示词变长 |
| 工具收窄 | 按阶段暴露少量工具 | 减少无效轮次 | 灵活性下降 |
max_tokens 是最便宜的一道闸:不设上限时,模型偶尔会输出一段冗长复述,一次就把预算吃掉。设上限的代价是可能截断,所以要配合"输出结构简单化"。
5. 缓存的三层
| 层 | 键 | 命中条件 | 风险 |
|---|---|---|---|
| 精确匹配 | 规范化后的完整 prompt 哈希 | 逐字节一致 | 命中率低(温度、时间戳都会变) |
| 语义相似 | embedding + 阈值 | 语义近 | 错配:近义不同问,答案不可互换 |
| 前缀缓存 | prompt 前缀 | 前缀一致 | 取决于供应商支持与计费方式 |
import hashlib, json
def cache_key(model, version, system, user, params):
raw = json.dumps({"m": model, "v": version, "s": system, "u": user,
"p": {"t": params.get("temperature"), "mt": params.get("max_tokens")}},
sort_keys=True, ensure_ascii=False)
return "llm:" + hashlib.sha256(raw.encode()).hexdigest()
缓存键必须含模型名与版本。换模型后键不变,就会把旧模型的答案发给新模型的用户——这类 bug 极难发现,因为"答案看起来是对的"。
语义缓存要用在"答案与措辞无关"的场景(政策条款、FAQ),不能用在"差一个字就不同答案"的场景(金额、日期、订单号)。判别方法:把两个语义相近但关键实体不同的 query 拿去比,若缓存判为同一,这个阈值就不能用。
6. 预算与配额
| 维度 | 计量 | 超限动作 |
|---|---|---|
| 用户 | 每用户每日 token | 降级到小模型 |
| 租户 | 每月费用 | 拒绝非关键接口、告警 |
| 接口 | 每接口 QPS × 单价 | 限流、排队 |
| 全局 | 日预算 | 熔断到缓存/模板回答 |
LIMITS = {"user_daily_tokens": 200_000, "tenant_monthly_cost": 5_000}
def guard(tenant, user, est_tokens):
if daily_used(user) + est_tokens > LIMITS["user_daily_tokens"]:
return "downgrade" # 降级,不拒绝
if monthly_cost(tenant) > LIMITS["tenant_monthly_cost"]:
return "reject_non_critical"
return "allow"
降级顺序要写死并演练:先降模型档位 → 再减检索片段数 → 再缩短输出上限 → 最后命中缓存或返回模板。顺序反了(先砍检索)会让答案质量断崖,而成本只省一点。
预算只做告警不做拦截,等于没有预算——账单是在超限之后才看到的。
7. 能力评测
同一批用例跑多个模型,比三条线:准确率、延迟、成本。缺一条都会误判。
CASES = load_cases("eval/routing.jsonl") # 每行: {"input":..., "expect":...}
def bench(model):
rows = []
for c in CASES:
t0 = time.perf_counter()
out = call(model, c["input"])
rows.append({"ok": score(out, c["expect"]),
"lat": time.perf_counter() - t0,
"cost": tokens(out) * PRICE[model]})
return {"model": model,
"acc": sum(r["ok"] for r in rows) / len(rows),
"p95_lat": percentile([r["lat"] for r in rows], 95),
"cost_per_case": sum(r["cost"] for r in rows) / len(rows)}
| 维度 | 看什么 | 决定 |
|---|---|---|
| 准确率 | 分任务类型统计,不只看总分 | 哪些任务能交给小模型 |
| 延迟 | p50 与 p95,不只看均值 | 交互场景是否可用 |
| 成本 | 单用例成本 × 预估日调用量 | 预算是否成立 |
| 稳定性 | 重跑两次的一致率 | 能否用于自动化链路 |
按任务类型分开统计是关键:好看的总分往往由「简单题几乎全对、难题大面积答错」构成,而简单题恰好占绝大多数流量。这个分布才决定路由阈值。
8. 模型替换的兼容
| 维度 | 检查项 | 不兼容的后果 |
|---|---|---|
| 输出格式 | JSON mode / 结构化输出是否支持 | 解析层整体失效 |
| 工具调用 | 是否支持并行、是否支持严格 schema | 多步 Agent 退化 |
| 上下文窗口 | 实际可用长度 | 长文档任务被截断 |
| 特殊 token | 是否支持 system 角色、是否有专属控制符 | 提示词行为漂移 |
| 计费口径 | 按输入/输出分别计价还是合并 | 成本估算失真 |
| 限流 | RPM/TPM 配额 | 高峰期大面积超时 |
替换前必须做的:拿同一套评测集跑一遍,对齐输出格式与工具调用语义;把"模型能力假设"从代码里抽出来做成配置(是否支持并行工具、最大窗口、是否支持 JSON schema),而不是散落在 if 里。
9. 常见坑
| 坑 | 后果 | 改法 |
|---|---|---|
| 只用最强模型 | 成本失控 | 按任务分流 + 级联 |
| 路由判据用模型自评 | 两级都贵 | 改规则/长度/意图分类 |
| 缓存键不含模型版本 | 换模型后返回旧答案 | 键里加 model + version |
| 预算只告警不拦截 | 超限才发现 | 硬拦截 + 降级顺序 |
| 语义缓存阈值过松 | 近义不同问被合并 | 用实体差异样本校准 |
| 升级重试不带失败原因 | 大模型重复同样的错 | 把失败原因拼进升级请求 |
10. 排错
| 现象 | 先查 | 处理 |
|---|---|---|
| 账单突然翻倍 | 单请求 token 分布 | 找超长输入/无上限输出 |
| 降级后质量断崖 | 降级顺序 | 先降档位再减片段 |
| 同一问题答案不一致 | 缓存命中情况 | 缩短 TTL,键加参数 |
| 高峰期大量超时 | 限流配额与并发 | 排队 + 主备切换 |
| 换模型后解析失败率上升 | 输出格式支持 | 降级 JSON mode + 校验 |
| 某租户费用异常 | 是否绕过配额校验 | 配额校验前置到网关 |
11. 边界
| 场景 | 更合适 | 理由 |
|---|---|---|
| 单租户、低流量 | 单一模型 + 缓存 | 路由复杂度不划算 |
| 强合规要求 | 固定区域部署,不做成本路由 | 合规优先于省钱 |
| 离线批处理 | 用便宜模型 + 人工抽检 | 延迟不敏感 |
| 答案必须可复现 | 固定模型版本 + 温度 0 | 路由会引入不确定性 |
| 极短请求(分类打标) | 专用小模型或规则 | 通用大模型是浪费 |
12. 自测
- 级联的总成本为什么不是 c1 + c2,命中率 h 在其中起什么作用?
- 为什么"让模型自评难度再路由"通常不省钱?
- 缓存键里为什么必须包含模型名与版本?
- 语义缓存在什么类型的问答上不能用,怎么校准阈值?
- 预算超限时的降级顺序为什么要把"减检索片段"排在"降模型档位"之后?
- 评测只看总分会导致什么误判,应该怎么拆?