传统断路器参数不适用于毫秒级/免费的本地调用;AI推理端点应调低故障计数阈值、缩短熔断时间,避免为超时请求付冤枉费同时保持降级路径可用。
断路器是一个存在了二十年的设计模式,配置项大家都很熟悉,但这些配置几乎都是针对「耗时毫秒级、成本可忽略」的调用场景设定的。把它直接套到推理端点上而不做任何调整,结果要么是永远不跳闸,要么是永远不复位。
断路器位于依赖前方,统计失败次数,一旦失败达到一定数量,就会在一段时间内停止调用,直接抛出错误。这样会带来三个效果,第三个是这个依赖特有的。
延迟不再雪崩。 当 Provider 开始超时的时候,所有在飞请求都在占用着 Worker、连接池以及并发限额槽位,而且要一直占用到整个超时时间结束。快速失败则能立即释放这三者——这就防止了一个出问题的依赖把跟它毫无关系的接口也拖下水。
兜底路径会被使用。 如果降级阶梯的第一级需要 8 秒,那每次故障期间每个请求都要额外等 8 秒。断路器打开时可以在微秒级跳过第一级,这样降级就变成了一件低成本的事,而不仅仅是「理论上可行」。
不再为失败付费。 超时的调用往往是 Provider 已经执行完成并扣费了的。在糟糕的 10 分钟里,断路器区别于「一百次浪费的生成」和「几次试探性探测」。
经典配置大约是:「在 10 秒滚动窗口内,50% 请求失败,且最少 20 个请求,触发跳闸」。这些数字假设的是一个每秒处理数百次调用的服务,20 个请求不过是几分之一秒的流量。
现在假设一个功能每分钟发起 4 次模型调用,每次耗时 5 秒。10 秒的窗口永远装不下 20 个请求,所以最小请求量门槛永远达不到,断路器永远不跳闸——就算故障持续一个小时它也会一直闭合。问题不在于阈值设置得不对,而在于单位选错了。时间窗口隐含了对请求速率的假设,而这个假设在这里根本不成立。
改用请求计数。维护一个最近 N 个结果的环形缓冲区,跳闸条件表达为「最近 N 次中 k 次失败」,这样在任何请求速率下行为都是一致的。N 的取值取决于你愿意在故障期间保持闭合多久:按正常请求速率,N 次调用就是你接受的检测延迟。k 的取值取决于你对误触的容忍度——毕竟被监控的依赖本身就有非零的基线错误率;「窗口内 N 次的过半数」是一个合理的起点形状,但它是起点形状而非推荐值,因为正确取值取决于你自身的基线,这个页面并不知道你的基线是多少。
给缓冲区加一条过期规则。一小时前的结果不应该影响今天的跳闸判断,所以要把超过几分钟的记录丢弃,尽管窗口本身是以请求数来计量的。这是时间重新进入的唯一一处,只是以过期机制的形式而非窗口的形式。
断路器的存在目的是检测依赖是否出了问题。自身请求导致的错误不算证据,把这类错误计入会让断路器为所有其他调用者跳闸——因为某一个客户端发了格式错误的 JSON。
type Outcome = { ok: boolean; at: number };
export class Breaker {
private ring: Outcome[] = [];
private openedAt = 0;
private probing = false;
constructor(
private readonly n = 20, // window, counted in requests
private readonly k = 11, // failures in the window that trip it
private readonly coolDownMs = 30_000,
private readonly staleMs = 300_000,
) {}
private state(): "closed" | "open" | "half" {
if (!this.openedAt) return "closed";
return Date.now() - this.openedAt >= this.coolDownMs ? "half" : "open";
}
async run<T>(call: () => Promise<T>): Promise<T> {
const state = this.state();
if (state === "open") throw new BreakerOpen(this.retryAfter());
// In half-open, exactly one probe is allowed through. Everyone else is
// rejected: a stampede of probes is how a recovering provider is knocked
// back over, and here each probe is also a purchase.
if (state === "half") {
if (this.probing) throw new BreakerOpen(this.retryAfter());
this.probing = true;
}
try {
const value = await call();
this.record(true, state);
return value;
} catch (error) {
if (trips(error)) this.record(false, state);
throw error;
} finally {
if (state === "half") this.probing = false;
}
}
private record(ok: boolean, state: "closed" | "open" | "half") {
if (state === "half") {
// One good probe closes it; one bad probe restarts the clock.
this.openedAt = ok ? 0 : Date.now();
if (ok) this.ring = [];
return;
}
const cutoff = Date.now() - this.staleMs;
this.ring = [...this.ring, { ok, at: Date.now() }]
.filter((o) => o.at >= cutoff)
.slice(-this.n);
const failures = this.ring.filter((o) => !o.ok).length;
if (this.ring.length >= this.n && failures >= this.k) this.openedAt = Date.now();
}
private retryAfter() {
return Math.max(0, this.openedAt + this.coolDownMs - Date.now());
}
}
注意 BreakerOpen 携带了 retry-after 信息。打开的断路器不只是一个错误,而是一个有明确过期时间的错误,把这个信息向上传递可以让队列智能地重新调度,而不用拿自己的重试预算去撞一扇已知关着的门。
在经典断路器里,半开探测是免费的——一次健康检查,一次廉价的读操作。在这里探测是一次真实的生成,有真实的成本,这就改变了两件事。
首先,用尽可能小的请求去探测,而不是重放用户的请求。一个短 prompt 加一个低 max_tokens 的请求,在连通性和认证方面的判断效果和完整调用一样,但成本低得多。如果你有能力用真实请求去探测,优先选那些本来就会发出的请求——但绝不要用重复会造成危害的请求去探测,因为探测本质上是你认为可能会失败的一次尝试。
其次,重复失败时要加大冷却时间。面对持续一个小时的故障,固定 30 秒冷却期意味着 120 次探测。每次探测失败后把冷却时间加倍,设置一个几分钟的上限,就能把 120 次变成几次。经典模式通常不做这个优化,因为探测是免费的;但在这里它是「尽快注意到恢复」和「花两千次的钱去问一个已经挂掉的服务同一个问题」的区别。
单一全局断路器几乎总是错的,因为故障域不是全局的。有效的粒度是最小可独立失败的单元,通常是 Provider 和模型的配对:一个模型被撤回或过载并不意味着同账户下其他模型也有问题,一个 Provider 的区域故障也并不意味着提供同一模型的其他 Provider 也有问题。
更细粒度的代价是每个断路器看到的请求更少,检测就更慢。如果你有很多低流量的模型-Provider 配对,保持按配对的断路器,但加一个按 Provider 层级的父断路器,由所有子断路器供给它——子级检测坏模型,父级检测坏 Provider,任意一层故障都会打开相应范围的系统。无论选哪种方案,都要导出状态作为指标,用配对作为标签。一个没人看得见的打开状态的断路器,和一个没人调用的依赖,是无法区分的。