☰
LLM置信度校准实战:33ms在线概率引擎与Agent可靠性控制
2026/11/3 8:50:46 网站建设 项目流程

1. 从一个被忽略的工程细节说起:LLM 的置信度为什么不可信

大模型输出里那个logprobs字段,很多人第一次看到会觉得捡到宝——既然模型自己给出了每个 token 的概率,那把它当成置信度用不就行了?我一开始也是这么想的,直到在一个医疗问答的 Agent 项目里被现实狠狠教育了一次。

当时的需求很朴素:用户问一个问题,模型给出答案,如果模型"没把握",就转人工或者触发检索增强。听起来合理吧?结果上线第一天就翻车。模型对一个完全编造的药品相互作用给出了 0.97 的 token 概率,而对一个它明明答对的基础常识问题,概率只有 0.4 出头。如果按这个阈值卡,等于把对的拦下来、把错的放过去,整个风控逻辑直接反向工作。

这不是个例。LLM 的原始 token 概率(raw logprob)和"这句话到底对不对"之间,隔着一道很深的鸿沟。这道鸿沟的成因,值得先掰开讲清楚,否则后面所有的校准工作都是空中楼阁。

1.1 原始 logprob 到底在表达什么

先纠正一个最常见的误解:token 概率衡量的是"语言上的顺滑程度",不是"事实上的正确程度"。

模型在生成每个 token 时,输出的是一个在词表上的分布。这个分布是训练目标(next token prediction)的直接产物,它优化的是"在给定上下文下,下一个词最可能是什么",而不是"这个陈述是否为真"。这两件事在大多数时候相关,但在关键场景下会严重背离。

举个我实测过的例子。给模型一个 prompt:"请补全:中国的首都是___"。模型输出"北京"的概率极高,接近 0.99,这个没问题。但换一个 prompt:"请补全:某虚构公司 XYZ 的 CEO 是___",模型照样能给出一个高概率的名字——因为从语言模式上看,"CEO 是 + 人名"这个结构非常顺滑,模型只是在做模式补全,它根本不知道 XYZ 存不存在。这时候高概率完全是幻觉的帮凶。

所以第一个结论:raw logprob 高,只说明模型觉得这个说法"读起来顺",不说明它"是真的"。

1.2 三个让置信度失真的系统性偏差

除了上面这个本质问题,还有几个工程层面的偏差在持续污染置信度信号。

偏差一:长度归一化缺失。一个 5 个 token 的短答案和一个 50 个 token 的长答案,如果直接把每个 token 的概率相乘,长答案的概率会被指数级压低。我见过有人直接拿exp(sum(logprobs))当置信度,结果所有长回答的分数都趋近于 0,短回答虚高。这是最基础也最容易踩的坑。

偏差二:采样温度与 top-p 的干扰。如果你用了 temperature > 0 或者 top-p 采样,输出的 logprob 分布已经被"削平"或"截断"过,它反映的是采样后的分布,不是模型真实的信念分布。想要干净的置信度信号,推理时最好用 temperature=0 或者至少记录原始 logits。

偏差三:RLHF 之后的过度自信。这是最隐蔽的一个。经过人类反馈对齐的模型,普遍表现出"过度自信"——它倾向于用非常肯定的语气输出,对应的 token 概率也被推高。学术界管这叫 calibration drift。一个在预训练阶段校准还算过得去的模型,经过对齐之后,置信度和准确率的对应关系会被系统性破坏。

提示:如果你在做 Agent 的可靠性控制,千万不要直接拿 API 返回的 logprob 当阈值判断依据。它需要经过校准才能用,而且校准参数是模型相关的,换模型就得重来。

1.3 为什么"校准"这件事必须单独做一层

到这里逻辑就清楚了:raw logprob 有系统性偏差,直接用它做决策会反向工作。那怎么办?答案是引入一个校准层——把模型输出的原始分数,通过一个映射函数,转换成"真实正确概率"的估计。

这个映射函数不是拍脑袋定的,而是用一批有标注的数据拟合出来的。核心思想是:我们收集一批"模型输出 + 这个输出到底对不对"的样本,然后学习一个函数,让"校准后的分数"尽可能接近"实际正确率"。

