Основной провайдер отвечает ошибкой, шлюз тихо переключается на запасного, и тот возвращает аккуратный ответ с кодом 200. В журнале зелёные строки, пользователь ничего не заметил. Означает ли это, что приложение продолжило работать правильно? Не обязательно. Успешный HTTP-ответ говорит лишь о том, что кто-то что-то вернул. Разобрать этот ответ сможет ваш код? Можно ли было вообще отправлять этому поставщику такой текст? Уложился ли он в срок, который ещё имеет смысл для пользователя? На эти три вопроса статус 200 не отвечает.
Поэтому выбор резервного поставщика начинается не с сравнения аптайма. Доступность нужна, но она закрывает только одно требование из нескольких. Дальше пойдём в таком порядке: сначала опишем, что на самом деле обещает ваше приложение, затем отделим разные виды отказов, потом сравним основной и запасной маршруты на одинаковых запросах, разберём трудный случай стриминга и посчитаем цену возврата назад.
Сначала контракт приложения, потом поставщик
У любого приложения вокруг языковой модели есть неявный контракт. Он редко записан, но нарушается первым, когда меняется модель. Запишите его до выбора резерва, иначе сравнивать будет не с чем.
В контракт обычно входят четыре вещи.
- Форма ответа. Ждёте ли вы свободный текст, JSON с конкретными полями или вызов функции с аргументами. Для свободного текста замена почти безболезненна, для структурированного вывода это самое хрупкое место.
- Инструменты. Вызывает ли модель функции: поиск по базе, создание заявки, расчёт. Если да, то нужно знать, как резервный маршрут описывает вызов и как принимает результат инструмента обратно.
- Длина контекста. Запрос, который помещался у основного поставщика, у запасного может не поместиться. Тогда шлюз получит ошибку именно в момент, когда он и так занят аварией.
- Допустимые данные. Какие тексты вообще можно отправлять наружу и кому. Это пункт не про качество, а про право, и у него другая логика отказа.
Удобно оформить это таблицей по классам запросов, а не по моделям. Класс запроса — это то, что делает приложение: «краткое резюме письма», «извлечение реквизитов из договора», «ответ на вопрос по внутренней базе знаний». Для каждого класса вы заполняете строку.
| Класс запроса | Форма ответа | Инструменты | Данные | Можно ли идти в резерв |
|---|---|---|---|---|
| Резюме публичной новости | Текст | Нет | Открытые | Да, при допустимой задержке |
| Извлечение реквизитов | Строгий JSON | Нет | Персональные и коммерческие | Только в одобренный резерв и после проверки схемы |
| Помощник по внутренней базе | Текст со ссылками | Поиск | Закрытые документы | Нет, если у резерва нет разрешения на такие данные |
Последняя колонка здесь главная, и заполняется она не по аптайму. Для одного приложения вполне нормально, что у трёх классов запросов три разные политики на случай отказа. Это не усложнение ради усложнения: так вы заранее признаёте, что резерв не подходит для всего подряд, и не обещаете равноценность моделей.
Три разных события, которые легко принять за одно
Слово «отказ» в разговорах об ИИ-шлюзах скрывает как минимум три ситуации. Они требуют разных реакций, и если их смешать, шлюз будет либо слишком нервным, либо слишком упрямым.
Первая ситуация: недоступен конкретный deployment, то есть конкретный развёрнутый экземпляр модели у поставщика. Остальные экземпляры того же поставщика могут работать. Если у вас есть второй deployment той же модели в другом регионе или под другим ключом, самый дешёвый ход — перейти на него. Контракт сохраняется почти полностью: тот же формат, та же политика данных, те же инструменты. Это ближе всего к настоящему резерву, и пробовать его стоит раньше всего.
Вторая ситуация: deployment выведен из ротации на время, то есть находится в состоянии cooldown. Идея простая: после серии ошибок маршрут перестают дёргать, чтобы не тратить на него время каждого запроса, и через паузу пробуют снова. В документации LiteLLM Router cooldown, fallback, таймауты и повторы с фиксированной или экспоненциальной задержкой описаны как возможности роутера, а состояние cooldown и учёт лимитов можно хранить в Redis (LiteLLM: Router). Для эксплуатации из этого следует практический вывод: если шлюз работает в нескольких процессах, а состояние не общее, то каждый процесс будет узнавать о проблеме самостоятельно, и часть запросов всё равно попадёт на больной маршрут. Общее хранилище состояния снимает эту проблему. Какие именно параметры включить, смотрите в документации той версии, которую запускаете: для нас важна сама логика.
Третья ситуация самая неприятная: нужно сменить не deployment, а поставщика. Здесь меняется всё: формат ошибок, способ описания инструментов, политика хранения запросов, пределы длины, манера отвечать. Автоматический переход в таком случае — решение не только инженерное, но и продуктовое, а иногда и юридическое. Поэтому полезно разделить цепочку fallback на уровни: сначала другие экземпляры той же модели, потом близкий по контракту аналог, и только для отдельных классов запросов — принципиально другой поставщик. Для остальных классов цепочка на этом обрывается ответом «сервис временно недоступен».
Такое разделение защищает от соблазнительной, но опасной картины, в которой есть список моделей по приоритету и шлюз просто идёт по нему сверху вниз. Список без условий удобен в конфигурации и бесполезен в аварии: он не знает, какой запрос что может себе позволить.
Таймаут и повторы: арифметика терпения
Пока вы не посчитали задержку, настройки таймаута и повторов кажутся безобидными. Посчитаем на условном примере, цифры взяты для расчёта, а не из измерений.
Пусть таймаут одной попытки 20 секунд. Два повтора с экспоненциальной задержкой 1 и 2 секунды. Основной маршрут молчит. Худший случай до перехода на резерв: три попытки по 20 секунд плюс две паузы, итого 63 секунды. Резерву на ответ даём ещё 20 секунд, и пользователь ждёт до 83 секунд. Для фонового пакетного задания это нормально. Для диалога в чате человек за это время успеет закрыть вкладку, а то и написать в поддержку.
Теперь уменьшим: один повтор с паузой в 1 секунду. До перехода проходит 41 секунда, вместе с резервом — до 61. Лучше, но всё ещё много. Выход — разные бюджеты для разных классов запросов. Интерактивный запрос получает короткий таймаут и ровно одну попытку на основной маршрут, после чего сразу идёт в резерв, если политика данных это позволяет. Пакетная задача может терпеливо повторять сколько нужно, потому что ей спешить некуда.
Тут есть тонкость, о которой часто забывают: повтор и переход на резерв конкурируют за один и тот же бюджет времени. Повторы полезны при кратковременных сбоях, когда следующая попытка с высокой вероятностью пройдёт. Если же основной маршрут лежит всерьёз, каждый повтор — это секунды, украденные у резерва. Экспоненциальная задержка помогает не бить по поставщику, который уже перегружен, но на общий срок она работает против вас. Поэтому общий дедлайн запроса стоит задавать отдельно от дедлайнов попыток и не позволять цепочке выйти за него, чтобы последняя попытка не начиналась в момент, когда ответ уже никому не нужен.
Ещё одна оговорка: повтор запроса к языковой модели не всегда безвреден. Если запрос сопровождается побочным действием (инструмент создаёт заявку, отправляет письмо), то повторная генерация может породить это действие второй раз. Новый идентификатор попытки сам по себе идемпотентности не создаёт: нужен ключ операции, который проверяет ваш собственный код инструмента. Шлюз к этому отношения не имеет, но в аварии именно такие дубли обнаруживаются позже всего.
Три несовместимости, которые видно по эффекту
Теперь о том, что на самом деле ломается при переходе. Ниже три несовместимости, которые легко воспроизвести в любой команде, и у каждой есть наблюдаемое последствие. Это не результаты замеров конкретных поставщиков, а типы проблем, которые стоит искать при сравнении.
Другой JSON
Допустим, приложение просит вернуть объект с полями inn, amount и due_date. Основная модель стабильно возвращает чистый объект. Резервная при том же промпте оборачивает его в пояснение («Вот результат:») или в блок с разметкой, называет поле total вместо amount, а дату пишет в другом формате. Для человека это один и тот же ответ, а для парсера три разные поломки. Наблюдаемый эффект: статус 200 в шлюзе, исключение разбора в приложении, пустая карточка в интерфейсе. Хуже, если парсер толерантный и молча подставляет значение по умолчанию: тогда в базу попадёт сумма ноль без единой ошибки.
Вывод: если приложение ждёт строгий JSON, то проверка схемы нужна на выходе шлюза, а не где-то глубоко в бизнес-логике. Ответ резерва, не прошедший схему, должен считаться неудачной попыткой, а не успехом. Тогда шлюз либо пробует следующий вариант, либо честно сообщает об ошибке, но не передаёт мусор дальше как нормальный результат.
Отказ от инструмента
Второй случай. Основная модель получает описание функции поиска по базе заказов и вызывает её, когда пользователь спрашивает про статус. Резервная получает то же описание, но либо не вызывает функцию вовсе и отвечает «из общих знаний», либо вызывает её с аргументами в неожиданной форме. Наблюдаемый эффект неприятнее, чем у сломанного JSON, потому что ответ выглядит осмысленно. Модель уверенно рассказывает о статусе заказа, которого не видела. Ошибки нет, исключения нет, есть правдоподобная выдумка.
Защита здесь тоже на стороне контракта. Для классов запросов, где ответ без вызова инструмента недопустим, это нужно проверять явно: если по логике запроса вызов был обязателен, а его нет, ответ считается неудачным. Иначе резерв превращается в генератор убедительных, но пустых ответов.
Приватный текст у неподходящего получателя
Третья несовместимость не про формат, и обнаружить её по ответу нельзя вообще. Запрос содержит фрагменты закрытого договора или персональные данные клиента. Основной маршрут настроен так, что вы понимаете условия обработки этих данных. Резервный поставщик отвечает быстро и корректно, но условия хранения, использования и места обработки у него другие, и ваша организация их не согласовывала. Наблюдаемый эффект: никакого. Ни ошибки, ни задержки, ни странного вывода. Последствия проявятся позже, например при проверке, и тогда уже нельзя «откатить» отправленный текст.
Именно поэтому политика данных должна быть свойством маршрута и класса запроса, а не побочным эффектом списка fallback. Закрытый запрос просто не имеет права попасть в маршрут без пометки о допустимости, независимо от того, насколько хорошо этот маршрут отвечает на остальные. Fallback не гарантирует ни одинакового качества, ни одинаковой политики данных, и вторую часть этого утверждения нужно держать в голове чаще первой.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Сравнение на одинаковых запросах
Как узнать, чем резерв отличается от основы, не дожидаясь аварии? Прогнать одинаковые запросы через оба маршрута и сравнить результаты по заранее заданным условиям. Для этого подходят инструменты оценки вроде Promptfoo: результаты проверяются assertions, условия можно объединять в группы, а среди проверок предусмотрены стоимость и задержка, при этом критерии задаются конфигурацией теста (Promptfoo: Assertions and Metrics).
Ценность здесь не в самом инструменте, а в наборе запросов. Соберите его из реальных классов запросов приложения, обезличив содержимое, и добавьте нарочно неприятные примеры: пустое поле во входных данных, очень длинный документ у границы контекста, просьба вызвать инструмент, два требования в одной фразе. Хороший набор небольшой, но злой: двадцать запросов, которые когда-то уже ломали вашу систему, полезнее двухсот типовых.
Для каждого класса запросов проверки складываются в группу условий, которая отражает контракт:
- для извлечения реквизитов: ответ разбирается как JSON, все обязательные поля на месте, типы совпадают, дата в ожидаемом формате;
- для вопросов с инструментом: вызов функции произошёл, аргументы проходят вашу валидацию;
- для всех интерактивных запросов: задержка не превышает бюджет, который вы определили выше;
- для всех: стоимость запроса не выходит за границу, заложенную в расчёт.
Задержка и стоимость в этом списке стоят наравне с содержанием, и это не случайность. Резерв, который отвечает правильно за 70 секунд, для чата непригоден так же, как резерв с неверным JSON. А резерв, который отвечает правильно и быстро, но стоит заметно дороже, превращает долгую аварию основного маршрута в статью расходов. Условный расчёт: пусть запрос к резерву обходится в 1,8 раза дороже, чем к основной модели, и за шесть часов сбоя через шлюз проходит 2 500 запросов. Тогда переплата составит 2 500 × 0,8 = 2 000 условных единиц стоимости запроса основной модели. Много это или мало, зависит от бюджета, но знать порядок лучше заранее, а не по счёту в конце месяца. Для такого расчёта вам и нужны измеренные стоимость и задержка на ваших запросах, а не цифры из чужих сравнений.
Интерпретируйте результат осторожно. Прохождение набора означает, что на этих запросах и при этих условиях резерв подходит. Оно не означает, что модели равноценны. Они разные, и вопрос только в том, достаточно ли отличий, чтобы приложение сломалось. Процент прохождения по группе тоже читается по-разному: если резерв проходит 95% проверок на резюме новостей, это приемлемо. Если он проходит 95% проверок строгого JSON, то каждый двадцатый запрос в аварии будет отказом, и вы должны заранее решить, что с ним делать: повторить, отдать ошибку или поставить в очередь.
Когда ответ уже начал приходить
Всё описанное выше относится к запросам, которые завершились целиком или не завершились вовсе. Стриминг добавляет четвёртый вариант: ответ начался, часть текста уже у пользователя, и вдруг поток обрывается. Допустим ли переход к резерву после того, как фрагменты переданы?
Технически шлюз может отправить тот же запрос резерву и продолжить поток. Но получится ли из этого осмысленный ответ? Нет оснований считать это безопасным автоматически, и вот почему.
- Резерв не знает, что уже сказано. Если отправить ему исходный запрос, он начнёт ответ сначала, своими словами. Пользователь увидит повтор вступления, а потом другую формулировку. Если дополнить запрос уже переданным текстом с просьбой продолжить, результат зависит от модели и непредсказуем: она может продолжить стиль, а может сменить тон, структуру или перечисление на середине.
- Для структурированного вывода это фатально. JSON, начатый одной моделью и дописанный другой, скорее всего не пройдёт схему. Частичный объект нельзя «склеить» с чужим продолжением и рассчитывать на корректность.
- Побочные эффекты. Если на начальном участке ответа модель уже вызвала инструмент, повтор запроса может вызвать его снова.
Разумная политика состоит из нескольких простых правил. Пока клиенту не передан ни один фрагмент, переход на резерв работает как обычный fallback: пользователь ничего не заметил. После первого переданного фрагмента запрос повторяется автоматически только для классов, где допустим полный перезапуск ответа, а интерфейс умеет сбросить уже показанный текст. Для всего остального обрыв поступает как явная ошибка с понятным сообщением, а клиент сам решает, предложить ли пользователю «повторить». Это менее эффектно, чем бесшовное продолжение, зато честно: человек видит, что произошёл сбой, и не принимает склейку двух разных ответов за единый текст.
Заметим ещё одну деталь: начало ответа можно задерживать. Если шлюз буферизует первые несколько сотен символов прежде чем начать отдавать поток, то ранние обрывы остаются невидимыми для клиента, и безопасное переключение возможно чаще. Цена — чуть большая задержка до первого символа. Для интерактивного чата это ощутимо, для классов с проверкой формата иногда оправдано. Решение зависит от того, что для вашего приложения дороже: секунда ожидания или разорванный ответ.
Цена возвращения на основной маршрут
Переключение на резерв воспринимается как главный момент аварии, но самое хитрое происходит позже, когда основной маршрут ожил. Возвращаться на него немедленно соблазнительно: он дешевле, привычнее, у него понятная политика данных. Но немедленный возврат часто оказывается ошибкой.
Представьте поставщика, который работает нестабильно: три минуты отвечает, две минуты нет. Если шлюз возвращается на основной маршрут при первом успехе и уходит при первой ошибке, трафик начнёт метаться между маршрутами. Каждый переход — это запросы с разным форматом, разной задержкой и разной ценой, и приложение живёт в режиме постоянной смены поведения. Это называется «дребезг» маршрута, и он хуже стабильного пребывания на резерве.
Cooldown как раз служит успокоителем: маршрут, в котором накопились ошибки, выводится на паузу, и пробуется снова только по её окончании. Если шлюз работает в нескольких экземплярах, состояние паузы должно быть общим, и для этого подходит хранилище вроде Redis, о котором шла речь в документации роутера (LiteLLM: Router). Но автоматизм имеет пределы, и их полезно признать.
- Cooldown не знает, почему маршрут упал. Короткая пауза подходит для всплеска ошибок и плохо подходит для долгой аварии поставщика: вы будете регулярно отправлять пробные запросы в пустоту.
- Возврат не учитывает накопленное состояние. Если часть пользователей уже получила ответы резерва в рамках многошаговой беседы, внезапная смена модели посреди диалога может изменить стиль и качество. Для разговоров с памятью иногда разумнее закрепить маршрут на всю сессию, а возвращать основной уже новым сессиям.
- Возврат меняет стоимость и задержку. Чаще всего в лучшую сторону, но не обязательно: если у основного маршрута есть ограничения по частоте запросов, а за время простоя накопилась очередь, первые минуты после возврата могут упереться в лимиты. Нагрузку стоит возвращать порциями, а не целиком.
Практичная схема возврата выглядит так: после окончания паузы на основной маршрут отправляется небольшая доля запросов, преимущественно малозначимых классов. Если они проходят в рамках бюджета задержки и проверок формата, доля растёт. Если нет, маршрут снова уходит на паузу, причём с более длинной. Это можно сделать и вручную, поручив решение дежурному, и для первых версий шлюза такой ручной переключатель часто оказывается надёжнее сложной автоматики: он проще, его поведение предсказуемо, а его отсутствие заметишь не по аварии.
Где живёт сам шлюз
Шлюз с резервным провайдером — это ещё один сервис, который нужно где-то запускать и за которым нужно следить. Он принимает запросы, держит состояние пауз, считает лимиты, хранит конфигурацию маршрутов и политики данных. Нагрузка у него обычно скромная: основная работа происходит на стороне поставщиков, а шлюз в основном ждёт ответов и перекладывает байты. Но «обычно скромная» — не цифра. Сколько запросов в секунду выдержит конкретная конфигурация, особенно при стриминге и буферизации, нужно измерять на своём трафике, а не подставлять из чужого опыта.
Для такой роли подойдёт обычный VPS. На VPS MAATRIX доступны NVMe-диски, процессоры AMD EPYC и root-доступ, а значит вы сами выбираете, чем и как запускать шлюз и хранилище состояния рядом с ним (аренда VPS MAATRIX). В линейке тарифов есть варианты от 1 vCPU и 2 ГБ памяти (Ba) до 16 vCPU и 32 ГБ (Djed). Для шлюза с Redis на старте разумно взять небольшой вариант и смотреть на реальную загрузку, а увеличивать по результатам измерений: ресурсы тарифа не равны обещанию ёмкости, и сколько запросов выдержит ваша связка, покажет только нагрузка.
Есть и обратная сторона: шлюз на одном сервере сам становится единственной точкой отказа. Если вся защита от недоступности модели построена на одном VPS, то авария этого VPS отключит и основной, и резервный маршруты разом. Два сервера у одного хостинга не дают автоматической отказоустойчивости приложению: нужно отдельно продумать, как клиенты узнают, к какому из шлюзов идти, и как делится состояние пауз. Принцип тот же, что в дисковых массивах: избыточность работает только там, где она спроектирована, а не там, где ресурсов просто больше (об этом хорошо видно на примере работы RAID при отказе диска). Резервный провайдер защищает от недоступности модели, но не от недоступности самого шлюза, и это два разных риска.
Расположение сервера тоже влияет на задержку до поставщиков. В публичном предложении MAATRIX перечислены локации UK, US, RU и FR, но доступность стороннего API с конкретного адреса зависит от самого поставщика и от сетевых условий, и гарантировать её нельзя. Проверьте реальные соединения из выбранной локации до запуска, а выбор места обработки данных не сводите к выбору страны сервера: вопросы допустимости передачи данных решаются отдельно, на уровне условий поставщика и ваших обязательств.
Случай, когда лучший ответ — «сейчас недоступно»
Вернёмся к закрытому классу запросов из самой первой таблицы: помощник по внутренней базе знаний, который отвечает по текстам договоров и регламентов. Допустим, основной поставщик ушёл в недоступность на несколько часов. Резервный отвечает быстро, JSON у него проходит схему, инструмент поиска он вызывает. Все технические проверки зелёные. Но для резерва у вас нет согласованных условий обработки таких документов.
В этот момент шлюз стоит перед выбором, и одна из его частей, самая автоматическая, подсказывает: отправляй, ведь он же работает. Правильный ответ противоположный. Фрагмент договора, однажды отправленный неподходящему получателю, обратно не вернуть, а сообщение «помощник временно недоступен, попробуйте позже» не стоит ничего. Пользователь потеряет время, но не потеряет контроль над документом. Ему можно предложить обратиться к тексту напрямую, оставить вопрос в очереди для обработки после восстановления или перейти к ручному поиску.
Такое поведение нужно не оставлять на волю случая, а проектировать так же тщательно, как успешный путь. У закрытого класса запросов в конфигурации просто нет резервного маршрута, и шлюз возвращает конкретную, понятную ошибку, которую приложение умеет показывать. Метрика «доля запросов, отвеченных резервом» здесь не цель, а побочный показатель: иногда хороший результат аварии — это ровно ноль ответов резерва по закрытым данным и несколько сотен аккуратных сообщений о недоступности.
Инженерная зрелость шлюза проявляется не в том, что он умеет отвечать всегда. Она проявляется в том, что он знает, когда отвечать нельзя, и говорит об этом прямо. Выбирая резервного поставщика, спрашивайте себя не «насколько он надёжен», а «для каких именно запросов я готов на него положиться, а для каких нет». Ответ на второй вопрос почти всегда оказывается длиннее и важнее, чем на первый.