Пользователь нажал «Отменить». Интерфейс послушно убрал индикатор, написал «Остановлено» и перестал показывать токены. А в это время инструмент, который агент вызвал минуту назад, продолжает обновлять строки в таблице. Ожидание закончилось, исполнение нет. Вся статья об этом расхождении: как ограничить время, которое агент ждёт, и не перепутать это с остановкой работы, которая происходит где-то за его спиной.

Фраза «агент завис» на деле склеивает несколько разных явлений. Чтобы лечить зависание, нужно сначала понять, какой именно срок жизни истёк и у чего.

Три срока жизни в одной цепочке

Когда агент вызывает инструмент, одновременно живут как минимум три сущности, и у каждой свои часы.

  • Диалог (запрос пользователя). Он существует, пока клиент держит соединение или пока в вашей системе есть задача, привязанная к сессии. Пользователь может закрыть вкладку, нажать «Отменить» или просто уйти.
  • Вызов инструмента. Это сообщение от агента к серверу инструмента и ответ обратно. У него есть идентификатор запроса, момент отправки и, если вы так решили, дедлайн.
  • Операция за инструментом. SQL-команда, HTTP-запрос к чужому сервису, запись файла, отправка письма. Она живёт по своим правилам и может пережить и вызов, и диалог.

Типичная ошибка проектирования: считать, что остановка верхнего слоя автоматически останавливает нижние. Это не так. Закрытие диалога не обязано отменять вызов, а отмена вызова не обязана прерывать операцию. Каждая такая передача сигнала должна быть кем-то написана, и у каждой есть условия, при которых она не сработает.

Полезно мысленно нарисовать три полоски на оси времени. Диалог начался раньше всех и заканчивается по решению пользователя. Вызов начался позже и заканчивается ответом или тайм-аутом. Операция началась ещё чуть позже и заканчивается тогда, когда сама закончит. Если оборвать верхнюю полоску, две нижние остаются, пока кто-то явно их не тронет.

Гонка отмены и результата

Теперь добавим то, что делает отмену интереснее, чем просто «послать стоп». В спецификации MCP отмена описана как уведомление, и документ об отмене в MCP прямо говорит: отмена выполняющегося запроса опциональна. Получатель вправе проигнорировать её, если запрос уже завершён или принципиально неотменяем, а сама отмена может разминуться с ответом.

Разберём гонку на конкретной последовательности. Пользователь нажимает «Отменить» в момент t. Клиент отправляет серверу инструмента уведомление об отмене. Но сервер за миллисекунды до этого уже закончил работу и отправил результат. Теперь в сети два сообщения, идущие навстречу друг другу: отмена в одну сторону, результат в другую. Что произойдёт дальше, зависит от реализации обеих сторон:

  1. Клиент уже пометил вызов отменённым, а потом получает результат. Что с ним делать? Выбросить? Но изменение данных уже случилось.
  2. Сервер получил отмену после завершения и молча её игнорирует. Допустимо, но клиент об этом не узнает, пока не придёт результат.
  3. Сервер получил отмену во время выполнения, но операция в этот момент находится в точке, где прервать её нельзя или опасно. Он тоже вправе её не исполнять.

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

Есть и вторая сторона. Отмена не равна откату. Даже когда сервер инструмента успешно прервал работу, всё, что было записано до прерывания, остаётся записанным, если только операция не была завёрнута в механизм, который умеет возвращать состояние. Отмена говорит «перестань делать дальше», а не «сделай так, будто ничего не было». Для инструментов только на чтение это почти не важно. Для инструментов с побочными эффектами это центральный вопрос.

Медленная команда и ожидание блокировки: два разных тайм-аута

Допустим, за инструментом стоит PostgreSQL. Здесь «завис» часто означает одно из двух, и лечатся они разными настройками. Согласно описанию клиентских параметров PostgreSQL, statement_timeout ограничивает длительность команды, а lock_timeout относится к ожиданию блокировки. Посмотрим на две короткие ситуации. Они условные, придуманные для иллюстрации.

Ситуация первая: тяжёлый отчёт

