MAATRIX / Блог / Как работает потоковая выдача токенов и почему кажется быстрее

Как работает потоковая выдача токенов и почему кажется быстрее

MAATRIX

Пользователь отправляет вопрос в чат-бота и несколько секунд смотрит на пустой экран — а потом текст появляется весь целиком, будто модель «подумала» и разом выдала готовый ответ. Это иллюзия: на самом деле почти все современные ИИ-чаты показывают вам не готовый ответ, а поток токенов, который печатается по мере генерации, как в терминале с tail -f. Разница между «подождать и получить всё сразу» и «видеть, как печатается» — не косметика интерфейса, а конкретный протокол поверх HTTP, который стоит понимать, если вы собираете свой чат-бот, прокси к API модели или любой инструмент поверх LLM.

Почему модель не может отдать ответ целиком сразу

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

Из этого вытекает жёсткое ограничение: чтобы получить финальный токен ответа, модель обязана сначала сгенерировать все предшествующие. Нет способа «пропустить очередь» и сразу посчитать двухсотый токен, не вычислив сначала первые сто девяносто девять — разве что генерировать заведомо неверный текст. Значит, если ответ занимает, скажем, три тысячи токенов, время на его полную генерацию — это сумма времени на каждый шаг, и никакой трюк на уровне API это не обходит: общее время генерации ответа стриминг не сокращает ни на секунду.

Что стриминг меняет — это момент, когда сгенерированный токен становится видимым получателю. Без стриминга сервер держит соединение открытым, копит все токены во внутреннем буфере и отправляет клиенту один HTTP-ответ целиком только после того, как модель поставила финальную точку. С стримингом каждый токен уходит клиенту сразу же, как только сгенерирован — сервер не ждёт остальных. Технически это два принципиально разных паттерна отдачи одного и того же вычисления, и второй не ускоряет вычисление, а меняет расписание доставки его промежуточных результатов.

Что видит пользователь без стриминга: тишина, потом всё сразу

Представьте API без стриминга для ответа на триста слов. Клиент отправляет запрос, дальше — полная тишина: ни байта в ответ, соединение просто висит открытым. Модель токен за токеном генерирует текст на сервере, но снаружи это никак не видно, потому что HTTP-ответ ещё не начал формироваться — сервер ждёт финальный токен, чтобы собрать весь текст в одно тело ответа. Пользователь видит спиннер или пустой экран всё это время, и субъективно это ощущается как «зависло» — даже если технически всё работает штатно, просто ответ длинный.

Затем — в один момент — на экране появляется весь текст сразу, целым куском. Для короткого ответа в пару предложений разница не критична: пользователь подождал полсекунды, получил результат. Но для развёрнутого ответа на сложный вопрос, инструкции с кодом или анализа документа, где генерация растягивается на многие секунды, необъяснимая тишина создаёт у пользователя ощущение сломанного интерфейса — он не может отличить «модель ещё думает» от «запрос повис и никогда не ответит».

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

SSE и chunked transfer encoding: как токены физически долетают до браузера

На уровне HTTP задача «отправлять данные клиенту по частям, не дожидаясь конца формирования всего ответа» решается двумя механизмами, которые часто путают, хотя они решают немного разные подзадачи. Первый — chunked transfer encoding, часть спецификации HTTP/1.1: сервер не обязан заранее знать и указывать заголовок Content-Length, а вместо этого разбивает тело ответа на последовательные куски произвольного размера, каждый со своим префиксом длины, и клиент читает их по мере поступления, не дожидаясь закрытия соединения. Это транспортный механизм — он ничего не говорит о формате содержимого внутри кусков.

Второй механизм — Server-Sent Events (SSE), формат содержимого поверх обычного HTTP-соединения с заголовком Content-Type: text/event-stream. SSE задаёт простой текстовый протокол: сервер шлёт события построчно, каждое в виде data: <полезная нагрузка>, события разделяются пустой строкой. Браузерный EventSource умеет разбирать этот формат из коробки, но в LLM-стриминге чаще используют не его, а fetch с ручным чтением через ReadableStream — нужен полный контроль над заголовками запроса, например передать Authorization с API-ключом, чего EventSource не позволяет.

