模型路由别写死在代码里:Policy-as-Code 才是 LLM 成本和质量的刹车片

上周我看一份 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 换了个语法,问题还在。

我更愿意把模型路由策略拆成四层。

  1. Facts:请求事实。比如任务类型、用户等级、输入长度、是否需要 JSON、是否包含图片、当前预算、候选模型健康度。
  2. Constraints:硬约束。比如单请求预算不能超过 0.02 元,必须支持结构化输出,P95 延迟目标低于 4 秒。
  3. Policy:选择规则。比如先过滤不满足约束的候选,再按质量分和成本分排序。
  4. 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 应用已经接了两个以上模型,下一步不要急着再接第三个。先问一个更朴素的问题:今天任意拿出一条请求,你能说清楚它为什么选了这个模型吗?

参考资料

相关推荐
小蒜学长2 小时前
“喵汪联盟”宠物领养系统的设计与实现(代码+数据库+LW)
java·spring boot·后端·宠物
SomeB1oody2 小时前
【RustyML入门】3.7. 循环层
开发语言·后端·机器学习·rust·教程
东风破_12 小时前
ESLint 是什么?为什么你的项目需要它?
前端·后端·代码规范
嘻哈∠※12 小时前
0061基于 SpringBoot 的投稿与稿件处理系统设计与实现
java·spring boot·后端
卷无止境12 小时前
在 awesome-fastapi 里,哪些库值得一看?
后端·python
卷无止境13 小时前
FastAPI 的Admin面板生态
后端·python
捡田螺的小男孩15 小时前
什么是 Skill?手把手带你写一个简单有用的 Skill!
前端·后端·程序员
IT_陈寒15 小时前
Redis集群这个坑,差点让我通宵
前端·人工智能·后端
用户83562907805115 小时前
Python 自动化 Word 文本框处理:创建、定位、填充内容与管理
后端·python