Инструмент «собери сводку по заказам» выполняет запрос с агрегацией по большой таблице. Блокировок нет, никто никого не ждёт, но запрос честно считает несколько минут, потому что агент сформировал условие без нужного фильтра по дате. Здесь срабатывает statement_timeout: команда работает слишком долго и будет прервана по истечении лимита. lock_timeout тут бесполезен, потому что ждать блокировку нечего.

Ситуация вторая: обновление, застрявшее за чужой транзакцией

Инструмент «обнови статус заказа» выполняет короткий UPDATE одной строки. Сам по себе он занимает доли секунды. Но другая сессия держит открытую транзакцию с блокировкой этой строки и не торопится её закрывать. Наш UPDATE встаёт в ожидание. С точки зрения агента инструмент завис, хотя вычислений никаких не происходит: он просто стоит в очереди на блокировку. Тут помогает lock_timeout: он ограничит именно ожидание, и вместо бесконечного простоя инструмент получит ошибку и сможет честно о ней сообщить.

Сравнение удобно держать перед глазами:

Какой лимит к какому симптому относится
ПризнакТяжёлый отчётОжидание блокировки
Что делает базаВыполняет запросЖдёт, пока другая сессия отпустит ресурс
Какой лимит подходитstatement_timeoutlock_timeout
Что чинить кроме лимитаФильтры, индексы, форму запросаДлительность чужих транзакций, порядок захвата блокировок
Что сказать агенту«Запрос слишком тяжёлый, сузь условие»«Данные заняты, повтори позже или спроси человека»

Последняя строка важнее, чем кажется. Ошибки двух типов требуют разной реакции агента. Первую можно попробовать исправить, переформулировав запрос. Вторую переформулировкой не вылечить: данные заняты, а не запрос плох. Если инструмент возвращает агенту одинаковое «таймаут», модель будет бессмысленно перепробовать варианты, хотя нужно было подождать или остановиться.

Где ставить эти лимиты

В той же документации есть предупреждение, которое легко пропустить: задание этих ограничений глобально затрагивает все соответствующие сессии. Это значит, что если выставить statement_timeout на уровне всего сервера ради защиты от агента, вы заодно обрежете ночные миграции, выгрузки и всё остальное, что законно работает долго. Для инструмента агента разумнее задавать лимит узко: на роль, под которой инструмент подключается, или на отдельную сессию или транзакцию. Тогда строгие рамки действуют на агента и не мешают остальным.

Конкретные значения здесь определяются вашими данными, и универсальных чисел нет. Рабочий подход такой: узнать, за сколько обычно выполняется нормальный вызов инструмента, взять запас и поставить лимит выше этого запаса, но заметно ниже общего времени, которое вы готовы ждать в диалоге. Например, если обычный вызов занимает порядка секунды, а пользователь готов ждать полминуты, лимит команды в несколько секунд выглядит разумно. Это иллюстрация, а не рекомендация числа.

Общий бюджет цепочки важнее отдельных лимитов

Отдельные тайм-ауты на каждом звене не складываются в понятное поведение. Представьте: у модели лимит на ответ, у вызова инструмента свой, у SQL-команды третий, и ещё агент умеет повторять попытки. Худший случай — произведение, а не сумма: три повтора по три таймаута дают время, которого пользователь никогда не дождётся.

Надёжнее мыслить дедлайном всей цепочки. На входе запроса вычисляется абсолютный момент, к которому всё обязано завершиться. Каждое нижнее звено получает не «свои 10 секунд», а остаток до этого момента. Вызов инструмента, начатый когда осталось четыре секунды, не должен ставить себе лимит в десять. Он берёт четыре, а лучше немного меньше, чтобы осталось время обработать ошибку и ответить пользователю.

Из этого вытекают несколько следствий:

  • Повтор операции допустим только пока остаток бюджета позволяет её завершить. Если времени на новую попытку нет, лучше сразу вернуть честное «не успели».
  • Лимиты нижних уровней должны быть строго короче лимитов верхних. Иначе верхний сработает первым, а нижний продолжит работать без наблюдателя.
  • Дедлайн нужно передавать вниз явно, а не надеяться, что каждый слой угадает его сам.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Состояния, которые должно знать приложение

Булево «выполняется / не выполняется» для вызова инструмента слишком бедно. Из-за гонки отмены и результата нужен хотя бы небольшой набор промежуточных состояний, которые приложение хранит и показывает.

