JavaRush /Курси /ChatGPT Apps /Потокові канали: SSE і HTTP/stream — коли та як їх викори...

Потокові канали: SSE і HTTP/stream — коли та як їх використовувати

ChatGPT Apps
Рівень 13 , Лекція 1
Відкрита

1. Де взагалі зʼявляються «потоки» в архітектурі ChatGPT App

Перш ніж вирішувати, що краще — SSE чи HTTP-stream, варто зрозуміти, де в нашому стеку взагалі є потоки.

Для зручності можна виокремити три рівні.

По‑перше, рівень ChatGPT і моделі. Модель сама по собі вже надсилає відповідь токенами: ви бачите, як текст «друкується» літера за літерою. Це теж потік, але ним повністю керує OpenAI, і він безпосередньо не належить до вашого коду.

По‑друге, рівень MCP. Коли ChatGPT підʼєднується до вашого MCP‑сервера, він зазвичай тримає SSE‑зʼєднання: сервер надсилає йому JSON‑RPC‑повідомлення MCP (відповіді та сповіщення), а ChatGPT у відповідь надсилає запити на окремий HTTP‑ендпойнт, наприклад /messages. У термінах MCP це базовий транспорт.

По‑третє, рівень Apps SDK і вашого бекенду. Ваш React‑віджет GiftGenius запущено в пісочниці ChatGPT, і він спілкується з вашим бекендом/MCP‑gateway через HTTP: через звичайний fetch, через fetch із потоком (ReadableStream) або через SSE‑підписку (EventSource).

Важливо не змішувати ці рівні в одну купу. Події MCP — це «дріт» між ChatGPT і серверами; а SSE/HTTP-stream між віджетом і вашим HTTP‑бекендом — це вже ваша ділянка шляху.

Можна намалювати схему.

flowchart TD
  subgraph ChatGPT
    UI[ChatGPT UI + модель]
    W[GiftGenius Widget]
  end

  subgraph YourInfra[Інфраструктура розробника]
    GW[MCP Gateway / Backend]
    MCP[MCP Server]
  end

  UI -- "tool-call / відповіді\n(внутрішній стрім токенів)" --> W

  UI <-- "MCP over SSE\n(/sse + /messages)" --> MCP

  W <-- "HTTP / fetch / SSE / stream" --> GW

  GW <-- "JSON-RPC MCP" --> MCP

Сьогодні ми зосередимося на стрілці Widget ↔ Backend і лише побіжно згадаємо, що MCP‑транспорт сам по собі теж базується на SSE.

Саме на цій ділянці — Widget ↔ Backend — нам і доводиться обирати, як спілкуватися: простими HTTP‑запитами чи потоками. У наступному розділі подивимося, чому звичайного HTTP тут швидко починає бракувати.

2. Чому звичайного HTTP‑запиту недостатньо

Стандартна модель HTTP — це «запит → одна відповідь». Клієнт щось запитав, сервер один раз відповів — і зʼєднання закрилося.

Для багатьох завдань цього достатньо: отримати поточний статус jobʼа, зберегти налаштування користувача, отримати готовий список подарунків, що вже є в базі даних.

Але щойно ви запускаєте тривалу операцію, усе починає «скрипіти».

Уявімо GiftGenius, який:

  • збирає сигнали з кількох джерел (історія покупок, список бажань, соцмережі),
  • пропускає це через кілька LLM‑запитів,
  • будує персональний рейтинг зі сотні кандидатів.

Усе це може зайняти десятки секунд. Якщо ви триматимете звичайний HTTP‑запит 40 секунд і весь цей час не надсилатимете жодної відповіді, користувацький досвід буде як у старих браузерах: користувач дивиться на спінер, що крутиться, і гадає — застосунок «помер» чи ще «думає».

Окрім UX, є й суто технічні проблеми:

  • тайм-аути в ChatGPT, у Vercel, у проксі;
  • неможливість надсилати прогрес, часткові результати тощо;
  • неможливість коректно обробити обрив зʼєднання та відновитися.

