Вызов инструментов и стриминг требуют от вас противоположного.
Стриминг хочет отправлять токены в браузер, как только они пришли. Вызов инструментов хочет сначала посмотреть на ответ модели, понять, просила ли она инструмент, запустить его и пойти на следующий круг. Неудобно то, что узнать, какой это ход, можно только сгенерировав его: в запросе нет поля «этот будет ответом».
Поэтому есть очевидный способ написать этот цикл, и почти все хотя бы раз пишут его именно так.
Как это выглядит
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, тогда этот код выполнится в любом случае: дописан ли ответ, упал ли запрос или ответ оборвался на полуслове.
try {
// ... стримим раунды, отдаём дельты
} finally {
billOnce(tokensSpentSoFar);
}
В JavaScript выход из for await по асинхронному генератору вызывает у него return(): генератор продолжается с последнего yield и выполняет свой finally. Отмена — не отдельный сценарий, о котором нужно помнить, а обычное сворачивание генератора. Нужно только поставить биллинг туда, через что это сворачивание проходит.
И сохраните то, что увидел пользователь. Остановленный ответ — тоже ответ: пол-абзаца у него на экране и, если вы храните переписки, те же пол-абзаца в панели управления с пометкой «остановлен». Если не сохранить ничего, ваши же логи разойдутся с тем разговором, который видел человек.
Как проверить, есть ли у вас эта ошибка
Считайте вызовы модели, а не токены. Пишите в лог по строке на каждый запрос к модели с id хода, задайте ассистенту вопрос, которому совсем не нужны инструменты, — «привет» или «что ты умеешь?», — и посмотрите, сколько вызовов модели дал этот один ход.
Если два, за один из них вы платите впустую.
ИИ-чат Kookee теперь стримит один вызов модели на раунд, а пока приходит ответ, кнопка отправки становится кнопкой остановки. О виджете читайте в статье «Как установить виджет ИИ-чата на сайт», а о другой ловушке в этой части стека — в посте «Почему список источников у вашего ИИ-чат-бота, скорее всего, неверный».