多 API Key 轮询与故障切换怎么实现?从 Key 池状态机到换 Key 重试的完整过程

多 Key 是干什么用的,先说答案

多 Key 的价值就三件事:把并发压力摊到几条独立额度上、按业务线把费用分开记账、某一条 Key 出问题时业务不停摆。轮询也不是"随便换一条",而是先看健康状态再挑:cooling 或者 dead 的直接跳过,只在 healthy 里按顺序轮。

把并发压力摊到几条独立额度上

一条 Key 扛整条业务线的全部请求,高峰期就是单点。拆成几条之后,同一时刻的请求落在不同 Key 上,单条 Key 的瞬时并发就下来了。这里分摊的是并发和流量分布,不是去撬动什么额度上限,池子只负责把流量排得均匀一点。

按业务线分开记账,出问题能定位到人

每条业务线用自己的 Key,月底拉账单不用靠猜:客服机器人烧了多少、批量翻译烧了多少,各算各的。全挤在一条上,只能看见总数,看不见钱花在哪儿。

一个 Key 出问题,业务不停摆

Key 失效的原因很多:账户欠费、权限被改、配置写错、上游临时收紧。只有一条 Key 时,一句 openai.AuthenticationError: Error code: 401 就能让整条业务线全挂,只能停机改配置。多条 Key 配上健康检查和自动切换,这条挂了就换下一条。

哪些场景真的需要多 Key

多 Key 不是必选项。一天几百次调用、一条 Key 半年没出过事,加池子只是给自己找活干。

多业务线要分开算成本

同时跑着客服机器人、内容审核、数据清洗三条链路时,成本要能拆开报到各业务方头上。一条 Key 记不清谁用了多少,只能按人头平摊,谁都不服气。一条业务线一条 Key,用量和费用天然对齐。

团队多人共用要隔离

三个人共用一个开发用的 Key,谁把脚本写成死循环,谁都说不清。分开之后,影响面限制在他自己那条 Key 上,看调用曲线就知道是谁的锅。

单条业务线并发高,或者一条 Key 出事不能全站陪葬

高并发下单条 Key 很容易撞到 openai.RateLimitError: Error code: 429,被限流之后整条链路跟着卡住。流量分到几条 Key 上,每条的请求数都低一些,撞限流的概率也就小了。这条业务线如果支撑线上服务,切换能力本身就是可用性的一部分。

Key 池的状态机设计

池子能不能自动扛住故障,全看状态设计得对不对:设计歪了,要么坏 Key 一直被挑中,要么好 Key 被误杀。

三个状态:healthy、cooling、dead

healthy 表示现在能用;cooling 表示暂时不能用,旁边挂一个 cool_until 时间戳,到点自己回来;dead 表示认证类失败,比如 Key 被删、权限被收,这类错误重试多少次结果都一样,标记出来等人处理。别再往里加状态:状态越多,流转组合越乱。

除了状态,还要一个 fail_count

状态只说明"此刻能不能用",说明不了"最近是不是老出问题"。一条 Key 每分钟偶发一次超时,状态永远是 healthy,可它一直在拖慢链路。用 fail_count 记连续失败次数,到阈值就推进冷却,snapshot() 里也能一眼看出该查哪条;成功一次归零,免得旧账挂着。

状态流转规则

这张表就是池子的全部逻辑,照着写代码不会跑偏:

上游返回池子动作持续多久怎么恢复
429openai.RateLimitErrorcooling60 秒到期自动回 healthy
401 / 403直接标 dead 并告警一直保持人工换 Key 或改配置
402(额度不足)dead 并告警一直保持充值或换 Key
连续 3 次 5xxcooling300 秒到期自动回 healthy
超时、连接错误fail_count 加一,状态不动下轮照常轮询
cooling 到期healthy,计数清零不用人工干预

