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)。流程分三步:
- 用一个决策模型给请求打难度分(Score,0 到 2 三档)。分数达到阈值,直接交给应用的大模型。
- 否则先让应用的小模型作答,再用同一个决策模型回答一个是非题(
booleanQuestion):“这个草稿是否完整、正确地回应了请求,没有含糊或拒答?” - 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,均为一次性购买、不自动续费。注意,示例里每个请求最多调用两次决策模型(难度评分 + 是非检查),算账时都要计入。
表中不比较各方案的准确率和延迟:所核来源中只有厂商自测数字,本站也没有实测。
延伸阅读
- 决策模型是什么?
- Jev 开源替代有哪些?
- 用 Score 替代 LLM 当裁判
- Agent 工具太多总选错?用 Choice 做工具路由
- Agent 什么时候算完成?
- System One 决策原语