dmilyin.ru AI-разработка · Нижний Новгород · обновлено 2026-07-09


Кейсы Свой продукт

llm-rotator: отказоустойчивая ротация LLM-провайдеров — circuit breaker, квоты и model-first routing

Своя Python-библиотека (MIT): при сбое или лимите LLM-провайдера сама переключается на следующий ключ, модель или провайдера — с учётом квот и потолка качества.

Обложка кейса: llm-rotator: отказоустойчивая ротация LLM-провайдеров — circuit breaker, квоты и model-first routing

Задача

Любой продукт с 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 через ту же ротацию и структурированный лог всей цепочки в одну строку.

RoutingTrace: вся цепочка маршрутизации запроса в одной строке лога — rate-limit, skip по tier, квоты и итоговый ответ

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

Цепочка ротации: перебор провайдер → группа → модель → ключ с проверками квот, circuit breaker и классификатором ошибок

Стек

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. Соблазн сделать «всё и сразу» я осознанно задавил в пользу рабочего ядра под тестами.