429 给 60 秒,是因为限流一般按分钟窗口统计,等一个窗口过去再试,比立刻重试划算。5xx 给 300 秒,是上游抖动往往几分钟才稳。401 和 403 直接标死,因为这两类错误不会因为"再等等"而变好。

轮询策略怎么挑

四种常见策略对比

策略怎么挑好处代价
轮询(round-robin)按顺序一条条轮着用每条 Key 被用到的次数接近要维护一个游标
加权额度大的多分流量额度不会被闲置权重靠人工维护
最少使用(least-used)挑累计调用次数少的长期看分布更均匀历史次数要落盘
随机从健康 Key 里随机挑几行代码写完可能连着撞同一条

轮询加冷却过滤,多数场景够用

轮询的好处是每条 Key 被用到的次数接近,月末看账单,哪条消耗偏高一眼就能看出来;再叠一层冷却过滤,坏掉的 Key 自动退出轮转。实现成本也低:一个游标加一个列表,几十行就写完。加权适合额度差得多的场景,最少使用适合长跑服务,代价是历史次数得落盘。多 Key 在这里是为了分摊并发和隔离故障,不是为了凑模型——一个 Key 就覆盖全系列模型。

完整可运行的 KeyPool 类

下面这个类只用标准库,threading.Lock 保护共享状态,状态文件用原子替换写入。直接存成 keypool.py 就能跑。

池子的数据结构和初始化

初始化干三件事:检查 Key 列表非空、查出重复项、把上一次的冷却状态读回来。重复 Key 一定要拦下来,不然它会被当成两条 Key 参与轮询,看着像两条并发,实际全打在同一份额度上。

acquire:全在冷却时不要返回 None

acquire() 先顺手把到期的 cooling 放回 healthy,再只在 healthy 里按游标挑一条,并支持传 exclude 集合,重试时避开刚失败的那条。一条都挑不出来时,别返回 None 让调用方去猜,直接抛 NoHealthyKeyError,把"最近的一条还要等多少秒"塞进异常,上层才能决定是等一下还是降级返回。

report_success、report_failure 与 snapshot

成功要把 fail_count 归零、累加调用次数;失败按状态码分流,决定进冷却还是标死。snapshot() 返回纯数据快照,日志和监控都从它取值,里面只打 Key 的后六位,完整 Key 不要写进日志文件。

# keypool.py —— 只用标准库,可直接运行
import json
import os
import time
from dataclasses import dataclass
from threading import Lock


@dataclass
class KeyState:
    key: str
    status: str = "healthy"      # healthy / cooling / dead
    cool_until: float = 0.0      # 冷却截止时间(秒级时间戳)
    fail_count: int = 0          # 连续失败次数,成功一次归零
    calls: int = 0               # 累计成功调用次数
    last_error: str = ""         # 最近一次失败原因,方便排查


class NoHealthyKeyError(RuntimeError):
    """池子里没有可用 Key。把等待时间带出去,让上层决定排队还是降级。"""

    def __init__(self, wait_seconds, total):
        self.wait_seconds = wait_seconds
        self.total = total
        super().__init__(
            "池子里 %d 条 Key 都不可用,最近的冷却还要等 %.1f 秒" % (total, wait_seconds)
        )


