11 · 模型供应商与凭据池

agent/credential_pool.py + plugins/model-providers/。这一章讲系统怎么和多家模型供应商打交道,以及一个 API key 用完了怎么办

11.1 为什么不能只支持一家

原因说明
能力差异不同模型擅长的事不一样。写代码、写文案、做视觉理解,各有强弱
成本差异同样一个任务,用最贵的模型和用便宜模型的价差可能有十倍
可用性任何一家都会宕机、限流、改价格、改条款
合规某些企业只能用云厂商托管的版本(数据不出自己的云账户)
地域不同地区可访问的服务不同

11.2 供应商适配器清单

agent/providers/
├── anthropic.py           Anthropic 官方 API
├── bedrock.py             AWS Bedrock(在自己的 AWS 账户里调 Claude)
├── vertex.py              Google Cloud Vertex AI(在 GCP 里调 Claude)
├── azure.py               Azure OpenAI
├── gemini_native.py       Google Gemini 原生接口
└── codex_responses.py     OpenAI Codex 的 responses 接口
注意前三个:同一个模型,三条接入路径

anthropic / bedrock / vertex 背后可能是同一个 Claude 模型,但:

所以「支持 Claude」不是一件事,是三件事。每一条路径都要单独实现、单独测试、单独跟进版本变化。

11.3 凭据池:核心机制

agent/credential_pool.py

"""Persistent multi-credential pool for same-provider failover."""

译:「用于同一供应商内部故障转移的持久化多凭据池」。逐词拆开:

含义
multi-credential
多凭据
你有多个 API key(多个账号、多个组织、多个付费计划)
same-provider
同一供应商
这些 key 都是同一家的。不是「Anthropic 挂了切到 OpenAI」,而是「Anthropic 的 key A 限流了切到 key B」
failover
故障转移
一个不行了自动换下一个,对上层透明
persistent
持久化
状态存到磁盘 —— 重启后还记得哪个 key 已经用完了

为什么需要它

凌晨 3 点,定时任务开始跑 第 1 个任务:正常 第 2 个任务:正常 ... 第 47 个任务:API 返回 429 Too Many Requests (本小时的额度用完了) ↓ 没有凭据池:全部剩余任务失败,早上起来看到一堆报错 有凭据池: 自动切到第二个 key,继续跑 ★ 关键:第 48、49、50 个任务不应该再去试第一个 key —— 已经知道它满了,试也是白试,还浪费一次往返

11.4 PooledCredential:单个凭据的状态

class PooledCredential:
    ...
    _exhausted_ttl        # 「耗尽」状态的存活时长
    priority              # 优先级排序

_exhausted_ttl:按 HTTP 状态码决定「冷却多久」

TTL = Time To Live(存活时间)。这里指的是「这个 key 被标记为不可用后,多久之后再试一次」。

关键设计:冷却时长不是固定的,而是根据 API 返回的错误码来决定的。

状态码含义合理的冷却策略
429 限流(Too Many Requests) 短冷却(几十秒到几分钟)。额度是按时间窗口重置的,等一会儿就恢复
401 认证失败(key 无效) 很长冷却,或者直接永久剔除。key 错了,等多久都不会自己变对
402 需要付费(余额不足) 长冷却(小时级)。要人工去充值,短时间重试没意义
403 无权限 长冷却。可能是这个 key 没开通某个模型的权限
5xx 服务端错误 很短冷却。不是 key 的问题,是供应商临时故障

为什么不能用统一的冷却时长?

统一设短(比如 30 秒)→ 一个 401 的错误 key 会每 30 秒被重试一次,永远浪费请求,而且日志里全是噪声。

统一设长(比如 1 小时)→ 一个只是被短暂限流的好 key,白白闲置 1 小时。你花钱买的额度用不上。

「用错误的类型来决定重试策略」是所有重试逻辑的核心。不区分错误类型的重试,要么太急要么太懒,没有中间状态。

priority:优先级排序

凭据不是平等的。典型的排序理由:

所以选凭据的逻辑是:从可用的凭据里,按优先级取第一个。不可用的(在冷却中的)直接跳过。

11.5 「持久化」为什么必要

不持久化的情况: 09:00 key-A 限流了 → 内存里标记为"耗尽,1 小时后重试" 09:05 进程重启(部署 / 崩溃 / 手动重启) 09:05 内存状态清空 → 系统以为 key-A 可用 09:05 用 key-A 发请求 → 又是 429 09:05 再次标记为耗尽 ↓ ★ 每次重启都要重新"撞一次墙"才知道 key 不能用 如果重启频繁(比如开发调试期间), 你会看到大量本可避免的 429 错误, 而且每一次都消耗了一个真实的 API 往返。

持久化的另一个价值:多进程共享。

Hermes 可能同时有:网关进程、定时任务进程、命令行会话进程。如果状态只在内存里,三个进程会各自撞墙三次。写到磁盘(或数据库)后,一个进程发现 key-A 满了,另外两个立刻就知道。

11.6 与其他机制的配合

和第 3 章的预算闸门

预算闸门管的是「这一轮对话花了多少钱」,凭据池管的是「用哪个 key 去花」。两者正交:预算闸门决定「还能不能花」,凭据池决定「从哪个口袋掏」。

和第 3 章的降级

回顾第 3 章:模型返回错误时会尝试降级到备用模型。现在可以看到完整的失败处理链条:

请求失败 ↓ ① 是凭据问题吗(429/401/402/403)? → 是:标记该凭据耗尽,换下一个凭据,重试 ↓ 换完了所有凭据还是不行 ② 是模型问题吗(模型过载 / 上下文超限)? → 是:降级到备用模型(第 3 章) ↓ 备用模型也不行 ③ 是上下文太长吗(413)? → 是:触发压缩,重试(第 7 章) ↓ 压缩后还是不行 ④ 真的失败了 → 把错误告诉用户 / 记录事件 / 触发告警

注意这四层的顺序不能乱:

换凭据是最便宜的(同一个模型、同样的上下文,只是换个身份)。
降级模型有质量代价
压缩上下文有信息损失

所以从代价最小的手段开始尝试。如果反过来,一遇到 429 就先压缩上下文,那就是白白丢了信息 —— 而问题根本不在上下文长度上。

11.7 供应商抽象要抽象到哪一层

这是设计多供应商支持时最容易做错的决策。

抽象层次做法问题
太薄 只统一「发消息」这个动作,其余暴露原始差异 上层代码里到处是 if provider == "bedrock",加一家供应商要改十处
太厚 抽象出一个「最大公约数」接口,只保留所有供应商都支持的功能 丢失特性。提示词缓存、扩展思考、结构化输出这些差异化能力全用不上 —— 而这些恰恰是最有价值的
刚好 统一核心流程(消息、工具调用、流式),把差异化能力做成可查询的能力位 上层写 if provider.supports_prompt_caching() 而不是 if provider == "anthropic"

「能力查询」这个模式在第 1 章的平台适配器里已经出现过一次。

BasePlatformAdaptersupports_threads() / supports_reactions() / supports_editing(),让上层代码问「你能不能做 X」而不是「你是谁」。

这里是完全相同的模式,用在了不同的领域。两个地方都面对「一群做同一件事但能力不同的外部系统」,解法一致:抽象「做什么」,查询「能做什么」,永远不要在业务代码里判断「你是谁」。

这是一个可以直接搬到任何项目里的模式 —— 支付渠道、短信通道、对象存储、推送服务,全都适用。