Звідси природне рішення: перейти від однієї великої відповіді до потоку невеликих частин, які сервер може надсилати в міру готовності.

Ці частини можуть бути:

  • подіями (job.progress, job.completed) — це про SSE;
  • фрагментами одного великого корисного навантаження (текст звіту, рядки NDJSON із подарунками) — це про HTTP-stream.

3. SSE (Server‑Sent Events): підписка на події

Почнімо з SSE, адже він багато в чому «рідніший» для MCP: сам MCP поверх HTTP використовує SSE‑зʼєднання, щоб надсилати події від сервера до клієнта.

Модель SSE «на пальцях»

SSE — це протокол поверх звичайного HTTP:

  1. клієнт відкриває GET‑запит на ендпойнт, який відповідає з Content-Type: text/event-stream;
  2. сервер не закриває зʼєднання, а періодично записує туди рядки такого вигляду:
event: job.progress
data: {"jobId":"123","percent":40}

event: job.completed
data: {"jobId":"123","resultCount":12}
  1. браузерна сторона використовує EventSource, який:
    • сам стежить за повторними підʼєднаннями;
    • розбирає формат event: + data: + подвійний перенос рядка;
    • викликає обробники onmessage / addEventListener("job.progress", ...).

Ключовий момент: канал односторонній. Лише сервер надсилає події клієнту. Клієнт цим зʼєднанням нічого не надсилає.

Для ChatGPT Apps ця модель чудово підходить, коли віджет просто хоче «підписатися» на події за jobId і реагувати на прогрес та завершення завдання.

Мініприклад SSE‑ендпойнта в Next.js 16

Нехай у нас є обробник маршруту для подій прогресу jobʼа:

app/api/gift-jobs/[jobId]/events/route.ts

import { NextRequest } from "next/server";