class KeyPool:
    COOL_429 = 60        # 429:冷却 60 秒
    COOL_5XX = 300       # 连续 3 次 5xx:冷却 300 秒
    MAX_5XX = 3

    def __init__(self, keys, state_path="keypool_state.json"):
        if not keys:
            raise ValueError("Key 池是空的,先检查 XYU_API_KEYS 配置")
        dup = sorted({k for k in keys if keys.count(k) > 1})
        if dup:
            raise ValueError("Key 有重复:%s,重复项不会带来额外并发" % ",".join(dup))
        self._lock = Lock()
        self._state_path = state_path
        self._states = {k: KeyState(key=k) for k in keys}
        self._rr = 0
        self._load_state()           # 重启后先把上次的冷却状态读回来

    # ---------- 状态持久化:先写临时文件,再原子改名 ----------
    def _load_state(self):
        if not os.path.exists(self._state_path):
            return
        try:
            with open(self._state_path, "r", encoding="utf-8") as f:
                saved = json.load(f)
        except (OSError, ValueError) as e:
            print("[KeyPool] 状态文件读不了,忽略并重新开始:%r" % e)
            return
        now = time.time()
        for key, st in self._states.items():
            old = saved.get(key)
            if not isinstance(old, dict):
                continue
            try:
                st.fail_count = int(old.get("fail_count", 0))
                st.calls = int(old.get("calls", 0))
                st.status = str(old.get("status", "healthy"))
                st.cool_until = float(old.get("cool_until", 0.0))
            except (TypeError, ValueError):
                continue                      # 单条脏数据不影响其它 Key
            if st.status == "cooling" and st.cool_until <= now:
                st.status, st.fail_count, st.cool_until = "healthy", 0, 0.0

    def _save_state(self):
        data = {k: {"status": s.status, "cool_until": s.cool_until,
                    "fail_count": s.fail_count, "calls": s.calls}
                for k, s in self._states.items()}
        tmp = self._state_path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False)
            f.flush()
            os.fsync(f.fileno())              # 先落盘,避免断电留下半截文件
        os.replace(tmp, self._state_path)     # Windows / Linux 上都是原子替换

    # ---------- 挑 Key ----------
    def acquire(self, exclude=None):
        exclude = exclude or set()
        with self._lock:                      # 锁只包状态读写,不包网络请求
            now = time.time()
            for st in self._states.values():   # 冷却到期的顺手放回 healthy
                if st.status == "cooling" and st.cool_until <= now:
                    st.status, st.fail_count, st.cool_until = "healthy", 0, 0.0
            pool = [st for st in self._states.values()
                    if st.status == "healthy" and st.key not in exclude]
            if not pool:
                waits = [st.cool_until - now for st in self._states.values()
                         if st.status == "cooling"]
                raise NoHealthyKeyError(min(waits) if waits else 0.0, len(self._states))
            self._rr = (self._rr + 1) % len(pool)
            return pool[self._rr].key

    def report_success(self, key):
        with self._lock:
            st = self._states.get(key)
            if st is None:
                return                        # 传进来的 Key 不属于本池,直接忽略
            st.status, st.cool_until, st.fail_count = "healthy", 0.0, 0
            st.calls += 1
        self._save_state()

    def report_failure(self, key, status_code=None, message=""):
        with self._lock:
            st = self._states.get(key)
            if st is None:
                return
            st.last_error = ("%s %s" % (status_code, message))[:160]
            st.fail_count += 1
            if status_code in (401, 403, 402):    # 认证 / 额度类,重试没意义
                st.status = "dead"
            elif status_code == 429:
                st.status, st.cool_until = "cooling", time.time() + self.COOL_429
            elif status_code is not None and status_code >= 500:
                if st.fail_count >= self.MAX_5XX:
                    st.status, st.cool_until = "cooling", time.time() + self.COOL_5XX
        self._save_state()

    def snapshot(self):
        """返回纯数据快照,日志和监控都用它;只打 Key 后六位,别把完整 Key 写进日志。"""
        with self._lock:
            now = time.time()
            rows = []
            for st in self._states.values():
                left = round(max(0.0, st.cool_until - now), 1) if st.status == "cooling" else 0.0
                rows.append({"key_tail": st.key[-6:], "status": st.status,
                             "cool_left": left, "fail_count": st.fail_count,
                             "calls": st.calls, "last_error": st.last_error})
            return rows


if __name__ == "__main__":
    pool = KeyPool(["sk-demo0001", "sk-demo0002"])
    ok_key = pool.acquire()
    pool.report_success(ok_key)
    pool.report_failure("sk-demo0002", 429, "RateLimitError")
    for row in pool.snapshot():
        print(row)

