Отмена генерации агента и сохранение частичного результата
Отмена генерации агента требует разделить поток, checkpoint и экран, чтобы безопасно сохранить видимый результат и продолжить работу.

Отмена генерации агента не означает «выбросить всё, что успело произойти». Она означает, что система должна остановить дальнейшую работу и честно зафиксировать границу: что пользователь уже видел, что сервер подтвердил, что внешние системы успели принять и что больше нельзя считать частью результата.
Если после кнопки «Отменить» следующий запрос продолжает не с того места, которое видел человек, проблема не в кнопке. Проблема в том, что поток токенов, состояние workflow и интерфейс живут как три независимые версии одной истории. В продакшене их надо свести к одному контракту.
Частичный текст и состояние процесса не одно и то же
Сохранённый частичный результат должен быть воспроизводимым состоянием диалога, а не случайным куском сетевого потока. Браузер может получить 800 символов, сервер может уже сформировать ещё 300, а оркестратор может в это же время запустить поиск, запрос к CRM или подготовку платежа. Если записать в историю только то, что дошло до экрана, вы получите разговор, который не совпадает с реальным состоянием выполнения.
Я обычно разделяю данные отменённого запуска на четыре слоя:
- Видимый результат: опубликованные пользователю сообщения и структурированные блоки, например таблица или готовый фрагмент письма.
- Подтверждённое состояние workflow: версия входных данных, пройденные узлы, результаты завершённых задач, выбранная ветка и checkpoint.
- Незавершённая работа: текущая генерация, ожидающий вызов инструмента, запрос в очереди, фоновые задачи.
- Следы исполнения: идентификатор запуска, причина отмены, время принятия команды и идемпотентные ключи внешних операций.
Эти слои нельзя сливать в одно поле messages. Текст «Я отправляю письмо клиенту» не доказывает, что письмо ушло. Результат API send_email не означает, что пользователь видел сформированный текст. А флаг cancelled не говорит, удалось ли остановить запрос до того, как внешний поставщик принял его.
Распространённая ошибка состоит в сохранении каждого пришедшего токена прямо в основную историю. На первый взгляд это решает всё: при отмене есть полный черновик. На деле вы сохраняете нестабильный артефакт. Модель могла не закрыть JSON, не закончить вызов функции, вывести промежуточную формулировку и затем исправить её в следующих токенах. При продолжении такой текст становится ложным контекстом, хотя пользователь воспринимает его как уже сказанное агентом.
В основную историю попадает только опубликованный блок. Поток можно хранить отдельно как технический буфер с коротким сроком жизни, если он нужен для восстановления соединения или расследования ошибки. Но продолжение агента должно читать checkpoint и опубликованные сообщения, а не сырой буфер SSE или WebSocket.
У отмены должен быть свой протокол, а не только сигнал воркеру
Команда отмены должна проходить через те же уровни, что и запуск: интерфейс, API, оркестратор, воркер, инструменты и хранилище состояния. Сигнал abort в браузере полезен, но он описывает только желание клиента перестать слушать ответ. Он не является подтверждением остановки работы на сервере.
Минимальная модель статусов выглядит так:
{
"run_id": "run_8f3c",
"state_version": 17,
"status": "running",
"cancel_requested_at": null,
"cancel_reason": null,
"published_message_id": "msg_204",
"active_operation": {
"kind": "model_generation",
"operation_id": "gen_771"
}
}
После запроса пользователя сервер не должен сразу отвечать окончательным cancelled, если он ещё не знает, что произошло. Сначала он атомарно записывает намерение остановить запуск:
{
"run_id": "run_8f3c",
"expected_state_version": 17,
"action": "cancel",
"reason": "user_requested"
}
Нормальный ответ на эту команду может быть таким:
{
"run_id": "run_8f3c",
"status": "cancel_requested",
"accepted_state_version": 18,
"resume_checkpoint_id": "cp_18",
"published_message_id": "msg_204"
}
Затем воркер проверяет флаг отмены в безопасных точках. Для генерации это граница между порциями потока, для очереди это момент до взятия следующей задачи, для инструмента это момент до необратимого действия. Когда воркер остановился или исчерпал уже начатую операцию, он переводит запуск в терминальный статус и записывает, что именно осталось доступно для продолжения.
Спецификация задач Model Context Protocol формулирует полезное правило: после принятой отмены задача переходит в cancelled и остаётся в этом статусе, даже если исполнение фактически добежит до завершения позднее. Это не придирка к именам статусов. Она защищает интерфейс от самого неприятного сценария: пользователь отменил задачу, увидел «отменено», а через секунду агент публикует новый ответ как будто отмены не было.
Для агентного приложения я добавляю ещё один статус, cancelling, если операция может завершаться заметное время. Он говорит правду: сервер принял команду, но пока не может обещать, что поставщик модели или инструмент уже остановился. Не используйте cancelled как декоративную анимацию.
Checkpoint фиксирует решение, а не каждый символ
Checkpoint должен появляться после смыслового перехода состояния. Это может быть завершённый поиск, подтверждённый выбор плана, опубликованный ответ, полученный результат инструмента или пауза перед операцией с последствиями. Он не обязан и не должен возникать после каждого токена.
У checkpoint есть три задачи. Он даёт точку, с которой можно продолжить. Он отделяет готовые результаты от работы в полёте. Он позволяет серверу объяснить интерфейсу, какую версию пользователь вправе редактировать или развивать.
Хороший checkpoint хранит не внутреннее «мышление» модели, а достаточно данных, чтобы продолжение приняло те же уже подтверждённые решения:
{
"checkpoint_id": "cp_18",
"run_id": "run_8f3c",
"parent_checkpoint_id": "cp_16",
"state_version": 18,
"conversation": [
{"id": "msg_201", "role": "user", "content": "Сравни варианты поставки"},
{"id": "msg_204", "role": "assistant", "content": "Я нашёл два применимых варианта...", "published": true}
],
"completed_steps": ["extract_requirements", "search_catalog"],
"tool_results": [
{"call_id": "tool_91", "name": "catalog_search", "result_ref": "obj_667"}
],
"pending": {
"step": "draft_recommendation",
"input_hash": "sha256:..."
},
"cancelled_generation": {
"discarded_stream_chars": 412,
"resume_policy": "regenerate_pending_step"
}
}
Поле discarded_stream_chars здесь не для продукта и не для аналитики. Оно помогает инженеру понять, почему пользователь видел более короткий ответ, чем сервер успел принять от модели. Но сам продолжатель не должен брать эти 412 символов и приклеивать их к новому тексту. Он заново запускает незавершённый шаг с подтверждённым контекстом.
Документация LangGraph проводит ту же границу: checkpointer сохраняет состояние потока, а thread_id указывает, какое состояние нужно загрузить при продолжении. Там же есть важная деталь, которую часто пропускают при проектировании отмены: при возобновлении узел исполняется с начала, а не с точной строки, где произошла пауза.
Следствие простое. Если ваш «узел» генерирует текст, делает три вызова инструментов и отправляет письмо, это не узел, который можно безболезненно отменить и продолжить. Это несколько разных переходов состояния, ошибочно упакованных в одну функцию.
Публикация в интерфейсе должна иметь границу версии
Интерфейс обязан знать не только текст, но и версию, в которой этот текст стал видимым. Иначе вы не сможете отличить «продолжить этот ответ» от «повторить запрос после того, как состояние уже изменилось в другой вкладке».
Для каждого опубликованного блока держите минимум четыре поля: message_id, state_version, publication_status и run_id. Когда пользователь жмёт «Продолжить», клиент отправляет не абстрактное «допиши», а ссылку на конкретную точку:
{
"thread_id": "thr_42",
"resume_from_checkpoint_id": "cp_18",
"expected_state_version": 18,
"user_message": "Продолжи сравнение и добавь риски"
}
Сервер обязан сравнить expected_state_version с текущей веткой. Если другая вкладка уже продолжила диалог и создала версию 19, сервер не должен молча подмешивать новый запрос в изменённый контекст. Он возвращает конфликт состояния:
{
"error": "state_version_conflict",
"current_checkpoint_id": "cp_19",
"current_state_version": 19,
"allowed_actions": ["reload", "fork_from_cp_18"]
}
Такой ответ кажется строгим, пока не увидишь обратный вариант. Пользователь отменяет черновик отчёта в браузере A. В браузере B агент заканчивает поиск и получает новые данные. Затем браузер A отправляет «продолжи, но короче». Если сервер принимает это без проверки, модель продолжает уже другой разговор, хотя экран A показывает старую версию. Пользователь получает ответ, который выглядит связным, но опирается на факты, которых он не видел и не выбирал.
Версионирование не требует сложной системы веток в первом релизе. Достаточно монотонного номера состояния, родительского checkpoint и явного решения при конфликте. Ветка нужна, когда продукт действительно разрешает сохранить обе линии работы, а не потому, что разработчикам не хотелось возвращать 409.
Показывайте пользователю только действия, которые сервер способен выполнить честно:
- «Продолжить с сохранённого места», если есть пригодный checkpoint.
- «Изменить запрос и продолжить», если новая реплика будет дочерним состоянием этого checkpoint.
- «Начать новую ветку», если текущая история уже ушла вперёд.
- «Удалить черновик», если опубликованный частичный текст не нужен в беседе.
Кнопка «Повторить» здесь слишком неопределённа. Она может означать повтор запроса к модели, повтор инструмента, продолжение генерации или создание нового ответа. Не заставляйте пользователя угадывать, какой из четырёх вариантов написали инженеры.
Инструменты требуют границы до необратимого действия
Генерацию текста можно прекратить и перезапустить. Внешние действия часто нельзя. Если агент создаёт заявку, отправляет письмо, меняет запись в CRM, бронирует слот или публикует документ, отмена должна учитывать фазу операции.
Практичный шаблон состоит из двух частей: подготовка и фиксация. На подготовке агент собирает аргументы, показывает пользователю действие и создаёт запись намерения с идемпотентным ключом. На фиксации отдельный воркер выполняет необратимый вызов. Между ними есть checkpoint.
Плохой порядок выглядит так:
сгенерировать письмо -> отправить письмо -> показать пользователю текст -> ждать подтверждения
После отмены вы уже не можете сказать, что сохранили. Письмо могло уйти, но пользователь не видел его финальный вариант. При повторном запуске агент может отправить дубликат, потому что в состоянии нет подтверждённой операции.
Рабочий порядок другой:
сгенерировать черновик -> опубликовать черновик -> сохранить checkpoint -> получить подтверждение -> отправить с idempotency_key -> записать результат
Даже здесь отмена не равна отзыву. Если провайдер принял запрос на отправку до того, как увидел сигнал остановки, статус вашего запуска может стать cancelled, а письмо всё равно придёт адресату. Это не противоречие, если интерфейс говорит: «Отмена принята. Отправка уже передана внешней системе, проверьте журнал операции». Ложная гарантия хуже неприятного, но точного сообщения.
Документация LangGraph прямо советует помещать побочные эффекты после точки прерывания или делать их идемпотентными, потому что возобновлённый узел может запуститься заново. Пример с повторным созданием записи в базе особенно узнаваем: код выглядит нормально на первом проходе и начинает плодить сущности только после паузы, ретрая или отмены.
Идемпотентный ключ должен описывать бизнес-действие, а не попытку выполнения. Для отправки предложения это может быть quote:483:revision:7:send, а не случайный UUID на каждый вызов. Тогда повтор после сетевого таймаута вернёт тот же результат или покажет, что операция уже прошла.
Не все отменённые данные стоит оставлять в истории
Сохранение частичного результата не означает, что надо делать его постоянной частью разговора. Иногда пользователь отменяет потому, что агент пошёл не туда. Иногда в поток попали неверные детали, невалидный JSON или фрагмент, который нельзя показывать следующему оператору. Иногда требования хранения данных запрещают оставлять сырой вывод.
Полезно заранее задать политику для четырёх типов артефактов.
| Артефакт | Сохранять после отмены | Использовать при продолжении |
|---|---|---|
| Реплика пользователя | Да | Да |
| Полностью опубликованный блок ассистента | Да, если пользователь не удалил его | Да |
| Незакрытый поток токенов | Отдельно и временно, если нужен для диагностики | Нет |
| Результат завершённого инструмента | Да, с происхождением и временем | Да, если он ещё применим |
| Черновой вызов инструмента | Да только как техническую запись | Нет без повторной валидации |
Самая опасная категория, это частично сформированный структурированный вывод. Допустим, агент строил JSON с параметрами заказа и остановился после поля quantity. Нельзя сохранять его как готовую карточку заказа только потому, что парсер сумел вытащить несколько полей. Либо система публикует явно помеченный черновик, который не участвует в исполнении, либо отбрасывает его и продолжает с последнего валидного объекта.
То же относится к извлечённым данным. Если агент успел показать три записи из поиска, а четвёртая пришла после отмены, не добавляйте четвёртую незаметно при следующем ходе. Покажите, что сохранён набор из трёх записей, и при продолжении либо повторите поиск, либо явно обновите набор. Пользователь должен понимать, на каком материале агент строит дальнейший вывод.
Для систем с требованиями локального хранения важен ещё один уровень. Состояние, журнал отмены и технический буфер потока имеют разный срок жизни и разную чувствительность. Не держите их в одном хранилище только ради удобства схемы. В AI Router команды могут применять единый OpenAI-совместимый endpoint, а требования к data residency, маскированию PII и аудит-логам следует учитывать отдельно для каждого из этих артефактов, а не считать отменённый поток безобидным черновиком.
Поток, checkpoint и экран надо связывать событиями
Серверный поток полезен для отзывчивости, но не должен быть единственным источником истины. Введите события, которые позволяют клиенту собрать экран из подтверждённых фактов, даже если соединение оборвалось и пользователь подключился снова.
Например, последовательность может быть такой:
{"type":"run.started","run_id":"run_8f3c","state_version":17}
{"type":"message.delta","message_id":"msg_204","seq":51,"text":"Я нашёл"}
{"type":"message.delta","message_id":"msg_204","seq":52,"text":" два варианта"}
{"type":"message.published","message_id":"msg_204","state_version":18,"checkpoint_id":"cp_18"}
{"type":"run.cancel_requested","run_id":"run_8f3c","state_version":19}
{"type":"run.cancelled","run_id":"run_8f3c","resume_checkpoint_id":"cp_18"}
message.delta может пропасть, прийти дважды или прийти после того, как пользователь уже нажал отмену. Поэтому клиент вправе показывать его как временный текст, но не должен записывать в постоянную историю без message.published. Событие публикации связывает экран с checkpoint. Событие отмены сообщает, какая точка доступна для следующего намерения.
Если продукту нужен режим «сохранить то, что видно прямо сейчас», сделайте это отдельным действием. Клиент отправляет запрос на фиксацию видимого черновика с номером последней последовательности, а сервер валидирует его как самостоятельный пользовательский артефакт. Не притворяйтесь, что этот фрагмент является законченным ответом модели. Подпись «Сохранённый фрагмент» снимает двусмысленность и у пользователя, и у следующего запуска.
В больших системах полезно разделить run_id и thread_id. Первый описывает одну попытку исполнения. Второй описывает разговор или рабочий объект. После отмены в одном thread_id может появиться новый run_id, который продолжает checkpoint 18. Попытка хранить оба понятия в одном идентификаторе обычно ломает аудит, ретраи и поддержку.
Продолжение должно повторять намерение, а не доклеивать фразу
Когда пользователь пишет «продолжи», он редко просит модель механически дописать оборванное предложение. Обычно он хочет вернуться к задаче, используя уже полученную работу. Это разные вещи.
Если отмена произошла во время свободного текста, продолжение может запустить тот же незавершённый шаг заново. Дайте модели подтверждённый контекст, укажите, что прошлый вывод был остановлен пользователем, и попросите сформировать следующий опубликованный блок без опоры на отброшенные токены. Текст получится не тем же самым, и это нормально. Детерминированность текста не является гарантией, которую стоит симулировать.
Если отмена произошла после завершённого инструмента, продолжение должно использовать его подтверждённый результат. Повторный поиск или повторное списание денег только потому, что пользователь остановил генерацию объяснения, это архитектурный дефект. Состояние должно хранить, какой шаг завершён, а какой нет.
Если отмена пришла во время инструмента, решение зависит от его контракта:
- Инструмент поддерживает отмену и подтверждает её. Пометьте результат как отсутствующий и повторите работу при продолжении.
- Инструмент не поддерживает отмену, но безопасен при повторе. Дождитесь результата или запросите его по idempotency key.
- Инструмент имеет последствия. Завершите сверку статуса до любого нового запуска и покажите пользователю факт операции.
Не стоит обещать «продолжение ровно с места остановки», если модель и поставщик не поддерживают такое продолжение на уровне самого вычисления. Честная формулировка интерфейса звучит проще: «Продолжим с сохранённого состояния». Она описывает то, что система действительно контролирует.
Проверяйте отмену на гонках, а не только на красивом демо
Тест, где пользователь отменяет медленную генерацию и видит остановленный курсор, почти ничего не доказывает. Нужны сценарии, в которых события приходят в неудобном порядке.
Проверьте хотя бы эти случаи:
- Клиент отправил cancel, а последний
message.deltaуже был в сети. Интерфейс не должен публиковать его задним числом. - Воркер успел завершить инструмент между записью
cancel_requestedи чтением флага. Следующий запуск должен увидеть точный статус операции. - Две вкладки продолжают один checkpoint. Одна получает новую версию, другая получает конфликт или создаёт явную ветку.
- Клиент потерял соединение, но не отменил запуск. При возврате он должен увидеть актуальный статус, а не автоматически считать работу отменённой.
- Воркер упал после публикации текста, но до финального статуса запуска. Восстановление должно найти checkpoint и решить, нужен ли retry, а не повторить весь диалог.
Для каждого случая пишите проверку на инвариант, а не только на HTTP-код. Например: «В истории не может быть опубликованного сообщения без checkpoint», «один idempotency key создаёт не более одного внешнего действия», «продолжение всегда ссылается на существующий checkpoint», «терминальный cancelled run не публикует новых сообщений».
У durable workflow есть неприятная, но полезная дисциплина: возобновление не должно зависеть от точного места внутри произвольной функции. Документация LangGraph описывает сохранение checkpoint после шагов и восстановление результатов завершённых задач, а не магическое возвращение процессора к строке кода. Если разрезать работу на явные переходы, отмена становится обычным состоянием процесса, а не исключением, которое прячут в try/except.
Начните с одного изменения, которое заставит систему говорить правду: добавьте версию состояния в публикацию ответа и потребуйте её в запросе продолжения. После этого быстро станет видно, где вы храните просто текст, где у вас есть checkpoint, а где агент пока существует только пока не оборвалось соединение.
Часто задаваемые вопросы
Можно ли продолжить агента после отмены ответа пользователем?
Да, если вы различаете видимый пользователю результат, подтверждённое состояние процесса и незавершённую работу. Сохраняйте только то, что прошло правила публикации и привязано к версии состояния. Черновой поток токенов сам по себе не делает продолжение безопасным.
Почему последний токен в интерфейсе нельзя считать checkpoint?
Обычно нет. Пользователь мог увидеть только часть сетевого потока, а сервер мог уже получить больше токенов, начать вызов инструмента или записать checkpoint. Продолжать надо от явно зафиксированной версии, а не от последнего принятого браузером байта.
Достаточно ли закрыть вкладку, чтобы остановить генерацию агента?
Нужно отправить отдельную команду отмены и дождаться подтверждённого статуса, например cancel_requested или cancelled. Простое закрытие вкладки разрывает соединение с интерфейсом, но не обязано останавливать воркер, очередь или внешний вызов.
Что сохранять при отмене агента с вызовами инструментов?
Если агент только писал текст, сохраните цель, сообщения, опубликованные блоки и версию контекста. Если он вызывал инструменты, отдельно сохраните идентификаторы операций, их статус и идемпотентные ключи. Не сохраняйте предположения модели о результате инструмента вместо самого результата.
Можно ли отменить отправку письма после вызова инструмента?
Нет, если у отправки уже есть необратимый эффект. В таком случае интерфейс должен показать, что операция передана внешней системе, а отмена относится только к дальнейшей оркестрации. Компенсация, отзыв и отмена доставки требуют отдельных контрактов с внешним сервисом.
Как не дать двум вкладкам продолжить один и тот же запуск?
Используйте монотонный номер версии или идентификатор checkpoint. Команда продолжения должна содержать именно его, а сервер обязан отклонить запрос, если текущая ветка уже изменилась. Это защищает от двух вкладок и запоздалых событий потока.
Что должен показать интерфейс после отмены генерации?
Сразу после отмены предложите продолжить от сохранённого места, изменить задачу и начать новую ветку или отбросить черновик. Не прячьте это решение за одной кнопкой «Повторить»: она часто смешивает повтор исполнения с новым намерением пользователя.
Чем отмена отличается от паузы агента?
Потому что pause предполагает, что агент сам дошёл до безопасной точки и согласился ждать. Cancel может прийти посреди генерации, обращения к очереди или вызова инструмента. В модели данных это разные причины остановки, разные гарантии и разные варианты возобновления.
Нужен ли аудит отменённых запусков агента?
Храните минимальный журнал: кто запросил отмену, когда сервер принял её, какая версия состояния была опубликована и какие внешние операции уже начались. Полный поток рассуждений для этого не нужен и часто только увеличивает риск утечки данных.
С чего начать исправление отмены в существующем агенте?
Сначала добавьте версию состояния, отдельный статус отмены и проверку версии в endpoint продолжения. Затем разделите подготовку операции и её необратимое выполнение. Если этого нет, красивый индикатор отмены лишь маскирует гонки в бэкенде.