Przejdź do treści

Jak odpowiedź pisze się słowo po słowie: streaming po HTTP

Profile photo of Adrian Zawadzki

Adrian Zawadzki

7 min czytania

Odpowiedź AI pojawia się stopniowo nie przez efekt wizualny, tylko przez streaming: ciało odpowiedzi HTTP jest strumieniem, więc serwer może odsyłać je kawałkami, a przeglądarka pokazywać każdy od razu. Na froncie czyta się to na dwa sposoby - prościutkim EventSource (SSE) albo elastycznym fetch ze strumieniem - i dokładnie tego drugiego używają pod spodem biblioteki do LLM-ów.

Zadajesz pytanie asystentowi AI, a odpowiedź nie pojawia się od razu w całości - leci słowo po słowie, jakby ktoś pisał ją na żywo. To nie efekt wizualny doklejony dla wrażenia. Tak działa streaming: odpowiedź serwera nie przychodzi jedną paczką, tylko kawałkami, a strona pokazuje każdy kawałek od razu, gdy doleci.

I nie ma w tym nic z samego AI - to zwykły mechanizm HTTP, starszy niż LLM-y. Dopiero one zrobiły z niego codzienność, ale pod spodem to ten sam web, który już znasz.

#Odpowiedź nie musi przyjść naraz

Domyślnie myślimy o żądaniu tak: wysyłasz je, czekasz, i dostajesz całą odpowiedź w jednym kawałku. Najpierw serwer przygotowuje ją w całości, potem odsyła, a przeglądarka pokazuje ją, gdy dotrze ostatni bajt.

Ale nie musi tak być. Ciało odpowiedzi HTTP to strumień - ciąg bajtów, które mogą płynąć stopniowo. Serwer nie musi czekać, aż uzbiera całość: może odsyłać ją porcjami, w miarę jak ją tworzy, i trzymać połączenie otwarte, dopóki nie skończy. Przeglądarka odbiera te porcje na bieżąco.

Dla odpowiedzi AI to idealnie pasuje: model i tak generuje tekst kawałek po kawałku, więc zamiast czekać na cały akapit, serwer odsyła każdy fragment od razu. Użytkownik widzi pierwsze słowa po ułamku sekundy, a nie po kilku sekundach ciszy. Łączny czas bywa ten sam - ale odczuwalny jest dużo krótszy, bo coś dzieje się od razu.

Przełącz tryb i odtwórz raz „naraz”, raz „strumień” - różnicę widać od pierwszego słowa:

Włącz odtwarzanie i porównaj. Naraz musisz czekać na całość; strumień pokazuje pierwsze słowa od razu.

#Najprościej: Server-Sent Events

Jeśli potrzebujesz tylko jednego kierunku - serwer wysyła, przeglądarka odbiera - jest na to gotowe, proste narzędzie: Server-Sent Events (SSE). W przeglądarce wystarczy new EventSource(url) i nasłuch na zdarzeniu message - i to w zasadzie wszystko. EventSource sam otwiera połączenie i odbiera kolejne porcje, a gdy połączenie padnie, sam próbuje połączyć się ponownie. Dlatego po skończonej odpowiedzi połączenie zamykasz sam (source.close()) - inaczej EventSource od razu połączy się od nowa. Serwer ze swojej strony oznacza odpowiedź nagłówkiem Content-Type: text/event-stream i wysyła kolejne kawałki jako linie zaczynające się od data:, oddzielone pustą linią.

Prosto - ale za tę prostotę płacisz ograniczeniami. SSE jest jednokierunkowe (tylko serwer → przeglądarka), działa wyłącznie z metodą GET i nie pozwala dodać własnych nagłówków do żądania. Dla zwykłego „serwer mówi, klient słucha” to nie problem. Gorzej, gdy chcesz wysłać pytanie metodą POST albo dołożyć nagłówek z tokenem - wtedy SSE staje ci na drodze.

