Стратегия тайм-аутов для LLM API, которая переживает медленные генерации
Почему один фиксированный тайм-аут ломает вызовы LLM и как задать дедлайны на подключение, первый токен и простой, использовать стриминг, чтобы оставаться в живых, и корректно отменять запрос через AbortController.
Запрос к чат-модели, которая обычно отвечает за три секунды, иногда может занять девяносто. Задайте один грубый 30-секундный тайм-аут по всему клиенту — и вы будете обрубать медленные, но корректные генерации, при этом ничего не делая с по-настоящему зависшими. Решение не в большем числе — оно в нескольких дедлайнах, каждый из которых измеряет что-то своё: как долго вы ждёте подключения, как долго вы ждёте первый токен и сколько молчания вы терпите, когда токены уже пошли. В этой статье показано, как построить такой слоистый тайм-аут, почему стриминг меняет расчёты и как отменить работу в тот же момент, когда сработал дедлайн или пользователь сказал «стоп».
Задержка у LLM необычна, и именно в этом корень проблемы. У обычного REST-вызова распределение задержек плотно сгруппировано вокруг среднего, поэтому фиксированный тайм-аут в несколько раз выше среднего безопасен. Задержка генерации модели имеет тяжёлый хвост и зависит от того, чего клиент не видит: длины вывода, которую выбирает модель, того, является ли она рассуждающей моделью, тратящей скрытые токены прежде чем написать хоть слово, очередей на стороне провайдера и холодной маршрутизации. Тайм-аут, настроенный на медиану, ампутирует хвост; тайм-аут, настроенный на хвост, позволяет по-настоящему зависшим запросам удерживать соединения и квоту слишком долго.
Разделите дедлайны, которые тайм-аут пытается покрыть
«Тайм-аут» — это одно слово по меньшей мере для четырёх независимых ограничений. Назвать их по отдельности — вот весь фокус.
- Тайм-аут подключения — потолок на установление TCP/TLS-соединения. Он должен быть коротким (пара секунд); медленное подключение — это проблема сети или DNS, а не медленной модели.
- Дедлайн времени до первого токена (TTFT) — как долго вы ждёте, пока модель начнёт отвечать. На рассуждающей модели это законно доходит до десятков секунд, потому что модель может думать прежде, чем что-либо выдать.
- Тайм-аут простоя / между токенами — как только стрим начался, максимальный промежуток, который вы терпите между чанками. Здоровый стрим шлёт токены непрерывно; долгое молчание означает, что вышестоящая сторона застряла.
- Общий дедлайн / по «настенным часам» — абсолютный предел на весь запрос, чтобы патологический случай не мог выполняться бесконечно.
Один фиксированный тайм-аут схлопывает все четыре в одно число и потому не служит ни одному из них хорошо. Надёжность при долгих генерациях достигается заданием щедрого TTFT и общего бюджета при одновременном сохранении жёсткого тайм-аута простоя, чтобы вы быстро проваливались при зависании, но оставались терпеливы во время реальной работы.
Стриминг превращает жёсткий общий тайм-аут в мягкий тайм-аут простоя
Самое эффективное изменение, которое вы можете сделать, — это стриминг. Когда вы отправляете stream: true, модель возвращает токены через Server-Sent Events вместо буферизации всего ответа, и вы перестаёте измерять «завершился ли весь ответ за N секунд» и начинаете измерять «пришёл ли следующий чанк за N секунд». Это переворачивает задачу в вашу пользу: ответ на пять тысяч токенов больше не рискует обрубанием по общему времени, потому что каждый чанк сбрасывает часы простоя. Если вы ещё не построили стриминговый клиент, стриминг ответов LLM через SSE охватывает проводной формат и парсинг; здесь нас интересует только поведение по времени, которое он открывает.
Логика тайм-аута простоя на потоке fetch выглядит так — сторож, который сбрасывается на каждом чанке и срабатывает только во время реального молчания:
async function streamWithIdleTimeout(url, body, { idleMs = 20000 } = {}) {
const controller = new AbortController();
let timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer AVE_GATEWAY_API_KEY" },
body: JSON.stringify({ ...body, stream: true }),
signal: controller.signal,
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
clearTimeout(timer); // a chunk arrived: reset the watchdog
timer = setTimeout(() => controller.abort(new DOMException("idle", "TimeoutError")), idleMs);
process.stdout.write(decoder.decode(value, { stream: true }));
}
} finally {
clearTimeout(timer);
}
}
Обратите внимание: бюджет простоя (20 секунд молчания) отвязан от общего времени. Запрос может выполняться минутами, пока он продолжает выдавать токены, но настоящее зависание срабатывает в пределах одного окна простоя. Это именно то поведение, которое фиксированный общий тайм-аут выразить не может.
Отменяйте через AbortController и читайте имя ошибки
Каждому тайм-ауту нужен механизм, который реально остановит работу, и в браузере и современных средах выполнения этот механизм — AbortController. Вы передаёте его signal в fetch, и вызов abort() разрывает соединение. Для простого предела по «настенным часам» без ручного создания контроллера AbortSignal.timeout(ms) возвращает сигнал, который отменяет сам себя по истечении заданных миллисекунд:
// Hard 60s cap on a non-streaming call:
const res = await fetch(url, { method: "POST", signal: AbortSignal.timeout(60000), /* ... */ });
Два пути отмены сообщают о разных ошибках, и различение их — именно то, что позволяет вам решить, повторять ли запрос. Ручной controller.abort() отклоняет fetch с DOMException по имени AbortError; сигнал от AbortSignal.timeout() отклоняет с именем TimeoutError. Чтобы объединить «пользователь нажал стоп» с «жёстким дедлайном», слейте сигналы через AbortSignal.any([userSignal, AbortSignal.timeout(60000)]), затем ветвитесь по err.name:
try {
await callModel();
} catch (err) {
if (err.name === "TimeoutError") retryOrEscalate(); // our deadline fired
else if (err.name === "AbortError") stopQuietly(); // the user cancelled
else throw err; // a real failure
}
Трактовать отмену пользователем как сбой для повтора — классическая ошибка: вы повторно запускаете запрос, который пользователь явно убил, платите за токены и загрязняете свои метрики. Ветвитесь по имени.
У серверной стороны свои тайм-ауты, и они выигрывают
Если ваш клиент общается со шлюзом или вашим собственным бэкендом, а не напрямую с провайдером, этот прыжок навязывает собственные дедлайны, и самый короткий из них вдоль пути решает исход. Частый сбой — клиент терпеливо ждёт пять минут, пока промежуточный прокси разрывает соединение на шестидесятой секунде, или тайм-аут простоя балансировщика нагрузки закрывает стриминговый ответ, который «висит», потому что буферизация ответа задержала первый байт за пределы лимита. Три серверных правила не дают этому кусаться:
- Пробрасывайте отмену вверх по цепочке. Когда клиент отключается, отмените запрос, который вы сделали к провайдеру. Отмена работает без абсолютной гарантии: после отключения провайдер может ещё недолго продолжать генерацию и тарификацию, но проброс отмены даёт вам единственный шанс прекратить расходы на токены, которые никто не прочитает. Это серверное зеркало
AbortController. - Не буферизируйте стриминговые ответы. Прокси, накапливающий тело перед пересылкой, задерживает первый байт и может сработать по вышестоящему тайм-ауту простоя, который никогда бы не сработал на сброшенном стриме.
- Отдайте дедлайн среде выполнения. В Go дедлайн
context.Context— идиоматичный носитель; когда он истекает, исходящий HTTP-запрос отменяется и ресурсы освобождаются. В любом языке есть эквивалент — суть в том, что тайм-аут должен доходить до сокета, а не только до обёртки.
Шлюз — естественное место для применения единообразной политики, потому что он и так завершает клиентские соединения и хранит учётные данные провайдеров. Регистрация каждого апстрима за ним позволяет применять один дедлайн и один путь отмены независимо от того, какой провайдер обслуживает запрос, а токены, реально израсходованные до отмены, записываются в его журналах использования, поэтому отменённый вызов всё равно учитывается. Помните, что локальный счётчик приблизителен: если финальная статистика апстрима не пришла, он может быть оценочным. Для всего, что критично для биллинга, сверяйтесь с собственными данными использования провайдера.
Тайм-ауты и повторы — это одна система, а не две
Сработавший тайм-аут — это сигнал для вашей логики повторов, поэтому эти два элемента должны проектироваться вместе, иначе они борются друг с другом. Два правила предотвращают частые патологии:
- Повтор имеет смысл только после быстрого сбоя. Если ваш общий тайм-аут длинный и вы повторяете при истечении, один медленный запрос превращается в несколько медленных запросов, наложенных встык, умножая задержку и стоимость. Держите дедлайн на попытку достаточно жёстким, чтобы повтор был дешевле, чем продолжать ждать, и ограничьте число попыток.
- Запрос с истёкшим тайм-аутом мог всё же выполниться. Когда вы обрываете соединение, провайдер мог уже произвести ответ, который вы так и не прочитали. Слепой повтор может списать дважды или продублировать побочные эффекты. Сочетайте тайм-ауты со стратегией идемпотентности, чтобы повтор после тайм-аута был безопасен — механика описана в повторах и идемпотентности LLM API.
Тайм-ауты также взаимодействуют с лимитами запросов. Всплеск тайм-аутов, за которым следуют немедленные повторы, выглядит, с точки зрения вышестоящей стороны, как всплеск нагрузки и может толкнуть вас к ответам 429. Координация дедлайнов с обратным давлением — постановка в очередь, а не долбёжка — рассмотрена в лимитах запросов и обратном давлении LLM API. Когда медленным становится целый провайдер, а не отдельный запрос, правильный ход часто в перенаправлении трафика; см. стратегию отказоустойчивости LLM API.
Рассуждающим моделям нужен больший бюджет терпения
Рассуждающие модели меняют профиль времени достаточно, чтобы заслужить собственные числа. Поскольку они тратят скрытые токены рассуждения прежде чем произвести видимый вывод, TTFT может быть длинным, даже когда модель работает идеально, и дедлайн TTFT, откалиброванный под быструю чат-модель, убьёт их преждевременно. Помогают две корректировки: поднимите бюджеты TTFT и общего времени специально для маршрутов рассуждающих моделей и предпочитайте стриминг, чтобы, как только видимые токены начались, вашим тайм-аутом управлял простой, а не общий предел. Настраивайте параметры рассуждения отдельно и резервируйте достаточно места под скрытые токены по правилам из управления контекстным окном LLM.
FAQ
Какое значение тайм-аута мне использовать для вызова LLM API?
Единого значения нет, в чём и суть слоистой стратегии. Используйте короткий тайм-аут подключения (пара секунд), щедрый дедлайн TTFT, рассчитанный под ваше самое медленное семейство моделей, жёсткий тайм-аут простоя для стриминговых ответов (десятки секунд молчания) и абсолютный общий предел как страховку. Настройка одного числа вынуждает вас разменивать корректность хвоста на обнаружение зависаний; отдельные дедлайны позволяют иметь и то, и другое.
Как отличить тайм-аут по дедлайну от отмены пользователем?
Проверьте name ошибки. Сигнал от AbortSignal.timeout() отклоняет с TimeoutError; ручной controller.abort() отклоняет с AbortError. Повторяйте или эскалируйте при TimeoutError, останавливайтесь молча при AbortError и перебрасывайте всё остальное. Смешение этих двух приводит к повтору запросов, которые пользователь намеренно убил.
Действительно ли отмена запроса останавливает списание провайдером?
Только если отмена доходит до провайдера, и даже тогда это не абсолютная гарантия. В браузере controller.abort() закрывает соединение с вашим сервером; ваш сервер затем должен отменить собственный вышестоящий запрос, иначе генерация, скорее всего, продолжится и вам выставят счёт за токены, которые никто не читает. Проброс отмены через каждый переход даёт ей шанс стать реальным контролем стоимости. Подтверждайте результат по данным использования или счёту провайдера, поскольку локальный счётчик токенов может быть оценочным.
Почему мой стриминговый ответ висит до самого конца вместо стриминга?
Почти всегда это буферизация ответа в посреднике. Обратный прокси или фреймворк, накапливающий тело перед пересылкой, побеждает стриминг и может также сработать по тайм-аутам простоя, задержав первый байт. Отключите буферизацию для ответов text/event-stream и сбрасывайте чанки по мере их поступления.
Разделите один маршрут на четыре дедлайна
Возьмите один продакшн-маршрут и разделите его единственный тайм-аут на четыре: тайм-аут подключения, дедлайн TTFT, тайм-аут простоя стрима и общий предел. Переключите этот маршрут на stream: true, подключите сторожа простоя из примера выше, а затем намеренно отмените запрос посреди стрима. Локально записанное число выходных токенов должно уменьшиться — это хороший признак того, что дедлайн дошёл до сокета, но считайте его сигналом, а не доказательством. Когда стоимость важна, сверяйтесь с собственными данными использования провайдера, поскольку после отключения апстрим может ещё недолго продолжать тарификацию.