线程安全与异步版本

不加锁会踩到的两个坑

两个线程同时调 acquire(),都读到游标是 3,都算出同一个下标,于是拿到同一条 Key:你以为是分摊并发,实际上是两条线程抢一份额度。第二个坑更隐蔽,一个线程读了 fail_count 准备写回去,另一个线程也在做同样的事,一交错前一次的计数就没了,日志里的失败次数比告警次数少。

加锁前后的差别

不加锁的版本在压测里很好认:某条 Key 的调用量明显偏高,另外几条几乎没被碰过,snapshot() 里的计数也和实际请求数差一截。加锁之后,acquire()report_*() 对共享字段的读写都在同一把锁里完成,谁先谁后有了明确顺序。

锁别把网络请求一起包进去

锁只包住"挑 Key、改计数"这几行。别把 client.chat.completions.create() 写进锁里:一次请求要几秒到几十秒,锁一直被占着,其他线程全在门口排队,等于把并发压回单线程。挑完 Key 立刻出锁,发请求时不持锁。

异步版:asyncio.Lock 与 asyncio.sleep

协程版要改两处。锁换成 async with asyncio.Lock(),写成 with threading.Lock() 会阻塞整个事件循环;等待换成 await asyncio.sleep(),协程里写一句 time.sleep(1),事件循环被按住一秒,这一秒内所有并发请求一起停。

# async_keypool.py —— 同步版的关键改动都在这几行上
import asyncio
import time


class AsyncKeyPool:
    COOL_429 = 60
    COOL_5XX = 300
    MAX_5XX = 3

    def __init__(self, keys):
        if not keys:
            raise ValueError("Key 池是空的")
        self._lock = asyncio.Lock()           # 换掉 threading.Lock
        self._states = {k: {"status": "healthy", "cool_until": 0.0, "fail_count": 0}
                        for k in keys}
        self._rr = 0

    async def acquire(self, exclude=None):
        exclude = exclude or set()
        async with self._lock:                # 必须 async with,with 会阻塞事件循环
            now = time.time()
            for st in self._states.values():
                if st["status"] == "cooling" and st["cool_until"] <= now:
                    st["status"], st["fail_count"], st["cool_until"] = "healthy", 0, 0.0
            pool = [k for k, st in self._states.items()
                    if st["status"] == "healthy" and k not in exclude]
            if not pool:
                waits = [st["cool_until"] - now for st in self._states.values()
                         if st["status"] == "cooling"]
                raise RuntimeError("全池不可用,最近的还要等 %.1f 秒"
                                   % (min(waits) if waits else 0.0))
            self._rr = (self._rr + 1) % len(pool)
            return pool[self._rr]

    async def report_failure(self, key, status_code):
        async with self._lock:
            st = self._states[key]
            st["fail_count"] += 1
            if status_code in (401, 403, 402):
                st["status"] = "dead"
            elif status_code == 429:
                st["status"], st["cool_until"] = "cooling", time.time() + self.COOL_429
            elif status_code and status_code >= 500 and st["fail_count"] >= self.MAX_5XX:
                st["status"], st["cool_until"] = "cooling", time.time() + self.COOL_5XX


async def acquire_with_wait(pool, timeout=30.0):
    """池子全在冷却时等一下再拿:协程里用 asyncio.sleep,不要用 time.sleep。"""
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            return await pool.acquire()
        except RuntimeError as e:
            print("这一轮没拿到 Key:%s" % e)
            await asyncio.sleep(1.5)          # time.sleep 会把整个事件循环按住
    return None                               # 等超时了交给上层降级

Key 从环境变量读,绝不硬编码

.env 怎么写,校验不过就别启动