#Z pełną kontrolą: fetch i strumień

Tu wchodzi drugi sposób, bardziej elastyczny, bo działa o poziom niżej. Zwykły fetch też daje ci strumień: response.body to ReadableStream, czyli ciąg porcji, które możesz czytać jedna po drugiej, w miarę jak nadlatują.

const response = await fetch("/api/odpowiedz", {
  method: "POST",
  body: JSON.stringify({ pytanie }),
});

const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  dopiszDoUI(decoder.decode(value, { stream: true }));
}

reader.read() oddaje kolejną porcję, gdy tylko doleci, albo done: true, gdy strumień się skończy. Jedna rzecz, której łatwo nie zauważyć: value to nie tekst, tylko surowe bajty (Uint8Array). Dlatego zamieniamy je na tekst TextDecoder-em, i właśnie tu liczy się flaga { stream: true }. Porcje są pocięte tam, gdzie akurat wypadło w transmisji, a nie na granicy liter - a niektóre znaki zajmują więcej niż jeden bajt (polskie „ż” to dwa bajty, emoji nawet cztery). Taki znak może więc trafić w pół: pierwszy bajt na końcu jednej porcji, reszta na początku następnej. Bez tej flagi dekoder wstawiłby w tym miejscu nieczytelny znak zastępczy (); z nią odkłada niepełny znak i składa go dopiero, gdy doleci reszta.

W zamian za te kilka linijek dostajesz pełną kontrolę: POST, własne nagłówki, dowolny format danych. I właśnie tej drogi używają pod spodem biblioteki do LLM-ów - wysyłają pytanie POST-em i czytają strumień odpowiedzi dokładnie tak.

#Który sposób kiedy

  • SSE - gdy wystarczy „serwer wysyła, klient słucha” i chcesz to mieć w pięć minut: powiadomienia, pasek postępu, prosty feed. Auto-reconnect w gratisie.
  • fetch ze strumieniem - gdy potrzebujesz POST-a, własnych nagłówków albo nietypowego formatu (czyli zwykle przy AI). Trochę więcej kodu, znacznie więcej swobody.
  • WebSocket - gdy ruch ma płynąć w obie strony naraz (czat, gra, wspólna edycja). To osobne narzędzie; do samego strumieniowania odpowiedzi z serwera jest przerostem formy.

#Pułapki

Trzy rzeczy psują streaming najczęściej:

  1. Buforowanie po drodze. Strumień działa tylko wtedy, gdy każda warstwa między serwerem a przeglądarką przepuszcza porcje od razu. Jeśli proxy albo CDN zbuforuje odpowiedź, żeby oddać ją w całości, cały efekt znika - użytkownik znów czeka na koniec. Dlatego strumieniowane endpointy serwuje się tak, żeby nic po drodze ich nie buforowało (na nginx wyłącza to X-Accel-Buffering: no) ani nie cache’owało (Cache-Control: no-cache).
  2. Anulowanie. Użytkownik zamknął kartę albo zadał nowe pytanie w połowie odpowiedzi? Strumień trzeba przerwać, żeby nie czytać go w nieskończoność. Służy do tego AbortController - ten sam, którym anuluje się zwykły fetch.
  3. Błąd w środku. Przy zwykłym żądaniu błąd dostajesz na starcie, zanim cokolwiek pokażesz. W strumieniu połączenie może paść w trakcie, gdy część odpowiedzi już wisi na ekranie. Trzeba to obsłużyć osobno: pokazać, że odpowiedź jest niepełna, zamiast udawać, że się udało.

Następnym razem, gdy odpowiedź pisze się słowo po słowie, już wiesz, że to nie sztuczka interfejsu. Ciało odpowiedzi to strumień bajtów, a cała sztuka sprowadza się do jednego: czytać je w miarę, jak nadlatują, zamiast czekać na ostatni.

Komentarze

Wczytywanie komentarzy…

Dodaj komentarz