理想情况下,校准后的分数应该满足:当模型说"我有 80% 把握"时,在这批样本里确实有大约 80% 是对的。这个性质叫 calibration,衡量它的经典指标是 ECE(Expected Calibration Error)。

标题里提到的"33ms 校准概率引擎",本质上就是把这一层校准做成了一个低延迟、可在线服务的组件。33ms 这个数字很关键——它意味着校准可以放在推理链路里实时跑,而不是离线批处理。对于 Agent 这种需要即时决策的场景,这个延迟量级是能接受的。

2. 校准概率引擎的整体设计:为什么是这套架构

理解了问题,接下来是方案。我在设计这个引擎时,参考了几个方向:温度缩放(Temperature Scaling)、Platt Scaling、Isotonic Regression,以及一些基于分桶的工程化方案。最终落地的是一套分层校准 + 在线服务的架构。下面把选型逻辑讲透。

2.1 方案选型:为什么不用最简单的温度缩放

温度缩放是最经典的校准方法,原理简单到一句话:在校验集上找一个温度参数 T,把 logits 除以 T 之后再 softmax,让校准误差最小。它只有一个参数,拟合快,不容易过拟合。

但我实测下来,纯温度缩放在 LLM 场景下有两个硬伤。

第一,它假设偏差是全局一致的。而 LLM 的过度自信在不同任务类型上差异巨大——事实问答、代码生成、开放式创作,各自的校准曲线形状完全不同。一个全局 T 参数没法同时照顾到。

第二,它对极端值的修正能力弱。温度缩放是单调的幂变换,对于"模型说 0.99 但其实只有 0.6 正确率"这种严重高估,它需要把 T 调得很大,但这会同时把中间段的分数压得过低。

所以我采用的是分桶 + 桶内温度缩放的混合方案。先按任务类型和输出长度把样本分到不同的桶里,每个桶单独拟合自己的校准参数。这样既保留了温度缩放的简单性,又获得了分段的灵活性。

2.2 分桶策略:按什么维度切

分桶的维度选择直接决定校准效果。我试过几种组合,最终保留的是这三个维度:

分桶维度取值示例为什么选它
任务类型事实问答 / 代码 / 摘要 / 开放生成不同任务的过度自信程度差异最大
输出长度短(<20 token)/ 中 / 长(>100 token)长度归一化偏差的主要来源
是否含检索上下文有 RAG / 无 RAG有上下文时模型置信度普遍更准

这三个维度组合起来,桶的数量会膨胀。我的做法是先按任务类型分主桶,再在桶内按长度做二次分段,检索上下文作为一个修正因子而不是独立维度。这样桶的数量控制在可管理的范围内(大约 12 到 20 个),每个桶有足够的样本量来拟合参数。

注意:桶不是越多越好。桶太多会导致每个桶样本不足,拟合出的参数过拟合校验集,线上表现反而更差。我的经验是每个桶至少要有 200 到 500 个标注样本,低于这个数就合并到相邻桶。

2.3 33ms 是怎么来的:延迟预算拆解

标题里的 33ms 不是随便写的,它是一整套延迟预算的结果。我把这个引擎的耗时拆成几块:

  • 特征提取:从原始 logprob 序列计算长度归一化分数、最小 token 概率、概率方差等特征,约 3 到 5ms。
  • 分桶判定:根据任务类型和长度确定桶 ID,查表操作,1ms 以内。
  • 校准计算:桶内温度缩放 + 分段线性修正,纯数值计算,2 到 4ms。
  • 序列化与网络往返:如果作为独立服务,这部分占大头,约 15 到 20ms。

加起来落在 30ms 上下,所以对外宣称 33ms 是一个留了余量的保守值。如果你的校准逻辑和主推理在同一个进程里,省掉网络往返,实际能压到 10ms 以内。

这里有个工程取舍值得说:为什么不做成纯离线?因为 Agent 的决策是动态的,同一个问题在不同上下文下,模型的置信度分布会变。离线校准只能给一个静态映射,在线校准能结合当前请求的特征做动态调整。33ms 的代价换来的是决策准确性,这笔账在 Agent 场景下是划算的。

