Проблема: агент, который не заканчивает
Без таймаутов «зависший» запрос не просто медленный — он ядовит для всей системы. Один застрявший агент держит: открытое соединение, поток/корутину, память под накопленный контекст, слот в очереди rate limiter, коннекшн к БД. Наберётся десяток таких — и сервер исчерпает ресурсы, отказывая здоровым запросам. Зависание одного превращается в отказ всех — это resource exhaustion.
Откуда у агента берутся зависания (то, чего нет у обычного «запрос-ответ»):
- Медленный/зависший инструмент. Внешний API не отвечает — а агент ждёт его синхронно.
- Долгая генерация. LLM выдаёт очень длинный ответ или «думает» в reasoning долго.
- Зацикливание. Агент ходит по кругу: вызвал инструмент → не понял → вызвал снова → … без сходимости.
- Каскад. Много шагов, каждый терпимый по времени, но в сумме — минуты.
Таймаут — не «ошибка», а контракт: «эта операция гарантированно закончится за N секунд — успехом или прерыванием». Без него у операции нет верхней границы времени, а значит, нет и предсказуемости: ты не можешь обещать пользователю ответ, не можешь планировать ресурсы, не можешь отличить «медленно» от «зависло навсегда». Production-система строится на дедлайнах — каждая операция обязана иметь предел.
Каскад таймаутов
Первое желание — поставить один таймаут на весь запрос. Этого мало: он скажет «прошло 60 с, что-то зависло», но что именно — непонятно, и ресурсы всё это время держались. Нужен каскад: таймауты на каждом уровне, вложенные друг в друга — от узкого (один вызов) к широкому (весь запрос).
Логика: чем ниже уровень, тем короче таймаут. Зависший инструмент отсекается за свои 10 с, не дожидаясь общего дедлайна в 60 с — ресурс освобождается быстро, и, если есть retry/fallback, они успевают сработать в оставшемся бюджете. Уровни:
- Один вызов (LLM / tool / HTTP) — самый короткий таймаут. Ловит зависший API/генерацию точечно.
- Один шаг агента (узел графа) — чуть больше: вмещает вызов + обработку.
- Весь запрос — самый большой, общий дедлайн для пользователя.
Ключевое правило вложенности: таймаут вложенной операции обязан быть строго меньше оставшегося времени внешней. Иначе внешний дедлайн сработает раньше внутреннего, и точечная диагностика («что зависло») теряется — ты снова получаешь бесполезное «весь запрос завис». Ещё важнее: если у тебя есть retry/fallback (прошлые уроки), они тоже тратят время из общего бюджета — таймаут одной попытки должен оставлять запас на повтор/запасной путь, иначе первый же таймаут съест весь дедлайн.
Deadline budget
Продвинутая (и правильная) форма каскада — не фиксированные таймауты на каждом уровне, а единый бюджет времени, который «спускается» сверху и тратится по мере выполнения. Пользователю обещано 60 с — это и есть весь бюджет; каждый уровень смотрит, сколько осталось, и не может выйти за остаток.
import time
class Deadline:
def __init__(self, budget_s: float):
self.deadline = time.monotonic() + budget_s
def remaining(self) -> float:
return self.deadline - time.monotonic()
def check(self):
if self.remaining() <= 0:
raise TimeoutError("deadline exceeded")
def run_agent(query: str, budget_s: float = 60):
dl = Deadline(budget_s) # общий бюджет задаётся ОДИН раз сверху
for step in range(10): # + лимит итераций (см. ниже)
dl.check() # не пора ли остановиться?
# каждому вызову даём МЕНЬШЕ, чем осталось (запас на fallback/следующий шаг)
call_timeout = min(15, dl.remaining() - 5)
if call_timeout <= 0:
break # времени не осталось → выходим корректно
result = call_llm(query, timeout=call_timeout)
if is_done(result):
return result
# бюджет исчерпан → отдаём ЧТО есть (частичный результат — следующий урок)
return partial_or_error()
Преимущество бюджета над независимыми таймаутами: он честен по отношению к пользователю. Если первые шаги съели 50 с из 60, оставшимся достанется 10, а не «ещё по 20 каждому» — суммарное обещание не нарушается. Многие HTTP-клиенты и gRPC поддерживают проброс дедлайна сквозь вызовы нативно.
Лимит итераций против зацикливания
Таймаут по времени ловит медленное. Но у агентов есть особый вид зависания — зацикливание: агент быстро крутит один и тот же цикл (вызвал инструмент → не понял → вызвал снова), каждый шаг быстрый, но их бесконечно много. По времени такой цикл может и не превысить дедлайн сразу, зато сожжёт кучу токенов и денег. Нужна вторая граница — по числу шагов.
- Лимит итераций (recursion_limit в LangGraph, max_iterations в агент-фреймворках): не больше N шагов reasoning-цикла. Достиг лимита → стоп.
- Детекция повторов. Если агент N раз подряд вызывает тот же инструмент с теми же аргументами — он застрял, прерываем раньше лимита.
- Бюджет вызовов/токенов. Ограничение «не больше K вызовов LLM на запрос» — прямая защита кошелька от циклящегося агента.
Особенность агентов: цикл из быстрых шагов может уложиться во временной таймаут, но за эти секунды сделать 50 LLM-вызовов — это реальные деньги и токены (вспомни cost-метрики из дашбордов). Поэтому у агента обязательно должны быть обе границы: по времени (против медленного зависания) и по числу итераций/вызовов (против быстрого зацикливания). Один таймаут по времени зацикливание не остановит вовремя.
Отмена — это только полдела
Таймаут сработал — но «истекло время» не значит «всё аккуратно остановилось». Прерванная операция могла оставить за собой мусор: открытое соединение, недописанную запись, занятый слот, начатую транзакцию. Простое прерывание без очистки ведёт к утечкам — тем самым, от которых таймаут и должен был спасти.
- Реальная отмена. Прерывание должно дойти до низлежащего вызова (закрыть HTTP-соединение, отменить корутину), а не просто «перестать ждать», пока запрос продолжает жить в фоне и жечь ресурсы/токены.
- Очистка (cleanup). Освободить соединения, откатить/зафиксировать транзакции, снять локи — в
finally/context manager, чтобы это выполнилось при любом исходе, включая таймаут. - Осторожно с побочными эффектами. Если таймаут случился после того, как инструмент уже что-то изменил (создал заказ), — тут вопрос идемпотентности из урока про retry: возможно, действие выполнено, хоть ты его и «отменил».
Частая иллюзия: обернул вызов в таймаут — и думаешь, что операция прекратилась. Но многие обёртки лишь перестают ждать результат, а сам запрос к LLM/API продолжает выполняться в фоне: генерация идёт, токены тратятся, соединение занято. Настоящий таймаут должен отменять низлежащую операцию (cancellation), а не только освобождать вызывающую сторону. Проверяй, что твой механизм таймаута реально прерывает работу, а не просто отпускает поток ждать другое.
Стриминг: лечит восприятие
Отдельная тактика для «долго, но не зависло»: стриминг. Даже если полный ответ агента честно занимает 30 с, пользователь не должен смотреть на пустой спиннер все 30 — можно отдавать токены/промежуточные шаги по мере готовности. Это не убирает таймаут (жёсткая граница всё равно нужна), но радикально меняет восприятие и даёт полезные сигналы:
- TTFT важнее общего времени. Первый токен через 0.5 с — и пользователь видит, что система работает, даже если полный ответ идёт 20 с (метрика TTFT из урока про дашборды).
- Прогресс агента. «Ищу…», «Анализирую…» — стриминг шагов показывает, что агент движется, а не завис.
- Частичный результат при таймауте. Если дедлайн истёк на середине — уже стримленное можно отдать как частичный ответ (мост к graceful degradation — финальный урок раздела).
Не путай: стриминг улучшает UX долгих задач, но не отменяет необходимость жёсткой границы — зависший на 3-м токене стрим так же ядовит для ресурсов, как молчащий запрос. Правильно: стриминг для отзывчивости плюс таймаут/дедлайн для гарантии завершения. Первый делает ожидание приятным, второй — конечным.
Типичные ошибки
Зависший агент держит соединение, память, слот — десяток таких кладёт сервер (resource exhaustion). Каждая операция обязана иметь верхнюю границу времени.
Ловит «что-то зависло» за 60 с, но не что именно, и держит ресурсы всё это время. Нужен каскад: короткий на вызов, средний на шаг, общий на запрос.
Быстрое зацикливание уложится во временной таймаут, но сожжёт токены и деньги. Нужны обе границы: время И число шагов/вызовов.
Запрос продолжает жить в фоне, жечь токены и держать соединение. Настоящий таймаут отменяет низлежащую операцию + очистка ресурсов в finally.
Если одна попытка съедает весь бюджет, на retry/fallback времени не остаётся. Таймаут вызова должен быть меньше оставшегося дедлайна.
Шпаргалка
ПРОБЛЕМА: агент зависает БОДРО (цикл, зависший tool, долгая генерация)
зависший запрос ЯДОВИТ: держит соединение/память/слот → resource exhaustion (кладёт всех)
ТАЙМАУТ = контракт «операция ЗАВЕРШИТСЯ за N сек» (успех или прерывание)
КАСКАД ТАЙМАУТОВ (вложенные, чем ниже — тем короче):
один вызов (LLM/tool/HTTP) — самый короткий · шаг агента (узел) — средний · весь запрос — общий
зависший tool ловится за 10с (узко), не за 60с (широко) → ресурс свободен быстро
⚠️ внутренний таймаут < оставшегося внешнего · оставляй запас на retry/fallback!
DEADLINE BUDGET: единый бюджет (60с) задан СВЕРХУ, тратится по мере выполнения
каждый уровень смотрит remaining(), не выходит за остаток → честно к юзеру
(HTTP/gRPC умеют пробрасывать deadline нативно)
ЛИМИТ ИТЕРАЦИЙ (против ЗАЦИКЛИВАНИЯ — время не ловит быстрый цикл!):
recursion_limit / max_iterations ≤ N · детекция повторов (тот же tool+args) ·
бюджет вызовов/токенов
💸 цикл из быстрых шагов = 50 LLM-вызовов = деньги → нужны ОБЕ границы (время И шаги)
ОТМЕНА = полдела:
реальная cancellation (дойти до низлежащего вызова!) — не просто «перестать ждать»
⚠️ «перестал ждать» ≠ «остановил»: запрос живёт в фоне, жжёт токены
cleanup в finally (соединения/транзакции/локи) · побочные эффекты → идемпотентность
СТРИМИНГ: лечит ВОСПРИЯТИЕ (TTFT, прогресс, частичный результат) — но НЕ заменяет таймаут
стриминг (отзывчивость) + таймаут (гарантия завершения)
ГРАБЛИ: нет таймаутов · один на весь запрос · только время без итераций ·
«перестал ждать» вместо отмены · нет запаса на fallback
Практическое задание
Задание: защити агента от зависаний
- Для агента из модулей 03–05 перечисли места, где он может зависнуть (tool, генерация, цикл). Что сейчас произойдёт, если инструмент не ответит?
- Спроектируй каскад таймаутов: значения на вызов LLM, вызов инструмента, шаг агента, весь запрос. Проверь правило «внутренний < внешнего».
- Реализуй deadline budget (общий бюджет сверху). Убедись, что каждый вызов получает
min(свой_лимит, остаток − запас)и остаётся время на fallback. - Добавь лимит итераций и детекцию повторов. Смоделируй зацикливание: ловится ли оно раньше временного таймаута? Сколько токенов сожгло бы без лимита?
- Проверь отмену: когда таймаут срабатывает, реально ли прерывается вызов к LLM/API или он живёт в фоне? Добавь cleanup в
finally. - Что твой агент отдаёт пользователю при истечении дедлайна? (Пока зафиксируй — полноценный ответ в следующем уроке про graceful degradation.) Добавь стриминг, чтобы ожидание было отзывчивым.
- Со звёздочкой: свяжи всё в единый контур надёжности: таймаут одной попытки → retry (backoff) в остатке бюджета → fallback-провайдер → при полном исчерпании дедлайна отдать частичный результат. Хватает ли времени на все уровни?
Что дальше
Ретрай, fallback, rate limiting, таймауты — каждый механизм ловит свой отказ. Но все они рано или поздно упираются в один вопрос: что отдать пользователю, когда починить не удалось? Падать с 500 — плохой финал. Ответ — деградировать частично, а не полностью. Финальный урок раздела — graceful degradation: как агент продолжает приносить пользу при частичных отказах.