1. Главная
  2. Блог
  3. Circuit breaker для ИИ-агента: как мы перестали жечь деньги, когда провайдер LLM отвечает через раз

Circuit breaker для ИИ-агента: как мы перестали жечь деньги, когда провайдер LLM отвечает через раз

Circuit breaker для ИИ-агента: как мы перестали жечь деньги, когда провайдер LLM отвечает через раз

Разбудил меня не алерт про упавший прод. Разбудил алерт про «странное»: очередь FastAPI за какие-то двадцать минут выросла в сотни раз. В обычную ночь в выходные там висит пара десятков задач, а тут — тысячи. Я долго тёр глаза, потому что графики продолжали ползти вверх, пока я на них смотрел.

Открыл дашборд — и проснулся окончательно. Наш ИИ-агент долбил в API провайдера буквально тысячами запросов в минуту. Latency улетела в стратосферу, успешных ответов оставалось процентов пять. У поставщика был инцидент: балансировщик отдавал 502 Bad Gateway на каждый третий запрос, а иногда просто рвал соединение по таймауту.

А наш агент честно делал то, чему его научили: не получилось — попробуй ещё раз. С exponential backoff, с джиттером, всё как в учебнике. За ночь он успел сжечь на бесполезных ретраях сумму, которую я не хотел объяснять финдиру. И самое обидное — никому не помог. Хорошо, что мы поймали это до утра понедельника; иначе счёт был бы совсем неприличным.

В понедельник я прикрутил circuit breaker. За полгода в проде он спас нас четыре раза. Ниже — конкретная реализация на Python, три состояния, метрики и, главное, честный рассказ про грабли. Если у вас в проде живёт ИИ-агент — это, наверное, самый недооценённый кусок инфраструктуры вокруг него.

Почему exponential backoff — это не защита, а иллюзия защиты

В книгах пишут: если внешний сервис лёг — жди по формуле base * 2^n секунд, добавь случайности, повтори. И это работает, если сервис лежит секунд десять.

Проблема начинается, когда сервис лежит два часа. Или лежит частично: одни запросы проходят, другие нет. Тогда backoff превращается в бесконечный молоток: агент дожидается 32 секунд, стучится, получает 502, ждёт 64, стучится, получает 502. Каждый повторный вызов — это оплаченный HTTPS-запрос, оплаченный процессинг на стороне провайдера (даже если он вернул ошибку) и занятый воркер FastAPI, который в это время не может обслужить нормальный запрос.

За те несколько часов подавляющее большинство наших воркеров были заняты ожиданием ретраев. Живые клиенты, которые писали в чат в это же время, ждали ответа десятки секунд вместо обычных двух. Никаких алертов на «клиент не получает ответ» у нас тогда ещё не было — это отдельная история, за которую до сих пор стыдно.

Мораль простая: retry защищает от разовой ошибки. От инцидента у провайдера защищает только отказ ходить в него какое-то время.

Первое, что я попробовал утром в понедельник, — тупо срезать количество ретраев с пяти до двух. Помогло условно: счёт за минуту упало бы во столько же раз, но семантика осталась той же — мы всё равно долбим в мёртвый сервис. Стало понятно, что решение не в цифрах, а в архитектуре.

Три состояния, которые всё меняют

Circuit breaker — это конечный автомат вокруг любого внешнего вызова. Три состояния:

Closed — норма. Запросы идут, ошибки считаются в скользящем окне.

Open — провайдер лёг. Все запросы к нему падают мгновенно, без сетевого хода. Ошибка возвращается локально за микросекунду. Никаких ретраев.

Half-open — пробный режим. После таймаута (у нас 45 секунд) в провайдер пропускается ровно один запрос. Успех — переходим в Closed. Провал — обратно в Open ещё на 45 секунд.

Ключевое слово — «мгновенно». Когда автомат в Open, вызов LLM не делает HTTP-запрос вообще. Он бросает CircuitOpen, и приложение либо переключается на резервную модель, либо кладёт задачу в отложенную очередь, либо честно отвечает клиенту «сервис временно недоступен».

Реализация на 80 строк

Мы не стали брать готовую библиотеку. Не потому что «not invented here», а потому что нам нужны были специфичные вещи: раздельный breaker на модель и на endpoint, интеграция с нашим биллинг-логом, экспорт метрик в Prometheus. Готовые библиотеки закрывают большую часть кейсов и мешают всему остальному. А ИИ-агенты для кода и в 2026-м всё ещё живут в условиях, где каждый второй вызов — это либо деньги, либо чужой rate limit, поэтому контроль над контуром хочется держать руками.