export async function GET(req: NextRequest, { params }: { params: { jobId: string } }) {
  const jobId = params.jobId;

  const stream = new ReadableStream({
    start(controller) {
      // Утиліта для надсилання SSE‑події
      const send = (event: string, data: unknown) => {
        const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
        controller.enqueue(new TextEncoder().encode(payload));
      };

      send("job.started", { jobId });

      let percent = 0;
      const interval = setInterval(() => {
        percent += 20;
        if (percent >= 100) {
          send("job.completed", { jobId, totalGifts: 10 });
          clearInterval(interval);
          controller.close();
        } else {
          send("job.progress", { jobId, percent });
        }
      }, 1000);
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream", // тут задаємо SSE
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

Це навчальна імітація: раз на секунду зростає відсоток, а наприкінці приходить job.completed. Пізніше ви заміните цей таймер на реальні події воркера/черги, але сама схема залишиться незмінною.

Клієнт: підписка на SSE у віджеті GiftGenius

Усередині React‑віджета ми можемо підписатися на цей потік, щойно зʼявиться jobId. Памʼятайте: API віджета працює в пісочниці ChatGPT, але EventSource там доступний так само, як і в звичайному браузері.

import { useEffect, useState } from "react";

export function GiftJobProgress({ jobId }: { jobId: string }) {
  const [percent, setPercent] = useState(0);

  useEffect(() => {
    const url = `/api/gift-jobs/${jobId}/events`;
    const es = new EventSource(url);

    es.addEventListener("job.progress", (event) => {
      const data = JSON.parse((event as MessageEvent).data);
      setPercent(data.percent);
    });

    es.addEventListener("job.completed", () => {
      setPercent(100);
      es.close();
    });

    es.onerror = () => {
      // тут можна показати "Проблеми зі звʼязком, пробуємо перепідʼєднатися"
    };

    return () => es.close();
  }, [jobId]);

  return <div>Прогрес підбору подарунків: {percent}%</div>;
}

Тепер ви можете поєднати це з MCP‑інструментом. Інструмент start_gift_job повертає jobId, а в ToolOutput вашого віджета ви просто рендерите GiftJobProgress.

Автоматичне перепідʼєднання та Last‑Event‑ID

EventSource за стандартом намагається автоматично перепідʼєднатися, якщо зʼєднання обривається. Сервер може використовувати стандартне поле SSE id: у подіях, а клієнт — заголовок Last-Event-ID, щоб після перепідʼєднання «надолужити» пропущені події.

Для простого GiftGenius можна поки що не реалізовувати ні id:, ні окремий ідентифікатор подій і просто допускати невелику «прогалину» в прогресі під час перепідʼєднання. Але у виробничому середовищі, особливо за високого навантаження, вам знадобиться:

  • додавати стандартне поле id: у кожну SSE‑подію, щоб клієнт міг передавати Last-Event-ID під час перепідʼєднання;
  • запроваджувати прикладний event_id у дані події та спиратися на нього під час ідемпотентної обробки на клієнті/бекенді.

Це безпосередньо повʼязано з ідемпотентністю: навіть якщо одна й та сама job.progress прийде двічі, обробник, побачивши знайомий event_id, не виконуватиме побічних ефектів повторно.

У підсумку SSE дає нам зручну підписку на події навколо jobId з автоперепідʼєднанням і контролем дублів через ідентифікатори подій. Тепер розберімося з другим типом потоків — коли в нас один запит, але дуже велика відповідь, яку хочеться віддавати частинами.

4. HTTP‑streaming: поступово відповідаємо на один запит

Якщо SSE — це «підписка на незалежні події», то HTTP‑стримінг — це «один запит, одна відповідь, але відповідь розтягнута в часі й приходить чанками».

Це саме той механізм, який ви бачите, коли використовуєте OpenAI API з stream : true: сервер надсилає частини JSON (часто у форматі SSE, але логіка та сама — «один запит ↔ потік часткової відповіді»), а клієнт збирає їх у підсумковий текст.

У власному API ви можете зробити те саме для:

  • великих текстових звітів (наприклад, пояснення логіки обраних подарунків),
  • довгих списків подарунків (надсилати їх частинами, а не тримати користувача в очікуванні).

Найпростіший HTTP‑stream‑ендпойнт у Next.js

Припустімо, нам потрібно згенерувати «пояснення» результату підбору, де LLM пише довгий текст. Ми хочемо передавати його у віджет потоково — у міру того, як він генерується.

app/api/gift-report/route.ts

import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();

      controller.enqueue(encoder.encode("Починаємо аналіз...\n"));

      // Тут могла б бути реальна генерація LLM з чанками
      for (const line of ["Збираємо вподобання...\n", "Рахуємо бюджет...\n", "Фінальні рекомендації...\n"]) {
        await new Promise((r) => setTimeout(r, 1000));
        controller.enqueue(encoder.encode(line));
      }

      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/plain; charset=utf-8",
      "Transfer-Encoding": "chunked", // тут вказуємо, що це HTTP/stream
    },
  });
}

Технічно Next сам керує chunked‑кодуванням. Вам важливо лише повертати ReadableStream.

Зчитування HTTP‑стриму у віджеті через fetch

На клієнті (усередині віджета) можна прочитати потік так:

async function fetchReport(setText: (s: string) => void) {
  const res = await fetch("/api/gift-report", { method: "POST" });
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();

  let acc = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    acc += decoder.decode(value, { stream: true });
    setText(acc); // оновлюємо UI у міру надходження
  }
}

І обгортка‑компонент:

import { useState } from "react";

export function GiftReport() {
  const [text, setText] = useState("");

  return (
    <div>
      <button onClick={() => fetchReport(setText)}>Згенерувати звіт</button>
      <pre style={{ whiteSpace: "pre-wrap" }}>{text}</pre>
    </div>
  );
}

Це класичний патерн: один запит POST /api/gift-report, у відповідь — потік тексту, який ви поступово відображаєте.

Стримимо JSON, а не текст

Часто вам захочеться стримити не рядки, а JSON‑обʼєкти. Найпопулярніший формат — NDJSON (Newline‑delimited JSON): кожна подія — це один JSON‑рядок і завершується символом \n.

Приклад серверної частини:

