Привет, Хабр! 👋

Если вы пробовали внедрять российские LLM в свои проекты, то наверняка сталкивались с "зоопарком" API. У GigaChat — OAuth2 и свои эндпоинты, у YandexGPT — IAM-токены и gRPC/REST, у локальных моделей через Ollama — третий формат.

В какой-то момент мне надоело писать бесконечные if provider == 'gigachat': ... elif provider == 'yandex': ..., и я решил создать универсальный слой абстракции.

Так появился Multi-LLM Orchestrator — open-source библиотека, которая позволяет работать с разными LLM через единый интерфейс, поддерживает умный роутинг и автоматический fallback (переключение на другую модель при ошибке).

Сегодня расскажу, как я её проектировал, с какими сложностями столкнулся при реализации потоковой генерации (Streaming), и как за неделю довел проект до версии v0.5.0 с поддержкой LangChain и 92% покрытия тестами.

Проблема: "Каждый пишет свой велосипед"

Представьте задачу: нужно сделать чат-бота, который использует GigaChat как основную модель, но если Сбер "лежит" или выдает ошибку 500 — незаметно переключается на YandexGPT.

Без абстракции код выглядит примерно так:

async def generate_response(prompt):
    try:
        # Пытаемся GigaChat
        token = await get_gigachat_token()  # OAuth2 логика
        response = await requests.post(..., headers={"Authorization": f"Bearer {token}"})
        return response.json()['choices'][0]['message']['content']
    except Exception:
        # Пытаемся YandexGPT
        response = await requests.post(..., headers={"Authorization": f"Bearer {iam_token}"})
        return response.json()['result']['alternatives'][0]['message']['text']

А теперь добавьте сюда:

  • Обработку Rate Limits (429)

  • Разные форматы messages

  • Разные названия параметров (max_tokens vs maxTokens)

  • Streaming (потоковую передачу токенов), где у каждого API свой формат чанков

  • Логирование и метрики

Решение: Архитектура Оркестратора

Роутер автоматически переключается между провайдерами при сбоях:

  • GigaChat (облако Сбер) — основной провайдер

  • YandexGPT (облако Яндекс) — резервный

  • Ollama (self-hosted) — локальная альтернатива

Все провайдеры поддерживают потоковую генерацию. Реальные метрики производительности смотрите в разделе "Боевое тестирование" ниже.

Автоматическое переключение между провайдерами при сбоях. Streaming поддерживается для всех провайдеров.

Единый интерфейс

Теперь код пользователя выглядит чисто и декларативно:

from orchestrator import Router
from orchestrator.providers import GigaChatProvider, YandexGPTProvider, ProviderConfig

# Конфигурируем роутер
router = Router(strategy="round-robin")

# Добавляем GigaChat
router.add_provider(GigaChatProvider(
    ProviderConfig(name="sber", api_key="...", scope="GIGACHAT_API_PERS")
))

# Добавляем YandexGPT
router.add_provider(YandexGPTProvider(
    ProviderConfig(name="yandex", api_key="...", folder_id="...", model="yandexgpt/latest")
))

# Используем! Если один упадет — роутер сам переключится на следующий
response = await router.route("Привет! Как дела?")

Под капотом: Интеграция и сложности

1. GigaChat и "протухающие" токены

Сбер использует OAuth2 Client Credentials flow. Главная сложность — токен живет 30 минут.

В GigaChatProvider я реализовал автоматическое управление токеном: он хранится в памяти и проверяется перед каждым запросом. Если API вернул 401 (токен отозван раньше времени), провайдер сам обновит его и повторит запрос.

2. YandexGPT и заголовки

У Яндекса другая специфика: нужен не только IAM-токен, но и folder_id (идентификатор каталога в облаке), который нужно передавать в заголовке x-folder-id. Пришлось расширить конфигурацию, сохранив обратную совместимость.

class ProviderConfig(BaseModel):
    name: str
    api_key: str | None = None
    folder_id: str | None = None  # Специфично для Yandex
    # ...

3. Самое сложное: Streaming (Потоковая генерация) 🌊

К версии v0.5.0 я добавил поддержку Streaming. Это когда ответ приходит не целиком, а по кусочкам (токенам), как в ChatGPT.

Это оказалось сложнее, чем обычный запрос:

  • Разные форматы: GigaChat использует Server-Sent Events (SSE) с префиксом data:, локальная Ollama отдает JSON-объекты, а мок-провайдер просто эмулирует задержки.

  • Обработка ошибок в потоке: Что делать, если соединение разорвалось на середине фразы?

    • Мое решение: Если ошибка произошла до первого полученного чанка — роутер делает fallback на другого провайдера. Если текст уже начал печататься — fallback не делается, чтобы не смешивать ответы разных моделей.

Пример использования стриминга:

# Асинхронный генератор с автоматическим fallback
async for chunk in router.route_stream("Напиши короткое стихотворение про Python"):
    print(chunk, end="", flush=True)

Эволюция проекта

Проект быстро развивался на основе моих потребностей и фидбека коллег. Что добавилось к текущей версии:

  1. Ollama Provider (v0.3.x): Теперь можно бесплатно гонять локальные модели (Llama 3, Mistral) и использовать облачные модели только как fallback, если локальная перегружена.

  2. LangChain Integration (v0.4.0): Я написал wrapper MultiLLMOrchestrator, который совместим с BaseLLM. Это позволяет вставить оркестратор в любые цепочки (Chains) и агенты LangChain одной строчкой:

