- BrainTools - https://www.braintools.ru -

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

Это продолжение первой статьи про Briefka [1] — там я описывал самого бота и базовую архитектуру каскада 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 [2]. Пишу про такие штуки в Tezarium [3].

Автор: Tezarium

Источник [4]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/31385

URLs in this post:

[1] первой статьи про Briefka: https://habr.com/ru/articles/996940/

[2] @Briefka_bot: https://t.me/Briefka_bot

[3] Tezarium: https://tezarium.com

[4] Источник: https://habr.com/ru/articles/1044546/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1044546

www.BrainTools.ru

Rambler's Top100