const stream = new ReadableStream({
  async start(controller) {
    const encoder = new TextEncoder();
    for (let i = 0; i < 5; i++) {
      const chunk = { type: "gift", index: i, name: `Подарунок #${i}` };
      controller.enqueue(encoder.encode(JSON.stringify(chunk) + "\n"));
      await new Promise((r) => setTimeout(r, 500));
    }
    controller.close();
  },
});

Клієнт читає через TextDecoder, ділить за \n і парсить окремі JSON‑обʼєкти.

5. SSE vs HTTP‑stream: у чому різниця і як обирати

На цьому етапі загальна картина вже має бути інтуїтивною. Але давайте все ж зафіксуємо її у вигляді невеликої таблиці.

Характеристика SSE (Server‑Sent Events) HTTP‑stream (chunked)
Ініціатор Клієнт робить GET і підписується Клієнт робить запит (GET/POST), сервер стримить відповідь
Напрямок Лише сервер → клієнт Відповідь сервера на конкретний запит
Семантика Підписка на потік подій (pub/sub) Часткова відповідь на один запит
Вбудований протокол Є (event:, data:, id: тощо) Немає, ви вигадуєте формат самі (рядки, NDJSON, JSON)
Клієнтське API EventSource fetch + ReadableStream / response.body
Підтримка перепідʼєднання Вбудована (EventSource, Last-Event-ID) Потрібно реалізовувати вручну
Типові кейси Прогрес, статуси, сповіщення за jobId Стримінг тексту, великих JSON‑відповідей, LLM‑виводу

Якщо спростити до стану «правил на пальцях» (обережно, без фанатизму):

  • у вас є job і багато подій навколо нього → SSE;
  • у вас один tool‑виклик повертає великий результат, який хочеться показувати частинами → HTTP‑stream.

Для GiftGenius це означає таке: SSE — для живого індикатора прогресу та статусів підбору; HTTP‑стрім — для довгого текстового підсумку або для поступового завантаження великого списку подарунків.

6. Як це поєднується з MCP і GiftGenius

Згадаємо нашу схему з початку лекції: модель ↔ MCP ↔ віджет ↔ бекенд. Ми вже подивилися на потоки на рівні «віджет ↔ бекенд», а тепер повернемося на крок назад і чітко розділимо: де в цій історії MCP, а де — «просто HTTP».

MCP визначає, як ChatGPT (як MCP‑клієнт) спілкується з вашим MCP‑сервером. Для цього є транспорт, у якому:

  • ChatGPT відкриває SSE‑зʼєднання /sse і отримує ним MCP‑повідомлення (відповіді, сповіщення, події);
  • ChatGPT надсилає MCP‑запити (call_tool, list_tools тощо) на /messages, зазвичай як POST з JSON‑RPC.

Цей рівень ви вже пройшли, коли підʼєднували GiftGenius до ChatGPT.

Тепер, коли ми додаємо асинхронні завдання і UX‑потоки у віджеті, у нас зʼявляються два варіанти архітектури.

Варіант перший — «чистий MCP»: MCP‑сервер сам генерує події job.progress і job.completed; ChatGPT отримує їх по MCP‑SSE; потім модель знову викликає ваш віджет з оновленим контекстом, а віджет рендерить прогрес, не спілкуючись безпосередньо з бекендом. Це найбільш «канонічний» шлях для MCP‑подій.

Варіант другий — гібридний: MCP‑tool start_gift_job створює завдання і повертає jobId; віджет отримує jobId і далі сам спілкується з бекендом через HTTP, підписуючись на SSE‑ендпойнт /api/gift-jobs/{jobId}/events і, за потреби, запитуючи HTTP‑стрім звіту. З боку MCP при цьому жодних особливих змін немає.

У курсі ми обираємо гібридний шлях: він краще вбудовується в App Router/Next і зручніший для локального налагодження. А вже потім ви зможете перейти на «чисті MCP‑сповіщення», коли відчуєте, що готові.

7. Перепідʼєднання, тайм-аути та інші реалії мережі

