Ваш чат-бот со стримингом, скорее всего, генерирует каждый ответ дважды

ai-agents

Вызов инструментов и стриминг требуют от вас противоположного.

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

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

Как это выглядит

Plain Text
loop:
  completion = create(messages, tools)          // без стриминга
  if completion has tool calls:
      run them, append results, continue
  else:
      break

// теперь мы знаем, что это ответ, — делаем его ещё раз, со стримингом
stream = create(messages, tools)                // со стримингом
forward every delta to the browser

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

И этот же код генерирует каждый обычный ответ дважды.

Первый вызов выдаёт готовый ответ: модель не знает, что это проба. Вы выбрасываете этот ответ, спрашиваете ещё раз и стримите второй. Пользователь ждёт оба.

Я выпустил ровно такой код, и он пережил несколько релизов, потому что ничего в нём не выглядит неправильным. Ошибок нет. Задержка — «медленновато, но это же LLM». Счёт — «примерно столько ИИ и стоит». Тесты проходят: они проверяют итоговый текст, а он правильный.

Во что это обходится

Потерь две, и вторая хуже первой.

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

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

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

Стриминг раунда, который может закончиться вызовом инструмента

Мешает ощущение, что стримить раунд небезопасно: а вдруг он окажется вызовом инструмента, когда текст уже пошёл в браузер?

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

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

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

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

Расход токенов приходит последним. Запросите его через stream_options: { include_usage: true }, и он придёт в последнем чанке с пустым массивом choices. Код, который обращается к chunk.choices[0].delta без проверки, упадёт как раз на том чанке, который говорит, сколько стоил ход.

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

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

Что достаётся бесплатно: остановка ответа

Кроме денег, меня убедил ещё один довод.

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

Когда структура правильная, остановка занимает несколько строк: AbortSignal от кнопки к fetch и проверка на сервере, которая прекращает запись, когда клиент ушёл. Выход из цикла завершает и запрос к модели, так что модель действительно перестаёт генерировать, а не дописывает ответ, который никто не прочтёт.

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

TypeScript
try {
  // ... стримим раунды, отдаём дельты
} finally {
  billOnce(tokensSpentSoFar);
}

В JavaScript выход из for await по асинхронному генератору вызывает у него return(): генератор продолжается с последнего yield и выполняет свой finally. Отмена — не отдельный сценарий, о котором нужно помнить, а обычное сворачивание генератора. Нужно только поставить биллинг туда, через что это сворачивание проходит.

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

Как проверить, есть ли у вас эта ошибка

Считайте вызовы модели, а не токены. Пишите в лог по строке на каждый запрос к модели с id хода, задайте ассистенту вопрос, которому совсем не нужны инструменты, — «привет» или «что ты умеешь?», — и посмотрите, сколько вызовов модели дал этот один ход.

Если два, за один из них вы платите впустую.


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