很多团队轮换 API Key 的操作只有两步:在控制台生成新 Key,然后把环境变量从旧值改成新值。这个流程看起来很快,却把所有风险压在同一个瞬间:配置可能没有同步到全部实例,新 Key 可能少了权限,发布中的 Pod 可能仍在读取旧 Secret,长连接和重试队列也可能继续使用旧值。
更稳妥的工程方案不是"替换",而是让两枚 Key 在一个短窗口内并存,先小流量验证,再切换主 Key,最后撤销旧 Key。核心顺序只有五步:创建、灰度、观察、提升、撤销。
为什么直接替换容易造成中断
假设服务有 12 个实例。运维人员修改 Secret 后滚动发布,新实例已经使用新 Key,旧实例仍使用旧 Key。此时如果马上在上游撤销旧 Key,尚未重启的实例会连续收到 401;如果新 Key 的模型权限或额度配置有误,所有新实例又会同时失败。
这类故障难排查,是因为"应用版本""配置版本"和"凭证状态"并不是同一个时间线。API 返回 401 时,日志里如果只记录错误码而没有记录 Key 的逻辑版本,开发者甚至无法判断请求到底用了旧 Key 还是新 Key。
因此轮换流程至少需要满足三个条件:
- 旧 Key 在新 Key 验收完成前继续有效;
- 每个请求能确定性选择 Key,并记录不敏感的逻辑版本;
- 新 Key 异常时只需清除候选配置,不需要重新发布代码。
一个可运行的双 Key 选择器
下面的实现没有保存真实 Key,也不会把 Key 打进日志。它把凭证分成 primary 和 candidate,用请求追踪 ID 做稳定分桶:同一个请求在重试时仍会选择同一枚 Key,避免随机切换放大问题。
js
class KeyPool {
constructor(primary) {
this.primary = primary;
this.candidate = null;
this.candidatePercent = 0;
}
prepare(candidate, percent = 5) {
if (!candidate || candidate === this.primary) {
throw new Error('candidate key is invalid');
}
if (percent < 1 || percent > 50) {
throw new Error('canary percent must be 1..50');
}
this.candidate = candidate;
this.candidatePercent = percent;
}
select(traceId) {
if (!this.candidate) return this.primary;
const bucket = [...traceId]
.reduce((sum, char) => sum + char.charCodeAt(0), 0) % 100;
return bucket < this.candidatePercent
? this.candidate
: this.primary;
}
promote() {
if (!this.candidate) throw new Error('no candidate key');
const old = this.primary;
this.primary = this.candidate;
this.candidate = null;
this.candidatePercent = 0;
return old;
}
rollback() {
this.candidate = null;
this.candidatePercent = 0;
}
}
实际接入 HTTP 客户端时,不要记录 Key 本身,只记录逻辑槽位和请求结果:
js
const key = pool.select(traceId);
const slot = key === pool.primary ? 'primary' : 'candidate';
const response = await fetch(`${baseURL}/v1/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${key}`,
},
body: JSON.stringify(payload),
});
metrics.count('relay_request', {
key_slot: slot,
status: String(response.status),
});
这里的 key_slot 只暴露 primary 或 candidate,不会泄露凭证。请求日志应继续避免记录 Authorization 请求头和完整请求正文。
灰度时到底观察什么
只看"请求成功"不够。新 Key 需要在与旧 Key 相同的请求集合上对比至少四类指标:
| 指标 | 旧 Key | 新 Key | 判断 |
|---|---|---|---|
| 401/403 比例 | 基线 | 不得显著增加 | 权限与状态 |
| 429 比例 | 基线 | 不得显著增加 | 额度与限流 |
| 5xx 比例 | 基线 | 不得显著增加 | 路由与上游 |
| P95 延迟 | 基线 | 在容忍范围内 | 路由质量 |
还要验证模型列表和真实业务接口。GET /v1/models 成功不代表 POST /v1/chat/completions 或 POST /v1/responses 一定可用;它们可能走不同的权限、路由或返回结构。配置 Base URL、模型名与 Key 时,可以同时对照 FishAI 官方文档 的当前接入说明,但上线前仍应使用自己的最小请求完成验收。
推荐的上线状态机
把轮换写成状态机,比写成一段临时脚本更可靠:
text
OLD_ONLY
-> CANARY_5
-> CANARY_25
-> NEW_PRIMARY
-> OLD_REVOKED
任意灰度阶段异常:CANARY_* -> OLD_ONLY
每次状态变化都必须有明确的准入条件。例如进入 CANARY_25 前,新 Key 至少完成模型列表、聊天接口、业务主模型和一次错误路径验收;进入 NEW_PRIMARY 前,灰度窗口内的 401、429、5xx 与延迟不能劣于旧 Key 的可比基线。
撤销旧 Key 也不能紧跟在提升操作之后。需要先等待:旧实例全部退出、队列中的旧任务结束、重试窗口过去、定时任务读取到新配置。这个等待时间应该来自系统的最长请求与重试策略,而不是拍脑袋定一个五分钟。
本地验收:5 个关键分支
本文配套脚本使用模拟 Key,不访问任何线上账户。运行结果如下:
text
PASS 旧 Key 单独承载
PASS 新 Key 进入 10% 灰度桶
PASS 非灰度请求仍使用旧 Key
PASS 灰度异常可回滚
PASS 提升后新 Key 全量承载
RESULT 5/5 passed
这 5 个用例验证的是选择器和状态切换,不代表某个生产 Key 已经通过线上验收。真正上线时还需要加入配置中心并发更新、实例版本分布、真实接口返回结构和撤销后的拒绝行为。
最容易遗漏的三个边界
第一,不要在失败后自动尝试未知 Base URL。401/403 更可能是 Key、权限或账号状态问题,擅自切换地址会把凭证发送到未经确认的主机。
第二,不要把旧 Key 当成永久兜底。旧 Key 长期保留会让轮换失去意义。提升完成后应设置明确的撤销时间,并在撤销后验证旧 Key 确实返回拒绝。
第三,不要把"配置已保存"当成"实例已生效"。必须通过实例版本、请求日志或最小真实请求证明运行时已经读取新配置。
总结
零中断轮换的关键不是更快地替换字符串,而是把凭证变化变成可观察、可回滚的发布过程:旧 Key 保底,新 Key 灰度,指标达标后提升,等待旧流量排空,再撤销旧 Key。这样即使新凭证权限、额度或路由配置有问题,影响也会被限制在小流量窗口内,而不是一次性打断全部请求。
如果团队只准备增加一个改动,建议先增加 primary/candidate 双槽位和稳定分桶;它们是后续灰度、回滚、审计和自动化轮换的共同基础。