Состояния вызова инструмента с точки зрения приложения
СостояниеЧто известноЧто можно сказать пользователю
ВыполняетсяВызов отправлен, ответа нет, дедлайн не истёк«Работаю над этим»
Отмена запрошенаКлиент отправил запрос на отмену, подтверждения нет«Запросил остановку, жду подтверждения»
Отменён подтверждённоСервер сообщил, что прервал работу«Остановлено. Уже внесённые изменения не откатывались»
ЗавершёнПришёл результатПоказать результат (даже если отмена была запрошена чуть раньше)
Результат неизвестенДедлайн истёк или связь потеряна, исход операции не подтверждён«Не знаю, выполнилось ли действие. Нужна проверка»

Последнее состояние самое неудобное и самое важное. Оно возникает, когда мы перестали ждать, но не получили ни результата, ни подтверждения отмены. Операция могла не начаться, могла идти, могла завершиться успешно, а ответ потерялся. Приложение не имеет права превращать неизвестность в «не выполнено»: именно так возникают задвоенные платежи и повторные письма.

Хранить эти состояния нужно рядом с самим вызовом: идентификатор запроса, время отправки, дедлайн, момент запроса на отмену, последнее подтверждённое состояние. Тогда при расхождении есть что разбирать, а интерфейс может честно показать, на каком этапе находится дело.

Если результат пришёл после того, как приложение уже перешло в «отмена запрошена», его не стоит выбрасывать. Запись в журнале «завершён, несмотря на запрос отмены» — это правда, и она пригодится при разборе. Показать пользователю её тоже полезно: он узнает, что действие всё-таки произошло.

Побочные эффекты: кому решать, повторять ли

Теперь самый тонкий момент. Вызов истёк, состояние «результат неизвестен». Агент, будучи моделью, склонен действовать: он видит ошибку и предлагает повторить. Для чтения это безопасно. Для операции с побочным эффектом (списание, отправка, создание записи, изменение статуса) повтор может удвоить действие.

Тут важно не обещать лишнего. Новый идентификатор попытки сам по себе ничего не делает идемпотентным: если сервер за инструментом не умеет распознавать повторы по ключу, вторая попытка просто выполнится второй раз. Идемпотентность — свойство операции и принимающей стороны, а не бирка на запросе. Равно как и транзакция вашей собственной базы не включает в себя вызов внешнего API: если вы записали «отправлено» в свою таблицу, а внешний вызов прошёл или не прошёл — это два независимых факта, и согласовывать их нужно отдельным механизмом.

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

  • Чтение и безвредные запросы. Повтор решает код оркестрации в пределах оставшегося бюджета. Модель можно не спрашивать.
  • Операции с побочным эффектом, у которых есть проверенный способ узнать итог. Сначала запрос статуса операции, и только потом решение. Это тоже может делать код, а не модель.
  • Операции с побочным эффектом и неизвестным итогом. Автоматический повтор запрещён. Решение передаётся человеку: оператору или самому пользователю, с понятным описанием, что именно, возможно, уже произошло.

Модель в этой схеме не принимает решение о повторе необратимых действий. Она может предложить варианты и объяснить ситуацию, но разрешение на повтор выдаёт тот, кто отвечает за последствия. Это не недоверие к модели, а обычное разделение полномочий: исполнитель не должен сам себе одобрять второй платёж.

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

Доведение отмены до самой операции

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

  1. Клиент → сервер приложения. Кнопка превращается в запись «отмена запрошена» и в сигнал задаче, которая ведёт диалог.
  2. Задача → вызов инструмента. Если протокол поддерживает отмену, как MCP, отправляется уведомление об отмене с идентификатором запроса. Помним, что сервер вправе его проигнорировать.
  3. Сервер инструмента → операция. Здесь нужна собственная реализация: прервать HTTP-запрос к внешнему сервису, закрыть курсор, отправить базе запрос на отмену команды. Если этого слоя нет, отмена упрётся в стену, и операция доработает до конца.
  4. Тайм-ауты базы как страховка. Даже если сигнал не дошёл, statement_timeout и lock_timeout не дадут команде или ожиданию блокировки длиться бесконечно. Это второй рубеж защиты, независимый от того, дошло ли уведомление.

