Как я довёл расходы на LLM до нуля: почему на бесплатных тарифах параллелизм — враг

от автора

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

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

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

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

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

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

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

python

# src/llm/client.pydef 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.pyclass 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.pyfor 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.pysemaphore = asyncio.Semaphore(1)  # Sequential LLM calls to avoid TPM rate limitsasync 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.

ссылка на оригинал статьи https://habr.com/ru/articles/1044546/