# pip install multi-llm-orchestrator[langchain]
from orchestrator.langchain import MultiLLMOrchestrator

llm = MultiLLMOrchestrator(router=router)
# Теперь 'llm' можно использовать внутри LangChain chains!

3. Streaming Support (v0.5.0): Полная поддержка потоковой генерации для GigaChat с SSE-парсингом и интеграция с LangChain streaming.

Качество кода: Mypy, Ruff и тесты

Я верю, что Open Source должен быть качественным. Поэтому в CI/CD сразу зашил жесткие требования:

  • Mypy в режиме --strict. Полная типизация спасла от кучи багов.

  • Ruff как линтер.

  • Tests Coverage ≈ 92%. Написано 133 теста, включая тесты на SSE-стриминг и моки для проверки rate limits.

Результат на сегодня:

  • ✅ 133 теста (включая 18 для LangChain integration)

  • ✅ 92% покрытия кода

  • ✅ Полностью асинхронная реализация (asyncio/httpx)

  • ✅ 4 провайдера: GigaChat, YandexGPT, Ollama, Mock

  • ✅ Совместимость с LangChain (streaming included)

Боевое тестирование (Real-world testing)

Тесты на моках — это база, но реальная жизнь интереснее. Перед релизом я провел серию «боевых» тестов с реальными ключами от Сбера и Яндекса.

1. Проверка роутинга (Round-Robin)

Вот лог работы роутера, который балансирует нагрузку между двумя провайдерами. Запросы уходят по очереди:

Скриншот терминала

Как видите, оркестратор корректно чередует запросы, обеспечивая отказоустойчивость.

2. Streaming в действии (New in v0.5.0!)

Самое интересное в новой версии — это потоковая генерация. Теперь ответ не нужно ждать целиком, он приходит по токенам, как в ChatGPT.

Вот как это выглядит при вызове GigaChat:

from orchestrator import Router
from orchestrator.providers import GigaChatProvider, ProviderConfig

router = Router(strategy="round-robin")
config = ProviderConfig(
    name="gigachat",
    api_key="your_key_here",
    model="GigaChat",
    verify_ssl=False  # Для российских сертификатов Сбера
)
router.add_provider(GigaChatProvider(config))

# Streaming: текст появляется постепенно
async for chunk in router.route_stream("Напиши хокку про Python"):
    print(chunk, end="", flush=True)

Результат в консоли:

Текст печатается в реальном времени, слово за словом. Скорость генерации впечатляет.

3. Метрики производительности

Я написал специальный тест (examples/real_tests/test_streaming_real.py), который замеряет ключевые метрики. Вот что он показал при вызове реального GigaChat API:

  • TTFT (Time to First Token): 1.4 секунды от отправки запроса до первого слова. Это включает OAuth2-авторизацию и сетевые задержки до серверов Сбера.

  • Speed137.7 токенов/сек — отличная скорость генерации на реальном API. Для сравнения: это быстрее, чем читает средний человек.

  • Total Time: 1.8 секунды на генерацию 248 токенов (полное стихотворение).

4. Автоматический Fallback

Также протестировал сценарий с ошибкой: что будет, если GigaChat недоступен?

Я специально указал неверный API-ключ для GigaChat, и роутер автоматически переключился на YandexGPT до начала стрима. Вот что произошло:

  1. Роутер попытался вызвать GigaChat → получил ошибку 401 Unauthorized.

  2. До того, как пользователь увидел хоть одно слово, роутер переключился на YandexGPT.

  3. Текст начал печататься от YandexGPT, как будто ничего не произошло.

Метрики fallback-теста:

Важно: после того как первый токен уже отправлен — fallback не происходит, чтобы не смешивать ответы разных моделей. Это стандартное поведение для streaming API.

Как попробовать?

Проект уже на PyPI. Установка элементарная:

pip install multi-llm-orchestrator

# Для использования с LangChain:
pip install multi-llm-orchestrator[langchain]

Пример с YandexGPT

import asyncio
from orchestrator import Router
from orchestrator.providers import YandexGPTProvider, ProviderConfig

async def main():
    router = Router(strategy="first-available")
    
    config = ProviderConfig(
        name="yandex",
        api_key="ваш_iam_token",
        folder_id="ваш_folder_id",
        model="yandexgpt/latest"
    )
    
    router.add_provider(YandexGPTProvider(config))
    
    try:
        response = await router.route("Расскажи шутку про Python")
        print(response)
    except Exception as e:
        print(f"Все провайдеры недоступны: {e}")

asyncio.run(main())

Планы (Roadmap)

Уже реализовано (v0.5.0):

  • ✅ Умный роутинг и Fallback

  • ✅ GigaChat, YandexGPT, Ollama, Mock

  • ✅ Streaming (потоковая генерация)

  • ✅ Интеграция с LangChain (включая streaming)

В планах (v0.6.0+):

  • 🛠 Observability: Структурные логи и метрики (latency, error rate) для мониторинга в Prometheus/Grafana

  • 🛠 Расширенный роутинг: Учёт латентности, стоимости запросов и качества ответов

  • 🛠 YandexGPT streaming: Расширение потоковой генерации на YandexGPT


Приглашаю к участию!

Проект полностью открытый (MIT License). Если вам интересно развитие инструментов для российских LLM — залетайте в репозиторий, ставьте звезды ⭐ и кидайте PR-ы!

🔗 GitHub: github.com/MikhailMalorod/Multi-LLM-Orchestrator
📦 PyPI: pypi.org/project/multi-llm-orchestrator/

Буду рад любой технической критике в комментариях! 👇