Подробный разбор алгоритма ApiKeyPoolService
ApiKeyPoolService реализует решение балансировки нагрузки для нескольких API-ключей: взвешенный round-robin для выбора ключа, сессионную привязку для поддержания Prompt Cache, экспоненциальный откат при превышении лимитов запросов (rate limiting) и постоянное отключение ключей при ошибках аутентификации.
Расположение файлов
| Файл | Путь |
|---|---|
| ApiKeyPoolService | packages/desktop/app/main/services/capabilities/llm/completion/ApiKeyPoolService.ts |
| Операции с API-ключами в БД | 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 |
| Frontend 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/>every 30 seconds]
end
subgraph InMemoryStructures ["In-Memory Data Structures"]
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 ExternalDeps ["External Dependencies"]
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> | Индекс round-robin для каждого провайдера | Сохраняется на протяжении всего времени работы приложения |
keyCache | Map<providerId, ApiKeyEntry[]> | Кэш списка ключей (исключает запросы к БД при каждом обращении) | Очищается через invalidateCache() после CRUD-операций |
cooldowns | Map<keyId, KeyCooldown> | Состояние кулдауна ключа | Устаревшие записи очищаются каждые 30 секунд |
Алгоритмы и логика
Алгоритм взвешенного round-robin
Параметр weight каждого ключа определяет количество «слотов», которые он занимает в цикле round-robin.
Шаги:
selectWeightedRoundRobin(providerId, keys):
1. Если только 1 ключ → вернуть сразу
2. Вычислить totalWeight = sum(keys[i].weight)
3. Продвинуть индекс round-robin: 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]
Пример:
Три ключа: A(weight=3), B(weight=1), C(weight=2), totalWeight=6
| Индекс round-robin (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 | (цикл повторяется) | A |
Смысл веса: ключ с weight=3 выбирается 3 раза за цикл, ключ с weight=1 — один раз.
Сессионная привязка (Session Affinity)
flowchart TD
Start[getKeyForSession] --> CheckBinding{Сессия имеет привязку?}
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 позволяет попасть в кэш, экономя средства и время
- Смена ключа приводит к промаху кэша
- Поэтому в пределах одной сессии следует по возможности использовать один и тот же ключ
Обработка недоступного ключа:
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) // не на кулдауне
)
Интервал очистки
cleanupExpiredCooldowns() запускается каждые 30 секунд:
cleanupExpiredCooldowns():
now = Date.now()
for each [keyId, cd] in cooldowns:
if cd.until <= now:
cooldowns.delete(keyId)
Таблица интеграции IPC
| IPC-канал | Направление | Параметры | Zod-схема | Описание |
|---|---|---|---|---|
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 минут
Пользовательская стратегия выбора ключа
Текущая стратегия — взвешенный round-robin. Для использования альтернативной (например, наименьшее количество подключений, случайный взвешенный выбор) замените метод selectWeightedRoundRobin().
Ключи из переменных окружения
Значения ключей с префиксом $ автоматически раскрываются как переменные окружения:
$OPENAI_API_KEY → process.env.OPENAI_API_KEY
Это обрабатывается функцией ApiKeyResolver, реализованной в CompletionService.resolveApiKey().
Связанные файлы
| Файл | Взаимосвязь |
|---|---|
capabilities/llm/completion/CompletionService.ts | Потребитель: обращается к пулу через resolveApiKeyForRequest() |
workers/db/apiKeys.ts | Источник данных: предоставляет loadKeys и CRUD-операции |
workers/types.ts | Определения типов DB Worker |
routers/llm/ApiKeyRouter.ts | IPC-слой: операции управления ключами на стороне фронтенда |
renderer/.../ApiKeyPoolSection.tsx | Frontend UI: CRUD для списка ключей |
shared/llm-config.ts | Определение типа ApiKeyEntry |