Потоковый ответ: события, обрывы и правильные таймауты

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

Что именно приходит

Поток — это последовательность событий, а не просто текст по кускам.

Поток ответа по событиямmessage_startоткрылся ответblock_startначался блокdeltaкусок текстаdeltaкусок текстаblock_stopблок закрытmessage_stopответ завершёнЧто это даёт и чего требуетДаётПервые слова видны через доли секундыПользователь не смотрит в пустой экранДолгий ответ можно прервать на серединеТребуетОбработки обрыва посреди потокаСборки текста из кусков на своей сторонеОтдельного таймаута — на паузу, не на ответ
Последовательность событий при потоковой выдаче ответа

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

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

Зачем это нужно

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

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

Обрыв посреди потока

Обрыв посреди потока1Копим кускиТекст собирается в буфер, а несразу на экран2Ловим разрывПоток прервался — событиезавершения не пришло3РешаемПоказать собранное илиповторить запрос4ПовторяемС задержкой и ограничениемчисла попытокЧастая ошибкаПовтор запускается сразу и без ограничений. Сервис, у которого уже проблемы, получает лавину повторов,а счёт растёт за каждую попытку. Задержка должна увеличиваться, а число попыток — быть конечным.Таймаут считайте по паузе между кускамиДлинный ответ идёт минуту и это норма. Ненормально — тридцать секунд тишины внутри потока.
Порядок действий, когда соединение разрывается посреди ответа

Это главная сложность приёма. При обычном запросе результат либо есть, либо нет. При потоковом бывает третье состояние: половина ответа получена, продолжения не будет.

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

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

Таймауты

Самая частая ошибка — поставить общий таймаут на запрос. Длинный ответ генерируется минуту, и это норма; обрубать его на тридцатой секунде означает регулярно терять почти готовый результат.

Считать нужно паузу между событиями. Пришёл кусок — таймер сбросился. Тридцать секунд тишины внутри потока — вот это аномалия, здесь обрывать уместно.

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

Повторы, которые не делают хуже

Повтор после сбоя — нормальная практика, но с тремя условиями.

  • Задержка нарастает: первая попытка через секунду, вторая через несколько, третья ещё позже. Немедленный повтор добивает сервис, у которого и так проблемы.
  • Число попыток конечно. Три — обычно достаточно; дальше честнее сообщить об ошибке.
  • Повторяются не все ошибки. Неверный ключ или заведомо слишком длинный запрос повторять бессмысленно — результат не изменится, а деньги и время потратятся.

Прерывание по инициативе пользователя

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

Проверяется это по расходу токенов: если прерванный ответ стоит столько же, сколько полный, отмена не доходит до модели и работает только в интерфейсе.

Когда поток не нужен

Список короче, чем кажется, но он есть.

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

Короткие ответы. Если модель возвращает одно слово или число, поток не даёт ничего: разница между «сразу» и «сразу» незаметна.

Строгие структуры. Ответ, который разбирается программой, всё равно нужен целиком — показывать половину нельзя, а собирать его из кусков ради последующего разбора бессмысленно.

Промежуточный слой

Если поток идёт от модели к вашему серверу, а оттуда в браузер, у вас два потока, а не один, и сломаться может каждый.

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

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

Проверка перед запуском

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

Что записывать в журнал

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

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

Коротко

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