Именно связка SSE поверх chunked HTTP — фактический стандарт для стриминга ответов LLM: OpenAI-совместимый API, к которому подстроились Anthropic, локальные раннеры вроде vLLM и Ollama, и шлюзы вроде LiteLLM, отдаёт поток именно в этом формате. Проверить его руками можно одной командой:

curl -N https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-4o-mini", "stream": true, "messages": [{"role": "user", "content": "Считай от одного до пяти"}]}'

Флаг -N у curl отключает буферизацию вывода на стороне самой утилиты — без него curl может придержать вывод на терминал, создавая впечатление, что стриминга нет, хотя сервер уже честно шлёт данные по частям. Это первая из нескольких граблей, где буферизация где-то в цепочке съедает эффект от честного стриминга на сервере — к остальным вернёмся ниже.

Формат OpenAI-совместимого потока: что лежит в каждом чанке

Когда клиент отправляет запрос с полем "stream": true, ответ вместо одного JSON-объекта превращается в последовательность SSE-событий, каждое из которых — небольшой JSON-фрагмент с префиксом data: . Типичное событие в середине генерации выглядит так:

data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1735689600,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"При"},"finish_reason":null}]}

data: {"id":"chatcmpl-abc123","object":"chat.completion.chunk","created":1735689600,"model":"gpt-4o-mini","choices":[{"index":0,"delta":{"content":"вет"},"finish_reason":null}]}

data: [DONE]

Ключевое поле — delta, а не content целиком: каждый чанк несёт только приращение текста с прошлого события, а не накопленный ответ. Клиент обязан сам конкатенировать эти приращения по мере поступления, чтобы собрать финальный текст, — API не присылает готовый ответ ни в одном отдельном чанке, только по кусочку за раз. Первый чанк в потоке обычно несёт role: "assistant" в delta без содержимого — это служебное событие, обозначающее начало ответа ассистента, а не токен текста. Поток завершается специальным маркером data: [DONE] — не JSON-объектом, а буквальной строкой, по которой клиент понимает, что дальше событий не будет и соединение можно закрывать.

Отдельная тонкость на границе токенизации: один токен модели не обязан совпадать с одним символом Unicode. Многобайтовые символы — кириллица, эмодзи, иероглифы — иногда режутся токенизатором так, что байты одного символа приходят в разных чанках. Наивная конкатенация фрагментов как обычных строк в языке с некорректной обработкой кодировки может на миг показать «битый» символ до прихода следующего чанка. Библиотеки для работы с потоками от серьёзных провайдеров это учитывают внутри, но при самостоятельном парсинге сырого SSE-потока стоит держать в уме, что делить входящие байты на символы нужно аккуратно, а не построчно.

Time to first token vs общее время генерации: что реально меняется

Стоит разделить две метрики, которые в обсуждениях стриминга постоянно смешивают. Time to first token (TTFT) — задержка от отправки запроса до появления самого первого токена ответа. Total generation time — суммарное время от запроса до последнего токена, когда ответ полностью готов. Стриминг напрямую влияет только на первую метрику восприятия — точнее, не столько уменьшает TTFT в абсолютных цифрах (он и без стриминга примерно такой же, просто раньше был не виден), сколько делает эту задержку видимой пользователю вместо скрытой за общим временем ожидания.

Общее время генерации стриминг не меняет вообще — оно определяется скоростью инференса модели: числом токенов в секунду, которое способна выдавать конкретная связка модели, железа (GPU/CPU) и параметров запуска. Если модель генерирует медленно, стриминг не замаскирует это — текст просто будет печататься медленно, по паре слов в секунду, вместо долгой паузы и мгновенного появления всего сразу. Разбор причин, почему генерация может быть медленной, и что с этим делать на уровне сервера — в материале про медленную генерацию токенов в Ollama: стриминг и скорость инференса — разные слои задачи, и один не лечит другого.

Есть и обратный эффект: стриминг добавляет накладные расходы — отдельная сериализация в JSON, отдельная запись в сокет и отдельное событие на клиенте под каждый чанк. Для очень коротких ответов (одно-два слова) это избыточно, разница пренебрежима, но не нулевая. Выгода стриминга растёт вместе с длиной ответа: чем дольше генерация, тем больше времени пользователь провёл бы в тишине без него.

