Как сделать потоковый вывод ответов LLM в веб-приложении
Практика4 минРедакция Innovialabs
Коротко
Запросите у API модели поток, передавайте каждый фрагмент в браузер как Server-Sent Events, читайте его через fetch и reader, дописывайте текст в интерфейс по мере поступления и убедитесь, что прокси не буферизует ответ.
Короткий ответ
Потоковый вывод — это когда пользователь видит ответ слово за словом, а не ждёт его целиком. Цепочка из трёх звеньев:
- Бэкенд → API модели: включаете режим стриминга в SDK провайдера и получаете фрагменты по мере генерации.
- Бэкенд → браузер: пересылаете фрагменты в потоковом HTTP-ответе, обычно в формате Server-Sent Events (SSE).
- Браузер → интерфейс: читаете поток и дописываете текст в сообщение на экране.
Не вызывайте API модели напрямую из браузера: ключ окажется открытым. Бэкенд всегда посередине.
SSE или чистый fetch-поток
| Вариант | Плюсы | Минусы |
|---|---|---|
EventSource (нативный SSE) | Простой API, автопереподключение | Только GET, без тела запроса и своих заголовков |
fetch + формат SSE | POST с JSON, заголовки авторизации, AbortController | Поток разбираете сами |
fetch + простой текст | Самый простой формат | Сложно передавать метаданные и ошибки |
| WebSocket | Двусторонняя связь | Лишняя инфраструктура для одностороннего потока |
Для чата практичнее всего fetch с POST-запросом и ответом в формате SSE: историю сообщений можно передать в теле, а структура событий остаётся понятной.
Бэкенд: пересылка потока
Пример использует стандартные Response и ReadableStream из Fetch API — они работают в современных Node.js-средах и route handlers. llm.stream — условный потоковый вызов вашего SDK.
export async function POST(req: Request) {
const { messages } = await req.json();
const encoder = new TextEncoder();
const send = (obj: unknown) => encoder.encode(`data: ${JSON.stringify(obj)}\n\n`);
const body = new ReadableStream({
async start(controller) {
try {
for await (const text of llm.stream(messages, { signal: req.signal })) {
controller.enqueue(send({ text }));
}
controller.enqueue(send({ done: true }));
} catch {
controller.enqueue(send({ error: "generation_failed" }));
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
});
}
Главное: передайте сигнал отмены запроса в вызов модели, чтобы генерация останавливалась, когда пользователь ушёл. Ошибки отправляйте обычным событием — к моменту сбоя посреди потока HTTP-статус уже 200.
Фронтенд: чтение и отмена
const controller = new AbortController();
async function ask(messages: Message[], onText: (t: string) => void) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages }),
signal: controller.signal,
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value;
const events = buffer.split("\n\n");
buffer = events.pop() ?? "";
for (const event of events) {
if (!event.startsWith("data: ")) continue;
const msg = JSON.parse(event.slice(6));
if (msg.error) throw new Error(msg.error);
if (msg.text) onText(msg.text);
}
}
}
// Кнопка «Стоп»: controller.abort();
Сетевой фрагмент не равен одному событию: в нём может быть половина события или несколько сразу. Поэтому код держит буфер и разбирает только завершённые события, разделённые пустой строкой.
Markdown на лету
- Перерисовывайте весь накопленный текст, а не каждый токен отдельно: разметка зависит от контекста (списки, блоки кода).
- Ограничьте частоту обновлений, например раз в кадр анимации, чтобы не перерисовывать интерфейс на каждый мелкий фрагмент.
- Незакрытый блок кода или таблица посреди потока — это нормально: хороший markdown-рендерер с этим справляется, а финальный рендер всё выравнивает.
- Очищайте HTML. Вывод модели — недоверенные данные, не вставляйте сырой HTML в страницу.
- Автопрокрутку держите только пока пользователь внизу чата.
Ошибки и буферизация на прокси
Самая частая жалоба: «локально стриминг работает, а на проде ответ появляется целиком». Причина почти всегда в буферизации:
- Nginx по умолчанию буферизует проксируемые ответы. Отключите это через
proxy_buffering off;для нужного маршрута или отправляйте заголовокX-Accel-Buffering: no. - Сжатие (gzip/brotli) на прокси или CDN может накапливать данные перед отправкой. Исключите
text/event-stream. - CDN и балансировщики могут обрывать соединение по таймауту простоя. При долгих паузах отправляйте строку-комментарий вроде
: ping. - Serverless-платформы могут ограничивать длительность ответа — проверьте поддержку стриминга у своей платформы.
Также обработайте: нажатие «Стоп» (сохраните частичный ответ), обрыв сети (покажите кнопку повтора) и ошибки лимитов провайдера (понятное сообщение вместо пустого пузыря).