3. 核心实现细节:从 logprob 到可用置信度

这一节是干货最密集的部分。我会把从原始 logprob 到最终校准分数的完整计算链路拆开,每一步都给出公式和实操注释。

3.1 第一步:长度归一化的正确做法

拿到一串 token 的 logprob 之后,第一件事是归一化。常见做法有三种,效果差别很大。

做法 A:几何平均(推荐)

import math def length_normalized_score(logprobs): # logprobs 是每个 token 的 log 概率列表 if not logprobs: return 0.0 avg_logprob = sum(logprobs) / len(logprobs) return math.exp(avg_logprob)

几何平均等价于对 logprob 取算术平均再 exp 回来。它的好处是量纲稳定,不会因为长度爆炸或塌缩。这是我在生产环境用的方案。

做法 B:直接求和

def sum_score(logprobs): return math.exp(sum(logprobs))

这个就是前面说的坑,长文本必然趋近 0,不要用。

做法 C:最小 token 概率

def min_token_score(logprobs): return math.exp(min(logprobs))

这个反映的是"最不确定的那个 token",对幻觉检测有一定价值,但作为整体置信度太悲观。我的做法是把它作为一个辅助特征,而不是主分数。

实测下来,几何平均做主分数、最小 token 概率做辅助特征,这个组合在幻觉检测上的 AUC 比单用任何一个都高。

3.2 第二步:桶内温度缩放的参数拟合

温度缩放的拟合目标是最小化负对数似然(NLL)。给定一批样本的原始分数 s_i 和真实标签 y_i(1 表示正确,0 表示错误),我们要找温度 T,使得校准后的概率 p_i = sigmoid(s_i / T) 与 y_i 的 NLL 最小。

import numpy as np from scipy.optimize import minimize_scalar def fit_temperature(scores, labels): """ scores: 原始归一化分数数组 labels: 0/1 标签数组 """ def nll(T): if T <= 0: return 1e10 # 把分数转成 logit 形式再除以温度 logits = np.log(scores / (1 - scores + 1e-9) + 1e-9) scaled = logits / T probs = 1 / (1 + np.exp(-scaled)) probs = np.clip(probs, 1e-7, 1 - 1e-7) return -np.mean(labels * np.log(probs) + (1 - labels) * np.log(1 - probs)) result = minimize_scalar(nll, bounds=(0.1, 10.0), method='bounded') return result.x

这段代码有几个实操要点。第一,分数要先转成 logit 形式,因为温度缩放作用在 logit 空间。第二,要加一个极小的 epsilon 防止 log(0)。第三,温度搜索范围我限制在 0.1 到 10,超出这个范围说明数据有问题,不该硬拟合。

拟合出来的 T 通常大于 1,因为 LLM 过度自信,需要"降温"来把概率拉回合理区间。我见过 T 在 1.5 到 3 之间的居多。

3.3 第三步:分段线性修正处理尾部

温度缩放是全局单调的,对尾部(极高和极低分数)的修正不够精细。我在温度缩放之后又加了一层分段线性修正,专门处理尾部。

具体做法是:把校准后的分数分成若干区间,在每个区间内用校验集的实际正确率做线性插值。这本质上是一个轻量的 isotonic regression,但用分段线性实现,计算量小、可解释性强。

def piecewise_calibrate(score, breakpoints, targets): """ breakpoints: 分段点,如 [0.0, 0.3, 0.6, 0.9, 1.0] targets: 每个分段点对应的实际正确率 """ for i in range(len(breakpoints) - 1): if breakpoints[i] <= score <= breakpoints[i+1]: x0, x1 = breakpoints[i], breakpoints[i+1] y0, y1 = targets[i], targets[i+1] if x1 == x0: return y0 ratio = (score - x0) / (x1 - x0) return y0 + ratio * (y1 - y0) return targets[-1]

分段点的选择有讲究。我一般在高分区(0.8 以上)放更多分段点,因为 Agent 决策的阈值通常卡在这里,需要更精细的分辨率。低分区可以粗一些。

