跳至主要内容
返回博客

模型路由怎么做?先判断难度,再分给小模型或大模型(TypeScript)

system-one.dev 提供 Jev、Clef Flash 和 Clef 模型服务,这三个模型都是决策模型,只负责判断(打难度分、回答是非题),不负责回答用户的问题,也不是路由的目标。真正回答问题的小模型和大模型由你的应用自己接入,System One 不提供文本生成接口。决策模型的基础知识见 决策模型是什么?。

模型路由是什么,和工具路由有什么区别

两者都是从预设选项中做一次选择,都可以用决策模型实现,但选项不同、指标也不同:

  • 工具路由决定这一步调用哪个工具(或者不调用)。主要指标是选择准确率、漏判率和参数有效率。
  • 模型路由决定把请求交给哪个模型处理。Microsoft 在 Microsoft-Decision-1 的发布博客中把它描述为“按质量、成本和延迟要求选出最合适的模型”(厂商说法),主要指标是质量、成本和延迟。

工具路由的完整做法见 Agent 工具太多总选错?用 Choice 做工具路由。

常见的路由方式

  • 规则路由:按长度、关键词、用户等级、请求类型等规则分流。便宜、可预测,但规则写不全,容易漏判。
  • 先判断再分流:先由一个判断步骤估计难度或类型,再按结果选模型。本文讲的就是这一种,判断步骤用决策模型完成。
  • 级联:先让小模型回答,再检查结果,不达标才升级到大模型。省钱的前提是检查本身足够便宜、足够可靠。
  • 托管路由器:把路由交给平台。例如 Microsoft Foundry 的 model router 是一个“训练过的语言模型”,实时把提示路由到最合适的 LLM,作为一个单独的部署使用(厂商说法)。

实际系统里,这几种方式经常组合使用:规则先挡掉明显的情况,判断步骤处理剩下的,级联兜底。

谁来判断难度:四种做法对比

下文的“判断方式对比表”列出四种做法的来源。概括起来:

  • 规则:没有额外的模型调用,但只能覆盖你想到的情况。
  • 再调一次生成式 LLM:灵活,但这次调用本身就有成本和延迟。如果判断模型和回答模型一样贵,路由就省不了多少钱。
  • 决策模型:只返回选项、分数或概率,不生成文本。Microsoft Learn 把“为请求选择模型、工具或 Agent”列为 Microsoft-Decision-1 的用途之一,并建议先用决策模型做路由或门禁,再把请求交给生成式模型(厂商说法)。
  • 托管路由器:比如 Foundry model router,省去自己写路由逻辑;代价是路由规则不由你定义。Microsoft 文档提醒,它的有效上下文窗口受限于池中最小的模型,并建议与当前基线对比,确认真的改善了你关心的结果。

用决策模型做路由判断

Choice:选项 = 模型档位

把模型档位作为选项,在描述里写清“什么情况下用它”,例如:

  • small:事实查询、格式转换、简短问答
  • large:多步推理、长文档、需要综合多方信息
  • review:涉及金钱、法律、医疗等高风险内容,需要人工或最强模型

措辞仅为示例。Microsoft Learn 建议把每个选项描述清楚,并把“最高概率偏低”当作升级的信号,交给人或另一个模型。TypeSafe 的 Choice 文档也建议在选项可能覆盖不全时加 other 或 none of the above。

Score:难度分档,再按阈值映射

如果“难度”更像一个连续的程度,可以用 Score 把难度分成几档(比如“一句话能答 / 需要查资料 / 需要多步推理 / 需要专业判断”),再在代码里把分数映射到模型。Microsoft Learn 写明,Score 返回的是各档索引的概率加权平均,可能落在两档之间;建议把分数用于相对排序和阈值判断,不要当作绝对、已校准的评分。

加一个“无法判断”选项

Microsoft Learn 建议,在不应该强制模型做选择时,加入一个弃权选项,比如 cannot tell。路由场景里,“无法判断”可以直接走默认的大模型或人工复核。微软还建议措辞保持清晰中立,可以考虑随机化选项顺序,并测试顺序变化会不会影响结果。

置信度与阈值

对于 Choice 和 Score,TypeSafe 返回的置信度是从概率分布算出来的统计量,不等于“选对的概率”;阈值应随风险变化。路由场景的默认取向是:不确定时往贵的方向走。把简单请求误送给大模型,只是多花钱;把难题误送给小模型,可能直接答错。阈值怎么用本地标注样本选、再用留出数据复核,见 用 Score 替代 LLM 当裁判。那篇文章也附有开发团队“高置信采纳、低置信升级给大模型”的 TypeScript 示例。