Key 只放在环境变量或者 .env 文件里,代码里一行都不许写死。启动时做校验,三类情况直接让进程起不来:列表里有空项(多半是结尾多打了一个逗号)、出现重复 Key、格式不对(少了前缀或者混进空格)。校验要在服务监听端口之前跑完,失败就用非零退出码结束进程:进程管理看到退出码会重启并留下日志,值班的人一眼就知道是配置问题。

# .env 放在项目根目录并加进 .gitignore,一行一条 Key,逗号分隔
# XYU_API_KEYS=sk-aaaa1111,sk-bbbb2222,sk-cccc3333
# XYU_BASE_URL=https://xyuapi.top/v1
# XYU_STATE_FILE=./keypool_state.json

# 启动前校验:空项 / 重复 / 格式不对,任何一种都非零退出(Windows 下存成 check_env.py 再运行)
python - <<'PY'
import re, sys
raw = open(".env", encoding="utf-8").read()
m = re.search(r"^XYU_API_KEYS=(.*)$", raw, re.M)
if not m:
    sys.exit("XYU_API_KEYS 没配置,先确认 .env 放在了项目根目录")
keys = [k.strip() for k in m.group(1).split(",")]
if not keys or any(not k for k in keys):
    sys.exit("Key 列表里有空项,多半是结尾多了一个逗号:%r" % keys)
dup = sorted({k for k in keys if keys.count(k) > 1})
if dup:
    sys.exit("Key 重复,重复项不会带来额外并发:%s" % ",".join(dup))
bad = [k for k in keys if not re.match(r"^sk-[A-Za-z0-9_\-]{8,}$", k)]
if bad:
    sys.exit("格式不对(少了 sk- 前缀或者混进了空格):%s" % bad)
print("校验通过,共 %d 条 Key" % len(keys))
PY

# 想确认某条 Key 现在能不能用,别拿业务代码去试,直接打 /v1/models 看状态码
curl -s -o /dev/null -w '%{http_code}\n' https://xyuapi.top/v1/models -H "Authorization: Bearer $KEY"

状态持久化与换 Key 重试

cool_until 和 fail_count 落盘,重启不丢

进程重启、容器滚动更新,都会把内存里的状态清空。如果 cool_until 只存在内存里,重启之后所有 Key 又都变回 healthy,明摆着还在限流的 Key 立刻被挑走,接着再撞一次 429。把 statuscool_untilfail_count 写进 JSON 文件,重启后读回来,冷却状态不丢,标为 dead 的 Key 也继续保持标记提醒人处理。

原子写:临时文件加 os.replace

直接往目标文件上覆盖写有风险:写到一半进程被杀或者断电,文件只剩半截,下次读的时候 JSON 解析直接失败。稳妥做法是先写同目录下的临时文件,flush()os.fsync() 落到磁盘,再用 os.replace() 改名覆盖。os.replace 在 Windows 和 Linux 上都是原子的,读的一方不会读到写了一半的内容。

三层职责分清楚,再拼调用函数

限流管速度、重试管单次失败、Key 池管账号级故障,三层各管一段,别混在一处写。调用函数只做四件事:从池子拿 Key、发请求、按结果回报池子、失败时换一条重试一次。重试一次就够了,无脑重试会把一次小故障放大成雪崩。池子空或者全在冷却时,把等待时间抛给上层,由上层决定排队等还是降级返回缓存。

# caller.py —— 拿 Key、发请求、回报池子、换 Key 重试一次
import os
import time

from openai import OpenAI

from keypool import KeyPool, NoHealthyKeyError


