Стриминг ответа нейросети в Node.js Express через OpenAI SDK
Рабочий Express-эндпоинт, который получает поток от модели через OpenAI SDK и пересылает его в браузер по SSE. Плюс фронтенд на fetch, отмена запроса и разбор ошибок.
4 мин · · разработка
Длинный ответ модели генерируется десятки секунд, и показывать пустой экран всё это время — плохая идея. Стриминг ответа в Express решает задачу: сервер получает поток токенов от модели через OpenAI SDK и сразу пересылает их в браузер как Server-Sent Events (SSE). Ниже — готовый server.js, страница на чистом JavaScript и разбор мест, где такие эндпоинты обычно ломаются: пустые choices, отключение клиента и ошибки API.
Подготовка проекта
mkdir llm-stream && cd llm-stream
npm init -y
npm pkg set type=module
npm install express openai dotenv express-rate-limittype=module нужен, потому что код ниже использует import. Ключ храните в .env рядом с сервером и не коммитьте его:
ZUKKO_API_KEY=fetch_ваш_ключ
PORT=3000
Ключ создаётся на странице API-ключи. Для серверного приложения удобно завести отдельный ключ с дневным лимитом — тогда ошибка в коде или злоупотребление не опустошат баланс.
Стриминг ответа в Express: server.js
import 'dotenv/config';
import express from 'express';
import rateLimit from 'express-rate-limit';
import OpenAI from 'openai';
const app = express();
app.use(express.json({ limit: '100kb' }));
app.use(express.static('public'));
const client = new OpenAI({
apiKey: process.env.ZUKKO_API_KEY,
baseURL: 'https://api.zukko.pro/v1',
});
Что важно в этом коде
- Базовый адрес —
https://api.zukko.pro/v1: OpenAI SDK добавляет к нему/chat/completions. - Пустые
choices. Последний чанк потока содержит толькоusage, а массивchoicesв нём пустой. Обращение кchunk.choices[0].deltaбез проверки упадёт сTypeErrorв самом конце ответа. Поэтому сначала проверяем длину массива, аusageобрабатываем отдельно, вне этой проверки. - Отключение клиента отслеживается через
res.on('close'), а неreq.on('close'): в современных версиях Node.js событиеcloseу запроса срабатывает, как только прочитано тело, то есть сразу послеexpress.json(). По закрытию ответаAbortControllerотменяет запрос к модели — генерация, которую никто не читает, не продолжается. - Заголовок
X-Accel-Buffering: noотключает буферизацию в nginx, если сервер стоит за ним. Без него поток может приходить в браузер одним куском. - Ошибки приходят как
OpenAI.APIErrorс полемstatus. Коды Zukko: 401 — неверный ключ, 402 — не хватает баланса, 403 — модель не разрешена для ключа, 413 — слишком большой запрос, 429 — слишком часто, 503 — провайдер недоступен. Пользователю отдаём понятный текст, подробности пишем в лог. - Ограничение частоты через
express-rate-limitи проверка длины сообщения защищают баланс от злоупотреблений. Отдельныйcors()не нужен: страница отдаётся с того же сервера. Открытый CORS позволил бы любому сайту тратить ваш баланс через этот эндпоинт.
Фронтенд: чтение потока через fetch
EventSource умеет только GET-запросы, а нам нужно отправить текст в теле POST. Поэтому читаем поток через fetch и ReadableStream. Файл public/index.html:
<!DOCTYPE html>
<html lang="ru">
<head><meta charset="UTF-8"><title>Стриминг ответа</title></head>
<body>
<textarea id="prompt" rows="4" cols="80"></textarea><br>
<button id="send">Отправить</button>
<button id="stop" disabled>Остановить</button>
<pre id="out" style="white-space: pre-wrap"></pre>
<script>
const out = document.getElementById('out');
const sendBtn = document.getElementById('send');
const stopBtn = document.getElementById('stop');
let controller;
События SSE разделяются пустой строкой, поэтому буфер режется по \n\n, а незаконченный хвост остаётся до следующего куска. Кнопка «Остановить» вызывает abort(): браузер закрывает соединение, на сервере срабатывает res.on('close'), и запрос к модели тоже отменяется.
Запуск: node server.js, затем откройте http://localhost:3000.
Другие модели и история диалога
Модель меняется одной строкой: gpt-5.6-sol, claude-opus-5-5, glm-5.3-flash и другие из таблицы цен. Набор моделей меняется, актуальный список — GET https://api.zukko.pro/v1/models или страница «Модели» в кабинете. Какая модель подходит вашему продукту, проверьте на своих запросах.
Чтобы бот помнил разговор, принимайте с фронтенда массив messages (роли user и assistant) и передавайте его в create целиком. Ограничьте длину истории на сервере: каждый новый запрос оплачивается вместе со всей переданной историей.
Расход виден в usage последнего чанка. Например, при 1 500 входных и 600 выходных токенах на claude-sonnet-5 запрос стоит (1 500 × 0.3 + 600 × 0.3) / 1 000 000 = $0.00063. Формат запроса и ответа описан в справке по chat completions, а базовая настройка — в быстром старте.
Частые вопросы
Почему ответ приходит одним куском, а не по частям?
Чаще всего поток буферизует прокси (nginx, балансировщик) или включено сжатие ответа. Проверьте заголовок X-Accel-Buffering: no, отключите compression для этого маршрута и убедитесь, что в запросе к модели стоит stream: true.
Откуда ошибка «Cannot read properties of undefined (reading 'delta')»?
Это обращение к chunk.choices[0] в последнем чанке, где choices пустой. Добавьте проверку длины массива, как в коде выше.
Можно ли вызывать Zukko прямо из браузера, без Express?
Технически да, но тогда ключ окажется в коде страницы, и его сможет забрать любой посетитель. Ключ должен оставаться на сервере.
Почему генерация продолжается после закрытия вкладки?
Скорее всего, вы слушаете req.on('close') и не передаёте signal в SDK. Используйте res.on('close') и AbortController, как в примере.
zukko
Платите в 25×
меньше
за Claude Code & ChatGPT
Один ключ fetch_* и base URL api.zukko.pro — в Cursor, Claude Code, Codex и любом SDK.
Похожие статьи
- Промпт-инъекция: что это и как защитить бота и агентаОбъясняем, как работает промпт-инъекция через пользовательский ввод и чужие документы, и какие меры снижают ущерб: изоляция данных, права инструментов, лимиты ключа.
- Свой прокси к нейросети на Node.js: не показывать API-ключ во фронтендеНебольшой сервер на Express между вашим фронтендом и API нейросети: ключ остаётся на сервере, а модели, длина запроса и частота ограничены.
- DeepSeek API на Python: запросы, стриминг, JSON и обработка ошибокГотовый код для DeepSeek V4.1 Flash и V4 Pro на OpenAI SDK: запрос, стриминг, разбор JSON и аккуратная обработка ошибок.
- Промпт-инжиниринг: примеры и 12 готовых шаблонов для работыДвенадцать шаблонов промптов для рабочих задач: что в них написано, почему именно так и сколько стоит один вызов по API.