Компанія запустила асистента ШІ для обробки запитів клієнтів. Модель генерувала відповідь за 4–6 секунд. Користувачі двічі натискали «Надіслати», думаючи, що нічого не відбувається. Після ввімкнення стрімінгу та сама модель генерує перші слова за 400 мс. Загальний час не змінився. Кількість подвійних кліків зменшилася на 80%.
Це суть стрімінгу: він не прискорює inference, а змінює сприйняття очікування.
Що таке стрімінг і чому він змінює UX#
Стандартний виклик LLM виглядає так: клієнт надсилає запит, модель генерує всі токени в пам'яті, сервер передає повну відповідь. Користувач бачить порожню сторінку протягом усіх 4–6 секунд, а потім раптово повний текст.
Стрімінг токен-за-токеном змінює порядок передачі. Модель генерує токен, сервер його надсилає, браузер рендерить. Користувач бачить текст, що росте літерочка за літерочкою, точно як у ChatGPT. Психологічно це фундаментальна різниця: система виглядає чуйною, навіть якщо вона така ж повільна обчислювально.
Дві метрики мають значення в контексті стрімінгу:
| Метрика | Що вимірює | Орієнтовні значення (2026) |
|---|---|---|
| TTFT (Time to First Token) | Час від надсилання запиту до першого токена | 200–900 мс (API cloud), 80–400 мс (local GPU) |
| Throughput (токени/с) | Швидкість генерації після першого токена | 20–80 ток/с (локальний GPU 7B–13B) |
| Загальний час відповіді | TTFT + (кількість токенів / throughput) | 2–15 с для типової відповіді |
Без стрімінгу користувач чекає на загальний час. Зі стрімінгом чекає лише на TTFT, а решта надходить прогресивно. Для відповіді довжиною 300 токенів при throughput 40 ток/с загальний час становить близько 7,5 с. TTFT 400 мс означає, що користувач починає читати через 0,4 с, а не через 7,5 с.
Архітектура SSE: як побудувати правильно#
Server-Sent Events (SSE) — це протокол HTTP, у якому сервер підтримує відкрите з'єднання та надсилає текстові події у форматі data: {...}\n\n. Клієнт (браузер або клієнт API) отримує кожну подію окремо та рендерить її прогресивно.
Мінімальний потік на стороні сервера (Python/FastAPI):
from fastapi.responses import StreamingResponse
async def generate_stream(prompt: str):
async for token in llm.stream(prompt):
yield f"data: {json.dumps({'token': token})}\n\n"
yield "data: [DONE]\n\n"
@app.post("/v1/chat/stream")
async def chat_stream(req: ChatRequest):
return StreamingResponse(
generate_stream(req.prompt),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
Чотири речі, які найчастіше псують стрімінг у продакшені:
Буферизуючі проксі. Nginx за замовчуванням буферизує відповіді. Заголовок X-Accel-Buffering: no вимикає буферизацію для конкретного ендпоінту. Без нього клієнт отримає весь стрім одразу після завершення, точно як без стрімінгу.
Шар BFF з gzip. Якщо Next.js або інший BFF переписує запити через rewrite, стиснення gzip буферизує чанки до порогового розміру. Рішення: File Route Handler, що повертає upstream.body безпосередньо, з заголовком Cache-Control: no-transform. У Cashcrown ми зіткнулися з цією проблемою при побудові Playground, де стандартний rewrite BFF буферизував SSE для браузерів з gzip, поки не перейшли на прямий handler.
Відсутність heartbeat. З'єднання SSE, що простоюють 30–60 с, закриваються load balancer'ами та CDN. Надсилайте коментар ": ping\n\n" кожні 15–20 с під час довгих генерацій.
Backpressure. Якщо клієнт читає повільніше, ніж сервер пише (наприклад, повільне мобільне з'єднання), буфер черги зростає. Імплементація повинна обмежувати розмір черги на з'єднання та закривати його при перевищенні порогу, замість того, щоб тримати дані нескінченно. У Cashcrown використовуємо чергу asyncio.Queue(100) на з'єднання з автоматичним закриттям при перевищенні ліміту.
Guardrails на стрімі: що працює, що ні#
Стрімінг ускладнює валідацію. При повній відповіді guardrail може перевірити весь текст перед надсиланням. На стрімі у вас лише фрагмент.
Три підходи, що застосовуються на практиці:
Guardrail вхідний (pre-stream). Працює з промптом перед надсиланням до моделі. Перевіряє ін'єкції, заборонені теми, PII. Якщо промпт не проходить, стрім ніколи не стартує. Це найпростіший та найефективніший патерн. Кожен запит у Cashcrown проходить через screen() перед ініціалізацією SSE.
Guardrail вихідний на буфері вікна. Підтримуйте буфер останніх N токенів та перевіряйте кожне нове вікно на наявність заборонених патернів. Ви можете зупинити стрім на півдорозі, надіславши подію помилки. Недолік: користувач бачить половину відповіді, а потім повідомлення про помилку. Застосовуйте лише для серйозних порушень (чутливі дані, content policy).
Post-stream review для незворотних дій. Якщо відповідь призводить до незворотної дії (підтвердження платежу, відправлення електронного листа, запис до бази), ніколи не виконуйте її на основі самого стріму. Буферизуйте повну відповідь, пропустіть через повний guardrail, чекайте на підтвердження від людини. Стрімінг — це шар UX, а не шар коректності. Незворотні рішення вимагають повної валідації та людського підтвердження.
Скасування стріму та очищення ресурсів#
Користувач може натиснути «Зупинити» посередині генерації. На стороні браузера AbortController закриває з'єднання SSE. На стороні сервера потрібно слухати request.is_disconnected() (FastAPI/Starlette) або аналог та переривати цикл генерації.
async def generate_stream(prompt: str, request: Request):
async for token in llm.stream(prompt):
if await request.is_disconnected():
break # зупинити генерацію, звільнити семафори
yield f"data: {json.dumps({'token': token})}\n\n"
Відсутність обробки скасування означає, що модель продовжує генерацію у фоновому режимі, займаючи GPU та семафор паралелізму, навіть коли клієнт вже не слухає. При багатьох одночасних користувачах це призводить до вичерпання пулу та зростання latency для всіх.
Стаття вартість токенів LLM: як її вимірювати та оптимізувати описує патерни семафора та бюджетів викликів, які працюють з пайплайном стрімінгу.
Observability: що вимірювати при стрімінгу#
Традиційних метрик HTTP (загальний час) недостатньо для оцінки якості стрімінгу. Потрібні чотири додаткові точки вимірювання.
TTFT на запит. Timestamp при першому токені мінус timestamp надсилання запиту. Ціль залежить від use case: для чат-асистента нижче 600 мс комфортно, для генерації документів нижче 2 с прийнятно.
Throughput на стрім. Токени, надіслані поділені на час від першого до останнього токена. Зниження throughput сигналізує про перевантаження GPU або backpressure у мережі.
Показник скасувань (cancellation rate). Відсоток стрімів, перерваних клієнтом до останнього токена. Високий показник (понад 15–20%) означає, що користувачі втрачають терпіння. Причини: занадто довгий TTFT, невідповідна якість відповіді, проблеми UX в інтерфейсі.
Помилки часткового виводу (partial output errors). Стріми, завершені помилкою після надсилання хоча б одного токена. Це складніша категорія для обробки, ніж повна помилка, бо користувач бачить обірваний текст.
Патерн збору цих метрик в observability: при кожній події SSE записуйте до кільцевого буфера телеметрію (timestamp, token count, connection id). При закритті з'єднання скидайте до системи метрик. Більше про шар observability для агентів ШІ описує моніторинг якості агента ШІ.
Коли стрімінг не має сенсу#
Стрімінг покращує UX при текстових відповідях для людини. Він не покращує, а може ускладнювати, у кількох сценаріях:
При structured output (JSON, XML) частковий вивід є некоректним до моменту закриття структури. Стрімінг полів JSON призводить до ситуації, коли клієнт отримує {"result": "poz і мусить чекати. Краще буферизувати та надсилати повний JSON після валідації схемою.
При batch processing (обробка документів без інтерфейсу користувача) TTFT не має значення для жодної людини. Оверхед стрімінгу (підтримка з'єднань) — це витрати без користі.
При коротких відповідях (менше 20–30 токенів) різниця між TTFT та загальним часом занадто мала, щоб бути перцептивно значущою. Класифікатор з одного речення не потребує стрімінгу.
Стаття локальні LLM: який залізо та GPU дійсно потрібні розглядає throughput як критерій підбору заліза для продакшн-пайплайнів.
FAQ#
Чи прискорює стрімінг генерацію токенів моделлю?#
Ні. Стрімінг не змінює швидкість генерації токенів. Модель LLM генерує один токен за раз у циклі авторегресії незалежно від режиму передачі. Стрімінг змінює лише момент доставки клієнту: кожен токен надсилається одразу після генерації, замість того, щоб чекати на завершення всієї відповіді. Ефект є перцептивним, але суттєвим: TTFT 400 мс замість 6 с — це різниця між системою «чуйною» та «завислою» в очах користувача.
Як обробити помилку посередині стріму?#
Стандартний HTTP не має механізму «помилка після 200 OK» для body, бо статус вже надіслано. Патерн SSE: надсилайте спеціальну подію помилки (event: error\ndata: {...}\n\n) перед закриттям з'єднання. Клієнт слухає подію error окремо від події message. На стороні UI покажіть фрагмент, що надійшов, з приміткою про переривання та запропонуйте можливість відновити. Ніколи не приховуйте факт, що відповідь є неповною, бо користувач може ухвалити рішення на основі обірваного тексту.
Чи можна стрімити через Next.js App Router без буферизації?#
Так, але це вимагає File Route Handler (app/api/.../route.ts), що повертає new Response(upstream.body, {...}) з заголовками Cache-Control: no-cache, no-transform та X-Accel-Buffering: no. Стандартний rewrite у next.config.js переписує запит через шар Node.js, який gzip-буферизує відповідь для браузерів з Accept-Encoding: gzip. File Route Handler оминає цей шар та передає body upstream безпосередньо. Стаття prompt caching LLM описує комплементарні техніки оптимізації пайплайну LLM на стороні сервера.
Як вимірювати TTFT у продакшені без доступу до клієнта?#
Вимірюйте на стороні сервера: timestamp перед надсиланням першої data: події мінус timestamp отримання запиту. Це проксі для реального TTFT (не враховує час мережі), але достатньо для відстеження трендів та виявлення регресій. Реальний TTFT end-to-end вимірюйте через інструментування клієнта: performance.mark('request-sent') при надсиланні, performance.mark('first-token') при першій події SSE. Експортуйте обидві значення до тієї ж системи метрик та корелюйте.
Які обмеження guardrails на стрімі?#
Guardrail на повному тексті має всю інформацію. Guardrail на стрімі бачить лише префікс. Патерни, що потребують контексту всього речення (подвійне заперечення, замасковане прохання), важко виявити на вікні кількох токенів. Підхід у Cashcrown: повний guardrail на вході усуває більшість ризиків перед стартом, guardrail на виході виявляє лексичні патерни (заборонені слова, фрагменти номерів карток, PII). Для контенту високого ризику весь стрім буферизується та верифікується перед відображенням, що нівелює перевагу UX від стрімінгу. Це свідомий trade-off.