def call_with_pool(pool, messages, model="gemini-2.5-pro", retry=1, timeout=60):
    base_url = os.environ.get("XYU_BASE_URL", "https://xyuapi.top/v1")
    tried, last_error = set(), None

    for _ in range(retry + 1):
        try:
            key = pool.acquire(exclude=tried)
        except NoHealthyKeyError as e:
            # 池子空或者全在冷却:把等待时间交给上层,别在这里死循环
            raise RuntimeError("暂时没有可用 Key,约 %.1f 秒后再来" % e.wait_seconds) from e
        tried.add(key)

        client = OpenAI(api_key=key, base_url=base_url, timeout=timeout)
        try:
            resp = client.chat.completions.create(model=model, messages=messages)
            pool.report_success(key)
            return resp.choices[0].message.content
        except Exception as e:
            # 各版本 SDK 的异常类名不完全一致,按状态码分流更稳;取不到就按可恢复错误处理
            code = getattr(e, "status_code", None)
            if code is None:
                text = str(e)
                for guess in (401, 403, 429, 500):
                    if ("Error code: %d" % guess) in text:
                        code = guess
                        break
            pool.report_failure(key, code if code is not None else 500, str(e))
            last_error = e
            time.sleep(1.0)                   # 简单退避,别贴着上一次的失败猛打

    raise RuntimeError("换了 Key 还是失败:%s" % last_error)

日常运维与成本账

每天看一眼 snapshot

每天早上扫一眼 snapshot():有没有 dead 的 Key 挂着、哪条的 fail_count 一直往上涨。dead 代表认证或者额度问题,重试没有用,得去后台查清楚是欠费、被删还是权限变动。调用次数也要看,如果某条的调用量是别人的三倍,要么是轮询写错了,要么是另外几条长期在冷却。

盯住每个 Key 的调用量和失败率

把调用量和失败率打到监控里,按小时看趋势。失败率突然抬头,通常是上游在收紧或者额度快见底;调用量长时间偏斜,多半是有 Key 长期不可用,实际参与轮转的只剩一两条。发现偏斜就回去看 snapshot(),别等撞上大规模 429 才动手。

算一笔成本账

按次计费的好处是账好算:一次调用一个固定价格,输入写多长都不影响单价,分摊并发时不用纠结谁的消息更长。3 个人的小团队,每人每天 2000 次调用,用 gemini-2.5-pro:每人每天 2000 × 0.031 = 62 元,团队一天 186 元,一个月按 30 天算大约 5580 元。短问答挑 0.031 到 0.05 元那一档,代码改造再用 0.09 元往上的。

模型单价(元/次)
gemini-2.5-pro0.031
deepseek-v3.2-thinking0.049
deepseek-r1-thinking0.049
deepseek-v4-flash-thinking0.05
grok-4.10.05
gemini-3-pro-preview0.05
grok-4.5 / grok-4.60.09
claude-sonnet-4-5-thinking0.09
deepseek-v4-pro-thinking0.09
kimi-k2.5 / kimi-k2.60.09
gemini-3.1-pro-preview0.09
claude-opus-4-5-thinking0.12
claude-sonnet-4-6-thinking0.2
gpt-5.50.2
claude-opus-4-6-thinking0.25
gpt-5.3-pro0.3

多 Key 在这里的作用是把账拆开:三个人各自一条 Key,谁跑了 2000 次、谁跑了 200 次,账单上写得清清楚楚;混在一条上,你只知道花了 186 元,不知道这钱算谁头上。可用性的账更值得算:只有一条 Key 时,它一旦失效,从发现异常到改配置、重启服务,停摆通常按小时计。按一个月 720 小时粗算,98% 可用意味着约 14 个小时用不了,99.9% 大约是 43 分钟,差别主要来自切换是自动的还是靠人。数字不是承诺,取决于切换逻辑写得稳不稳,但这个量级的差距值不值得写一百多行代码,账是清楚的。

接入上,主入口 https://xyuapi.top/v1,备用 https://xyuai.cc/v1,Gemini 原生协议走 /v1beta,其余按 OpenAI 兼容协议走 /v1/chat/completions。按次计费为主,另有按量计费与无限卡套餐;最低充值 7 元,支付宝、微信都能付,不需要海外信用卡。一个 Key 就覆盖全系列模型,池子里的多条 Key 是为了分摊并发、隔离业务和故障切换,不是按模型各备一条。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信