Stack Overflow 博客第三篇,探讨如何为 AI Agent 构建置信度评估层,让 Agent 在知识不足时主动识别而非硬撑,提升系统可靠性。
智能体产生的最重要的数字不是它的答案——而是它的确定程度。将这个数字由独立信号组合而成,检查它是否经过校准,用第二个模型对高风险决策进行评审,其余路由给人类。
这是生产环境中运行 LLM 系统的六级成熟度模型的第三级。第一级和第二级让你到达系统能工作且你能看到它工作的阶段。第三级是置信度:系统仅在其校准后的置信度较高时才自动行动,用独立的评审器对重要决策进行评审,并将所有不确定的内容以能实际捕获错误的方式路由给人类。
这三件事是一个机制。置信度是决定什么自动运行的拨盘。评审器是构建该拨盘的信号之一——也是将决策从自动化路径上拉下的触发器。人工交接是拨盘将线以下所有内容发送到的位置。三者都做对了,你就拥有了一个知晓自己何时不知的系统,因此可以承担更多任务。
问大多数 LLM 系统"你有多确定?"你得不到任何有用的东西——要么没有数字,要么是模型自我报告的置信度,这是出了名的未校准(模型在出错时也会愉快地确信)。所以你必须工程化一个置信度信号。一个生产分数由独立信号组合而成,绝不来源于模型自身的声称:
@dataclass
class Signals:
model_self: float # 模型自己的评分 — 有用但权重降低(过度自信)
verification: float # 确定性检查通过的比例(0..1)
judge_agreed: bool | None # 如果该决策被评审了则为 True/False;未采样则为 None(大多数决策都不会被评审)
historical: float # 该切片的过去准确率(例如 0.99 vs 0.80)
WEIGHTS = {"model_self": 0.25, "verification": 0.35, "judge": 0.25, "historical": 0.15}
def compose(s: Signals) -> float:
# 只有约 8% 的决策会被评审。当 judge_agreed 为 None 时,删除 judge 项并重新归一化其余部分——
# 未评审的决策不应被惩罚(False)也不应被伪造(True)。
parts = {"model_self": s.model_self, "verification": s.verification, "historical": s.historical}
if s.judge_agreed is not None:
parts["judge"] = 1.0 if s.judge_agreed else 0.0
return sum(WEIGHTS[k] * v for k, v in parts.items()) / sum(WEIGHTS[k] for k in parts)
精确的权重不如原则重要:单一置信度来源是单一故障点。注意 model_self 被刻意设为最小权重——一个自信地出错的模型会被失败的验证或持反对意见的评审拖下来,所以"自信地出错"本身无法越过门槛。另外三个信号也是你可以检查的:确定性验证要么通过要么没通过,评审器要么同意要么不同意,历史准确率是该切片的实测事实。模型对自身的看法是唯一一个你无法独立验证的输入,这正是它话语权最低的原因。
只有经过校准,分数才有用——如果"0.9"意味着"约 90% 的情况下正确"。检查它:按预测置信度对决策分桶,测量每个桶的真实准确率(可靠性图)。
预测桶 预测均值 实际准确率 判定
-------------- -------------- ------------- ---------------------------------------
0.90 – 1.00 0.95 0.71 ⚠ 过度自信 — 重新校准 / 提高阈值
0.80 – 0.90 0.85 0.84 ✓ 校准良好
0.70 – 0.80 0.75 0.77 ✓
< 0.70 0.55 0.52 ✓(正确地不确定)
如果你的顶部桶只有 71% 的时间正确,你的自动化阈值太松了。生成该表的分桶方式(以及触发告警的差距):
def reliability(samples, bins=10): # samples: list[(confidence, was_correct: 0|1)]
rows, ece, n = [], 0.0, len(samples)
for b in range(bins):
lo, hi = b/bins, (b+1)/bins
last = (b == bins - 1)
bucket = [s for s in samples if lo <= s[0] < hi or (last and s[0] == 1.0)] # 顶部桶包含 1.0
if not bucket: continue
conf = sum(c for c, _ in bucket) / len(bucket)
acc = sum(ok for _, ok in bucket) / len(bucket)
rows.append((round(lo, 1), round(conf, 3), round(acc, 3), len(bucket)))
ece += (len(bucket) / n) * abs(conf - acc)
return rows, ece
加权求和不是天然校准的——compose() 返回 [0,1] 范围内的数字,不是概率。闭合循环:从原始组合分数拟合单调映射到经验准确率,然后基于此进行路由。
from sklearn.isotonic import IsotonicRegression
calibrate = IsotonicRegression(out_of_bounds="clip").fit(raw_scores, correct)
p_correct = calibrate.predict([composed])[0] # 这是阈值比较的对象
在滚动窗口上重新计算——校准会随着模型和输入漂移。优先使用 Brier score 或带符号的每桶差距而不是 ECE 来告警(ECE 对分桶敏感,对于未校准的模型可能读数为 ~0)。注意独立性警告:将历史切片准确率权重加入 compose() 然后按切片校准是重复计算——在一个地方编码切片可靠性,而不是两处。
一旦组合并校准完毕,自动化就是一个阈值——正确的 T 是按切片设置的,从校准数据中得出:
def route(conf: float, slice_key: str, thresholds: dict[str, float]) -> str:
T = thresholds.get(slice_key, 0.95) # 默认保守
if conf < ABSTAIN_FLOOR: return "abstain" # 太不确定,甚至无法推荐
return "auto" if conf >= T else "hitl_recommended"
这里的路由是第一级四路词汇表的精简版:abstain 是人工审查状态之下的新地板(对于太不确定甚至无法预填草稿的输入),而第一级的 reject(验证失败)和 hitl_required(非常低的置信度)仍然适用。将 T 设高(几乎所有内容都交给人类),然后只有当该切片的校准证明该区间是安全的之后才降低它。你可以通过指向证明该自动化区间合理的数据来为每个自动化区间辩护。
这方面的最高形式是弃权——拒绝做决定。一个说"我不确定;人类应该看看"的智能体比一个总是回答的智能体更值得信赖。把它作为一等公民的结果,而不是失败路径。这也是你对抗未知未知的最佳防御:你无法枚举生产会发送的每一个奇怪输入,但校准后的分数加上弃权地板意味着奇怪的那些会自动落到人类那里。
ABSTAIN_FLOOR = 0.40 # 低于此值:甚至不要提议;升级为"需要人工"
整个系统围绕三个杠杆调整,校准数据告诉你每个杠杆该往哪个方向移动:
杠杆 提高它 降低它
------------------ -------------------------------------- ---------------------------------------
阈值 `T` 更少的自动错误,更多人工负载 更多自动化,更多风险
`model_self` 权重 更多信任模型(风险更高) 依赖验证/评审
弃权地板 更少的糟糕自动提案,更多升级 更少的升级,更多噪声给人类
一些反模式可靠地破坏这一层:使用原始模型置信度作为拨盘(未校准——应该组合);单一全局阈值(不同切片的准确率差异巨大);从不重新检查校准(它会腐坏——一个季度前的阈值是一个季度前的风险模型);以及将弃权视为错误(这是系统正确识别其能力边界——衡量它,而不是压制它)。
compose() 中的信号之一是 judge_agreed。这就是它的来源——它值得独立一节,因为对于高风险决策,它是自信猜测和有据可查的决策之间的区别。
单次模型调用是单一故障点。它在出错时很自信,有特征性的盲点,你无法仅通过看输出区分好的答案和看似合理的答案。廉价而有效的保险是评审器:一个独立的第二个模型,在你根据第一个模型的决策行动之前对其进行评估。
@dataclass
class Verdict:
agrees: bool
confidence: float
failure_mode: str | None # 如果它不同意,原因是什么
def judged_decision(inputs, primary_out, judge) -> Verdict:
return judge.evaluate(inputs=inputs, proposed=primary_out) # 窄问题:这个对吗?
三个设计选择让它有效。
让评判者保持独立——并且持怀疑态度。如果评判者使用的是同一个模型和同一个提示词,它会复制盲点并充当橡皮图章。在条件允许时,使用不同的模型家族、不同的框架,并提示它去反驳而不是确认。
You are a strict reviewer. You will be given INPUTS and a PROPOSED DECISION made by another system.
Your job is to find the strongest reason the PROPOSED DECISION is WRONG, unsafe, or unsupported by
the inputs. Do not be agreeable. If, after genuinely trying to refute it, you cannot, then agree.
Return JSON: {"agrees": bool, "confidence": 0..1, "failure_mode": string|null}
INPUTS: {{inputs}}
PROPOSED DECISION: {{proposed}}
一个被指示去反驳的评判者能捕捉到远多于被指示去批准的内容——框架真的在发挥作用,因为一个随和的评审者被问"这对吗?"时会想办法说"对",而一个被要求"哪里错了?"的对立评审者会暴露那些你只有在生产环境中才会发现的故障模式。
采样判断——不要评判一切。 评判会使模型成本翻倍。把预算花在高风险或高不确定性的地方:
decision class judge policy
-------------------------------------- ------------------------------------------------------
highest-impact / irreversible 100% judged
borderline confidence (near threshold) always judged
everything else random sample, e.g. 5–10% (a continuous quality probe)
def should_judge(d, thresholds, rate=0.08) -> bool:
if d.stakes == "high": return True
if abs(d.confidence - thresholds.get(d.slice, 0.95)) < 0.05: return True # borderline (per-slice T, safe default)
return deterministic_hash(d.decision_id) % 10000 < rate * 10000 # deterministic + sub-1% safe
将分歧视为路由信号——也是指标。当评判者不同意时,该决策每次都转给人工。而且分歧率是你最好的健康信号之一——飙升意味着攻击、错误部署或模型回归。
def combine(primary_out, verdict: Verdict) -> tuple:
emit("judge_invocations_total")
if not verdict.agrees and verdict.confidence >= 0.6: # confident refutation → human, every time
emit("judge_disagreements_total")
return route_to_human(primary_out, reason=verdict.failure_mode), False # judge_agreed = False
if not verdict.agrees: # weak/low-confidence refutation: INCONCLUSIVE
return primary_out, None # don't fake agreement — None makes compose() drop the judge term
return primary_out, True # genuine agreement → judge_agreed = True for compose()
有一个坑你一定会遇到:测试中的非确定性。如果评判者随机采样,而那个路径在你的测试套件中运行,你的测试就会变得不稳定——同一个输入在一次运行中评判通过,在下一次运行中就不通过了,导致断言间歇性失败。这看起来像个神秘的 bug;其实是你的采样率渗入到了确定性测试中。在测试中固定采样率,并使用决策 ID 的确定性哈希(而不是 RNG),这样即使是采样行为也是可重现的:
# production samples; tests pin the rate. Any randomness in an agent must be injectable.
judge = Judge(model=secondary, sampling_rate=0.0 if TESTING else 0.08)
你不是在让模型变得完美;你是在构建一个比任何单次调用都更可靠的系统——对于经过评判的决策,两个独立模型必须同意你才能行动,而它们的不同意就是系统举起了手。对于重要的决策,这是一种非常廉价的安全保障方式。
刻度盘和评判者都通向同一个地方:被路由到个人的决策。但"路由到人工"正是很多本来不错的系统在交接处悄悄失败的地方——不是模型的问题,是交接的问题。做得好,人是真正的安全层和训练信号来源。做得差,你就建了一个人们不阅读就点击"批准"的按钮——这比没有人工更糟,因为它制造了虚假的问责。
懒惰的交接只显示提议的答案和批准/拒绝。在大量数据下,人类会选择批准——提议锚定了他们,拒绝需要付出努力,队列又很长。现在你有了一个没有检查任何东西的人在环中,外加一条虚假显示"有人审查过"的审计追踪。好的 HITL UX 让真正的判断变得廉价,并把注意力引导到重要的地方。
显示一份附有证据的草稿,而不是一份需要批准的裁决。防止橡皮图章最大的杠杆是显示推理过程和证据——人类无法检查他们看不到依据的结论。
{
"proposed": { "decision": "approve", "fields": { "amount": 250 } }, // editable draft, not a verdict
"confidence": 0.62,
"routed_because": "confidence_below_threshold (0.62 < 0.85)", // tell them where to look
"reasoning": ["matched policy 4.2", "no prior flags"], // the WHY, not just the what
"evidence": [ { "label": "policy", "ref": "...", "snippet": "..." } ],
"stakes": "reversible | high_cost | irreversible", // calibrate their attention
"alternatives": [ { "decision": "reject", "would_trigger": "..." } ]
}
将每个操作都捕获为信号。每一个人类操作都是一个标签——记录下来,结构化地对应到决策上。编辑是最丰富的信号:一个被纠正的答案,是你golden set的黄金,也是发现智能体系统性地在哪里出了问题的依据。
def on_review(decision_id, review):
ledger.append_outcome(decision_id, review) # audit: who decided, what, when
emit("human_override_rate", 1.0 if review["action"] != "approve" else 0.0,
slice=slice_of(decision_id))
if review["action"] == "edit":
golden_set.add_candidate(decision_id, review["corrected"]) # edits become eval cases
将循环闭合回到刻度盘。HITL 和置信度阈值是一个反馈循环,这就是让整个层自改进而不是静态的原因:
observation on a slice action
---------------------------- --------------------------------------------------------------
humans approve ~unanimously candidate to automate — lower `T` for that slice
frequent overrides raise `T` or pull the slice back to human-only
many edits of the same field the agent is systematically wrong there — fix the prompt/logic
你可以防御性地开放自动化——指向每个频带批准的比率。这就是循环自我闭合:评判者的分歧和人类的编辑都流回到 compose() 读取的历史和分片准确性信号中,所以每个被路由的决策都会让下一批自动决策的校准稍微更好一些。
不要淹没人类。 把所有东西都路由到个人不是安全;这是对你的审查者的 DoS,泛滥的队列会被橡皮图章式地处理。好的置信度和路由的意义在于,只有真正不确定的决策才会到达个人——少到足以获得真正的关注。如果队列不堪重负,从上游修复(更好的置信度,将证明安全的分片自动化),而不是加更多的审查者。还有两个值得指出的陷阱:让拒绝比批准更难(一次点击 vs 五字段表单会偏向批准),以及省略 routed_because(人类不知道该把注意力放在哪里,所以会略读)。
置信度是安全自动化的关键,它是一个机制三个部分。将数字从独立信号中组合出来——绝不是模型自己的声称——并根据真实结果对其进行校准,这样"0.9"实际上意味着 90%。对于重要的决策,让一个独立的、持怀疑态度的评判者试图证明第一个模型是错误的,按风险采样并保持确定性,这样你的测试就能保持正常;其同意计入分数,其不同意见举起手来。低于线的所有内容都路由给人类,交接时附上草稿、推理过程以及为什么被路由的原因——不同意的成本和批准一样低,每个操作都被捕获为信号,这些信号会将阈值调低或调高。
以这种方式构建的系统知道自己什么时候不知道。它可以被信任处理更多的事情,因为它不应该处理的案例会自行路由出去——而监督的外观,所有结果中最危险的,正是它拒绝制造的东西。
系列:在生产环境中运行 LLM 系统——Level 3 of 6:置信度。