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:
- клієнт відкриває GET‑запит на ендпойнт, який відповідає з Content-Type: text/event-stream;
- сервер не закриває зʼєднання, а періодично записує туди рядки такого вигляду:
event: job.progress
data: {"jobId":"123","percent":40}
event: job.completed
data: {"jobId":"123","resultCount":12}
- браузерна сторона використовує 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, щоб надолужувати події.
Мінімальний набір практик:
- На сервері періодично надсилати щось на кшталт heartbeat, щоб зʼєднання не вважалося повністю idle. Це може бути окрема подія event: ping або просто коментар : keep-alive.
- На клієнті в onerror показувати користувачу зрозуміле повідомлення на кшталт «Проблеми зі звʼязком, намагаємося перепідʼєднатися…», а не «ламати» весь віджет.
- Під час перепідʼєднання, якщо ви використовуєте 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 щомілісекунди, то, найімовірніше, ви перевантажите мережу й клієнт — і точно не порадуєте користувача плавною анімацією. Набагато розумніше агрегувати оновлення й надсилати прогрес, скажімо, раз на 200–500 мс або під час зміни стадії процесу. Тему throttling і backpressure ми ще обговорюватимемо, але вже на цьому етапі варто думати про частоту подій.
Помилка №4: робити складні протоколи поверх HTTP‑стріму без явного формату.
Типовий антипатерн: стримити JSON без роздільників і намагатися «вгадати», де закінчується один обʼєкт і починається інший. Або змішувати в одному потоці текст і JSON. Найкращий шлях — обрати простий і зрозумілий формат: текст по рядках або NDJSON (один JSON‑обʼєкт на рядок), або явні роздільники. Тоді парсер на клієнті залишиться притомним.
Помилка №5: забувати про тайм-аути і «вічні» стріми.
Іноді розробники роблять SSE‑ендпойнти, які нічого не надсилають 5–10 хвилин, а потім дивуються, що зʼєднання обриваються на шляху від користувача до сервера (балансувальники, API‑шлюзи, корпоративні проксі). Регулярні heartbeat‑події або коментарі дають змогу тримати зʼєднання живим і вчасно виявляти обриви. А HTTP‑стріми не повинні перетворюватися на нескінченні відповіді — для «вічних» підписок є SSE.
Помилка №6: намагатися зробити через HTTP‑стрім складний pub/sub замість нормальних подій.
Іноді виникає спокуса: «Зробимо один стрім, а ним надсилатимемо і прогрес, і partial results, і випадкові логи». У результаті на клієнті зʼявиться складний мультиплексор, який аналізує кожен чанк і вирішує, до якого jobId він належить. У більшості випадків простіше й надійніше використовувати SSE з подіями типу job.progress, job.completed та окремим каналом на job, ніж вигадувати саморобний «мегапротокол» поверх HTTP‑стріму.
Помилка №7: жорстко привʼязувати UX до того, що потік «ніколи не падає».
Будь‑який потік колись обірветься. Якщо ваш віджет після цього просто лишається з «вічно» анімованим індикатором прогресу і без варіантів дій — UX сприймається як «зламаний». Навіть просте повідомлення «Схоже, зʼєднання перервалося. Спробуйте перезапустити підбір подарунків» з кнопкою «Повторити» набагато краще, ніж мовчання.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