Досі все звучало як ідеал: відкрили SSE або стрім, усе тече, події прилітають, і користувацький досвід виглядає бездоганно. У реальному житті мережа любить обривати зʼєднання в найнесподіваніші моменти, а інфраструктура — виставляти тайм-аути.

Що може піти не так

З SSE і HTTP-streamʼами ви рано чи пізно зіткнетеся з:

  • idle‑тайм-аутами на проксі: «якщо зʼєднанням нічого не йшло N секунд — закриваємо»;
  • перезапуском вашого бекенду (деплой, аварія);
  • нестабільною мережею на боці користувача (особливо на мобільних).

Це нормально. Важливо бути до цього готовими, а не сподіватися «само якось мине».

Стратегія для SSE

У SSE багато плюсів саме тут:

  • EventSource сам перепідʼєднується з певною затримкою;
  • у вас є id: і Last-Event-ID, щоб надолужувати події.

Мінімальний набір практик:

  1. На сервері періодично надсилати щось на кшталт heartbeat, щоб зʼєднання не вважалося повністю idle. Це може бути окрема подія event: ping або просто коментар : keep-alive.
  2. На клієнті в onerror показувати користувачу зрозуміле повідомлення на кшталт «Проблеми зі звʼязком, намагаємося перепідʼєднатися…», а не «ламати» весь віджет.
  3. Під час перепідʼєднання, якщо ви використовуєте id:, віддавати із сервера лише нові події після цього ID. Для GiftGenius можна почати взагалі без id: і просто відновлювати стан за останнім отриманим job.progress/job.completed.

Стратегія для HTTP‑stream

HTTP‑стрім — це один запит. Тож у разі обриву вам, по суті, доведеться починати заново:

  • якщо ви стримите текстовий звіт, можна просто сказати користувачу: «Не вдалося отримати повний звіт. Спробуйте ще раз», — і запустити все спочатку;
  • якщо ви стримите структуровані дані (NDJSON), можна подумати про механізм resume: наприклад, передавати в запиті offset або cursor, з якого потрібно продовжити.

Для початку можна не ускладнювати й зробити просту політику: якщо стрім відповіді обірвався до завершення — показуємо те, що встигли, і кнопку «Продовжити генерацію звіту», яка надішле новий запит.

Головне — не залишати користувача у стані «вічного очікування».

8. Застосовуємо до GiftGenius: сценарій від початку до кінця

Тепер зведімо докупи все, що обговорили про SSE, HTTP‑stream і два варіанти архітектури з MCP, на прикладі GiftGenius — від запиту користувача до готового звіту.

Користувач у ChatGPT пише: «Підбери подарунок для фаната настільних ігор, бюджет до 100 доларів». Модель вирішує викликати GiftGenius. Застосунок або агент робить tool‑call start_gift_job на вашому MCP‑сервері. Сервер:

  • записує job до БД;
  • надсилає його у внутрішню чергу (деталі черг і воркерів — у наступній лекції; поки що припускаємо, що «хтось» його виконує);
  • синхронно повертає jobId у відповідь на tool‑call.

Віджет GiftGenius отримує ToolOutput з jobId і рендерить компонент:

function GiftGeniusRoot({ jobId }: { jobId: string }) {
  return (
    <div>
      <h2>Шукаємо ідеальні подарунки...</h2>
      <GiftJobProgress jobId={jobId} />
      <GiftReport />
    </div>
  );
}

Компонент GiftJobProgress підписується на SSE /api/gift-jobs/{jobId}/events і показує прогрес. Кожен job.progress оновлює відсотки, job.completed — ставить 100% і, можливо, вмикає кнопку «Показати докладний звіт».

Компонент GiftReport після натискання кнопки надсилає POST /api/gift-report (передаючи туди jobId) і поступово відображає текстовий звіт, поки сервер віддає чанки HTTP‑стріму.

У разі обриву SSE‑зʼєднання віджет показує мʼяке попередження, а EventSource намагається перепідʼєднатися. У разі проблем зі стрімом звіту користувач бачить частину звіту і кнопку «Продовжити генерацію» або «Спробувати ще раз».

