Brievio 对外承诺的 SLO 是 聊天 API 每月 99.95% 的可用性。这听起来像个营销数字, 但它是实打实的 —— 它把我们的错误预算上限锁定在每月 21 分钟,而从上线 以来我们每个月都达成了它。这篇文章讲的是它背后到底有什么:上游故障 转移、首字节看门狗、计费算盘,以及那些比上述任何一项都更要紧、却毫不 起眼的运维琐事。
现实是:每一个上游都有状态不好的时候
我们在幕后对接着约 12 个上游。在任意一个月里,我们都会看到它们当中 每一个至少出现一次 5 到 30 分钟的局部降级。有时是某个区域宕机 (Anthropic 的 us-east-1 本季度就出了两次事故)。有时是悄无声息的 令牌桶收紧(Vertex AI 的限流器一到周一就很抠门)。有时是供应商 整体宕机(kie.ai 在三月断线了 47 分钟)。
如果某个上游挂了,而你又没有故障转移,那你的 SLO 就只能是它的 SLO —— 再减去你的传输开销。这就把上限硬生生压在了 99.5% 左右。要突破 99.9%,就必须做到任何单个上游的失败都不能把你 拖垮;而要做到 99.95%,则要求两个上游同时失败也照样 拖不垮你。
第一层:加权候选路由
对于我们托管的每一个模型,都有 1 到 4 条上游路径。调度器会按加权 顺序逐一尝试,而权重会基于滚动成功率(每个上游每个区域最近 100 次 调用)实时衰减。当 us-east-1 开始返回 5xx 时,它的权重会在约 5 秒内 跌到 us-west-2 之下,新请求在客户察觉之前就不再发往那里了。
// 上游调度器的伪代码。真正的实现
// (TypeScript,仅服务端运行)与此非常接近。
async function dispatch(req: ChatRequest): Promise<ChatResponse> {
const candidates = pickCandidates(req.model);
// candidates = [{provider: "anthropic-direct", weight: 100},
// {provider: "google-vertex", weight: 80},
// {provider: "kie-wholesale", weight: 60}]
// 权重越低 = 越靠后的备用路径。初始权重来自滚动统计的成功率。
const deadline = Date.now() + 540_000; // 硬性 540 秒上限
let lastError: Error | null = null;
for (const candidate of candidates) {
const budgetMs = chunkBudget(deadline, candidates.length);
try {
return await Promise.race([
callUpstream(candidate, req),
timeout(budgetMs),
]);
} catch (err) {
if (!isRetryable(err)) throw err; // 4xx → 不做故障转移
recordFailure(candidate, err); // 权重实时衰减
lastError = err;
}
}
throw lastError ?? new Error("all upstreams exhausted");
}这里有两点很容易做错,却至关重要:
- 不要对 4xx 做故障转移。上游返回 400 意味着请求 本身就有问题 —— 换一个上游重试只会得到同样的 400,只是更慢而已。 只有 408、429、5xx 和网络错误才会触发故障转移。
- 给每个候选限定预算。如果把 540 秒的总期限分摊给 3 个候选,就该给每一个约 150 秒,让它要么开始流式输出、要么就此 失败。别把整整 540 秒都给第一个 —— 它一旦卡死,你就没有时间留给 备用路径了。
第二层:首字节看门狗(50ms)
上游事故里有一半并不是硬性失败 —— 连接建立了,请求发出去了,然后 什么都没有。没有响应,没有错误。只有一片沉默。一个天真的重试会 干等满整个超时时间,再去试下一个,把用户能感知到的延迟翻了一倍。
我们的调度器会在请求发上线路之后立刻启动一个 激进的 50ms 首字节计时器。如果在这个窗口内连一个 响应字节都看不到,我们就中止并落到下一个候选。50ms 低于人的感知 阈值,所以在顺利路径上客户感受不到任何延迟。而在失败路径上,客户的 请求会在单帧人眼可感知的时间内故障转移到备用上游。
// 首字节探测 —— 上游一旦卡死就快速失败。
async function callUpstream(c: Candidate, req: ChatRequest) {
const ac = new AbortController();
// 如果请求发出到线路上后 50ms 内还看不到一个响应字节,
// 就把这个上游当作沉默,直接落到下一个候选。
const firstByteWatchdog = setTimeout(() => ac.abort("ttfb-50ms"), 50);
const res = await fetch(c.url, {
method: "POST",
body: JSON.stringify(req),
signal: ac.signal,
});
clearTimeout(firstByteWatchdog);
if (!res.body) throw new Error("no-body");
// 现在已经拿到首字节。把数据流交回去;客户端会随着每一个
// 数据块到达就立刻收到 —— 不做任何缓冲。
return new Response(res.body, {
headers: { "content-type": "text/event-stream" },
});
}我们之所以定在 50ms,是测量了自己上游 TTFB 的 P99:哪怕是最慢的 上游,也有 99% 的情况能在 40ms 以内吐出第一个响应字节。任何超过 50ms 的,都是信号,而非噪声。请根据你自己上游的 P99 来调这个数。
第三层:计费算盘
可靠性不只关乎可用性 —— 还关乎计费在高负载下是否依然准确。早期 我们遇到过一个竞态:两个并发请求都通过了余额检查,然后都提交了, 结果用户账户透支了 0.12 美元。修复办法是在写入时做预留:
// “计费算盘” —— 先把预估成本预留下来,这样两个并发的
// 大请求就不会双双通过余额检查后又一起把账户透支。
async function reserveBalance(userId: string, estCents: number) {
return await db.transaction(async (tx) => {
const bal = await tx.balance.findUnique({ where: { userId } });
if (bal.balanceCents - bal.reservedCents < estCents) {
throw new InsufficientQuotaError();
}
await tx.balance.update({
where: { userId },
data: { reservedCents: { increment: estCents } },
});
return { release: () => releaseReservation(userId, estCents) };
});
}每一次调用都会预先估算它的最大成本(输入 token × 输入费率 + max_tokens × 输出费率),在单个事务里把这笔金额在钱包上预留下来, 等真实用量明确后再把没用掉的部分释放回去。并发请求会在事务层 串行化。永远不会透支,一次都不会。
那些更要紧、却毫不起眼的东西
每一个读到这里的工程师,都在对着调度器示意图点头,心想「不错, 加权重试、首字节看门狗,我懂了」。但真正让我们的 SLO 月复一月 稳稳兜住的,反而是些没那么激动人心的东西:
- 每一个定时任务都带心跳。令牌轮换、余额清算、 用量汇总 —— 它们每一个在完成时都会去 ping 一个 Healthchecks.io 的端点。心跳一旦缺失,5 分钟内就会告警。我们从缺失心跳里抓到的 故障,比从显式告警里抓到的还多。
- 一个真正准确的状态页。 brievio.com/status 每 90 秒就对每一个模型跑一遍合成检查。当某个上游开始劣化时, 它会在客户察觉之前就转黄。
- 在需要运行手册之前就把它写好。我们经历的每一次 事故,包括那些在影响到客户之前就被我们抓住的,都产出了一条运行 手册条目。凌晨 4 点的告警有一份清单可循;工程师无需临场动脑。
- 处处接 Sentry,热点路径上做性能剖析。调度器会 把每一个故障转移决策连同候选、原因和剩余预算一起记录下来。一旦 有劣化悄悄混进来,搜索就是一句「把这一小时里 reason=ttfb-50ms 的 故障转移按上游分组给我列出来」。
我们刻意不做的事
有几件事我们刻意没有去做,尽管你可能会以为一个 99.95% 的网关就 该做:
- 多区域双活数据库。我们的数据库是单区域的 (iad)。复制延迟会带来我们不愿去排查的计费不一致。如果 iad 硬性宕机,我们会降级到只读模式 —— 客户仍然可以调用模型(调度器 在边缘是无状态的),只是有一个小时看不到用量历史。这个取舍对 我们来说是对的。
- 悄悄替换模型。有些聚合商在够不到 Claude Opus 时,会把请求路由到 Haiku。我们不会 —— 我们宁可让你拿到一个 503, 也不愿给你一个你没付钱的、不一样的模型。调度器只会故障转移到同一个模型在不同上游上的版本。
- 抢先重试。Anthropic 的 429 是有含义的。立刻 重试只会让所有人的问题都更糟。我们的退避是带抖动的指数退避, 上限为 5 次尝试、总等待 30 秒 —— 这正是我们在 错误指南里推荐的同一套策略。
预算实际花在了哪里
我们 2026 年 5 月的错误预算是 21 分 36 秒。我们用掉了:
- 3 分 12 秒:一次部署期间 us-east-1 Anthropic 的抖动。故障转移触发了,没有请求被丢弃,但在那个窗口里 P99 延迟冲到了目标值之上。
- 1 分 50 秒:某个视频模型上 kie.ai 的一波 5xx 爆发。所有重试都成功了,但单次调用的延迟违反了 SLO。
- 0 硬性宕机。没有任何请求在无重试余地的情况下 返回 5xx。
那是在 21:36 的预算里烧掉了 5:02 —— 用了 23%,远在目标之内。 剩下的 77% 正是让我们能放手激进部署、并吸收下一个意外的底气。
这对你意味着什么
如果你在 Brievio 上做开发,你不需要自己去实现供应商故障转移、不 需要自己的 TTFB 看门狗,也不需要自己那套 Anthropic 与 Vertex 之间 的路由。这正是关键所在:一个 base_url、一个 bearer token、货真价实 的模型,而 SLO 由我们来守。
如果你正在做另一个网关,读这篇是想找点思路:调度器只有 600 行 TypeScript。它不是复杂的那部分。复杂的部分是那些失效模式 运行手册、心跳、状态页,以及几个月里那些一点点累积、最终汇成一套 可靠系统的细小运维伤疤。在你发布之前,就先为它们做好打算。
想看当前的可用性数字和各区域明细,请访问 brievio.com/status,或在 错误文档里查看完整的错误分类。