agent/credential_pool.py + plugins/model-providers/。这一章讲系统怎么和多家模型供应商打交道,以及一个 API key 用完了怎么办。
| 原因 | 说明 |
|---|---|
| 能力差异 | 不同模型擅长的事不一样。写代码、写文案、做视觉理解,各有强弱 |
| 成本差异 | 同样一个任务,用最贵的模型和用便宜模型的价差可能有十倍 |
| 可用性 | 任何一家都会宕机、限流、改价格、改条款 |
| 合规 | 某些企业只能用云厂商托管的版本(数据不出自己的云账户) |
| 地域 | 不同地区可访问的服务不同 |
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」不是一件事,是三件事。每一条路径都要单独实现、单独测试、单独跟进版本变化。
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 已经用完了 |
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:优先级排序凭据不是平等的。典型的排序理由:
所以选凭据的逻辑是:从可用的凭据里,按优先级取第一个。不可用的(在冷却中的)直接跳过。
持久化的另一个价值:多进程共享。
Hermes 可能同时有:网关进程、定时任务进程、命令行会话进程。如果状态只在内存里,三个进程会各自撞墙三次。写到磁盘(或数据库)后,一个进程发现 key-A 满了,另外两个立刻就知道。
预算闸门管的是「这一轮对话花了多少钱」,凭据池管的是「用哪个 key 去花」。两者正交:预算闸门决定「还能不能花」,凭据池决定「从哪个口袋掏」。
回顾第 3 章:模型返回错误时会尝试降级到备用模型。现在可以看到完整的失败处理链条:
注意这四层的顺序不能乱:
换凭据是最便宜的(同一个模型、同样的上下文,只是换个身份)。
降级模型有质量代价。
压缩上下文有信息损失。
所以从代价最小的手段开始尝试。如果反过来,一遇到 429 就先压缩上下文,那就是白白丢了信息 —— 而问题根本不在上下文长度上。
这是设计多供应商支持时最容易做错的决策。
| 抽象层次 | 做法 | 问题 |
|---|---|---|
| 太薄 | 只统一「发消息」这个动作,其余暴露原始差异 | 上层代码里到处是 if provider == "bedrock",加一家供应商要改十处 |
| 太厚 | 抽象出一个「最大公约数」接口,只保留所有供应商都支持的功能 | 丢失特性。提示词缓存、扩展思考、结构化输出这些差异化能力全用不上 —— 而这些恰恰是最有价值的 |
| 刚好 | 统一核心流程(消息、工具调用、流式),把差异化能力做成可查询的能力位 | 上层写 if provider.supports_prompt_caching() 而不是 if provider == "anthropic" |
「能力查询」这个模式在第 1 章的平台适配器里已经出现过一次。
BasePlatformAdapter 有 supports_threads() / supports_reactions() / supports_editing(),让上层代码问「你能不能做 X」而不是「你是谁」。
这里是完全相同的模式,用在了不同的领域。两个地方都面对「一群做同一件事但能力不同的外部系统」,解法一致:抽象「做什么」,查询「能做什么」,永远不要在业务代码里判断「你是谁」。
这是一个可以直接搬到任何项目里的模式 —— 支付渠道、短信通道、对象存储、推送服务,全都适用。