级联:小模型先答,Noul 校验,必要时升级

另一种做法是先不判断难度,直接让小模型回答,再用几个是非问题检查结果,例如:

  • “回答是否完整回应了用户的请求?”
  • “回答中是否含有拒答、明显的不确定或自相矛盾?”

每个问题用 Noul 得到 P(true),代码按阈值决定采用小模型的回答,还是升级到大模型。这和 Agent 完成判断里的做法相同:一个问题只问一件事,能用代码直接检查的(比如 JSON 能否解析、测试是否通过)就不交给模型。

级联有一个前提要注意。大模型当裁判又慢又贵?用决策模型给 Agent 评估做第一道关引用的研究发现,当第二道关会重复第一道关的错误时,级联主要是省钱,准确率提升有限。路由也一样:如果校验步骤和小模型在同一类问题上一起出错,被放过的坏回答不会少。所以上线前要用标注数据单独看“被放过的错误回答”有多少。

怎么证明路由真的省钱、没掉质量

Microsoft 关于评估 model router 的文档给出的维度,同样适用于自己写的路由(这一点是本文的推论):

  • 质量:可接受的最低得分或胜率,包括准确、完整、清晰和有用。
  • 成本:每个请求的预估成本上限,或者相对基线的最低节省幅度。
  • 延迟:可接受的中位延迟和尾部延迟(例如 p90 或 p95)。
  • 策略:组织允许使用哪些模型、区域和部署配置。

另外几点建议(本文建议):

  • 端到端算账:把路由判断、级联校验、升级后的重复调用、人工兜底都算进成本,不要只比较单次调用的价格。
  • 看升级率:升级比例越高,省下的钱越少。
  • 按类别看:Microsoft 文档也建议,某一类请求表现不同,就先单独评估这一类。高风险类别不要只按成本做路由。
  • 换模型就重新验证:换了做判断的决策模型、应用接入的作答模型、选项描述或阈值,都要重跑评估。

本站没有做过路由前后的实测对比,所以本文不给出节省比例。

TypeScript 示例:Score 判断难度,Noul 检查小模型的回答

下面这段代码来自开发团队提供的示例(examples/decision-articles/07-model-routing.ts)。流程分三步:

  1. 用一个决策模型给请求打难度分(Score,0 到 2 三档)。分数达到阈值,直接交给应用的大模型。
  2. 否则先让应用的小模型作答,再用同一个决策模型回答一个是非题(booleanQuestion):“这个草稿是否完整、正确地回应了请求,没有含糊或拒答?”
  3. P(合格) 达到阈值就采用小模型的回答,否则升级给大模型。
ts/**
 * Article 6 — Model routing: Score difficulty, then cascade LLMs.
 *
 * System One estimates request difficulty (Score) and whether a small-model
 * draft is adequate (Noul via `booleanQuestion`). It does not run chat
 * completions. Small/large generation stays in application callbacks.
 *
 * The decision-model id comes from GET {baseURL}/models (hosted: GET /v1/models).
 * When catalog entries include `pricing.creditsPerBlock`, this file defaults to
 * the cheapest name (on the current hosted catalog that is typically
 * `typesafe/jev`). It does not invent that id if the catalog omits pricing.
 *
 * Packages: @system-one-ai/core, adapter-system-one, transport-fetch.
 * Evaluate: POST {baseURL}/systemone. Catalog listing is not an SDK method.
 */
import { createSystemOne, score, booleanQuestion } from '@system-one-ai/core';
import type { EvaluationClient } from '@system-one-ai/core';
import { systemOneAdapter } from '@system-one-ai/adapter-system-one';
import { createFetchTransport } from '@system-one-ai/transport-fetch';

export const difficultyQuestion = score('How much generative capacity does this request need?', [
  'A short factual or formatting answer a small model can handle',
  'Some reasoning or multi-step wording; try a small model first',
  'Specialized, high-stakes, or long-form work that needs a larger model',
]);

export const adequacyQuestion = booleanQuestion(
  'Does this draft fully and correctly address the request without hedging or refusal?',
  {
    true: 'The user can use this answer as-is',
    false: 'The answer is incomplete, wrong, hedged, or a refusal',
  },
);