实操心得:分段线性修正的 breakpoints 和 targets 必须用独立的校验集拟合,不能和温度缩放用同一批数据,否则会过拟合。我的做法是数据按 6:2:2 分成训练、温度拟合、分段拟合三份。

3.4 完整链路串起来

把上面三步串起来,一个完整的校准流程是这样的:

class CalibrationEngine: def __init__(self, bucket_config): self.buckets = bucket_config # 每个桶存 temperature 和分段参数 def calibrate(self, logprobs, task_type, length, has_context): # 1. 长度归一化 raw_score = length_normalized_score(logprobs) min_score = min_token_score(logprobs) # 2. 分桶 bucket_id = self._resolve_bucket(task_type, length, has_context) params = self.buckets[bucket_id] # 3. 温度缩放 logit = math.log(raw_score / (1 - raw_score + 1e-9) + 1e-9) scaled = 1 / (1 + math.exp(-logit / params['temperature'])) # 4. 分段修正 final = piecewise_calibrate(scaled, params['breakpoints'], params['targets']) # 5. 融合辅助特征 final = 0.85 * final + 0.15 * min_score return final

最后那个 0.85/0.15 的融合权重是我调出来的经验值。最小 token 概率作为辅助信号,权重不宜过高,否则会把整体分数拉得太悲观。这个权重建议在自己的数据上重新调。

4. 落地到 Agent 场景:校准分数怎么用

校准分数本身不是目的,它是为了让 Agent 的决策更可靠。这一节讲几个我在实际 Agent 项目里验证过的用法。

4.1 动态阈值:不同任务用不同门槛

最直接的用法是设阈值。但阈值不能一刀切,要按任务类型分别设。我的做法是:根据业务对"漏判"和"误判"的容忍度,反推阈值。

举个例子。在一个医疗咨询 Agent 里,把错误答案放给用户的代价极高,所以阈值要设得保守——宁可多转人工,不可放过错误。我设的是校准分数低于 0.75 就触发人工复核。而在一个闲聊 Agent 里,错误代价低,阈值可以放到 0.4。

这里的关键是:阈值应该基于校准后的分数来设,而不是原始分数。因为只有校准后的分数才有"分数即正确率"的语义,你才能说"低于 0.75 意味着正确率不到 75%"。

4.2 触发检索增强:置信度低时补上下文

Agent 里一个高频模式是:模型置信度低时,自动触发 RAG 检索,把相关文档塞进上下文重新生成。这个逻辑要生效,前提是置信度信号准确。

我踩过的坑是:早期用原始 logprob 做触发条件,结果模型在"编造但很顺滑"的时候 logprob 很高,根本不触发检索,幻觉直接输出。换成校准分数之后,这类幻觉的分数被压到 0.5 以下,检索触发正常工作了。

具体实现上,我会设一个双阈值:低于低阈值直接转人工,介于低阈值和高阈值之间触发检索重试,高于高阈值直接输出。这样形成一个三级决策。

4.3 多候选重排:用校准分数选最优

当 Agent 生成多个候选答案时,怎么选?很多人用原始 logprob 排序,但校准分数排序更靠谱。

我的做法是:生成 N 个候选,每个都过一遍校准引擎,然后选校准分数最高的。实测下来,这个策略在事实性任务上比原始 logprob 排序的准确率高 8 到 12 个百分点。原因还是那个——原始 logprob 偏好"顺滑",校准分数偏好"正确"。

注意:多候选重排会增加推理成本(N 倍生成 + N 次校准)。如果延迟敏感,可以先用原始分数粗筛出 top-3,再对 top-3 做校准精排,这样成本可控。

4.4 与 Agent 记忆系统的配合

热词里提到了 Agent 记忆和 a-memguard 这类防御框架。校准分数在这里也有用武之地:当 Agent 从记忆里检索出历史信息时,可以用校准分数判断这条记忆的可靠程度。

