Как я довёл расходы на LLM до нуля: почему на бесплатных тарифах параллелизм — враг. asyncio.. asyncio. circuit breaker.. asyncio. circuit breaker. deepseek.. asyncio. circuit breaker. deepseek. DevOps.. asyncio. circuit breaker. deepseek. DevOps. fallback.. asyncio. circuit breaker. deepseek. DevOps. fallback. Groq.. asyncio. circuit breaker. deepseek. DevOps. fallback. Groq. llm.. asyncio. circuit breaker. deepseek. DevOps. fallback. Groq. llm. python.. asyncio. circuit breaker. deepseek. DevOps. fallback. Groq. llm. python. rate-limit.. asyncio. circuit breaker. deepseek. DevOps. fallback. Groq. llm. python. rate-limit. искусственный интеллект.. asyncio. circuit breaker. deepseek. DevOps. fallback. Groq. llm. python. rate-limit. искусственный интеллект. Машинное обучение.

Это продолжение первой статьи про Briefka — там я описывал самого бота и базовую архитектуру каскада LLM-провайдеров. За прошедшие 4 месяца бот органически вырос с 59 до 84 пользователей, и именно на этом масштабе бесплатный каскад начал срываться на платного провайдера. Расскажу, почему так вышло и как я вернул расходы к нулю — с цифрами и кодом.

Код ниже — реальные фрагменты из боевого Briefka, слегка сокращённые для читаемости: убраны логирование и сбор статистики.

Что за каскад (коротко)

Вместо одного платного провайдера — лесенка из пяти, с автоматическим фолбэком при rate limit:

Groq #1 (бесплатно, 12K TPM)
  → Groq #2 (бесплатно, второй аккаунт)
    → Mistral (бесплатно)
      → Cerebras (бесплатно, быстрый)
        → DeepSeek (платный — якорь, чтобы не было полного отказа)

Идея простая: пока хоть один бесплатный провайдер не уперся в лимит, мы не платим. DeepSeek внизу — страховка от полного отказа, а не основной рабочий вариант.

Сам каскад собирается из тех ключей, что есть в окружении, — DeepSeek всегда добавляется последним:

python

# src/llm/client.py
def build_llm_chain(deepseek_key, groq_key=None, groq_key_2=None,
                    mistral_key=None, cerebras_key=None):
    """Собираем каскад из доступных ключей. DeepSeek всегда последний (якорь)."""
    clients = []
    if groq_key:     clients.append(LLMClient(groq_key,     provider="groq"))      # бесплатный
    if groq_key_2:   clients.append(LLMClient(groq_key_2,   provider="groq"))      # второй аккаунт
    if mistral_key:  clients.append(LLMClient(mistral_key,  provider="mistral"))   # бесплатный
    if cerebras_key: clients.append(LLMClient(cerebras_key, provider="cerebras"))  # бесплатный
    clients.append(LLMClient(deepseek_key, provider="deepseek"))                   # платный якорь
    return LLMChainClient(clients)

А вот сам перебор. Важная деталь, которой не было в первой статье, — circuit breaker: провайдер, словивший 429, уходит на cooldown и какое-то время просто пропускается, чтобы не долбиться в исчерпанный лимит на каждом запросе.

python

# src/llm/client.py
class LLMChainClient:
    # Ошибки доступности, по которым уходим к следующему провайдеру
    FALLBACK_ERRORS = ("rate_limit", "429", "quota", "capacity",
                       "overloaded", "timeout", "connection", "503", "502", "500")
    COOLDOWN_SECONDS = 600  # после 429 провайдер «отдыхает» 10 минут

    async def complete(self, prompt, **kw):
        last_error = None
        for i, client in enumerate(self.clients):
            # circuit breaker: пропускаем «остывающих», кроме якоря в самом конце
            if self._is_cooling(i) and i < len(self.clients) - 1:
                continue
            try:
                return await client.complete(prompt, **kw)
            except Exception as e:
                last_error = e
                if not self._is_fallback_error(e):
                    raise                        # контентная/auth-ошибка — не фолбэчим
                if self._is_rate_limit_error(e):
                    self._set_cooldown(i)        # 429 → провайдера на cooldown
                if i == len(self.clients) - 1:   # упал даже якорь
                    raise
                # иначе — молча пробуем следующего
        raise last_error

