ApiKeyPoolService 算法详解
ApiKeyPoolService 实现了面向多 API 密钥的负载均衡方案:加权轮询选择密钥,会话级亲和性保持 Prompt Cache,指数退避冷却处理速率限制,以及认证失败时的永久禁用。
文件位置
| 文件 | 路径 |
|---|---|
| ApiKeyPoolService | packages/desktop/app/main/services/capabilities/llm/completion/ApiKeyPoolService.ts |
| API Key DB 操作 | packages/desktop/app/main/workers/db/apiKeys.ts |
| DB Worker 注册 | packages/desktop/app/main/workers/db/index.ts |
| DB Worker 类型 | packages/desktop/app/main/workers/types.ts |
| IPC Router | packages/desktop/app/main/services/routers/llm/ApiKeyRouter.ts |
| 前端 UI | packages/renderer/src/features/settings/components/provider-settings/llm/ApiKeyPoolSection.tsx |
架构上下文
graph TB
subgraph CompletionService
Resolve[resolveApiKeyForRequest]
Retry[retry on 429/529]
Success[reportSuccess]
end
subgraph ApiKeyPoolService
direction TB
GetKey[getKeyForSession]
GetKeyNoSession[getKey]
Report[reportError]
ReportOk[reportSuccess]
Select[selectWeightedRoundRobin]
Available[getAvailableKeys]
Cooldown[applyCooldown]
AuthFail[handleAuthFailure]
Cleanup[cleanupExpiredCooldowns<br/>每 30 秒]
end
subgraph 内存数据结构
SB["sessionBindings<br/>Map<sessionId, SessionBinding>"]
RRI["rrIndex<br/>Map<providerId, number>"]
KC["keyCache<br/>Map<providerId, ApiKeyEntry[]>"]
CD["cooldowns<br/>Map<keyId, KeyCooldown>"]
end
subgraph 外部依赖
DB[(SQLite<br/>llm_provider_api_keys)]
Loader["loadKeys(providerId)"]
Disabler["disableKey(keyId)"]
end
Resolve --> GetKey
Retry --> Report
Success --> ReportOk
GetKey --> SB
GetKey --> Available
Available --> KC
Available --> CD
KC --> Loader
Loader --> DB
Select --> RRI
Report --> Cooldown
Report --> AuthFail
AuthFail --> Disabler
Disabler --> DB
Cleanup --> CD
数据结构
核心类型
// 会话绑定:将会话锁定到特定密钥
interface SessionBinding {
keyId: string; // 绑定的密钥 ID
providerId: string; // 所属提供商 ID
}
// 密钥冷却状态
interface KeyCooldown {
until: number; // 冷却过期时间戳 (Date.now() + cooldownMs)
errors: number; // 连续错误次数(用于指数退避)
}
// API 密钥条目(来自数据库)
interface ApiKeyEntry {
id: string; // UUID
providerId: string; // 所属提供商
label?: string; // 显示标签(如 "生产密钥 #1")
apiKey: string; // 实际密钥值(可能以 $ 开头表示环境变量)
enabled: boolean; // 是否启用
weight: number; // 权重 (1-100)
}
// 密钥加载器函数签名
type ApiKeysLoader = (providerId: string) => Promise<ApiKeyEntry[]>;
// 密钥禁用器函数签名
type ApiKeyDisabler = (keyId: string) => Promise<boolean>;
// 密钥解析器(处理 $ 环境变量前缀)
type ApiKeyResolver = (rawKey: string) => string;
内存数据结构一览
| 结构 | 类型 | 用途 | 生命周期 |
|---|---|---|---|
sessionBindings | Map<sessionId, SessionBinding> | 会话到密钥的绑定映射 | 会话结束时通过 releaseSession() 清除 |
rrIndex | Map<providerId, number> | 每提供商的轮询索引 | 应用生命周期内持续 |
keyCache | Map<providerId, ApiKeyEntry[]> | 密钥列表缓存(避免每次查库) | CRUD 操作后通过 invalidateCache() 清除 |
cooldowns | Map<keyId, KeyCooldown> | 密钥冷却状态 | 每 30 秒清理过期条目 |
算法/逻辑说明
加权轮询算法(Weighted Round-Robin)
每个密钥的 weight 决定其在轮询周期中占据的「槽位」数量。
步骤:
selectWeightedRoundRobin(providerId, keys):
1. 如果只有 1 个密钥 → 直接返回
2. 计算 totalWeight = sum(keys[i].weight)
3. 更新轮询索引: idx = (rrIndex[providerId] + 1) % totalWeight
4. 保存新索引: rrIndex[providerId] = idx
5. 累积遍历:
accum = 0
for each key in keys:
accum += key.weight
if idx < accum:
return key
6. 兜底返回 keys[0]
示例:
假设 3 个密钥:A(weight=3), B(weight=1), C(weight=2),totalWeight=6
| 轮询索引 (idx) | 累积值 | 选中密钥 |
|---|---|---|
| 0 | A: 3 | A (0 < 3) |
| 1 | A: 3 | A (1 < 3) |
| 2 | A: 3 | A (2 < 3) |
| 3 | A: 3, B: 4 | B (3 < 4) |
| 4 | A: 3, B: 4, C: 6 | C (4 < 6) |
| 5 | A: 3, B: 4, C: 6 | C (5 < 6) |
| 0 | (cycle repeats) | A |
权重含义:weight=3 的密钥在每轮中被选中 3 次,weight=1 的被选中 1 次。
会话亲和性(Session Binding)
flowchart TD
Start[getKeyForSession] --> CheckBinding{session 有绑定?}
CheckBinding -->|是| CheckProvider{providerId 匹配?}
CheckProvider -->|是| CheckAvailable{绑定的密钥可用?}
CheckAvailable -->|是| Return[返回已绑定的密钥]
CheckAvailable -->|否| ReBind[重新绑定]
CheckProvider -->|否| ReBind
CheckBinding -->|否| ReBind
ReBind --> GetKeys[getAvailableKeys]
GetKeys --> Empty{密钥列表为空?}
Empty -->|是| ReturnEmpty[返回空字符串]
Empty -->|否| WRR[selectWeightedRoundRobin]
WRR --> Bind[sessionBindings.set]
Bind --> ReturnNew[返回新密钥]
为什么需要会话亲和性:
- Anthropic 等提供商实现了 Prompt Cache
- 使用同一个 API Key 发送请求可以命中缓存,节省费用和时间
- 切换 Key 会导致缓存失效
- 所以同一个会话(session)内尽量使用同一个密钥
密钥不可用时的处理:
getKeyForSession(providerId, sessionId):
binding = sessionBindings.get(sessionId)
if binding && binding.providerId === providerId:
keys = getAvailableKeys(providerId) // 过滤 enabled + 非冷却中
boundKey = keys.find(k.id === binding.keyId)
if boundKey:
return resolveKey(boundKey.apiKey) // 命中:返回
// 密钥被禁用/删除/冷却中 → 需要重新绑定
log.info("Session key no longer available, re-binding")
// 选择新密钥并绑定
keys = getAvailableKeys(providerId)
if keys.length === 0: return ''
selected = selectWeightedRoundRobin(providerId, keys)
sessionBindings.set(sessionId, { keyId: selected.id, providerId })
return resolveKey(selected.apiKey)
冷却/退避机制
指数退避公式
cooldownMs = min(DEFAULT_COOLDOWN_MS * 2^(errors - 1), MAX_COOLDOWN_MS)
| 连续错误次数 | 计算 | 冷却时间 |
|---|---|---|
| 1 | 60,000 * 2^0 | 60 秒 (1 分钟) |
| 2 | 60,000 * 2^1 | 120 秒 (2 分钟) |
| 3 | 60,000 * 2^2 | 240 秒 (4 分钟) |
| 4 | 60,000 * 2^3 | 480 秒 (8 分钟) |
| 5+ | 60,000 * 2^4 | 900 秒 (15 分钟上限) |
常量配置:
| 常量 | 值 | 说明 |
|---|---|---|
DEFAULT_COOLDOWN_MS | 60,000 (60 秒) | 基础冷却时间 |
MAX_COOLDOWN_MS | 900,000 (15 分钟) | 最大冷却时间 |
COOLDOWN_MULTIPLIER | 2 | 指数底数 |
| 清理周期 | 30,000 (30 秒) | cleanupExpiredCooldowns() 间隔 |
冷却应用流程
applyCooldown(keyId, providerId, statusCode):
current = cooldowns.get(keyId)
errors = (current?.errors ?? 0) + 1
cooldownMs = min(60_000 * 2^(errors-1), 900_000)
cooldowns.set(keyId, {
until: Date.now() + cooldownMs,
errors: errors
})
冷却重置
- 成功请求时调用
reportSuccess(sessionId) - 如果绑定的密钥有冷却记录,直接删除
认证失败处理
对于 HTTP 401 和 403 错误,密钥被视为永久无效:
flowchart TD
Error[reportError] --> Check{HTTP 状态码}
Check -->|429/529| Cooldown[applyCooldown<br/>指数退避]
Check -->|401/403| AuthFail[handleAuthFailure]
Check -->|其他| Ignore[忽略]
AuthFail --> Disable[disableKey<br/>在数据库中禁用]
Disable --> InvalidateCache[keyCache.delete<br/>清除缓存]
Cooldown --> Rebind[重新选择密钥]
InvalidateCache --> Rebind
Rebind --> HasMore{还有可用密钥?}
HasMore -->|是| Return[返回新密钥]
HasMore -->|否| ReturnNull[返回 null]
HTTP 状态码分类:
| 状态码 | 分类 | 处理方式 |
|---|---|---|
| 401 | AUTH_FAILURE | 永久禁用密钥 |
| 403 | AUTH_FAILURE | 永久禁用密钥 |
| 429 | RATE_LIMIT | 指数退避冷却 |
| 529 | RATE_LIMIT | 指数退避冷却(Anthropic 过载) |
| 其他 | 无 | 不处理,返回 null |
密钥可用性过滤
getAvailableKeys(providerId):
all = getAllKeys(providerId) // 从缓存或数据库加载
now = Date.now()
return all.filter(key =>
key.enabled === true // 必须启用
&& !(cooldowns[key.id]?.until > now) // 不在冷却中
)
清理周期
每 30 秒运行 cleanupExpiredCooldowns():
cleanupExpiredCooldowns():
now = Date.now()
for each [keyId, cd] in cooldowns:
if cd.until <= now:
cooldowns.delete(keyId)
IPC 集成表
| IPC 通道 | 方向 | 参数 | Zod Schema | 说明 |
|---|---|---|---|---|
llmConfig:getApiKeys | R → M | providerId: string | 无 | 获取提供商的所有密钥 |
llmConfig:addApiKey | R → M | { providerId, label?, apiKey, enabled?, weight? } | AddApiKeySchema | 添加密钥(默认 weight=1, enabled=true) |
llmConfig:updateApiKey | R → M | { id, label?, apiKey?, enabled?, weight? } | UpdateApiKeySchema | 更新密钥信息 |
llmConfig:deleteApiKey | R → M | { id: string } | 无 | 删除密钥 |
llmConfig:toggleApiKey | R → M | { id, enabled } | ToggleApiKeySchema | 启用/禁用密钥 |
Zod 验证规则:
// weight 范围限制
weight: z.number().int().min(1).max(100)
// apiKey 非空
apiKey: z.string().min(1)
// providerId 非空
providerId: z.string().min(1)
缓存失效:所有写操作(add/update/delete/toggle)完成后都会调用 apiKeyPool.invalidateCache(providerId) 以确保下次请求时重新从数据库加载。
扩展点
调整冷却策略
修改 ApiKeyPoolService 的常量:
// 更激进的冷却(适合低 QPS 场景)
private readonly DEFAULT_COOLDOWN_MS = 30_000; // 30 秒
private readonly MAX_COOLDOWN_MS = 5 * 60_000; // 5 分钟
// 更宽松的冷却(适合高 QPS 场景)
private readonly DEFAULT_COOLDOWN_MS = 120_000; // 2 分钟
private readonly MAX_COOLDOWN_MS = 30 * 60_000; // 30 分钟
自定义密钥选择策略
当前使用加权轮询。如需其他策略(如最少连接、随机加权),替换 selectWeightedRoundRobin() 方法。
环境变量密钥
密钥值以 $ 开头时自动展开为环境变量:
$OPENAI_API_KEY → process.env.OPENAI_API_KEY
由 ApiKeyResolver 函数处理,在 CompletionService.resolveApiKey() 中实现。
关联文件表
| 文件 | 关联方式 |
|---|---|
capabilities/llm/completion/CompletionService.ts | 消费者:通过 resolveApiKeyForRequest() 调用 pool |
workers/db/apiKeys.ts | 数据源:提供 loadKeys 和 CRUD 操作 |
workers/types.ts | DB Worker 类型定义 |
routers/llm/ApiKeyRouter.ts | IPC 层:前端密钥管理操作 |
renderer/.../ApiKeyPoolSection.tsx | 前端 UI:密钥列表增删改查 |
shared/llm-config.ts | ApiKeyEntry 类型定义 |