Проблема: агент, который не заканчивает

Без таймаутов «зависший» запрос не просто медленный — он ядовит для всей системы. Один застрявший агент держит: открытое соединение, поток/корутину, память под накопленный контекст, слот в очереди rate limiter, коннекшн к БД. Наберётся десяток таких — и сервер исчерпает ресурсы, отказывая здоровым запросам. Зависание одного превращается в отказ всех — это resource exhaustion.

Откуда у агента берутся зависания (то, чего нет у обычного «запрос-ответ»):

  • Медленный/зависший инструмент. Внешний API не отвечает — а агент ждёт его синхронно.
  • Долгая генерация. LLM выдаёт очень длинный ответ или «думает» в reasoning долго.
  • Зацикливание. Агент ходит по кругу: вызвал инструмент → не понял → вызвал снова → … без сходимости.
  • Каскад. Много шагов, каждый терпимый по времени, но в сумме — минуты.
ℹ️ Таймаут — это обещание завершиться

Таймаут — не «ошибка», а контракт: «эта операция гарантированно закончится за N секунд — успехом или прерыванием». Без него у операции нет верхней границы времени, а значит, нет и предсказуемости: ты не можешь обещать пользователю ответ, не можешь планировать ресурсы, не можешь отличить «медленно» от «зависло навсегда». Production-система строится на дедлайнах — каждая операция обязана иметь предел.

Каскад таймаутов

Первое желание — поставить один таймаут на весь запрос. Этого мало: он скажет «прошло 60 с, что-то зависло», но что именно — непонятно, и ресурсы всё это время держались. Нужен каскад: таймауты на каждом уровне, вложенные друг в друга — от узкого (один вызов) к широкому (весь запрос).

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
Каскад таймаутов: вложенные дедлайны Весь запрос: ⏱ 60с (deadline budget) Запуск графа / агента: ⏱ 55с · лимит итераций ≤ 10 Шаг: узел агента ⏱ 20с LLM-вызов ⏱ 15с Tool-вызов ⏱ 10с Шаг: узел агента ⏱ 20с LLM-вызов ⏱ 15с Tool-вызов ⏱ 10с Каждый уровень ЖЁСТЧЕ внешнего · внутренний таймаут < оставшегося бюджета DEADLINE BUDGET: 60с задан вверху → каждый уровень тратит из ОБЩЕГО остатка зависший tool ловится за 10с (узко), а не за 60с (широко) → ресурс освобождён быстро

Логика: чем ниже уровень, тем короче таймаут. Зависший инструмент отсекается за свои 10 с, не дожидаясь общего дедлайна в 60 с — ресурс освобождается быстро, и, если есть retry/fallback, они успевают сработать в оставшемся бюджете. Уровни:

  • Один вызов (LLM / tool / HTTP) — самый короткий таймаут. Ловит зависший API/генерацию точечно.
  • Один шаг агента (узел графа) — чуть больше: вмещает вызов + обработку.
  • Весь запрос — самый большой, общий дедлайн для пользователя.
⚠️ Внутренний таймаут должен быть меньше внешнего

Ключевое правило вложенности: таймаут вложенной операции обязан быть строго меньше оставшегося времени внешней. Иначе внешний дедлайн сработает раньше внутреннего, и точечная диагностика («что зависло») теряется — ты снова получаешь бесполезное «весь запрос завис». Ещё важнее: если у тебя есть retry/fallback (прошлые уроки), они тоже тратят время из общего бюджета — таймаут одной попытки должен оставлять запас на повтор/запасной путь, иначе первый же таймаут съест весь дедлайн.

Deadline budget

Продвинутая (и правильная) форма каскада — не фиксированные таймауты на каждом уровне, а единый бюджет времени, который «спускается» сверху и тратится по мере выполнения. Пользователю обещано 60 с — это и есть весь бюджет; каждый уровень смотрит, сколько осталось, и не может выйти за остаток.

Deadline budget: единый дедлайн на весь запрос
python
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-м токене стрим так же ядовит для ресурсов, как молчащий запрос. Правильно: стриминг для отзывчивости плюс таймаут/дедлайн для гарантии завершения. Первый делает ожидание приятным, второй — конечным.

Типичные ошибки

❌ Ошибка 1: вообще нет таймаутов

Зависший агент держит соединение, память, слот — десяток таких кладёт сервер (resource exhaustion). Каждая операция обязана иметь верхнюю границу времени.

❌ Ошибка 2: один таймаут на весь запрос

Ловит «что-то зависло» за 60 с, но не что именно, и держит ресурсы всё это время. Нужен каскад: короткий на вызов, средний на шаг, общий на запрос.

❌ Ошибка 3: только время, без лимита итераций

Быстрое зацикливание уложится во временной таймаут, но сожжёт токены и деньги. Нужны обе границы: время И число шагов/вызовов.

❌ Ошибка 4: «перестал ждать» вместо отмены

Запрос продолжает жить в фоне, жечь токены и держать соединение. Настоящий таймаут отменяет низлежащую операцию + очистка ресурсов в finally.

❌ Ошибка 5: таймаут не оставляет запаса на fallback

Если одна попытка съедает весь бюджет, на retry/fallback времени не остаётся. Таймаут вызова должен быть меньше оставшегося дедлайна.

Шпаргалка

Timeout management — всё в одном месте
text
ПРОБЛЕМА: агент зависает БОДРО (цикл, зависший 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

Практическое задание

Задание: защити агента от зависаний

  1. Для агента из модулей 03–05 перечисли места, где он может зависнуть (tool, генерация, цикл). Что сейчас произойдёт, если инструмент не ответит?
  2. Спроектируй каскад таймаутов: значения на вызов LLM, вызов инструмента, шаг агента, весь запрос. Проверь правило «внутренний < внешнего».
  3. Реализуй deadline budget (общий бюджет сверху). Убедись, что каждый вызов получает min(свой_лимит, остаток − запас) и остаётся время на fallback.
  4. Добавь лимит итераций и детекцию повторов. Смоделируй зацикливание: ловится ли оно раньше временного таймаута? Сколько токенов сожгло бы без лимита?
  5. Проверь отмену: когда таймаут срабатывает, реально ли прерывается вызов к LLM/API или он живёт в фоне? Добавь cleanup в finally.
  6. Что твой агент отдаёт пользователю при истечении дедлайна? (Пока зафиксируй — полноценный ответ в следующем уроке про graceful degradation.) Добавь стриминг, чтобы ожидание было отзывчивым.
  7. Со звёздочкой: свяжи всё в единый контур надёжности: таймаут одной попытки → retry (backoff) в остатке бюджета → fallback-провайдер → при полном исчерпании дедлайна отдать частичный результат. Хватает ли времени на все уровни?

Что дальше

Ретрай, fallback, rate limiting, таймауты — каждый механизм ловит свой отказ. Но все они рано или поздно упираются в один вопрос: что отдать пользователю, когда починить не удалось? Падать с 500 — плохой финал. Ответ — деградировать частично, а не полностью. Финальный урок раздела — graceful degradation: как агент продолжает приносить пользу при частичных отказах.