Agent 模型分级升级路由:用置信度阈值把贵模型省下来
原文:OpenRouter Blog - 《Confidence Thresholds for Model Escalation Routing》(https://openrouter.ai/blog/insights/confidence-thresholds-for-model-escalation-routing/)
把每个请求都发给最强的模型,答案质量最好,价格也最高------很多请求便宜模型本来就能答对,你却按前沿模型的价格付了钱。按关键词或任务类型写死路由能省钱,但流量一变、模型一升级,规则就得重写。
置信度升级路由是这两者之间的第三条路:让模型先给自己的答案打个分,再用这个分数决定要不要升级。分数高的留在便宜模型上,分数低的转给更强的模型。原文把它拆成五个步骤,这篇按开发者的落地顺序讲一遍,重点放在那个最容易搞错的地方------这个分数到底能当什么用。
一、先分清:置信度分数是什么,又不是什么
原文把这条边界划得很重:这个分数是模型的自述,它生成分数的方式和生成正文一样,因此带着同样的不确定性。
- 两个相同的分数不保证相同的正确概率:同一个提示词上的 0.85,和另一个提示词、另一个模型给出的 0.85,不能互换;
- 这个数字不是校准过的概率,0.85 不等于「85% 的概率答对」;
- 真正能用的是排序。把一批自己的请求跑过便宜模型、人工判对错、按分数分组,如果低分组的错误率确实高于高分组,这个排序就可以拿来做路由;
- 你要找的不是「等于 90% 正确的那个分数」,而是排序里的那条分界线:线以上的答案可以直接交付,线以下的需要第二次调用。
这条区分是整个方法的地基。把分数当概率用,阈值就失去意义;把它当排序用,它就变成可测量、可调参的工程对象。
二、用结构化输出把置信度变成数字
不要从自由文本里读不确定性。模型不确定时,它的措辞未必含糊,系统也没有一份固定的含糊词表供你解析,靠语言判断迟早翻车。
正确做法是让模型在 schema 约束的响应里返回置信度字段。请求里带上 response_format,类型为 json_schema,同时要求 answer 和一个 0 到 1 的数字 confidence:
json
{
"model": "openai/gpt-5.6-luna",
"messages": [{ "role": "user", "content": "..." }],
"provider": { "require_parameters": true },
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "answer_with_confidence",
"strict": true,
"schema": {
"type": "object",
"properties": {
"answer": { "type": "string" },
"confidence": {
"type": "number",
"description": "How likely the answer is correct, from 0 (a guess) to 1 (certain)."
}
},
"required": ["answer", "confidence"],
"additionalProperties": false
}
}
}
}
上面这段请求体来自原文。里面有三个容易踩的细节:
- 0 到 1 的取值范围写在字段的 description 里,而不是用 minimum 和 maximum 关键字。原文解释了原因:Anthropic 的结构化输出文档把数值约束类关键字列为不支持,用 description 描述范围才是跨厂商都能用的写法;
- strict 只是向有原生严格模式的服务商请求严格校验。原文提醒,各家的执行力度不一样,有的只把它当强提示,所以路由之前必须自己校验解析出来的 JSON;
- 结构化输出能力挂在服务商端点上,不是挂在模型上:同一个模型可能有的端点支持、有的不支持。把
require_parameters设为 true,请求才会只被送到支持全部参数(包括 response_format)的端点;不设这个标志,response_format 只是一个软偏好,遇上不支持的端点会被直接忽略。
做完这一步,每个响应都带上了代码能直接读取的分数,是否升级从「猜语义」变成了「比大小」。
三、阈值不能抄,要从自己的错误率里量出来
阈值来自你对自己流量的测量,不是从某篇指南里复制一个数字。做法是:拿一批有代表性的请求跑过便宜模型,记录置信度分数和这条答案是对是错,然后把切点放在错误率开始爬升的地方。线以上留在便宜模型,线以下升级。
原文给了一个演示样本(它自己说明这只是算术自洽的示例,不是目标值,你的分布一定不同):
| 分数区间 | 请求占比 | 实测错误率 |
|---|---|---|
| 0.95 到 1.00 | 41% | 1% |
| 0.85 到 0.94 | 27% | 4% |
| 0.70 到 0.84 | 18% | 11% |
| 0.50 到 0.69 | 9% | 34% |
| 低于 0.50 | 5% | 61% |
错误率在 0.70 以下陡增,所以 0.70 是候选切点:它升级掉下面两个分段,也就是 14% 的请求,剩下 86% 留在便宜模型上。这个样本里便宜模型的整体错误率接近 10%,把最低的 14% 升级之后,留下来的答案错误率降到约 4%,代价是大约每七个请求多一次调用。
原文的取舍建议很直白:先偏保守。一开始升级过多、之后再放宽阈值,只是多花钱;反过来升级不足,会把自信的错误答案发出去。
四、路由决策要写在你的代码里
原文特别点出一个边界:模型 fallback 只在模型返回错误时触发,不会因为模型返回了一个合法但置信度很低的答案而触发。换句话说,这个决定得你自己做。有两条结构性选择:
- 在同一个函数里重试:请求先发给便宜模型,读出置信度,低于阈值就把同样的消息发给更强的模型并返回它的答案。升级策略集中在一处,改阈值或换升级目标只改一次,不会出现某个调用点还在用旧策略;
- 跑一次独立的第一遍:把便宜模型的调用当成第一遍,把它的答案和分数记下来,只有分数低于线时才调更强的模型。代码多一些,但它记录下了便宜模型多久升级一次、它的分数是否仍然和真实错误对齐,这正是后面重新调阈值要用的数据。
下面是按第一种结构写的示意骨架。模型 ID 是字符串,所以两层模型都可以走配置,不必改代码:
python
CHEAP_MODEL = "openai/gpt-5.6-luna" # 便宜层,示例来自原文
STRONG_MODEL = "你的升级目标模型 slug"
THRESHOLD = 0.7 # 由自己流量的分档错误率量出来
def answer_with_escalation(messages):
# 第一遍:便宜模型 + 结构化置信度
first = call_model(CHEAP_MODEL, messages)
if first["confidence"] >= THRESHOLD:
return first["answer"]
# 低于阈值才升级:同样的 messages 交给更强的模型
second = call_model(STRONG_MODEL, messages)
return second["answer"]
上面这段是我按原文描述的两个结构补的示意代码,不是原文代码,call_model 的具体实现按你选的 SDK 补上即可。真正要照抄的是它的形状:阈值判断只出现一次,升级目标是一个可替换的标识。
五、上线之后盯三个数字
一个上线时合适的阈值,之后可能就不合适了。原文要求从第一天就记录三件事:
- 分数分布,模型或流量变化后用它重跑第三节的校准;
- 升级率随时间的变化;
- 未升级答案的错误率,这个数字才说明切点还在不在干活。
需要重新调的信号也很明确:换了便宜模型或升级目标(分数分布会变)、流量整体变难或变易(错误率的分档会移动)、成本压力让你愿意用更高的错误率换更低的升级率。
六、三个常见错误
- 把自述分数当校准概率。方法成立在排序上,不在字面数值上:把一批答案按分数排序,错的确实挤在低端,但 0.9 不代表这条答案有 90% 的概率正确。阈值应该来自第三节的分档错误率,而不是那个原始数字;
- 所有任务共用一个阈值。客服机器人回答「退款政策是什么」和回答「我今天取消会不会被收费」,答错的代价完全不一样。一个全局阈值要么把简单场景升级过头,要么把高风险场景升级不足。知道任务类型的场景,就给每个类型自己的阈值;
- 上线后不看升级率。校准批次上合适的阈值,会随着流量漂移或底层模型更新而失配,而这个过程不会报任何错。
小结
置信度升级路由把请求留在便宜模型上,只有当模型自报低置信度时才为更强的模型付钱,而且不需要关键词表或任务类型规则。整条回路很小:用结构化输出逼出数字分数,用自己流量里按分档统计的错误率找切点,选一种跟踪方式(一个函数管策略,或者独立第一遍留下日志),上线后盯住未升级答案的错误率并按变化重调。
对写 Agent 的人来说,这个模式的性价比尤其高。一次 Agent 任务会打出几十次调用,其中大部分是读文件、选工具、拼参数这类窄调用;把它们的置信度接进路由,等于给整条链路加了一层自动的成本闸门,而不必给每种任务提前写好规则。