Побочная грабля: переезд с российского сервера

До оптимизации затрат был отдельный сюрприз. Первый VPS был с российским IP — и обращения к зарубежным LLM с него блокируются. После деплоя всё встало. Пришлось переехать на зарубежный VPS с чистым IP; только тогда провайдеры заработали стабильно, без проксей и обёрток. Если гоняете иностранные модели — закладывайте это сразу.

Проблема на масштабе: thundering herd против бесплатных лимитов

При росте до 80+ пользователей вылез неприятный эффект. Ежедневная рассылка стартовала по расписанию, и все LLM-запросы уходили практически одновременно. Бесплатные провайдеры дружно выбивали свои rate-limit’ы — и каскад массово сваливался на DeepSeek.

В цифрах за май — 915 вызовов DeepSeek. По деньгам это смешные ~$0.10, дело не в сумме: проблема в том, что «бесплатный» каскад переставал быть бесплатным, и расход рос бы линейно с числом пользователей. Я по сути сам себя DDOS-ил против собственных бесплатных лимитов.

Фикс: два изменения

1. Разнос пользователей во времени. Пауза ~10 секунд между пользователями. Весь цикл рассылки растягивается примерно с минуты до ~13 минут — зато бесплатные лимиты успевают восстанавливаться, и пик нагрузки размазывается.

python

# src/scheduler/jobs.py
for user in users:
    if self._parse_digest_hour(user.digest_time) == current_hour:
        try:
            await self._send_digest_to_user(user.telegram_id, session)
        except Exception as e:
            logger.error(f"Failed to send digest to {user.telegram_id}: {e}")
        await asyncio.sleep(10)  # Stagger LLM load: 10s between users

2. Сериализация LLM-вызовов внутри дайджеста. Вместо параллельных запросов — последовательные, через семафор на 1. Снижает пиковую конкуренцию за лимиты. Бонусом — кросс-юзерный кэш: один и тот же пост, который читают несколько подписчиков, через LLM прогоняется один раз.

python

# src/scheduler/jobs.py
semaphore = asyncio.Semaphore(1)  # Sequential LLM calls to avoid TPM rate limits

async def process_one(item):
    db_post, content, channel_name = item
    # кросс-юзерный кэш: общий для подписчиков пост не гоняем через LLM дважды
    cache_key = hashlib.md5(content.encode("utf-8", errors="replace")).hexdigest()
    if cache_key in self._post_cache:
        return (self._post_cache[cache_key], channel_name)
    async with semaphore:
        result = await self.processor.analyze_post(content, channel_name)
        if result:
            self._post_cache[cache_key] = result
        return (result, channel_name)

results = await asyncio.gather(*(process_one(p) for p in posts_to_process))

Результат

26 мая — первый день с нулём вызовов DeepSeek. Весь цикл из 81 дайджеста прошёл целиком на бесплатных провайдерах.

Период

Вызовов DeepSeek

Стоимость

Февраль–март

0

$0

Апрель

88

~$0.02

Май (до фикса)

915

~$0.10

Май, 26+ (после фикса)

0

$0

Вывод

Главный инсайт контринтуитивный: на малом масштабе параллелизм бесплатен, а на границе бесплатных тарифов он становится врагом. Когда упираешься в rate-limit бесплатных провайдеров, спасает не «сделать быстрее», а наоборот — размазать во времени и сериализовать. Ты разменваешь латентность на стоимость.

И этот размен почти всегда выгоден, если задача не интерактивная. Для ежедневного дайджеста никто не заметит разницы между «готово за минуту» и «готово за 13 минут» — а расходы падают до нуля. Будь это чат в реальном времени, размен был бы невозможен, и пришлось бы платить за пропускную способность.

Текущие цифры: 84 пользователя, 1 806 отправленных дайджестов, 237 уникальных каналов, аптайм без перезапуска — 11 дней.


Бот — @Briefka_bot. Пишу про такие штуки в Tezarium.

Автор: Tezarium

Источник