具体来说,每条写入记忆的信息,可以附带一个写入时的校准分数。检索时,如果某条记忆的分数很低,说明它当初就是模型不确定时生成的,可靠性存疑,应该降权或者触发二次验证。这个机制能有效防止"幻觉污染记忆,记忆再污染后续推理"的雪崩。

5. 常见问题与排查技巧实录

这一节是我在实际部署中攒下来的问题清单,都是文档里不会写、但一定会遇到的。

5.1 校准后分数分布异常怎么办

现象:校准后所有分数都挤在 0.5 附近,失去了区分度。

排查思路:先看温度参数是不是拟合得过大。如果 T 超过 5,说明校验集里正负样本比例严重失衡,或者原始分数本身就没有区分度。这时候要回头检查数据标注质量,而不是继续调参。

另一个常见原因是分桶太细,每个桶样本不足,拟合出的参数噪声大。解决办法是合并桶,或者对桶参数做平滑(相邻桶参数加权平均)。

5.2 换模型后校准失效

现象:换了底层模型,原来的校准参数直接套用,效果崩了。

原因:校准参数是模型相关的。不同模型的过度自信程度、logprob 分布形状都不一样。换模型必须重新拟合。

实操建议:把校准参数和模型版本绑定,做成配置项。每次换模型,跑一遍校验集重新拟合,把新参数写进配置。这个过程可以自动化,我写了个脚本,换模型时一键重跑。

5.3 长文本校准分数偏低

现象:长回答的校准分数普遍比短回答低,即使长回答质量更好。

原因:长度归一化虽然缓解了这个问题,但没有完全消除。长文本里只要有一个 token 概率低,几何平均就会被拉下来。

解决办法:在长度分桶的基础上,对长文本桶单独放宽温度参数,或者引入"截断平均"——去掉最低的 10% token 概率再平均。我用的是截断平均,效果比纯几何平均稳。

5.4 问题速查表

问题现象可能原因排查动作
分数全挤在 0.5温度过大 / 桶太细检查 T 值,合并小桶
换模型后失效参数未重新拟合重跑校验集拟合流程
长文本分数偏低长度归一化不足启用截断平均
高分区区分度差分段点太稀高分区加密分段点
校准分数与人工判断不符校验集标注偏差抽查标注样本质量
延迟超标网络往返占比高改为进程内调用

5.5 几个容易被忽略的细节

细节一:校验集的分布要和线上一致。如果你的校验集是从某个特定数据集采的,但线上流量分布不同,校准会失效。我的做法是定期从线上采样一批真实请求,人工标注后补充进校验集,保持分布对齐。

细节二:校准分数要监控漂移。模型没换,但线上数据分布变了,校准也会慢慢失效。我会监控校准分数和实际正确率的 ECE 指标,超过阈值就触发重新拟合。

细节三:不要过度追求 ECE 最小。ECE 是整体指标,但业务关心的往往是某个阈值附近的准确性。我见过 ECE 很漂亮但阈值附近区分度很差的校准结果。所以拟合时要看业务相关的局部指标,不能只盯全局 ECE。

6. 关于这套方案的一些个人体会

做这个校准引擎的过程中,我最大的感受是:LLM 的可靠性问题,很多时候不是模型能力问题,而是信号解读问题。模型其实"知道"自己不确定,只是它表达不确定的方式(logprob)和我们直觉理解的方式(置信度)之间有偏差。校准做的就是搭这座桥。

33ms 这个延迟,一开始我觉得是负担,后来发现它反而倒逼我把整个链路做得很干净——没有冗余计算,没有不必要的序列化。现在回头看,如果当初允许自己用 300ms 的预算,可能就会写出很多低效的代码。

另外一点,校准不是一劳永逸的。它是一个需要持续维护的组件,模型换、数据变、业务调,都会影响它。把它当成一个需要定期体检的服务来运营,而不是一次性的脚本,这个心态转变很重要。

最后分享一个我在调试时常用的小技巧:拿一批你自己知道答案的问题去测校准引擎,看它给的分数是否符合你的直觉。如果连你自己都觉得"这个问题模型肯定答对,但校准分数只有 0.3",那说明校准链路有问题。用人的判断做 sanity check,比看任何指标都直接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询