import time
from dataclasses import dataclass, field
from enum import Enum
from collections import deque
from threading import Lock

class State(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

@dataclass
class Breaker:
    name: str
    failure_threshold: float = 0.5     # 50% ошибок в окне
    window_size: int = 20               # последних 20 вызовов
    open_duration: float = 45.0         # секунд в Open
    min_calls: int = 10                 # раньше не судим

    state: State = State.CLOSED
    calls: deque = field(default_factory=lambda: deque(maxlen=20))
    opened_at: float = 0.0
    lock: Lock = field(default_factory=Lock)

    def allow(self) -> bool:
        with self.lock:
            if self.state == State.CLOSED:
                return True
            if self.state == State.OPEN:
                if time.monotonic() - self.opened_at >= self.open_duration:
                    self.state = State.HALF_OPEN
                    return True
                return False
            # HALF_OPEN: пропускаем один пробный
            return False

    def record(self, success: bool):
        with self.lock:
            self.calls.append(success)
            if self.state == State.HALF_OPEN:
                if success:
                    self.state = State.CLOSED
                    self.calls.clear()
                else:
                    self.state = State.OPEN
                    self.opened_at = time.monotonic()
                return
            if len(self.calls) >= self.min_calls:
                failure_rate = 1 - (sum(self.calls) / len(self.calls))
                if failure_rate >= self.failure_threshold:
                    self.state = State.OPEN
                    self.opened_at = time.monotonic()

Использование в вызове GigaChat Pro:

breaker = Breaker(name="gigachat_pro_chat")

async def call_llm(payload):
    if not breaker.allow():
        raise CircuitOpen("gigachat_pro_chat")
    try:
        resp = await http.post(URL, json=payload, timeout=15)
        resp.raise_for_status()
        breaker.record(success=True)
        return resp.json()
    except (HTTPError, TimeoutError) as e:
        breaker.record(success=False)
        raise

Обратите внимание на детали, о которые мы спотыкались.

min_calls=10. Без этого автомат срабатывает после первых двух ошибок и уходит в Open, хотя это могла быть просто пара разорванных соединений. Мы поставили десять после того, как breaker несколько раз за день ложно срабатывал на утреннем разогреве пула соединений — было обидно и стыдно перед коллегами, которые пришли утром и увидели заглушки.

monotonic(), а не time.time(). Если сисадмин переведёт часы (или NTP сделает большой прыжок) — Open застрянет на часы. У нас однажды такое было: NTP скорректировал разошедшийся таймер, и breaker остался в Open до перезапуска пода. Час на разбор, минута на фикс — классика.

window_size=20. Слишком маленькое окно — ложные срабатывания. Слишком большое — breaker слишком медленно реагирует на реальный инцидент. Двадцать — компромисс, который мы подобрали эмпирически на нашей нагрузке в несколько сотен RPS. У вас, скорее всего, будет другое число, и это нормально.

Что делать, когда breaker в Open

Открытый breaker — это не «извините, сервис недоступен». Это точка ветвления, где вы решаете, что важнее для конкретного сценария. Здесь и начинается настоящая инженерия, а не просто «прикрутить библиотеку».

Для входящих сообщений в чат: переключение на резервную российскую модель попроще. Качество ответа проседает, но клиент получает связный текст почти сразу, а не «попробуйте позже». Пару раз пользователи вообще не замечали подмены — разве что говорили, что «сегодня ассистент как-то короче отвечает».

Для фоновых задач — суммаризации звонков, извлечения реквизитов из УПД, разбора почты — откладываем в отдельную очередь postponed, которую воркер разбирает каждую минуту, проверяя breaker перед взятием задачи в работу. Если breaker всё ещё Open — задача остаётся, воркер идёт спать. Ни одна задача не потерялась ни разу.

Для внутренних инструментов, где ответ нужен сейчас или никогда: возвращаем структурированный отказ. Frontend показывает «AI-функции временно недоступны, форма работает как обычно». Пользователь не блокирован, менеджер продолжает вбивать заявку руками, как делал это до появления ассистента.

Разница между этими сценариями — три строчки в коде. Ценность разницы — то, что бизнес не встаёт, когда провайдер чихнул.

Метрики, без которых breaker бесполезен

Breaker без наблюдаемости — это чёрный ящик, который иногда что-то отключает. И это, наверное, самое неприятное, что может быть в проде: ты не понимаешь, почему у части пользователей всё хорошо, а у части — нет. Мы отдаём в Prometheus три метрики на каждый экземпляр:

  • breaker_state{name="gigachat_pro_chat"} — 0/1/2 для Closed/HalfOpen/Open.
  • breaker_calls_total{name, outcome="success|failure|short_circuit"} — счётчик.
  • breaker_open_duration_seconds{name} — гистограмма длительности каждого открытия.

В Grafana на дашборде ИИ-агента добавили панель «Circuit breakers»: три линии, одна на модель. Когда линия уходит наверх — сразу видно, у кого проблема, и не надо лезть в логи.

Алерт: если breaker находится в Open дольше пяти минут — на дежурного. Не потому что что-то сломано у нас (наоборот, breaker работает как надо), а потому что нужно понять — это инцидент у провайдера или мы упёрлись в квоту. Реакция разная: в первом случае мы ждём, во втором звоним в поддержку и просим временно поднять лимит.

Полгода в проде: как это выглядит на живой системе

Первое срабатывание случилось всего через пару недель после раскатки. Утренний спайк на квоту у провайдера, минуты три-четыре. Breaker открылся, задачи ушли в отложенную очередь, ни одна не потерялась, разгребли за несколько минут после закрытия. Я узнал об этом из Slack-канала мониторинга, а не от пользователей — и это, пожалуй, лучший показатель, что решение работает.

Второе — большой инцидент у провайдера, почти час. До breaker такое стоило бы нам заметных денег на бессмысленные ретраи. С breaker стоило ноль: он всё это время был Open, ретраев не было, живые клиенты переключились на резервную модель, потерь для бизнеса — никаких. Утром мне только написал руководитель одного из отделов: «ассистент как-то странно отвечал вчера, но работал».

Третье и четвёртое — короткие мигания сети между нашим ЦОДом и провайдером. Breaker открывался на минуту, потом закрывался. Никто из клиентов не заметил, я узнал постфактум из дашборда.

Средняя latency LLM-вызовов заметно снизилась — за счёт того, что при массовых ошибках мы больше не сидим в 15-секундном таймауте, а фейлимся мгновенно. Пользователи в чате перестали жаловаться на «долго думает».

Про цену ИИ-агента, если честно

Когда обсуждают цену ИИ-агента, обычно считают токены и подписку на модель. Про инфраструктурные грабли молчат — а зря. Один инцидент у провайдера без circuit breaker способен утянуть в минус месячный бюджет на модель, а то и квартальный. Наш случай был не самым страшным: я слышал истории, где команды за ночь сжигали столько, что потом полгода объяснялись с финансовым директором.

Разработка ИИ-агентов — это далеко не только про промпты и RAG. Это про то, как система ведёт себя, когда всё вокруг работает плохо, но не совсем сломано. Именно в этих серых зонах живёт настоящая цена: либо вы вложились в circuit breaker, retry-политики, отложенные очереди и резервные модели — либо это заплатит за вас первый же инцидент. Ровно поэтому у нас как у сервиса, разрабатывающего ИИ-агентов на заказ, эти три файла — breaker, fallback, queue — попадают в репозиторий раньше, чем сам агент начинает что-то отвечать.

Вывод

Retry защищает от неудачного нажатия кнопки. Circuit breaker защищает от инцидента. Это два разных инструмента, и они не заменяют друг друга — они работают в паре: retry внутри breaker, breaker поверх retry.

Если у вас в проде живёт ИИ-агент и у него нет circuit breaker — вы играете в лотерею. Билет стоит один инцидент у провайдера. Приз — счёт, который придётся объяснять финдиру, и разговор, который придётся вести с продактом про «почему у клиентов ничего не работало».

А как у вас устроена защита от «провайдер отвечает, но плохо»? Мы намеренно не стали смотреть только на HTTP-код: 502 и 200 с невалидным JSON для агента одинаково бесполезны. Но есть аргумент считать «медленные 200» отдельной категорией — они не ошибка, но и не успех. Интересно, кто как решает.

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

Опишите задачу — подберём ИИ-решение под ваш процесс и бюджет: что сделать агентом, какие нужны данные и интеграции, с чего начать пилот.