Firma uruchomiła asystenta AI do obsługi zapytań klientów. Model generował odpowiedź w 4–6 sekund. Użytkownicy klikali „Wyślij” dwukrotnie, myśląc, że nic się nie dzieje. Po włączeniu streamingu ten sam model generuje pierwsze słowa po 400 ms. Całkowity czas nie zmienił się. Liczba podwójnych kliknięć spadła o 80%.
To jest istota streamingu: nie przyspiesza inference, zmienia percepcję czekania.
Czym jest streaming i dlaczego zmienia UX#
Standardowe wywołanie LLM wygląda następująco: klient wysyła zapytanie, model generuje wszystkie tokeny w pamięci, serwer przesyła pełną odpowiedź. Użytkownik widzi pustą stronę przez całe 4–6 sekund, a potem nagle pełny tekst.
Streaming token-po-tokenie odwraca kolejność przesyłania. Model generuje token, serwer go wysyła, przeglądarka renderuje. Użytkownik widzi tekst rosnący literka po literce, dokładnie jak w ChatGPT. Psychologicznie to fundamentalna różnica: system wygląda na responsywny, nawet gdy jest tak samo wolny obliczeniowo.
Dwie metryki mają znaczenie w kontekście streamingu:
| Metryka | Co mierzy | Wartości orientacyjne (2026) |
|---|---|---|
| TTFT (Time to First Token) | Czas od wysłania zapytania do pierwszego tokenu | 200–900 ms (API cloud), 80–400 ms (local GPU) |
| Throughput (tokeny/s) | Prędkość generowania po pierwszym tokenie | 20–80 tok/s (GPU lokalny 7B–13B) |
| Całkowity czas odpowiedzi | TTFT + (liczba tokenów / throughput) | 2–15 s dla typowej odpowiedzi |
Bez streamingu użytkownik czeka na całkowity czas. Z streamingiem czeka tylko na TTFT, a reszta przychodzi progresywnie. Dla odpowiedzi o długości 300 tokenów przy throughput 40 tok/s całkowity czas wynosi ok. 7,5 s. TTFT 400 ms oznacza, że użytkownik zaczyna czytać po 0,4 s, nie po 7,5 s.
Architektura SSE: jak to zbudować poprawnie#
Server-Sent Events (SSE) to protokół HTTP, w którym serwer utrzymuje otwarte połączenie i wysyła zdarzenia tekstowe w formacie data: {...}\n\n. Klient (przeglądarka lub klient API) odbiera każde zdarzenie osobno i renderuje je progresywnie.
Minimalny przepływ po stronie serwera (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"},
)
Cztery rzeczy, które najczęściej psują streaming w produkcji:
Buforujące proxy. Nginx domyślnie buforuje odpowiedzi. Nagłówek X-Accel-Buffering: no wyłącza buforowanie dla konkretnego endpointu. Bez niego klient otrzyma cały strumień naraz po zakończeniu, dokładnie jak bez streamingu.
Warstwa BFF z gzip. Jeśli Next.js lub inny BFF przepisuje żądania przez rewrite, kompresja gzip buforuje chunki do progowego rozmiaru. Rozwiązanie: File Route Handler zwracający upstream.body bezpośrednio, z nagłówkiem Cache-Control: no-transform. My w Cashcrown spotkaliśmy ten problem przy budowie Playground, gdzie standardowy rewrite BFF buforował SSE dla przeglądarek z gzip, dopóki nie przeszliśmy na bezpośredni handler.
Brak heartbeatu. Połączenia SSE bezczynne przez 30–60 s są zamykane przez load balancery i CDN. Wysyłaj komentarz ": ping\n\n" co 15–20 s podczas długich generowań.
Backpressure. Jeśli klient czyta wolniej niż serwer pisze (np. wolne połączenie mobilne), bufor kolejki rośnie. Implementacja powinna ograniczać rozmiar kolejki per połączenie i zamykać je przy przekroczeniu progu, zamiast trzymać dane nieskończenie. W Cashcrown używamy kolejki asyncio.Queue(100) per połączenie z automatycznym zamknięciem przy przekroczeniu limitu.
Guardrails na strumieniu: co działa, co nie#
Streaming komplikuje walidację. Przy pełnej odpowiedzi guardrail może sprawdzić cały tekst przed wysłaniem. Na strumieniu masz tylko fragment.
Trzy podejścia stosowane w praktyce:
Guardrail wejściowy (pre-stream). Działa na promptcie przed wysłaniem do modelu. Sprawdza injection, zakazane tematy, PII. Jeśli prompt nie przejdzie, strumień nigdy nie startuje. To najprostszy i najskuteczniejszy wzorzec. Każde zapytanie w Cashcrown przechodzi przez screen() przed inicjalizacją SSE.
Guardrail wychodzący na buforze okienkowym. Utrzymuj bufor ostatnich N tokenów i sprawdzaj każde nowe okno pod kątem zakazanych wzorców. Możesz zatrzymać strumień w połowie, wysyłając zdarzenie błędu. Wada: użytkownik widzi połowę odpowiedzi, a potem komunikat o błędzie. Stosuj tylko dla poważnych naruszeń (dane wrażliwe, content policy).
Post-stream review dla nieodwracalnych akcji. Jeśli odpowiedź prowadzi do nieodwracalnej akcji (zatwierdzenie płatności, wysłanie e-maila, zapis do bazy), nigdy nie wykonuj jej na podstawie samego strumienia. Buforuj pełną odpowiedź, przepuść przez pełny guardrail, czekaj na potwierdzenie człowieka. Streaming jest warstwą UX, nie warstwą poprawności. Decyzje nieodwracalne wymagają pełnej walidacji i ludzkiego zatwierdzenia.
Anulowanie strumienia i sprzątanie zasobów#
Użytkownik może kliknąć „Zatrzymaj” w połowie generowania. Po stronie przeglądarki AbortController zamyka połączenie SSE. Po stronie serwera trzeba nasłuchiwać na request.is_disconnected() (FastAPI/Starlette) lub odpowiednik i przerywać pętlę generowania.
async def generate_stream(prompt: str, request: Request):
async for token in llm.stream(prompt):
if await request.is_disconnected():
break # zatrzymaj generowanie, zwolnij semafory
yield f"data: {json.dumps({'token': token})}\n\n"
Brak obsługi anulowania oznacza, że model kontynuuje generowanie w tle, zajmując GPU i semafor współbieżności, nawet gdy klient już nie słucha. Przy wielu równoczesnych użytkownikach prowadzi to do wyczerpania puli i wzrostu latencji dla wszystkich.
Artykuł koszt tokenów LLM: jak go mierzyć i optymalizować opisuje wzorce semafora i budżetów wywołań, które współpracują z pipeline'em streamingowym.
Obserwabilność: co mierzyć przy streamingu#
Tradycyjne metryki HTTP (czas całkowity) nie wystarczą do oceny jakości streamingu. Potrzebujesz czterech dodatkowych punktów pomiarowych.
TTFT per zapytanie. Timestamp przy pierwszym tokenie minus timestamp wysłania żądania. Cel zależy od use case'u: dla asystenta czat poniżej 600 ms jest komfortowe, dla generowania dokumentów poniżej 2 s jest akceptowalne.
Throughput per strumień. Tokeny wysłane podzielone przez czas od pierwszego do ostatniego tokenu. Spadek throughput sygnalizuje przeciążenie GPU lub backpressure w sieci.
Wskaźnik anulowań (cancellation rate). Procent strumieni przerywanych przez klienta przed ostatnim tokenem. Wysoki wskaźnik (powyżej 15–20%) oznacza, że użytkownicy tracą cierpliwość. Przyczyny: zbyt długie TTFT, nieodpowiednia jakość odpowiedzi, problemy UX w interfejsie.
Partial output errors. Strumienie zakończone błędem po wysłaniu przynajmniej jednego tokenu. To trudniejsza kategoria do obsługi niż całkowity błąd, bo użytkownik widzi urwany tekst.
Wzorzec zbierania tych metryk w observability: przy każdym zdarzeniu SSE zapisuj do ring-buffer telemetrię (timestamp, token count, connection id). Na zamknięciu połączenia flush do systemu metryk. Więcej o warstwie observability dla agentów AI opisuje monitoring jakości agenta AI.
Kiedy streaming nie ma sensu#
Streaming poprawia UX przy odpowiedziach tekstowych dla człowieka. Nie poprawia go, a może komplikować, w kilku scenariuszach:
Przy structured output (JSON, XML) częściowy output jest nieważny do momentu zamknięcia struktury. Streamowanie pól JSON prowadzi do sytuacji, gdzie klient dostaje {"wynik": "poz i musi czekać. Lepiej buforować i wysłać pełny JSON po walidacji schematem.
Przy batch processing (przetwarzanie dokumentów bez interfejsu użytkownika) TTFT nie ma znaczenia dla żadnego człowieka. Overhead streamingu (utrzymanie połączeń) jest kosztem bez korzyści.
Przy krótkich odpowiedziach (poniżej 20–30 tokenów) różnica między TTFT a całkowitym czasem jest zbyt mała, żeby była percepcyjnie istotna. Klasyfikator jedno-zdaniowy nie wymaga streamingu.
Artykuł lokalne LLM: jaki sprzęt i GPU naprawdę potrzebujesz omawia throughput jako kryterium doboru sprzętu dla pipeline'ów produkcyjnych.
FAQ#
Czy streaming przyspiesza generowanie tokenów przez model?#
Nie. Streaming nie zmienia szybkości generowania tokenów. Model LLM generuje jeden token naraz w pętli autoregresyjnej niezależnie od trybu przesyłu. Streaming zmienia tylko moment dostarczenia do klienta: każdy token wysyłany jest natychmiast po wygenerowaniu, zamiast czekać na zakończenie całej odpowiedzi. Efekt jest percepcyjny, ale istotny: TTFT 400 ms zamiast 6 s to różnica między systemem „responsywnym” a „zamrożonym” w oczach użytkownika.
Jak obsłużyć błąd w połowie strumienia?#
Standardowy HTTP nie ma mechanizmu „błąd po 200 OK” dla body, bo status jest już wysłany. Wzorzec SSE: wysyłaj specjalne zdarzenie błędu (event: error\ndata: {...}\n\n) przed zamknięciem połączenia. Klient nasłuchuje na zdarzenie error osobno od zdarzenia message. Po stronie UI wyświetl fragment, który dotarł, z adnotacją o przerwaniu, i zaoferuj możliwość wznowienia. Nigdy nie ukrywaj faktu, że odpowiedź jest niekompletna, bo użytkownik może podjąć decyzję na podstawie urwanego tekstu.
Czy można streamować przez Next.js App Router bez buforowania?#
Tak, ale wymaga File Route Handler (app/api/.../route.ts) zwracającego new Response(upstream.body, {...}) z nagłówkami Cache-Control: no-cache, no-transform i X-Accel-Buffering: no. Standardowy rewrite w next.config.js przepisuje żądanie przez warstwę Node.js, która gzip-buforuje odpowiedź dla przeglądarek z Accept-Encoding: gzip. File Route Handler omija tę warstwę i przesyła body upstream bezpośrednio. Artykuł prompt caching LLM opisuje komplementarne techniki optymalizacji pipeline'u LLM po stronie serwera.
Jak mierzyć TTFT w produkcji bez dostępu do klienta?#
Mierz po stronie serwera: timestamp przed wysłaniem pierwszego data: zdarzenia minus timestamp przyjęcia żądania. To proxy dla realnego TTFT (nie uwzględnia czasu sieci), ale wystarczające do śledzenia trendów i wykrywania regresji. Realne TTFT end-to-end mierz przez instrumentację klienta: performance.mark('request-sent') przy wysłaniu, performance.mark('first-token') przy pierwszym zdarzeniu SSE. Eksportuj obie wartości do tego samego systemu metryk i koreluj.
Jakie są ograniczenia guardrails na strumieniu?#
Guardrail na pełnym tekście ma wszystkie informacje. Guardrail na strumieniu widzi tylko prefiks. Wzorce wymagające kontekstu całego zdania (podwójna negacja, zakamuflowana prośba) są trudne do wykrycia na oknie kilku tokenów. Podejście w Cashcrown: pełny guardrail wejściowy eliminuje większość ryzyk przed startem, guardrail wychodzący wykrywa wzorce leksykalne (zakazane słowa, fragmenty numerów kart, PII). Dla treści wysokiego ryzyka cały strumień jest buforowany i weryfikowany przed wyświetleniem, co cofa korzyść UX ze streamingu. To świadomy trade-off.
