Cancel - это не состояние UI: как сделать рабочий kill switch для AI-агентов
В большинстве агентных систем кнопка Stop останавливает только спиннер. Разбираю, как сделать отмену, которая реально рвёт цикл, убивает запрос к провайдеру, откатывает сайд-эффекты и ограничивает расходы.
Pavel Duglas
AI Automation & MVP Architect
В прошлом месяце клиент написал мне с простой претензией: “Я нажал отмену, а счёт всё равно вырос на четыре доллара”. Это не баг биллинга. Это система, где кнопка Stop меняет строчку в интерфейсе и больше ничего. Агент продолжал крутить цикл, продолжал дёргать модель, продолжал писать в их CRM. Остановился только спиннер.
Отмена - одна из тех фич, которые выглядят задачей на два часа, а оказываются архитектурным решением. Если прикрутить её после запуска, получится худший из возможных вариантов: кнопка, которая врёт пользователю, пока агент жжёт деньги и правит продакшн-данные. Разберу, как я это делаю на самом деле.
”Отмена” означает четыре разные вещи
До того как писать код, разделите смыслы. Реальной системе нужны все четыре, и ломаются они независимо друг от друга.
- Подтверждение в UI. Пользователь видит, что запрос принят. Дёшево. Здесь большинство реализаций и заканчивается.
- Остановка цикла. Агент перестаёт планировать новые шаги. Именно это экономит деньги.
- Abort in-flight запроса. HTTP-запрос к провайдеру модели или к API инструмента реально рвётся.
- Разбор сайд-эффектов. То, что агент уже успел сделать во внешнем мире, либо доводится до конца, либо компенсируется.
Если реализованы только 1 и 2, отмена срабатывает с задержкой до одного полного шага. На медленной reasoning-модели это 90 секунд оплаченных токенов. Если сделали 1, 2, 3 и забыли 4 - получаете наполовину отправленную рассылку и заказы, созданные без записи об оплате. Настоящий ущерб живёт в пункте 4.
Источник истины - запись о запуске, а не сокет
Первая типовая ошибка: отмена реализована как “вебсокет закрылся” или “HTTP-запрос оборвался”. Работает до первого засыпания ноутбука, до первого reverse proxy, который рубит idle-соединения, до первого refresh страницы. И вот вы отменили запуск, который пользователь всё ещё хотел, или, что хуже, думаете, что отменили, а воркер об этом не знает.
Запуски агента живут в таблице. У каждого есть id, статус, дедлайн, бюджет и флаг отмены. Запрос на отмену со стороны клиента - это просто update этой строки плюс быстрый сигнал воркеру.
create table agent_runs (
id uuid primary key,
tenant_id uuid not null,
status text not null, -- queued|running|cancelling|cancelled|done|failed
cancel_reason text,
deadline_at timestamptz not null,
budget_cents integer not null,
spent_cents integer not null default 0,
step_count integer not null default 0,
created_at timestamptz not null default now()
);
Обратите внимание на cancelling как отдельное состояние от cancelled. Пользователь попросил, воркер ещё не подтвердил. Не показывайте “отменено”, пока воркер сам не записал это. Иначе вы снова врёте в интерфейсе, только теперь с большим количеством таблиц.
В качестве сигнала беру то, что уже есть в стеке: ключ в Redis с коротким TTL или LISTEN/NOTIFY в Postgres. Строка в базе - авторитет, сигнал - оптимизация задержки.
Кооперативная отмена и честные checkpoints
Агент - это цикл. И это хорошая новость: у циклов есть естественные швы. Я ставлю checkpoint перед каждым вызовом модели, после него, перед каждым вызовом инструмента и после него. Checkpoint делает три вещи: читает флаг отмены, проверяет дедлайн, проверяет бюджет.
class Cancelled(Exception):
pass
async def checkpoint(run, phase: str):
state = await store.load(run.id) # кэш, ~1ms
if state.status == "cancelling":
raise Cancelled(f"user cancel at {phase}")
if state.spent_cents >= state.budget_cents:
raise Cancelled(f"budget exceeded at {phase}")
if now() >= state.deadline_at:
raise Cancelled(f"deadline exceeded at {phase}")
async def run_agent(run):
try:
while True:
await checkpoint(run, "pre-plan")
step = await plan(run) # вызов LLM, cancellable
await checkpoint(run, "pre-tool")
result = await execute(run, step) # вызов инструмента, cancellable
await record(run, step, result)
if step.is_final:
return await finish(run)
except Cancelled as e:
await compensate(run)
await store.mark_cancelled(run.id, reason=str(e))
Две детали важнее, чем кажутся. Первая: checkpoint должен быть дешёвым, потому что вызывается он часто. Кэшируйте состояние запуска на секунду-две, но никогда не кэшируйте флаг отмены дольше, чем ваша допустимая задержка остановки. Вторая: отмена - это исключение, а не возвращаемое значение. Если это возвращаемое значение, кто-нибудь забудет проверить его во вложенном хелпере, и у вас будет цикл, отменённый на верхнем уровне и всё ещё работающий тремя фреймами ниже.
Рвите запрос к модели и честно считайте, за что всё равно платите
Отмены, которая срабатывает только между шагами, недостаточно, когда один reasoning-шаг занимает минуту. Прокидывайте токен отмены в HTTP-слой и реально закрывайте соединение.
В Python я запускаю вызов провайдера как asyncio.Task и гоняю его в race с вотчером, который опрашивает флаг отмены. В Node - AbortController и signal в fetch или в SDK. Оба варианта рабочие. Ошибаются обычно в учёте.
Если вы стримите и прерываете ответ на середине, вы всё равно платите за input-токены и за каждый уже сгенерированный output-токен. Abort экономит хвост, а не голову. Поэтому логируйте usage при отмене. Я пишу partial запись с токенами, посчитанными по стриму на момент обрыва, и в конце месяца мой дашборд расходов совпадает с инвойсом провайдера. Если этим пренебречь, атрибуция расходов тихо разъедется, и вы больше никогда ей не поверите.
И ещё: если вы не стримите, при abort вы не получаете ничего, но платите за всю генерацию целиком. Это само по себе неплохой аргумент стримить каждый вызов внутри агентного цикла, даже когда UI токены не показывает.
Сайд-эффекты - то, что реально больно
Вот кейс, который стоил клиенту живых денег. Агент вызвал инструмент, который поставил в очередь пачку исходящих сообщений, после чего пользователь нажал отмену. Цикл остановился, сокет закрылся, а совершенно отдельный воркер через двадцать секунд спокойно отправил 400 сообщений. Отмена агента не отменила работу, которую агент уже запустил.
Лечится скучно и надёжно. Каждый вызов инструмента, который трогает внешний мир, получает:
- Idempotency key из run id плюс индекс шага, чтобы ретраи после частичного сбоя не дублировали действие.
- Запись о намерении до выполнения. Строка в таблице: что собираемся сделать, с какими аргументами, когда.
- Путь завершения или компенсации. После выполнения помечаем done. При отмене проходим по всем намерениям, которые записаны, но не завершены, и либо доводим их, либо откатываем.
Далее явно классифицируйте инструменты. Я использую три корзины:
- Можно бросить. Чтения, поиск, парсинг. Abort и забыли.
- Обязаны завершиться. Всё, где половинчатое состояние хуже полного: двухфазная запись, capture платежа. Даём закончить, потом останавливаемся. Отмена - это не разрешение испортить данные.
- Требуют компенсации. Создали запись, поставили джобу, опубликовали сообщение. При отмене - удалить, снять из расписания, опубликовать опровержение.
И пробрасывайте контекст. Если инструмент запускает работу ниже по стеку, run id должен ехать вместе с ней, а тот воркер обязан сделать тот же checkpoint перед стартом. Иначе граница отмены заканчивается на краю вашего процесса - то есть ровно там, где начинается дорогое.
Отменяйте себя раньше, чем это сделает пользователь
Самый полезный kill switch - тот, который никто не нажимает. У каждого запуска, который я выпускаю в прод, при создании есть дедлайн и бюджет, взятые из тарифа, а не захардкоженные в воркере. Тот же код checkpoint проверяет все три условия остановки: отмена пользователем, дедлайн, бюджет. Один код-пас, три триггера.
Ещё добавляю защиту от зацикливания: максимум шагов плюс детектор повторов. Если последние три вызова инструментов имеют идентичные аргументы, агент застрял, а застрявшие агенты - главный источник неожиданных инвойсов из всего, что я видел. Отменяем с причиной loop_detected и показываем её. Поле с причиной - золото при разборе инцидентов, потому что статус “cancelled” без причины через шесть недель не скажет вам ничего.
Ищите сирот
Воркеры получают OOM. Поды переезжают посреди шага. Нужен sweeper, который находит запуски в статусе running или cancelling без heartbeat последние N секунд и либо возобновляет их, либо помечает failed с внятным статусом. Пусть воркер пишет timestamp heartbeat на каждом checkpoint. Хук у вас уже есть.
После этого выведите на дашборд две цифры: время от запроса отмены до подтверждённого cancelled и токены, оплаченные после запроса отмены. Если p95 задержки остановки 40 секунд, ваша кнопка Stop - декорация. Если расход после отмены не стремится к нулю, ваши abort не доезжают.
Тестируйте отмену как обычный путь
Отмена не тестируется одним кликом по кнопке. Пишите тесты, которые отменяют на каждом шве: до первого вызова модели, посреди стрима, между вызовом инструмента и записью о его завершении, и после того как финальный шаг уже закоммичен. Последний случай должен быть no-op, а не откатом успешного запуска. Добавьте fault injector, который отменяет на случайном checkpoint, и проверяйте, что в журнале сайд-эффектов не осталось строк, застрявших в pending. Фаззер по пяти checkpoint находит баги, которые ручное QA не найдёт никогда.
Короткий чеклист
- Состояние запуска в таблице, а не в сокете.
cancellingотделён отcancelled.- Checkpoint до и после каждого вызова модели и инструмента.
- Отмена бросает исключение, а не возвращает значение.
- In-flight HTTP рвётся токеном или signal, partial usage логируется.
- Каждый инструмент классифицирован: бросить, завершить, компенсировать.
- Запись о намерении плюс idempotency key на каждую внешнюю запись.
- Run id пробрасывается в downstream-воркеры и проверяется там же.
- Дедлайн, бюджет и max steps проверяются тем же checkpoint.
- Heartbeat плюс sweeper для осиротевших запусков.
- Дашборд: p95 stop latency, токены после отмены.
Рабочая кнопка Stop - один из самых дешёвых сигналов доверия, который можно отгрузить в агентном продукте. Пользователи прощают медленно. Они не прощают систему, которая продолжает тратить их деньги после того, как ей сказали остановиться.
Вопросы и ответы
Какая задержка отмены считается нормальной?
Я ориентируюсь на p95 не больше 2-3 секунд от нажатия кнопки до статуса cancelled в базе, если внутри нет инструментов из категории "обязаны завершиться". Достигается это двумя вещами: checkpoint до и после каждого вызова модели и инструмента плюс реальный abort in-flight HTTP-запроса. Если abort не сделан, ваша задержка равна длительности самого долгого шага, а это на reasoning-моделях легко 60-90 секунд. Отдельно измеряйте вторую метрику - сколько токенов оплачено после запроса отмены. Она честнее, чем задержка, потому что показывает деньги, а не статусы.
Нужна ли вся эта машинерия в MVP, или можно обойтись флагом в базе?
В MVP минимально достаточный набор такой: run-таблица со статусом и флагом отмены, checkpoint в цикле и бюджет с дедлайном, проверяемые тем же checkpoint. Это часа три работы и уже закрывает главный риск - неконтролируемые расходы. Что нельзя откладывать, если агент делает внешние записи: запись о намерении до выполнения и idempotency key. Это тоже недорого, но именно эти две вещи потом позволяют написать компенсацию, не переписывая всю логику инструментов. Полноценный sweeper, фаззер по checkpoint и дашборд метрик спокойно подождут до первых реальных пользователей.
Что делать, если отмена пришла посреди платежа или двухфазной записи?
Не отменять. Это категория "обязаны завершиться": доводим операцию до консистентного состояния, записываем результат, и только потом переводим запуск в cancelled. Пользователю в этот момент показываем состояние cancelling с пояснением, что завершается критическая операция. Отмена никогда не должна быть разрешением оставить данные в половинчатом виде. Если для операции есть штатный откат на стороне провайдера, например refund или void, она переезжает в категорию "требует компенсации", и тогда вы завершаете операцию, а затем в compensate вызываете обратное действие с тем же idempotency key.
Похожие статьи
Сделаю под ключ
Соберу ИИ-агента под реальную задачу
С инструментами, памятью и логами, чтобы он работал в проде, а не только в демо.
от 1 500 $ · 1-2 недели