Практика: реализация на клиенте и сервере, и типичные грабли

На стороне бэкенда, если вы проксируете запросы к OpenAI-совместимому API (своему инференсу на vLLM/Ollama или внешнему провайдеру через шлюз), задача сводится к тому, чтобы не буферизовать ответ модели целиком, а прокидывать чанки клиенту по мере получения. На Python с FastAPI это выглядит примерно так:

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from openai import OpenAI

app = FastAPI()
client = OpenAI(base_url="http://localhost:8000/v1", api_key="local")

@app.post("/chat")
async def chat(payload: dict):
    def event_stream():
        stream = client.chat.completions.create(
            model=payload["model"],
            messages=payload["messages"],
            stream=True,
        )
        for chunk in stream:
            delta = chunk.choices[0].delta.content or ""
            if delta:
                yield f"data: {delta}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(event_stream(), media_type="text/event-stream")

На клиенте — если это веб-интерфейс — обработка чанков через fetch с ручным чтением потока:

const response = await fetch("/chat", {
  method: "POST",
  body: JSON.stringify({ model: "llama-3", messages }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let text = "";

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  text += decoder.decode(value, { stream: true });
  renderToChat(text); // обновляем UI по мере прихода токенов
}

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

Nginx буферизует ответ по умолчанию. Если между вашим бэкендом и клиентом стоит nginx (а он стоит почти всегда), по умолчанию он собирает весь upstream-ответ в буфер, прежде чем начать отдавать его клиенту — то есть честный стриминг с бэкенда превращается обратно в «подождать и получить всё разом» уже на уровне реверс-прокси, а вы даже не поймёте почему. Лечится директивой в конфиге location:

location /chat {
    proxy_pass http://backend;
    proxy_buffering off;
    proxy_cache off;
    add_header X-Accel-Buffering no;
    proxy_read_timeout 300s;
}

proxy_buffering off отключает буферизацию именно на уровне nginx, X-Accel-Buffering: no — тот же эффект для конкретного ответа, если буферизация управляется динамически из бэкенда. Отдельно стоит увеличить proxy_read_timeout — по умолчанию он часто в районе минуты, а долгая генерация с паузами между чанками (не читай как гарантированную цифру — зависит от модели и нагрузки) может этот таймаут превысить, и nginx оборвёт соединение посреди ответа.

Gzip на лету, таймауты балансировщика и клиента. Сжатие ответа тоже иногда требует накопить блок данных перед отправкой — для text/event-stream его разумно отключать явно. Балансировщики и CDN держат свои лимиты простоя между чанками (idle timeout), а HTTP-клиенты — свои таймауты на чтение: если модель на секунду задумалась перед сложным токеном, а лимиты не подняты под долгие частичные ответы, соединение оборвётся раньше времени. Все три лимита стоит явно проверить и увеличить на своей инфраструктуре, если ответы бывают длинными.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Развернуть ИИ на сервере

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Стриминг делает модель быстрее?

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

Можно ли отменить генерацию до её завершения, если включён стриминг?

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

SSE и WebSocket — это одно и то же?

Нет. SSE — однонаправленный поток от сервера к клиенту поверх обычного HTTP-запроса, проще в реализации и совместим с обычными HTTP-инструментами и прокси. WebSocket — двунаправленный канал с собственным протоколом поверх TCP, избыточный для задачи «сервер поток за потоком присылает токены ответа», и OpenAI-совместимые API его для этого не используют.

Нужно ли что-то менять в самой модели, чтобы включить стриминг?

Нет, модель ничего не знает о стриминге — она просто генерирует токены по одному, как всегда. Стриминг — целиком слой API и транспорта поверх уже существующего процесса генерации: инференс-серверу нужно уметь отдавать частичный результат по мере готовности, а не ждать финальный токен.

Почему иногда стриминг «зависает» на несколько секунд, хотя обычно работает быстро?

Чаще всего это буферизация где-то в цепочке (nginx, CDN, балансировщик) — сервер честно шлёт чанки, но промежуточный узел копит их перед отправкой дальше. Проверяется прямым запросом к бэкенду в обход прокси и сравнением поведения.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →