Стриминг ответа нейросети в Node.js Express через OpenAI SDK

Рабочий Express-эндпоинт, который получает поток от модели через OpenAI SDK и пересылает его в браузер по SSE. Плюс фронтенд на fetch, отмена запроса и разбор ошибок.

4 мин · · разработка

Длинный ответ модели генерируется десятки секунд, и показывать пустой экран всё это время — плохая идея. Стриминг ответа в Express решает задачу: сервер получает поток токенов от модели через OpenAI SDK и сразу пересылает их в браузер как Server-Sent Events (SSE). Ниже — готовый server.js, страница на чистом JavaScript и разбор мест, где такие эндпоинты обычно ломаются: пустые choices, отключение клиента и ошибки API.

Подготовка проекта

bash
mkdir llm-stream && cd llm-stream
npm init -y
npm pkg set type=module
npm install express openai dotenv express-rate-limit

type=module нужен, потому что код ниже использует import. Ключ храните в .env рядом с сервером и не коммитьте его:

ZUKKO_API_KEY=fetch_ваш_ключ PORT=3000

Ключ создаётся на странице API-ключи. Для серверного приложения удобно завести отдельный ключ с дневным лимитом — тогда ошибка в коде или злоупотребление не опустошат баланс.

Стриминг ответа в Express: server.js

bash
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:

bash
<!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.