/** Inclusive Score threshold on the 0..2 rubric. Tune on labeled traffic. */
export const LARGE_IF_DIFFICULTY_AT_LEAST = 2;
/** Keep the small draft only when P(adequate) meets this application threshold. */
export const KEEP_SMALL_IF_ADEQUATE_AT_LEAST = 0.7;

export interface CatalogModel {
  name: string;
  aliases: readonly string[];
  pricing?: { creditsPerBlock: number; bytesPerBlock: number };
}

export type LlmSize = 'small' | 'large';

export interface LlmCallbacks {
  completeWithSmallLlm: (prompt: string) => Promise<string>;
  completeWithLargeLlm: (prompt: string) => Promise<string>;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

/** Parse the published `{ models: [...] }` envelope. Extra fields are ignored. */
export function parseCatalogModels(payload: unknown): CatalogModel[] {
  if (!isRecord(payload) || !Array.isArray(payload.models)) {
    throw new Error('GET /v1/models must return { models: [...] }.');
  }
  return payload.models.map((entry, index) => {
    if (!isRecord(entry) || typeof entry.name !== 'string' || entry.name.trim() === '') {
      throw new Error(`Catalog model ${index} is missing name.`);
    }
    let pricing: CatalogModel['pricing'];
    if (
      isRecord(entry.pricing)
      && typeof entry.pricing.creditsPerBlock === 'number'
      && typeof entry.pricing.bytesPerBlock === 'number'
    ) {
      pricing = {
        creditsPerBlock: entry.pricing.creditsPerBlock,
        bytesPerBlock: entry.pricing.bytesPerBlock,
      };
    }
    const aliases = Array.isArray(entry.aliases)
      ? entry.aliases.filter((alias): alias is string => typeof alias === 'string' && alias.length > 0)
      : [];
    return { name: entry.name, aliases, pricing };
  });
}

export async function fetchCatalogModels(input: {
  baseURL: string;
  apiKey: string;
  fetchImpl?: typeof fetch;
}) {
  const response = await (input.fetchImpl ?? fetch)(`${input.baseURL.replace(/\/$/, '')}/models`, {
    headers: { Authorization: `Bearer ${input.apiKey}`, Accept: 'application/json' },
  });
  if (!response.ok) {
    throw new Error(`GET /v1/models failed with HTTP ${response.status}.`);
  }
  return parseCatalogModels(await response.json());
}

/** Lowest `pricing.creditsPerBlock`, then name. Undefined when no entry is priced. */
export function pickCheapestCatalogModel(models: readonly CatalogModel[]) {
  const priced = models.filter(model => model.pricing !== undefined);
  if (priced.length === 0) return undefined;
  return priced.reduce((lowest, model) => {
    const current = model.pricing!.creditsPerBlock;
    const best = lowest.pricing!.creditsPerBlock;
    if (current < best) return model;
    if (current === best && model.name < lowest.name) return model;
    return lowest;
  });
}

export function findCatalogModel(models: readonly CatalogModel[], requested: string) {
  const name = requested.trim();
  return models.find(model => model.name === name || model.aliases.includes(name));
}

/**
 * Prefer an explicit catalog name/alias (SYSTEM_ONE_MODEL). Otherwise the
 * cheapest priced entry. Do not invent a hosted id such as typesafe/jev.
 */
export function resolveDecisionModelId(models: readonly CatalogModel[], preferred?: string) {
  if (preferred && preferred.trim() !== '') {
    const match = findCatalogModel(models, preferred);
    if (!match) {
      throw new Error(`SYSTEM_ONE_MODEL ${preferred} is not in GET /v1/models.`);
    }
    return match.name;
  }
  const cheapest = pickCheapestCatalogModel(models);
  if (!cheapest) {
    throw new Error('Set SYSTEM_ONE_MODEL to a name from GET /v1/models (catalog has no pricing to pick a cheapest default).');
  }
  return cheapest.name;
}

export function routeByDifficulty(difficultyScore: number): LlmSize {
  return difficultyScore >= LARGE_IF_DIFFICULTY_AT_LEAST ? 'large' : 'small';
}

export function keepSmallAnswer(adequacyProbability: number) {
  return adequacyProbability >= KEEP_SMALL_IF_ADEQUATE_AT_LEAST;
}

export async function createDecisionClientFromCatalog(options?: { fetchImpl?: typeof fetch }) {
  const apiKey = process.env.SYSTEM_ONE_API_KEY;
  const baseURL = process.env.SYSTEM_ONE_BASE_URL;
  if (!apiKey || !baseURL) {
    throw new Error('Set SYSTEM_ONE_API_KEY and SYSTEM_ONE_BASE_URL (…/v1). Model id is read from GET /v1/models.');
  }
  const models = await fetchCatalogModels({ apiKey, baseURL, fetchImpl: options?.fetchImpl });
  const model = resolveDecisionModelId(models, process.env.SYSTEM_ONE_MODEL);
  return {
    model,
    models,
    client: createSystemOne({
      apiKey,
      baseURL,
      model,
      adapter: systemOneAdapter,
      transport: createFetchTransport(),
      timeoutMs: 30_000,
      maxRetries: 0,
    }),
  };
}

export async function estimateDifficulty(client: EvaluationClient, prompt: string) {
  const result = await client.evaluate({
    state: { prompt },
    questions: { difficulty: difficultyQuestion },
  });
  return {
    difficulty: result.answers.difficulty.score,
    model: result.model,
  };
}

export async function checkSmallAnswer(client: EvaluationClient, prompt: string, draft: string) {
  const result = await client.evaluate({
    state: { prompt, draft },
    questions: { adequate: adequacyQuestion },
  });
  return {
    adequate: result.answers.adequate.probability,
    model: result.model,
  };
}

/**
 * @param llms Your existing small/large completion paths.
 *        System One has no chat/completions URL for either callback.
 */
export async function routeToLlm(
  client: EvaluationClient,
  prompt: string,
  llms: LlmCallbacks,
) {
  const estimated = await estimateDifficulty(client, prompt);
  const lane = routeByDifficulty(estimated.difficulty);
  if (lane === 'large') {
    const text = await llms.completeWithLargeLlm(prompt);
    return {
      action: 'used_large' as const,
      reason: 'difficulty_at_or_above_threshold' as const,
      difficulty: estimated.difficulty,
      decisionModel: estimated.model,
      text,
    };
  }

  const small = await llms.completeWithSmallLlm(prompt);
  const checked = await checkSmallAnswer(client, prompt, small);
  if (keepSmallAnswer(checked.adequate)) {
    return {
      action: 'used_small' as const,
      reason: 'small_answer_adequate' as const,
      difficulty: estimated.difficulty,
      adequate: checked.adequate,
      decisionModel: checked.model,
      text: small,
    };
  }

  const text = await llms.completeWithLargeLlm(prompt);
  return {
    action: 'escalated_large' as const,
    reason: 'small_answer_below_threshold' as const,
    difficulty: estimated.difficulty,
    adequate: checked.adequate,
    decisionModel: checked.model,
    smallDraft: small,
    text,
  };
}

async function main() {
  const prompt = process.env.EXAMPLE_MESSAGE ?? 'How many credits does a single Score question cost?';
  const { client, model } = await createDecisionClientFromCatalog();
  const decision = await routeToLlm(client, prompt, {
    completeWithSmallLlm: async () => {
      throw new Error('No System One LLM-completion endpoint. Wire your small-model call here.');
    },
    completeWithLargeLlm: async () => {
      throw new Error('No System One LLM-completion endpoint. Wire your large-model call here.');
    },
  });
  console.log(JSON.stringify({ catalogModel: model, ...decision }, null, 2));
}

if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split(/[\\/]/).pop() ?? '')) {
  main().catch((error: unknown) => {
    console.error(error instanceof Error ? error.message : error);
    process.exitCode = 1;
  });
}