З точки зору ChatGPT і MCP:

  • MCP бачить tool‑виклик start_gift_job і, можливо, потім сповіщення про статуси jobʼа;
  • UX навколо потоків реалізовано переважно на рівні HTTP між віджетом і вашим бекендом.

9. Типові помилки під час роботи з SSE і HTTP‑stream

Помилка №1: вважати SSE і HTTP‑stream «одним і тим самим».
Так, на нижчому рівні в них спільні HTTP і chunked‑відповіді, але семантика зовсім різна. SSE — це підписка на незалежні події, які можуть приходити коли завгодно, і клієнт заздалегідь цього не знає. HTTP‑стрім — це одна конкретна відповідь, розтягнута в часі. Якщо спробувати реалізувати підписку на безліч jobId через один HTTP‑стрім, доведеться самостійно вигадувати протокол поверх байтів — фактично заново побудувавши половину SSE.

Помилка №2: ігнорувати автоперепідʼєднання SSE і не думати про ідемпотентність.
Часто пишуть «простий» SSE‑сервер: надсилають data: ... і не додають ні стандартного id: (для Last-Event-ID), ні прикладного event_id у тілі події. Потім, щойно зʼєднання обривається і відбувається перепідʼєднання, починають множитися дублікати подій. Без продуманого event_id і логіки «я вже бачив цю подію» обробник на клієнті ризикує двічі оновлювати стан, двічі показувати один і той самий job.completed або, що гірше, двічі списувати гроші/нараховувати бонуси.

Помилка №3: надсилати кожну дрібницю воркера окремою SSE‑подією.
Якщо надсилати прогрес завдання по SSE щомілісекунди, то, найімовірніше, ви перевантажите мережу й клієнт — і точно не порадуєте користувача плавною анімацією. Набагато розумніше агрегувати оновлення й надсилати прогрес, скажімо, раз на 200500 мс або під час зміни стадії процесу. Тему throttling і backpressure ми ще обговорюватимемо, але вже на цьому етапі варто думати про частоту подій.

Помилка №4: робити складні протоколи поверх HTTP‑стріму без явного формату.
Типовий антипатерн: стримити JSON без роздільників і намагатися «вгадати», де закінчується один обʼєкт і починається інший. Або змішувати в одному потоці текст і JSON. Найкращий шлях — обрати простий і зрозумілий формат: текст по рядках або NDJSON (один JSON‑обʼєкт на рядок), або явні роздільники. Тоді парсер на клієнті залишиться притомним.

Помилка №5: забувати про тайм-аути і «вічні» стріми.
Іноді розробники роблять SSE‑ендпойнти, які нічого не надсилають 510 хвилин, а потім дивуються, що зʼєднання обриваються на шляху від користувача до сервера (балансувальники, API‑шлюзи, корпоративні проксі). Регулярні heartbeat‑події або коментарі дають змогу тримати зʼєднання живим і вчасно виявляти обриви. А HTTP‑стріми не повинні перетворюватися на нескінченні відповіді — для «вічних» підписок є SSE.

Помилка №6: намагатися зробити через HTTP‑стрім складний pub/sub замість нормальних подій.
Іноді виникає спокуса: «Зробимо один стрім, а ним надсилатимемо і прогрес, і partial results, і випадкові логи». У результаті на клієнті зʼявиться складний мультиплексор, який аналізує кожен чанк і вирішує, до якого jobId він належить. У більшості випадків простіше й надійніше використовувати SSE з подіями типу job.progress, job.completed та окремим каналом на job, ніж вигадувати саморобний «мегапротокол» поверх HTTP‑стріму.

Помилка №7: жорстко привʼязувати UX до того, що потік «ніколи не падає».
Будь‑який потік колись обірветься. Якщо ваш віджет після цього просто лишається з «вічно» анімованим індикатором прогресу і без варіантів дій — UX сприймається як «зламаний». Навіть просте повідомлення «Схоже, зʼєднання перервалося. Спробуйте перезапустити підбір подарунків» з кнопкою «Повторити» набагато краще, ніж мовчання.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