上周我看一份 LLM 账单,第一反应不是"贵",而是"奇怪"。流量曲线几乎没动,用户数也没涨,P95 延迟还比上周好看一点,但总费用突然抬了一个台阶。最后查到的原因很尴尬:团队做了一个"聪明"的模型路由器,简单请求先走便宜模型,失败再升级到强模型。代码没报错,监控没告警,用户也没投诉。只是某个输出校验规则变严以后,升级率从 18% 慢慢爬到 71%。
这类事故最麻烦的地方在于,它看起来不是事故。系统仍然可用,回答也大多正确,唯一被吞掉的是预算和可解释性。你问"为什么这个请求走了贵模型",大家只能从业务代码、网关配置、Prompt 模板、临时 feature flag 里拼线索。
所以今天不聊"哪个模型最强",也不做路由器产品横评。我们聊一个更工程化的问题:模型路由策略应该像代码一样被评审、测试、发布和回滚。换句话说,把路由从 if/else 和散落配置里拿出来,做成 Policy-as-Code。
本文会给出一套可运行的 TypeScript 小骨架。它不追求完整网关功能,只回答四个生产问题:
- 一个请求为什么会被路由到某个模型;
- 这个决策用了哪些事实和约束;
- 策略改动能不能离线回放;
- 当成本、延迟、质量互相打架时,系统怎么保留证据。
1. 模型路由不是 fallback 的高级叫法
很多团队第一次做模型路由,实际做出来的是 fallback chain。
主模型超时就切备用模型,主供应商 5xx 就换另一个供应商,某个 API Key 被限流就换一把 Key。这些都很重要,但它解决的是可用性,不是模型选择。
真正的模型路由至少有三类目标。
| 目标 | 典型问题 | 成功指标 | 常见误区 |
|---|---|---|---|
| 韧性路由 | 某个模型/供应商挂了怎么办 | 成功率、错误率、恢复时间 | 把 fallback 当成质量优化 |
| 成本路由 | 哪些请求可以走便宜模型 | 单请求成本、预算消耗、升级率 | 只看便宜模型命中率,不看失败后升级 |
| 质量路由 | 哪个模型更适合当前任务 | 评测分、人工反馈、业务指标 | 用"感觉更聪明"替代评测 |
这三件事共用同一套"候选模型列表"和"策略执行器",但它们的评判标准完全不同。把它们混在一个函数里,后面一定会变成一坨不可解释的 if/else。
一个典型坏味道是这样的:
ts
async function callLLM(req: Request) {
if (req.userPlan === 'free') return cheapModel(req);
if (req.task === 'code') return strongModel(req);
if (Date.now() % 10 === 0) return newModel(req);
try {
return defaultModel(req);
} catch {
return backupModel(req);
}
}
这段代码短期很好用,长期很危险。它把用户分层、任务分类、灰度实验、可用性 fallback 混在一起。策略没有版本,没有解释,没有回放,也没有独立测试。等账单出问题时,你甚至不知道哪条规则命中了多少次。
2. Policy-as-Code 要解决的不是"配置化",而是可回放
"把策略写到 YAML 里"并不自动等于工程化。如果 YAML 只是把 if/else 换了个语法,问题还在。
我更愿意把模型路由策略拆成四层。
- Facts:请求事实。比如任务类型、用户等级、输入长度、是否需要 JSON、是否包含图片、当前预算、候选模型健康度。
- Constraints:硬约束。比如单请求预算不能超过 0.02 元,必须支持结构化输出,P95 延迟目标低于 4 秒。
- Policy:选择规则。比如先过滤不满足约束的候选,再按质量分和成本分排序。
- Decision Log:决策证据。记录命中的策略版本、候选列表、过滤原因、最终选择、fallback 原因。
你会发现,最关键的是第 4 层。没有决策日志的路由器,省钱时没人知道为什么省,翻车时也没人知道为什么翻。
一个最小策略文件可以长这样:
yaml
version: 2026-08-15.routing.v1
routes:
- name: simple_qa_low_cost
when:
task: qa
max_input_tokens: 1200
requires_json: false
constraints:
max_cost_cny: 0.01
max_p95_latency_ms: 2500
prefer:
- lowest_cost
- healthy
- stable_quality
- name: code_or_reasoning_quality_first
when:
task_in: [code, reasoning]
constraints:
min_quality_score: 0.82
max_p95_latency_ms: 12000
prefer:
- highest_quality
- lower_escalation_rate
- cost_under_budget
fallback:
on_timeout: next_healthy_same_tier
on_schema_error: escalate_one_tier
max_escalations: 1
这份策略不关心具体业务函数叫什么,也不直接调用模型。它只描述决策。工程上要做的是把请求事实喂进去,让策略产出一个可解释选择。
3. 一个可运行的最小路由器
下面这段 TypeScript 可以直接复制到本地跑。为了避免依赖外部服务,我把候选模型的价格、延迟、质量分都写成模拟数据。生产环境里,这些数据应该来自网关 telemetry、离线 eval、线上反馈和健康检查。
ts
type Task = 'qa' | 'code' | 'reasoning' | 'extract';
type RequestFacts = {
task: Task;
inputTokens: number;
requiresJson: boolean;
userTier: 'free' | 'pro' | 'enterprise';
budgetCny: number;
};
type ModelCandidate = {
id: string;
tier: 'cheap' | 'balanced' | 'strong';
supportsJson: boolean;
healthy: boolean;
p95LatencyMs: number;
costPer1kTokensCny: number;
qualityScore: Record<Task, number>;
escalationRate7d: number;
};
type Decision = {
policyVersion: string;
routeName: string;
selectedModel: string;
reasons: string[];
rejected: Array<{ model: string; reason: string }>;
estimatedCostCny: number;
};
const POLICY_VERSION = '2026-08-15.routing.v1';
const candidates: ModelCandidate[] = [
{
id: 'qwen-turbo',
tier: 'cheap',
supportsJson: true,
healthy: true,
p95LatencyMs: 1600,
costPer1kTokensCny: 0.002,
qualityScore: { qa: 0.78, extract: 0.81, code: 0.55, reasoning: 0.50 },
escalationRate7d: 0.12,
},
{
id: 'deepseek-chat',
tier: 'balanced',
supportsJson: true,
healthy: true,
p95LatencyMs: 3100,
costPer1kTokensCny: 0.006,
qualityScore: { qa: 0.84, extract: 0.86, code: 0.76, reasoning: 0.72 },
escalationRate7d: 0.08,
},
{
id: 'deepseek-r1',
tier: 'strong',
supportsJson: false,
healthy: true,
p95LatencyMs: 7800,
costPer1kTokensCny: 0.018,
qualityScore: { qa: 0.86, extract: 0.78, code: 0.88, reasoning: 0.91 },
escalationRate7d: 0.03,
},
];
function estimateCost(model: ModelCandidate, facts: RequestFacts) {
const outputTokensGuess = Math.min(1500, Math.ceil(facts.inputTokens * 0.8));
return ((facts.inputTokens + outputTokensGuess) / 1000) * model.costPer1kTokensCny;
}
function route(facts: RequestFacts): Decision {
const rejected: Decision['rejected'] = [];
const routeName = facts.task === 'code' || facts.task === 'reasoning'
? 'code_or_reasoning_quality_first'
: 'simple_or_extract_cost_aware';
let pool = candidates.filter((m) => {
if (!m.healthy) {
rejected.push({ model: m.id, reason: 'unhealthy' });
return false;
}
if (facts.requiresJson && !m.supportsJson) {
rejected.push({ model: m.id, reason: 'requires_json_not_supported' });
return false;
}
const cost = estimateCost(m, facts);
if (cost > facts.budgetCny) {
rejected.push({ model: m.id, reason: `cost_${cost.toFixed(4)}_over_budget` });
return false;
}
return true;
});
if (pool.length === 0) {
throw new Error(`no_route_available: ${JSON.stringify(rejected)}`);
}
pool = pool.sort((a, b) => {
const qa = a.qualityScore[facts.task];
const qb = b.qualityScore[facts.task];
const ca = estimateCost(a, facts);
const cb = estimateCost(b, facts);
if (routeName === 'code_or_reasoning_quality_first') {
return (qb - qa) || (a.escalationRate7d - b.escalationRate7d) || (ca - cb);
}
return (ca - cb) || (qb - qa) || (a.p95LatencyMs - b.p95LatencyMs);
});
const selected = pool[0];
return {
policyVersion: POLICY_VERSION,
routeName,
selectedModel: selected.id,
estimatedCostCny: Number(estimateCost(selected, facts).toFixed(4)),
rejected,
reasons: [
`task=${facts.task}`,
`requiresJson=${facts.requiresJson}`,
`budget=${facts.budgetCny}`,
`quality=${selected.qualityScore[facts.task]}`,
`p95=${selected.p95LatencyMs}ms`,
`escalationRate7d=${selected.escalationRate7d}`,
],
};
}
const cases: RequestFacts[] = [
{ task: 'qa', inputTokens: 600, requiresJson: false, userTier: 'free', budgetCny: 0.01 },
{ task: 'code', inputTokens: 1800, requiresJson: false, userTier: 'pro', budgetCny: 0.08 },
{ task: 'extract', inputTokens: 2200, requiresJson: true, userTier: 'enterprise', budgetCny: 0.03 },
];
for (const c of cases) {
console.log(JSON.stringify(route(c), null, 2));
}
运行后你会看到三类决策:简单问答优先低成本,代码推理优先质量,结构化抽取会过滤掉不支持 JSON 的强推理模型。这不是因为这些模型"永远应该这样用",而是因为策略把原因写清楚了。
4. 路由策略最该先观测哪几个指标
模型路由的监控不能只看请求成功率。成功率 99.9% 的路由器,照样可能每天多烧几百块。
我建议第一版至少埋这些指标。
| 指标 | 为什么重要 | 告警信号 |
|---|---|---|
| route_hit_count | 每条策略真实命中量 | 新策略命中为 0,说明灰度条件写错 |
| selected_model_count | 各模型流量占比 | 便宜模型占比突然下降 |
| escalation_rate | 便宜模型升级到强模型比例 | 慢慢爬升通常代表校验/任务分布漂移 |
| reject_reason_count | 候选模型被过滤原因 | 大量 cost_over_budget 或 unhealthy |
| cost_per_route | 每条策略的人均/单次成本 | 某条 route 成本异常 |
| quality_score_by_route | 质量分随策略版本变化 | 成本下降但质量同步下滑 |
| decision_latency_ms | 路由器自身耗时 | 语义分类器拖慢整体 P95 |
尤其要盯 escalation_rate。便宜优先策略是否真的省钱,取决于"便宜模型一次解决"的比例。如果校验器、输入分布或模型输出格式变化,升级率会悄悄把省下的钱吃回去。
一个很实用的告警规则是:
yaml
alerts:
- name: route_escalation_rate_spike
expr: escalation_rate{route="simple_qa_low_cost"} > 0.35 for 30m
action: freeze_policy_version_and_notify_owner
- name: cost_per_request_regression
expr: cost_per_request{policy_version="candidate"} > baseline * 1.25
action: stop_rollout
注意这里的动作不是"立刻切回便宜模型"。如果升级率升高是因为真实任务变难,盲目压回便宜模型会伤质量。正确动作是冻结策略版本、停止灰度扩大、拉取样本做回放。
5. 生产里最容易踩的 5 个坑
坑一:把 fallback 写成质量路由
fallback 的触发条件通常是 timeout、5xx、限流、连接失败。质量路由的触发条件应该来自任务类型、评测分、置信度、结构化校验、业务结果。两者混在一起,最后会出现一种怪现象:只要便宜模型没报错,就永远不会升级,即使回答质量明显不够。
解决方式是把 fallback_reason 和 route_reason 分开记录。前者解释"为什么原调用失败",后者解释"为什么一开始选它"。
坑二:语义路由器没人评测
语义路由看起来很轻,给请求打个 intent,然后分到不同链路。但 intent 分类一旦漂移,会把问题送进错误工具、错误知识库或错误模型。更糟糕的是,下游模型可能还能编出一个像样答案,让错误不明显。
我的建议是给语义路由器单独做 golden set。不要只评最终回答,要评"请求应该去哪条 route"。每次加新意图、改 embedding、改阈值,都跑一次 route-level eval。
坑三:预算只在月度账单里出现
如果路由器决策时不知道当前用户、团队、功能的预算状态,它只能做局部最优。比如企业用户的关键工作流可以允许更贵模型,免费用户的闲聊请求应该更严格限额。预算不是财务报表里的数字,预算应该进入 request facts。
坑四:策略发布没有灰度
路由策略改动比普通业务改动更危险,因为它会改变大量请求的成本和质量分布。策略发布至少要支持三个动作:shadow、canary、rollback。
- shadow:新策略只产出决策,不真正执行,用历史流量比较差异;
- canary:只让 1% 或指定租户生效;
- rollback:按 policy_version 一键回退。
坑五:没有"为什么选它"的用户级证据
当某个企业客户问"为什么这个请求变慢了",只看平均延迟没用。你需要能查到那一次请求的 decision log:策略版本、命中规则、候选模型、过滤原因、最终模型、fallback 次数、成本估算、质量分。
这类证据也是技术增长的底座。没有它,你很难把"我们接入了多模型"讲成可信的稳定性和成本优势。
6. 一份更接近生产的决策日志
上面的 demo 已经返回了 rejected 和 reasons。生产里我会再加这些字段:
json
{
"trace_id": "tr_20260815_001",
"policy_version": "2026-08-15.routing.v1",
"route_name": "code_or_reasoning_quality_first",
"request_facts": {
"task": "code",
"input_tokens_bucket": "1k-2k",
"requires_json": false,
"user_tier": "pro"
},
"selected_model": "deepseek-r1",
"candidate_models": ["qwen-turbo", "deepseek-chat", "deepseek-r1"],
"rejected": [
{ "model": "qwen-turbo", "reason": "quality_below_threshold" }
],
"estimated_cost_cny": 0.0594,
"decision_latency_ms": 4,
"fallback_used": false,
"quality_score_source": "eval_set_2026_08_code_v3"
}
这里有两个细节。
第一,request_facts 里不要记录完整用户输入。路由排障需要的是任务、长度、约束和分桶,不是原文。日志能排障,也要能脱敏。
第二,quality_score_source 要写清楚。否则"质量分 0.82"只是一个魔法数字。它来自离线评测、线上人工反馈、规则校验,还是另一个模型打分,可信度完全不同。
7. 路由策略怎么测试
Policy-as-Code 的好处,是它终于可以进入 CI。
我通常会写三类测试。
固定样例测试
给几组典型请求,断言它们命中预期 route。
ts
import assert from 'node:assert';
const d = route({
task: 'extract',
inputTokens: 800,
requiresJson: true,
userTier: 'enterprise',
budgetCny: 0.02,
});
assert.equal(d.routeName, 'simple_or_extract_cost_aware');
assert.ok(d.rejected.every((r) => r.reason !== 'unhealthy'));
assert.ok(d.estimatedCostCny <= 0.02);
回放测试
从生产 trace 里抽样 1000 条,只保留脱敏 facts,用新策略跑一遍,不真正调用模型。比较新旧策略的模型分布、预估成本、预估质量。
反事实测试
刻意模拟模型不健康、预算降低、延迟升高、质量分下降,看策略是否按预期切换。
ts
// 伪代码:把 balanced 模型标成 unhealthy,断言不会再选它
withCandidatePatch('deepseek-chat', { healthy: false }, () => {
const d = route({ task: 'qa', inputTokens: 500, requiresJson: true, userTier: 'pro', budgetCny: 0.02 });
assert.notEqual(d.selectedModel, 'deepseek-chat');
});
如果策略不能测试,它就不该直接进生产。
8. 和 AI Gateway 的关系
有人会问:既然很多 AI Gateway 已经支持路由、fallback、权重和预算,为什么还要自己写 Policy-as-Code?
我的答案是:不要重复造网关,但要拥有策略。
网关负责统一 API、鉴权、限流、密钥、日志、供应商适配、重试和 fallback。Policy-as-Code 负责描述你的业务如何在这些能力上做选择。成熟团队会把二者接起来:策略仓库产生 routing config,网关执行 routing config,trace 系统回收决策结果,eval 系统给质量分,下一次策略发布再用这些数据回放。
也就是说,Policy-as-Code 不是要替代网关,而是给网关一个可评审的大脑。
9. 上线前清单
如果你准备把模型路由从代码里抽出来,可以按这份清单走。
- 每次请求都有 policy_version 和 route_name;
- 每个候选模型都有健康状态、价格估算、延迟分位数、质量分来源;
- route_reason 和 fallback_reason 分开;
- 升级率、单请求成本、质量分、P95/P99 延迟按 route 聚合;
- 新策略支持 shadow 回放,不直接全量;
- 策略变更走 PR,有 diff、owner 和回滚点;
- 决策日志不保存原始敏感输入,只保存脱敏 facts;
- 便宜优先策略有 escalation_rate 告警;
- 质量优先策略有预算上限;
- fallback 链有最大升级次数,避免无限重试。
10. 最后的判断
模型路由最吸引人的卖点通常是省钱,但我觉得它真正的价值是把"模型选择"变成一件可治理的工程活动。
当模型越来越多,价格越来越碎,供应商状态越来越动态时,把选择写死在业务代码里,本质上是在用软件工程最脆弱的方式管理成本和质量。Policy-as-Code 不会让路由策略自动变聪明,但它会让每一次选择都能解释、能回放、能评审、能回滚。
这就是我说它是刹车片的原因。油门是更多模型、更大上下文、更复杂 Agent。刹车片是预算、质量门槛、升级率告警和决策日志。没有刹车片的系统,跑得越快,越容易在账单和质量事故上撞墙。
如果你的 LLM 应用已经接了两个以上模型,下一步不要急着再接第三个。先问一个更朴素的问题:今天任意拿出一条请求,你能说清楚它为什么选了这个模型吗?