llm-rotator: отказоустойчивая ротация LLM-провайдеров — circuit breaker, квоты и model-first routing
Своя Python-библиотека (MIT): при сбое или лимите LLM-провайдера сама переключается на следующий ключ, модель или провайдера — с учётом квот и потолка качества.
Задача
Любой продукт с LLM внутри — бот поддержки, ассистент, контент-конвейер — упирается в одни и те же грабли. Провайдер отвечает 429 (лимит) или 5xx (лёг) — и AI-фича встаёт. На бесплатных тирах это происходит постоянно: лимиты, очереди, модель ушла из free без предупреждения. При этом легко переплачивать, гоняя простые задачи (классификация, парсинг) на дорогих flagship-моделях. А привязка к одному API — это вендор-лок: сменить провайдера значит переписать интеграцию.
Мне это мешало сразу в нескольких своих проектах, где LLM — не эксперимент, а часть продукта. Нужен был инфраслой между приложением и провайдерами: чтобы запрос всё равно доходил до ответа (перебирая ключи, модели и провайдеров), не превышал квоты и не тратил flagship-бюджет на ерунду. Не разовый костыль в каждом проекте, а протестированная библиотека, которую можно переиспользовать.
Решение
llm-rotator — Python-библиотека (async-first на asyncio + httpx, без SDK провайдеров — работаю с REST напрямую; конфиг на Pydantic v2; MIT). Ядро — оркестратор ротации, вокруг него сменные адаптеры провайдеров и бэкенды состояния.
Model-First Chain of Responsibility. Ротация строится вокруг качества, а не ключа: провайдеры перебираются по priority, внутри — группы моделей по tier, и для каждой модели пробуются все ключи пула прежде чем даунгрейдить на модель послабее. То есть gpt-5 на всех ключах → и только потом gpt-4o, а не наоборот.
Circuit Breaker с классификатором ошибок. Отказы различаются по типу и блокируются гранулярно:
429→ ключ+модель блокируются по TTL из заголовкаRetry-After(для других моделей этот ключ жив);401/402→ ключ мёртв глобально (баланс/бан);5xx→ один быстрый retry, затем переключение.
Заблокированная комбинация пропускается без HTTP-запроса, пока не истечёт TTL.
Превентивные квоты. Помимо реакции на ошибки — счётчики токенов и запросов с гранулярностью на уровне модели / группы / ключа и сбросом daily_utc или скользящим окном. Если квота исчерпана, кандидат пропускается без обращения к API — не тратим впустую попытку.
Потолок качества (tier ceiling). Клиент передаёт RoutingContext(tier=N) — ротация идёт от указанного уровня вниз, никогда вверх. Простую задачу помечаешь tier=3 — используются только экономные модели, даже если flagship-ключи свободны. Это гарантирует, что FAQ-бот не выест квоту flagship, зарезервированную под сложные задачи.
Низкая связность с бизнес-логикой. Библиотека отвечает только за инфраструктуру (ключи, лимиты, провайдеры) и ничего не знает про деньги, тарифы и биллинг. Влиять на маршрутизацию per-request можно через RoutingContext (tier, allowed_providers, model_group, теги) и async-хуки before_request / after_response / on_fallback — в них клиент вешает свой бюджет-контроль, алерты, учёт стоимости.
Единый интерфейс поверх разных API. Tool calling и structured output принимаются в одном формате, а адаптер каждого провайдера сам транслирует его в нативный вид (паттерн Strategy). Плюс LangChain drop-in RotatorChatModel(BaseChatModel), streaming с восстановлением после обрыва, embeddings через ту же ротацию и структурированный лог всей цепочки в одну строку.

Инверсия зависимостей. Ядро зависит только от абстракций AbstractLLMClient и AbstractStateBackend. Бэкендов состояния два: InMemory (asyncio.Lock, для одного процесса) и Redis (INCRBY / SET NX EX, атомарно, для нескольких инстансов). Один набор контрактных тестов, параметризованный по обоим, гарантирует идентичное поведение.

Стек
Python 3.11+ · asyncio · httpx · Pydantic v2 · Redis · LangChain · pytest (respx · freezegun · fakeredis) · hatchling / uv · GitHub Actions · MIT
Результат
- Библиотека собрана по TDD: 2578 строк кода на 6459 строк тестов (≈2.5:1), 24 тест-файла — юнит, интеграционные, контрактные (InMemory + Redis) и e2e к реальным API. Цель покрытия ≥90% (из PRD).
- 3 семейства провайдеров: OpenAI-совместимые (вкл. OpenRouter, Groq), Google Gemini, Anthropic Claude; 2 бэкенда состояния.
- 3 релиза за 3 дня (0.1.0 → 0.3.0), semantic versioning, Keep-a-Changelog, CI на GitHub Actions (ruff + pytest), собранные wheels, MIT.
- Проверена в бою — держит AI-фичи в нескольких моих проектах:
- матчинг резюме (кейс
ai-resume-matcher): через ротатор идут текст, vision-OCR и эмбеддинги, с tier-маршрутизацией по группам моделей (дешёвые на парсинг, лучшие на матчинг); - контент-система мерчанта (кейс
tg-content-system); - Дзен-бот.
- матчинг резюме (кейс
Это не библиотека «в вакууме», а инфраслой под уже работающими проектами.
Что не сработало
Экспоненциальный backoff я убрал. Пробовал классический retry с нарастающей задержкой (1→2→4 с) на 5xx — но задача ротатора не «дождаться», а быстро найти живую комбинацию. Переключиться на другого провайдера за 50 мс полезнее, чем висеть на упавшем. Оставил один быстрый retry, дальше — следующий кандидат. Кому нужен backoff — вешает его через хук before_request.
Retry с середины стрима не получился. Хотелось при обрыве стриминга продолжить с места разрыва, но стрим не идемпотентен — уже отданные пользователю токены не откатишь чисто. Зафиксировал как ограничение: при mid-stream ошибке ротатор перегенерирует ответ с начала на следующем кандидате.
Библиотека принципиально не знает про деньги. Она считает токены и запросы, но не рубли. Соблазн зашить ценообразование и бюджеты внутрь был, но это связало бы инфру с бизнес-логикой конкретного приложения. Оставил это клиенту (через хуки и RoutingContext) — ценой того, что стоимость в деньгах каждый проект считает у себя.
Scope набирался итеративно, а не сразу. MVP (0.1.0) — только ядро, circuit breaker, квоты и InMemory-бэкенд. Tool calling, LangChain, Redis, Anthropic и embeddings приехали в 0.2–0.3. Соблазн сделать «всё и сразу» я осознанно задавил в пользу рабочего ядра под тестами.