Хороший признак зрелой реализации — наличие обоих механизмов. Явная отмена даёт быструю реакцию на действия пользователя. Лимиты базы страхуют от ситуации, когда отмену никто не услышал: вызов потерялся, процесс завис, клиент отключился. Если полагаться только на первый, то любой пропущенный сигнал оставляет операцию без присмотра. Если только на второй, то пользователь будет ждать срабатывания лимита, хотя уже передумал.

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

Где всё это живёт: немного про сервер

Эта логика не привязана к конкретному железу, но запускать её надо где-то, где вы управляете процессами и сетью. Оркестратор агента, сервер инструментов и база обычно живут рядом, и для них важно спокойно распоряжаться процессами, лимитами и логами. На VPS MAATRIX есть root-доступ, NVMe-диски и процессоры AMD EPYC, так что можно самим настроить роли базы с узкими лимитами, поднять отдельный процесс-обработчик для инструментов и вести журнал состояний вызовов на диске. Размещение возможно в UK, US, RU и FR; доступность отдельных образов уточняется в форме заказа.

По ресурсам линейка идёт от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и Uab (2 vCPU, 4 ГБ, 80 ГБ) до Heka (4 vCPU, 8 ГБ), Maat (8 vCPU, 16 ГБ) и Djed (16 vCPU, 32 ГБ, 320 ГБ NVMe). Для небольшого агента с одной базой и несколькими инструментами начального уровня может хватить, но это зависит от нагрузки, а не от названия тарифа: сколько одновременных диалогов, сколько тяжёлых запросов, как долго живут вызовы. Ёмкость стоит определять замером на собственной нагрузке. В публичном каталоге также упомянуты n8n и Nextcloud с установкой, если часть автоматизаций вы строите на них. Оплата картой РФ и через СБП, а VPS создаётся автоматически через пару минут после оплаты.

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

Что мы наблюдаем: метрики, которые выдают зависание раньше пользователя

Тайм-аут срабатывает, когда проблема уже случилась. Раньше о ней скажет наблюдаемость, если она настроена по слоям. Полезный минимум:

  • Время от принятия запроса до отправки вызова инструмента. Рост показывает очередь или медленную модель, а не инструмент.
  • Время от отправки вызова до подтверждения старта. Рост указывает на перегруженный сервер инструментов.
  • Число вызовов в состоянии «отмена запрошена» дольше заданного срока. Это прямой индикатор неуслышанных отмен.
  • Число вызовов в состоянии «результат неизвестен». Каждый такой случай требует разбора, потому что рядом может лежать побочный эффект.
  • Количество ошибок по lock_timeout отдельно от statement_timeout. Первые говорят о конкуренции за данные, вторые о тяжёлых запросах. Смешивать их в одну метрику значит терять причину.

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

Что кнопка «Отменить» на самом деле обещает

Вернёмся к пользователю, который нажал её, пока инструмент менял данные. Что можно честно пообещать в архитектуре, описанной выше?

Во-первых, что агент перестанет ждать и не будет продолжать цепочку: следующие шаги не запустятся, повторы не начнутся. Это полностью в вашей власти. Во-вторых, что приложение отправило запрос на остановку операции и зафиксировало это. Дойдёт ли он и будет ли исполнен, зависит от принимающей стороны: отмена по своей природе опциональна и может разминуться с результатом. В-третьих, что даже если сигнал не услышан, лимиты на команды и ожидание блокировок ограничат время, в течение которого операция продолжит работать без присмотра.

Чего кнопка не обещает: отката уже сделанного. Если часть строк обновлена до остановки, они останутся обновлёнными, пока отдельный механизм не вернёт их. И она не гарантирует, что в момент нажатия действие не завершилось. Поэтому честная подпись состоит из двух частей: «остановка запрошена» сразу после нажатия и итоговый статус, когда он станет известен, вместе с пометкой, если исход остаётся неизвестным и нужна проверка человеком.

Такая формулировка менее эффектна, чем простое «Остановлено», зато она совпадает с тем, что происходит на сервере. А когда подпись под кнопкой совпадает с реальностью, расхождение между ожиданием и исполнением, с которого мы начали, перестаёт быть сюрпризом и становится описанным, наблюдаемым состоянием.