需要说明几点(均出自示例代码和注释):

  • 决策模型 ID 不写死。 示例从 GET /v1/models 读取模型目录。设置了 SYSTEM_ONE_MODEL 时,用目录中匹配的名称;没有设置时,取 pricing.creditsPerBlock 最低的一个。目录里没有价格信息时,示例会报错,而不是自己编一个 ID。
  • 作答的模型由应用接入。 completeWithSmallLlm 和 completeWithLargeLlm 是你自己的小模型、大模型调用。System One 没有 chat/completions 接口,示例在没有接入时会直接抛错。
  • 阈值是示例值。 LARGE_IF_DIFFICULTY_AT_LEAST = 2(0 到 2 三档,含等号)和 KEEP_SMALL_IF_ADEQUATE_AT_LEAST = 0.7 都要在你自己带标注的流量上调。
  • 示例没有覆盖的部分:Choice 选模型档位的写法、“无法判断”选项、端到端成本统计。

用 System One 托管 API 计费时,费用按“问题数 × max(1, ⌈请求字节数 ÷ 模型计费块大小⌉) × 模型费率”扣积分。API 页面列出的费率是:Jev 每问题每 4096 字节计费块 1 积分,Clef Flash 3 积分,Clef 6 积分。积分价格按定价页所示,以预付积分包出售:10,000 积分 $10、60,000 积分 $50、300,000 积分 $200,均为一次性购买、不自动续费。注意,示例里每个请求最多调用两次决策模型(难度评分 + 是非检查),算账时都要计入。

