——《象信 · 系统一》系列之四
先看一个真实的设计难题。
手机银行 App 的智能助手,用户说一句话,可能是三件事之一:
- 查余额
——只读操作,判断错了顶多显示错一个数字,用户自己会发现; - 确认一笔待处理的转账
——动钱,判断错了很麻烦; - 冻结银行卡
——用户可能正在被盗刷。
这三个动作,该用同一个置信度阈值吗?
用同一个,只有两种结果:要么动钱的操作太容易自动执行,要么查余额这种低风险操作也被卡去人工,体验糟得没人用。
答案是:置信度不是一个"准入开关",它是一个可以按风险定价的旋钮。
先说清楚:置信度是怎么算出来的
每个 Choice 和 Score 答案都带一个 confidence,0 到 1。
关键点:它不是模型额外"报告"的一个数,而是在返回之前,根据答案自己的 probabilities 算出来的。公式公开且固定:
同样是 0.70,2 个选项和 20 个选项的含金量差一倍
|
|
|
|
|---|---|---|
| Choice | (n × p_max − 1) / (n − 1) |
|
| Score | p_max |
|
第一次看这个 Choice 公式的人,几乎都会问同一个问题:为什么不直接用 p_max?
因为它有个陷阱。同样是 p_max = 0.70:
- 两个选项
时,瞎猜也有 0.50,0.70 只比瞎猜好一点 → 换算成置信度是 0.40 - 二十个选项
时,瞎猜只有 0.05,0.70 已经相当集中 → 换算成置信度约 0.68
同一个 0.70,含金量差了将近一倍。这个公式把"比均匀分布集中了多少"换算到 0–1,让 2 个选项的分类和 20 个选项的分类可以共用同一套阈值。
算一遍就清楚了。一个有 4 个选项的 Choice 返回:
json { "probabilities": { "物流": 0.70, "售后": 0.20, "账务": 0.10, "其他": 0.00 } } |
text confidence = (4 × 0.70 − 1) / (4 − 1) = 1.80 / 3 = 0.60 |
分布是均匀的,置信度就是 0;全部集中在一个选项上,置信度就是 1。 这就是它的全部含义。
顺便说一句:你不被这个定义锁死。完整的 probabilities 就在响应里,如果你更关心"前两名的差距",可以自己算:
python probs = sorted(resp.answers["route"].probabilities.values(), reverse=True) margin = probs[0] - probs[1] # 第一名比第二名高多少 |
Noul 为什么没有置信度
Noul 的分布只有"是"和"否"两个结果,所以 noul 这一个数已经完整描述了它:
noul = 0.95意味着"否"的概率是 0.05,把握程度就体现在离 0.5 有多远; -
0.95 或 0.05:非常确定; -
0.70 或 0.30:倾向明显,但不确定; -
0.5 附近:两种可能差不多。
所以你不需要一个单独的 confidence,而是直接用两个阈值划出三个区间。
三个区间,三种行为
一个实用的起点:
|
|
|
|---|---|
| 高 |
|
| 中 |
|
| 低 |
|
区间边界画在哪里,取决于风险。
阈值随风险变化:把风险偏好写进代码
回到开头那个银行 App。同一个模型、同一套问题,三种动作三种门槛:
同一个模型,三种动作,三种门槛
python UNSURE = 0.5 # 低于它:模型拿不准,不猜 CONFIRM_FREE = 0.9 # 高风险操作免确认所需的置信度
def handle(user_message: str, account_id: str) -> None: resp = client.system_one( state=user_message, questions={ "action": Choice( instructions="用户想做什么?", criteria={ "check_balance": "查询账户余额或近期交易", "approve_transfer": "确认或授权一笔待处理的转账", "freeze_card": "挂失或冻结银行卡", "support": "咨询问题或寻求帮助", }, ), }, ) action = resp.answers["action"]
if action.confidence < UNSURE: # 模型确实拿不准,不要猜 route_to_human(user_message)
elif action.choice == "check_balance": # 低风险:只读操作,展示错了也能挽回 show_balance(account_id)
elif action.choice == "freeze_card": # 冻结宁可错冻:漏掉一个真实挂失的代价更高 freeze_card(account_id) notify_user(account_id, "已为您临时冻结银行卡,如非本人操作可一键解冻。")
elif action.choice == "approve_transfer": if action.confidence >= CONFIRM_FREE: # 高风险、高置信:仍然走一次确认,但流程更短 confirm_then_execute(account_id) else: # 高风险、中等置信:先让用户明确确认 ask_user_to_confirm(account_id)
else: route_to_support_bot(user_message) |
三个值得注意的地方:
freeze_card的门槛反而最低。因为它"做了可以撤销,不做可能损失更大"。这不是模型在权衡,是你在代码里表达风险偏好。 approve_transfer即使在最高置信度下也要确认一次,只是流程更短。动钱的操作,再确定也让人点一下。 UNSURE这条线是硬的。模型明确说没把握时,不许猜——这是整段代码里唯一一个跟业务无关的规则。
模型只负责如实报告它有多确定,风险偏好完全由你的代码表达。 这是整个设计里最舒服的一点:想改策略,改常量,不用动模型。
校准是"一群预测"的性质,不是一个答案的保证
象信经过训练,它的概率是校准的:在所有它给出约 0.8 的答案里,大约 80% 是对的。
这句话有三层意思,缺一层就容易误用:
- 校准是对一批预测的统计性质。
它不保证任何单个答案正确。0.95 的答案仍然可能错。 - 校准依赖数据分布。
在我们的评测集上校准良好,不代表在你的业务数据上同样好——领域术语、行文风格、中英混排程度都会影响。 - 所以阈值必须用你自己的数据来定。
第三条最容易被跳过,但它决定系统能不能上线。
阈值怎么定:分桶看一眼
做法不复杂:拿一两百条已知正确答案的样本跑一遍,按置信度分桶,看每个桶的准确率。
python from collections import defaultdict
# samples: [(state, 正确答案), ...],resps: 对应的响应 buckets = defaultdict(lambda: [0, 0]) # 置信度桶 → [答对数, 总数] for (state, truth), resp in zip(samples, resps): ans = resp.answers["action"] b = min(int(ans.confidence * 10), 9) / 10 # 0.0, 0.1, …, 0.9 buckets[b][0] += int(ans.choice == truth) buckets[b][1] += 1
for b in sorted(buckets): right, total = buckets[b] print(f"置信度 ≥ {b:.1f}: 准确率 {right / total:.0%}({total} 条)") |
然后找"准确率达到业务要求"的那条线:
-
你要求自动处理准确率 ≥ 98%?找到 98% 对应的最低置信度当阈值; -
那条线以上能覆盖多少流量?如果只覆盖 5%,说明要么模型不合适,要么问题拆得不够细。
这个"阈值—覆盖率—准确率"的三角,才是上线时要做的那个决定。
官方那个预解析取值的实测就很有代表性:阈值 0.60 时自动通过 108 个全部正确,剩 26 个转人工——覆盖率 80% 左右,准确率 100%。这比"全部自动处理、准确率 95%"要好用得多,因为人工队列的规模是可控的。
两个容易踩的工程细节
1. 近似确定,不是逐位确定
象信不采样,同样的请求会得到几乎相同的结果。但推理用低精度浮点并与其他请求批量计算,所以同一个请求重复调用时,中间区间的概率会小幅波动。接近 0 或 1 的值(如 0.99)则非常稳定。
实测示意(同一请求重复 10 次):
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
所以:
-
单元测试里不要写 assert noul == 0.81,改成assert noul > 0.7; - 不要把阈值恰好设在你观察到的某个值上
(看到 0.62 就把阈值设成 0.62),要留余量; -
落在阈值附近的样本,本来就应该按"不确定"处理。
2. 调好阈值后,固定模型版本
xiangxin-latest 这个别名会随新版本发布而移动。你已经针对某个版本调好的阈值,在新版本上不一定成立。
python # 阈值调好之后 resp = client.system_one(state=..., questions=..., model="xiangxin-2.0.0") |
响应里的 model 字段总是报告实际作答的版本号,建议写进日志——将来追溯"这个结果出自哪个版本"时,你会需要它。
今天就试一次
如果你现在有任何一个"靠模型判断 + 靠人抽查"的流程,可以这样开始:
- 给答案加上阈值分支。
先不管阈值是否最优,先把"高 / 中 / 低"三档的行为写出来; - 跑 100 条带标注的数据。
用上面那段分桶代码,看每个桶的准确率; - 改阈值,看人工队列的变化。
你会很快找到一个"覆盖率够高、准确率也够用"的点; - 把阈值集中放在一个文件里。
这是最需要人审阅的部分。
最后一条经验:先假设你的阈值是错的,然后去证明它。 文档里的数字都是示意,包括这篇文章里的。
下一篇:《让 DeepSeek 教会它,别让 DeepSeek 次次来》——聊聊怎么把高频判断"固化"下来。
动手入口
-
领免费额度(注册送 ¥5):console.xiangxinai.cn -
置信度详解:confidence -
置信度门控路由:patterns/confidence-routing -
Noul 怎么用两个阈值:Noul 是非题 -
哪些地方会抖:已知短板 · 近似确定
数据说明:分桶示例与阈值区间为方法演示,不是效果承诺;请用你自己的标注数据确定阈值。概率抖动幅度来自官方实测,具体数值随任务与版本变化。

