下文引用的两篇论文研究的都是 TypeSafe 的 Jev。它们的结论不能直接套到其他决策模型上。system-one.dev 提供 Jev(typesafe/jev)、Clef Flash(cloudflare/clef-flash)和 Clef(cloudflare/clef)模型服务;本站没有在托管端复现论文实验,文中数字是作者实验条件下的结果。
还不熟悉决策模型和 Choice / Score / Noul 三种原语的话,可以先读 决策模型是什么?。
LLM 裁判的三个问题:慢、贵、不稳定
用大模型当裁判(LLM-as-a-Judge)已经是评估开放式输出的常见做法,但开发者的抱怨也很集中:
- 慢:推理模型每做一次判断都要额外花算力。卡内基梅隆大学的论文在其计时实验中测得,GPT-6 的中位延迟为 1.89 秒,Jev 为 0.15 秒(作者实验条件下)。
- 贵:同一篇论文估算,GPT-6 以低推理强度做裁判,大约每 1000 次判断 12 美元(作者实验条件下)。每换一个模型版本或一批数据,这笔费用就要重来一次。
- 不稳定:中文开发者的原话是:“用大模型当裁判又慢又贵,还可能同一份答案打出不同分。”(原帖)
但要注意,“稳定”不等于“对”。宾夕法尼亚大学的论文发现,在有序量表上,各个裁判彼此之间的一致程度高于它们与人工标注的一致程度,而且大多都比人工打出更低的档位(作者实验条件下)。结果一致只说明大家判得一样,不说明判得对。
决策模型做裁判:两篇论文说了什么
结论一:能从文本读出判决时,级联可行
论文《JEV-as-a-Judge: Accept When Confident, Escalate When Unsure》(arXiv 2609.26550,卡内基梅隆大学)把 Jev 和 16 个生成式裁判、奖励模型做了对比,并用盲审的人工复核来检验结论。摘要中的主要结论(均为作者实验条件下):
- 在判决可以直接从文本读出的场景,Jev 与 GPT-6 的差距在 3 个百分点以内;费用约为 GPT-6 的 0.36%,中位延迟 0.15 秒。
- 在判决需要推导的场景,比如数学、代码、逻辑,Jev 明显落后。正文给出的差距是 7 到 28 个百分点。
- 先冻结阈值,置信度高的直接采纳、其余升级给 GPT-6:在 1610 个留出样本对上,准确率比单用 GPT-6 高 0.9 个百分点,费用为 GPT-6 的 41%。在两个预先设计好的新任务上做实时测试,级联的准确率与 GPT-6 完全持平。
- 两种场景下置信度路由会失效:一是“风格误导”的样本对(写得更花哨的那个答案反而是错的),二是没有参考答案的文风评估。在后者上,所有被测裁判都接近随机水平,却都很“自信”。
结论二:但它们会“错在同一处”
论文《Jev vs. LLMs as Rubric Judges: Cheaper, Faster, and Wrong in the Same Places》(arXiv 2609.29769,宾夕法尼亚大学)把 Jev 和三个 flash 档的 LLM 裁判放在 7 个基准、9 组带人工标注的评分面板上比较。摘要中的主要结论(均为作者实验条件下):
- 三个 LLM 裁判的费用是 Jev 的 16 到 325 倍,耗时是 28 到 350 倍。
- 无论哪种设置,Jev 和它们的准确率在 27 组配对比较中最多只有 8 组差异显著。Jev 领先的大多是二元清单项,落后的只出现在有序量表上。
- 在 Jev 最自信的那批错误上,约 96% 的 LLM 判决重复了同样的错误答案;如果错误彼此独立,这个比例应该只有一半左右。
- 因此,以 Jev 为第一道关的级联主要是省钱,准确率提升有限:即使用事后挑选的最优阈值,也没有一个级联比最好的单个裁判高出 2.7 个百分点以上。
两个结论怎么一起理解
两篇论文并不矛盾,只是回答了不同的问题。第一篇问的是:用决策模型做第一道关,能不能在保住强模型准确率的同时省钱?在它测试的场景里,能。第二篇问的是:级联能不能超过最好的单个裁判?它的回答是很难,因为第二道关会重复第一道关的错误。
对开发者来说,实用的结论是:把决策模型当成省钱的第一道关,不要指望它让评估变得更准。级联能否有效,取决于第二道关是否会在不同的地方出错。
另外,Microsoft 在 Microsoft-Decision-1 的发布博客中,也把“基于评分标准给 AI 回答和 Agent 动作打分”列为该模型的用途之一(厂商说法)。
哪些评估交给 Score / Noul,哪些不要
下文的“适用边界表”整理自两篇论文的结论。每一行都是作者在特定数据集上的实验结果,只能当作判断的起点,不能当作部署保证。概括起来:
- 适合做第一道关:普通偏好比较、回答是否有给定材料支持、二元清单项是否满足。
- 先在本地验证:最终答案判定、多选一、有参考的摘要,以及多档的有序量表(第二篇发现各个裁判普遍比人工打分偏低)。
- 直接升级给强模型或人工:需要推导的数学、代码、逻辑、专业知识题,以及错误答案写得更像样的“风格误导”样本。
- 任何自动裁判都不要用:没有参考答案的文风、散文质量评估。第一篇的结论是,所有被测裁判在这类任务上都接近随机。
把 rubric 写成 Score 层级
Score 的意思是:你给出一组从低到高排列的档位描述,模型为每一档返回一个概率,再按概率加权得出一个分数。这个分数可以落在两档之间。写 rubric 时有几条要点:
- 层级数量:System One 文档规定最多 10 个 Score 层级。开发团队示例的注释写明,托管端 Score 支持 2 到 10 个有序层级,并且只有在同一份 rubric、同一个模型、同一套说明下得出的分数才能互相比较。
- 描述情境,不要描述程度:TypeSafe 文档建议,每一档写成一个可以对照的情境,比如“功能损坏但有替代方案”,而不是“中等严重”。每一档是被单独评估的,模型看不到档位编号和相邻档位,所以“比上一档更差”这样的写法没有意义。
- 一个问题只量一个维度:一个问题里混了好几个维度,置信度就会下降。应该拆成几个 Score,再在代码里加权合并。
- 同样的分数可能代表不同的分布:分数 1.0 可能是全部概率都在第 1 档,也可能是第 0 档和第 2 档各一半。做决定时要同时看
probabilities和confidence。 - 偏好对比时两种顺序各问一次:第一篇论文的做法是把两个候选答案按两种顺序各评一次,再取平均。
- 分数更适合排序和设阈值,不适合当绝对评分:Microsoft-Decision-1 文档建议,Score 优先用于相对排序和阈值判断,不要当作绝对、已校准的评分,并且要在自己的标注样本上验证。
阈值:先在本地样本上选,再在留出集上复核
第一篇论文最后给了一套可以照做的流程(作者建议):
- 准备本地标注:从你自己的评估任务里抽大约 100 个样本,人工标注正确答案,同时跑决策模型和你的强裁判。
- 用置信下界选阈值:在一组候选阈值上模拟级联,找出“准确率损失不超过两个百分点”的最宽松阈值。判断时要看损失的置信下界,不要只看点估计。论文报告,这样做可以把“实际损失超过两个百分点”的风险从约 45% 降到约 5%(作者模拟条件下)。
- 每类任务单独选阈值:不同评估任务的最佳阈值差别很大,混在一起选很容易出问题。
- 用留出数据复核:确认阈值在没参与选择的样本上依然成立。
根据第二篇论文“错在同一处”的结论,还可以多做一项检查(这是本稿的建议,论文没有给出这样的操作步骤):在选阈值用的标注样本上,统计第二道关重复第一道关错误的比例,并和“错误彼此独立”时约一半的基线比较。如果重复率远高于基线,级联就只能省钱,不能提升准确率。
还有一点:换了模型或模型版本之后,阈值必须重新验证。不同模型的 confidence 算法不同,详见 Jev 开源替代有哪些?中的说明。
TypeScript 示例:先用 Score 预筛,再交给你自己的裁判
在线试一下:AI 回答谁更好(示例结果,登录后可跑真实数据)
下面这段代码来自开发团队提供的示例(examples/decision-articles/03-score-eval-prefilter.ts)。它先给每个候选回答打一个 Score,分数达到阈值的才交给应用自己提供的裁判回调函数(例如你的 LLM 裁判),低于阈值的直接跳过。
ts/**
* Article 3 — Use Score as a coarse eval pre-filter instead of an LLM-as-judge.
*
* System One exposes Score (ordered rubric → weighted number). It does not
* expose an LLM-as-judge, eval harness, or dataset API. Those stay in your
* application. This file scores each candidate first; only items at or above
* your threshold are passed to an application-supplied judge callback.
*
* Hosted Score limits: 2–10 ordered levels. Compare scores only under the
* same rubric, model, and instructions.
*/
import { createSystemOne, score } 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 helpfulness = score('How clear and actionable is this answer for the question?', [
'Incorrect or unhelpful',
'Slightly relevant',
'Useful but incomplete',
'Clear and actionable',
]);
/** Inclusive Score threshold on the 0..3 rubric above. Tune on your own labeled set. */
export const PREFILTER_MIN_SCORE = 2;
export interface EvalCandidate {
id: string;
question: string;
answer: string;
}
export interface JudgeInput extends EvalCandidate {
prefilterScore: number;
}
export function createHostedClient() {
const apiKey = process.env.SYSTEM_ONE_API_KEY;
const baseURL = process.env.SYSTEM_ONE_BASE_URL;
const model = process.env.SYSTEM_ONE_MODEL;
if (!apiKey || !baseURL || !model) {
throw new Error('Set SYSTEM_ONE_API_KEY, SYSTEM_ONE_BASE_URL (…/v1), and SYSTEM_ONE_MODEL from GET /v1/models.');
}
return createSystemOne({
apiKey, baseURL, model, adapter: systemOneAdapter,
transport: createFetchTransport(), timeoutMs: 30_000, maxRetries: 0,
});
}
export async function scoreCandidate(client: EvaluationClient, candidate: EvalCandidate) {
const result = await client.evaluate({
state: { question: candidate.question, answer: candidate.answer },
questions: { helpfulness },
});
return result.answers.helpfulness.score;
}
/**
* @param judge Application-owned expensive check. Not a System One method.
* Omit it to only run the Score pre-filter.
*/
export async function prefilterThenJudge(
client: EvaluationClient,
candidates: readonly EvalCandidate[],
judge?: (input: JudgeInput) => Promise<unknown>,
) {
const rows = [];
for (const candidate of candidates) {
const prefilterScore = await scoreCandidate(client, candidate);
if (prefilterScore < PREFILTER_MIN_SCORE) {
rows.push({ id: candidate.id, prefilterScore, status: 'skipped_low_score' as const });
continue;
}
const judged = judge ? await judge({ ...candidate, prefilterScore }) : undefined;
rows.push({ id: candidate.id, prefilterScore, status: 'sent_to_application_judge' as const, judged });
}
return rows;
}
async function main() {
const candidates: EvalCandidate[] = [
{
id: 'reset-password',
question: 'How can I reset my password?',
answer: 'Select Forgot password on the sign-in page and follow the email link.',
},
{
id: 'unrelated',
question: 'How can I reset my password?',
answer: 'Our office is open on weekdays.',
},
];
// No System One judge endpoint exists. Live runs stop after Score unless you pass your own judge.
const rows = await prefilterThenJudge(createHostedClient(), candidates);
console.log(JSON.stringify(rows, 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;
});
}
需要说明几点(均出自示例注释):
- System One 不提供 LLM 裁判、评估框架或数据集 API。 示例里的
judge是应用自己的回调函数,不是 System One 的方法;不传judge时,示例只运行 Score 预筛。 - 阈值归应用所有:
PREFILTER_MIN_SCORE = 2(对应 0 到 3 的量表,含等号)要在你自己的标注集上调。 - 托管模型:
SYSTEM_ONE_MODEL要从GET /v1/models获取。system-one.dev 提供 Jev(typesafe/jev)、Clef Flash(cloudflare/clef-flash)和 Clef(cloudflare/clef)模型服务。
这段示例和上面论文里的级联不是一回事。 示例按分数高低决定是否送去复核,是“低分直接淘汰、高分再细审”的预筛;论文里的级联按置信度决定是否升级,是“高置信采纳、低置信升级”。后者见下面的第二个示例。
TypeScript 示例:高置信采纳,低置信升级给大模型
下面这段代码同样来自开发团队提供的示例,文件为 examples/decision-articles/06-confident-or-escalate.ts。它用一个 Choice 判断请求该怎么处理:置信度存在且不低于阈值时,直接采用决策模型的结果;置信度偏低或缺失时,把请求、模型的初步判断和置信度一起交给应用自己的大模型处理。
ts/**
* Article 3 — Confident: use the decision-model result.
* Uncertain: escalate to a large LLM in the application.
*
* Choice/Score answers may include `confidence`. That value is an optional
* provider statistic, not necessarily P(selected option), and not a product
* default threshold. Missing confidence stays unknown — this file treats
* unknown as uncertain.
*
* System One has no chat/completions URL. Escalation is an application
* callback you already own. Packages: @system-one-ai/core,
* adapter-system-one, transport-fetch. Endpoint: POST /v1/systemone.
*/
import { createSystemOne, choice } from '@system-one-ai/core';
import type { ChoiceAnswer, 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 routeQuestions = {
handling: choice('Choose how this request should be handled.', {
billing: 'Payments, invoices, and refunds',
support: 'Technical issues with the product',
review: 'Needs a person; the request is incomplete or consequential',
}),
};
/** Application-owned. Tune on your own labeled cases; not a hosted default. */
export const MIN_DIRECT_CONFIDENCE = 0.6;
export function isConfident(answer: ChoiceAnswer<string>, minimum = MIN_DIRECT_CONFIDENCE) {
if (answer.confidence === undefined) return false;
return answer.confidence >= minimum;
}
export interface EscalationInput {
message: string;
proposed: string;
confidence: number | undefined;
}
export function createHostedClient() {
const apiKey = process.env.SYSTEM_ONE_API_KEY;
const baseURL = process.env.SYSTEM_ONE_BASE_URL;
const model = process.env.SYSTEM_ONE_MODEL;
if (!apiKey || !baseURL || !model) {
throw new Error('Set SYSTEM_ONE_API_KEY, SYSTEM_ONE_BASE_URL (…/v1), and SYSTEM_ONE_MODEL from GET /v1/models.');
}
return createSystemOne({
apiKey, baseURL, model, adapter: systemOneAdapter,
transport: createFetchTransport(), timeoutMs: 30_000, maxRetries: 0,
});
}
/**
* @param escalateToApplicationLlm Your existing large-LLM path.
* Not a System One method and not a hosted completion endpoint.
*/
export async function routeOrEscalate(
client: EvaluationClient,
message: string,
escalateToApplicationLlm: (input: EscalationInput) => Promise<string>,
) {
const result = await client.evaluate({
state: { message },
questions: routeQuestions,
});
const answer = result.answers.handling;
if (isConfident(answer)) {
return {
action: 'use_decision' as const,
handling: answer.choice,
confidence: answer.confidence,
};
}
const escalated = await escalateToApplicationLlm({
message,
proposed: answer.choice,
confidence: answer.confidence,
});
return {
action: 'escalate_llm' as const,
handling: answer.choice,
confidence: answer.confidence,
reason: answer.confidence === undefined ? 'confidence_unknown' as const : 'confidence_below_threshold' as const,
escalated,
};
}
async function main() {
const message = process.env.EXAMPLE_MESSAGE ?? 'The invoice PDF will not download.';
const decision = await routeOrEscalate(createHostedClient(), message, async () => {
throw new Error('No System One LLM-completion endpoint. Wire your existing large-LLM call here.');
});
console.log(JSON.stringify(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;
});
}
需要说明几点(均出自示例代码和注释):
- 示例场景是请求分流,不是给回答打分。 示例用 Choice 在
billing/support/review之间做选择,演示的是“置信度门控 + 升级”的结构。把它用在评估上时,要换成你自己的判决问题(例如偏好比较的 Choice),阈值也要按上文的方法在本地标注样本上重新选。 - 置信度是可选字段。 SDK 的
ChoiceAnswer中confidence可能不存在。示例注释写明,它是服务端提供的统计量,不一定等于“选中项的概率”,也不是产品默认阈值;缺失时示例一律按“不确定”处理并升级(confidence_unknown)。System One 决策原语文档也写明,Choice/Score 统计在规范化答案中是可选的。 - 阈值是示例值。
MIN_DIRECT_CONFIDENCE = 0.6只是示例,示例注释写明“要在自己的标注案例上调,不是托管端默认值”。 - 升级是应用自己的回调。 System One 没有 chat/completions 接口,
escalateToApplicationLlm是你已有的大模型调用,不是 System One 的方法。 - 它不做论文里的“两种顺序取平均”和“用置信下界选阈值”。 这些步骤需要你在离线评估中自己实现。
表中数字均来自论文作者的实验,研究对象是 TypeSafe 的 Jev,不代表其他决策模型的表现。对照模型按论文表 2 的说明填写:凡是跑过 GPT-6 的样本都与 GPT-6 对比,自然散文类样本则与 GPT-5.4 对比。本站没有在托管端复现论文实验,文中数字是作者实验条件下的结果。