表中不比较各方案的准确率和延迟:所核来源中只有厂商自测数字,本站也没有实测。

延伸阅读


判断方式对比表(核验日期 2026-10-11)

做法怎么判断额外调用可控性来源
规则路由长度、关键词、请求类型等规则无规则由你定义,但覆盖不全本文概括
生成式 LLM 判断让一个 LLM 读请求后说该用哪个模型一次生成式调用输出是自由文本,需要解析本文概括
决策模型(例如 Microsoft-Decision-1)来源: Microsoft Learn · 核验日期 2026-10-11用 choice 选模型,或用 score 分档;返回每个选项的概率来源: Microsoft Learn · 核验日期 2026-10-11一次决策调用;Microsoft-Decision-1 输入每百万 token $0.042,输出免费来源: Microsoft 发布博客 · 核验日期 2026-10-11选项、阈值和弃权规则由你定义官方文档(厂商说法)
托管路由器(Foundry model router)来源: Microsoft Learn: model router · 核验日期 2026-10-11训练过的语言模型实时选择底层 LLM;Balanced(默认)、Cost、Quality 三种模式来源: Microsoft Learn: model router · 核验日期 2026-10-11由平台处理,作为一个部署调用可选择路由模式和模型子集;有效上下文窗口受限于最小的模型来源: Microsoft Learn: model router · 核验日期 2026-10-11官方文档(厂商说法)
System One 托管决策 API来源: System One 托管 API 页 · 核验日期 2026-10-11用 SDK 的 `choice()`、`score()`、`booleanQuestion()` 定义判断一次决策调用,按积分计费来源: System One 托管 API 页 · 核验日期 2026-10-11system-one.dev 提供 Jev、Clef Flash 和 Clef 模型服务,三者都是决策模型,作答模型由应用接入来源: System One 托管 API 页 · 核验日期 2026-10-11官方页面

常见问题

什么是模型路由?
在请求到达模型之前,先判断它的类型、难度和风险,再交给合适的模型:简单请求用小模型,复杂或高风险请求用大模型。它和工具路由一样,都是从预设选项中做选择,但衡量指标是质量、成本和延迟。
用一个模型来判断该用哪个模型,划算吗?
取决于判断步骤有多贵。如果判断用的是和回答一样贵的生成式模型,就很难省钱。决策模型只返回选项和概率,不生成文本,单次调用很便宜。例如微软公布的 Microsoft-Decision-1 价格是输入每百万 token $0.042、输出免费(厂商博客)。到底划不划算,要用你自己的流量把端到端成本算出来。
怎么用 Choice 或 Score 做路由?
Choice 的选项写成模型档位(如 small / large / review),每个选项附一句“何时使用”;Score 则把难度分成几档,再在代码里映射到模型。两种做法都建议加一个“无法判断”选项,并在不确定时把请求送往更强的模型。
小模型答完之后怎么决定要不要升级?
用几个是非问题(Noul)检查回答,比如“是否完整回应了请求”“是否含有拒答或明显的不确定”,每个问题得到一个概率,由代码按阈值决定采用还是升级。能用代码直接验证的(如格式、测试),优先用代码。
高风险请求可以为了省钱路由到小模型吗?
不建议只按成本决定。Microsoft 建议,涉及人的重大决定(信贷、就业、住房、医疗、法律)要有实质性的人工复核。路由时可以给高风险类别单独设规则,直接走更强的模型或人工。
托管路由器和自己写路由怎么选?
托管路由器(例如 Foundry model router)省去路由逻辑的开发,但规则不由你定义,且受平台的模型池和上下文窗口限制。自己写路由时,选项、阈值和升级策略都在你的代码里,可以按业务风险细调。无论选哪种,都要和当前基线对比质量、成本和延迟。
换了模型之后,阈值要重新验证吗?
要。换了做判断的决策模型、应用接入的作答模型、选项描述或阈值,都要在标注数据上重新评估。不同决策模型的置信度算法也不一样。