Skip to content
← Wszystkie wpisy
6 min czytania Dawid Skłodowski

Strumieniowanie odpowiedzi LLM z Turbo Streams

Wywołanie LLM trwa sekundy, a użytkownik gapiący się na spinner czuje każdą z nich. Strumieniowanie tokenów w miarę ich generowania zamienia martwe oczekiwanie w żywy tekst — a Turbo Streams to dokładnie ten prymityw Railsów, który wypycha ten tekst na stronę. Deep-dive o podłączeniu wolnego wyjścia modelu do responsywnego UI.

Wywołanie LLM jest wolne. Nie wolne jak zapytanie do bazy zajmujące 50ms — wolne jak kilka sekund, czasem dziesięć lub więcej dla długiego uzupełnienia. Jeśli każesz użytkownikowi czekać na całą odpowiedź, zanim cokolwiek pokażesz, siedzi gapiąc się na spinner przez cały czas trwania, a funkcja sprawia wrażenie zepsutej, nawet gdy działa. Naprawą jest strumieniowanie: model emituje swoje wyjście token po tokenie, a ty wypychasz każdy fragment na stronę w miarę jego przybywania, więc użytkownik patrzy, jak odpowiedź się pojawia jak ktoś piszący, zamiast czekać, aż ściana tekstu spadnie na końcu. Model nie jest szybszy — ale doświadczenie jest przemienione, bo martwe oczekiwanie staje się żywym postępem.

I oto satysfakcjonująca część dla dewelopera Railsów: wypychanie przyrostowego HTML na żywą stronę to dokładnie to, do czego Turbo Streams zostało zbudowane. Spędziliśmy lata w tych wpisach na idei „HTML po drucie” — serwer renderuje HTML i wysyła go do przeglądarki, która wstawia go do DOM. LLM strumieniujący swoje wyjście to po prostu ten wzorzec z nowym źródłem fragmentów. Przejdźmy, jak części się składają.

Dlaczego strumieniowanie, konkretnie

Argument za strumieniowaniem dotyczy postrzeganego opóźnienia, które jest jedynym opóźnieniem, jakie użytkownik czuje:

  • Spinner przez 8 sekund sprawia wrażenie zepsutego. Użytkownicy zakładają, że coś zawisło. Nawet pasek postępu ledwo pomaga, bo nic się nie dzieje, co mogliby zobaczyć.
  • Tekst pojawiający się w ciągu sekundy, potem płynący, sprawia wrażenie żywego. Całkowity czas do kompletnej odpowiedzi jest ten sam — ale użytkownik dostał informację zwrotną natychmiast i patrzył, jak się buduje, więc sprawia wrażenie szybkiego i responsywnego, choć model zajął tyle samo.
  • Ustawia właściwe oczekiwanie. Oglądanie strumieniujących tokenów mówi użytkownikowi „to jest generowane dla ciebie właśnie teraz”, co jest uczciwe co do tego, co się dzieje i jakiego opóźnienia oczekiwać.

To ta sama lekcja co każda inna sztuczka postrzeganej wydajności: poczucie szybkości u użytkownika dotyczy informacji zwrotnej, nie surowej przepustowości. Strumieniowanie daje informację zwrotną ciągle, zamiast raz na końcu.

Strona modelu: tokeny w miarę generowania

API OpenAI (i inne) wspiera tryb strumieniowy: zamiast zwracać całe uzupełnienie w jednej odpowiedzi, strumieniuje wyjście jako serię zdarzeń server-sent, każde niosące następny mały fragment tekstu. W Ruby konsumujesz to jako strumień i dostajesz callback na fragment:

client.chat(
  parameters: {
    model: "gpt-3.5-turbo",
    messages: [{ role: "user", content: question }],
    stream: proc do |chunk, _bytesize|
      token = chunk.dig("choices", 0, "delta", "content")
      broadcast_token(token) if token
    end
  }
)

Każdy token to mały kawałek odpowiedzi — czasem słowo, czasem jego część. Naszym zadaniem jest dostać każdy na ekran użytkownika w miarę przybywania. Tu wkraczają Railsy.

Strona Railsów: Turbo Streams wypycha każdy fragment

Turbo Streams pozwala serwerowi wysłać fragment HTML i akcję („dołącz to do tego elementu”) po trwałym połączeniu, a Turbo na stronie to stosuje. Dla strumieniowania LLM chcemy dołączać każdy token do elementu docelowego, gdy przybywa:

def broadcast_token(token)
  Turbo::StreamsChannel.broadcast_append_to(
    "answer_#{@conversation.id}",
    target: "answer_body",
    html: token
  )
end

Na stronie subskrybujemy ten strumień i dajemy celowi dom:

<%= turbo_stream_from "answer_#{conversation.id}" %>
<div id="answer_body"></div>

Teraz każdy token, który model emituje, zostaje dołączony do #answer_body w chwili wygenerowania, a użytkownik patrzy, jak odpowiedź sama się wypisuje. Bez przeładowania strony, bez odpytywania, bez własnego JavaScriptu — ta sama maszyneria Turbo Streams, która rozgłasza nowy komentarz na żywą stronę, rozgłasza tokeny modelu. „Nowe źródło fragmentów” naprawdę jest jedyną nową rzeczą.

Uruchom to w zadaniu w tle

Wywołanie strumieniujące zajmuje sekundy i trzyma otwarte połączenie do modelu przez cały czas. To nie należy do żądania webowego — wiązanie workera Pumy (albo gorzej, wątku żądania) na dziesięć sekund na pytanie to dokładnie ten rodzaj wolnego, blokującego I/O, dla którego istnieją zadania w tle. Więc kształt jest:

  1. Użytkownik wysyła pytanie. Kontroler kolejkuje zadanie i natychmiast zwraca, renderując pusty kontener odpowiedzi i subskrybując go do Turbo Stream.
  2. Zadanie uruchamia strumieniujące wywołanie modelu, rozgłaszając każdy token do tego strumienia, gdy przybywa.
  3. Użytkownik, już patrzący na (początkowo pusty) kontener, ogląda, jak wypełnia się w czasie rzeczywistym.
class AnswerJob < ApplicationJob
  def perform(conversation)
    StreamingAnswer.new(conversation).call  # rozgłasza tokeny w miarę strumieniowania
  end
end

To ta sama separacja, po którą sięgaliśmy raz za razem: żądanie webowe pozostaje szybkie i robi minimum, a wolne wywołanie zewnętrzne dzieje się w workerze. Strumieniowanie nie zmienia tej zasady — jeśli już, to ją wyostrza, bo wywołanie jest teraz zarazem wolne i długowieczne.

Czyste zamknięcie strumienia

Gdy model kończy, strumień tokenów się kończy i zwykle chcesz zrobić trochę porządków: oznacz odpowiedź jako kompletną, utrwal pełny tekst, może podmień wskaźnik „generuję…” na stan gotowości. Dołącz końcowe rozgłoszenie, które zastępuje kontener albo przełącza flagę:

def finish
  @conversation.update!(answer: @buffer, status: :complete)
  Turbo::StreamsChannel.broadcast_replace_to(
    "answer_#{@conversation.id}",
    target: "answer_body",
    partial: "answers/complete",
    locals: { conversation: @conversation }
  )
end

Zauważ, że akumulujesz też tokeny do bufora w miarę strumieniowania, by gdy się skończy, mieć kompletny tekst do utrwalenia. Użytkownik widział, jak pojawia się na żywo; baza dostaje finalną wersję. Oba mają znaczenie — żywy widok dla doświadczenia, zapisana kopia, by odpowiedź przetrwała przeładowanie strony.

Co może pójść nie tak i jak to obsłużyć

Strumieniowanie dodaje parę trybów awarii wartych zaprojektowania:

  • Wywołanie modelu pada w połowie. Wystrumieniowałeś pół odpowiedzi i połączenie pada albo API daje błąd. Rozgłoś powiadomienie o błędzie do tego samego kontenera i oznacz rozmowę jako nieudaną — nie zostawiaj pół-odpowiedzi siedzącej tam i wyglądającej na kompletną. Ponieważ jesteś w zadaniu w tle, stosuje się twoja normalna logika ponowień, ale uważaj: ponowienie częściowo wystrumieniowanej odpowiedzi oznacza, że użytkownik może zobaczyć, jak zaczyna od nowa, więc zdecyduj świadomie, czy ponawiać, czy ujawnić awarię.
  • Użytkownik odchodzi. Zadanie działa dalej i rozgłasza do strumienia, którego nikt nie słucha, co jest nieszkodliwe, ale marnotrawne dla długiej generacji. Zwykle niewarte rozwiązywania wcześnie, ale warte wiedzy.
  • Tokeny przybywają szybciej, niż się renderują. Rzadko problem przy szybkościach LLM (model jest wąskim gardłem, nie przeglądarka), ale jeśli kiedyś batchujesz tokeny dla wydajności, dołączaj w małych grupach, a nie jeden element na token, by ograniczyć tłuczenie DOM.

Żadne z tych nie jest egzotyczne — to zwykła obsługa awarii dowolnego wolnego wywołania zewnętrznego, co jest dokładnie tym, jak nauczyliśmy się traktować API LLM przez całą tę serię: wolna, opomiarowana, czasem padająca usługa zewnętrzna, którą wołasz z zadania i której awarie obsługujesz jawnie.

Werdykt

Odpowiedzi LLM są dość wolne, by kazanie użytkownikowi czekać na całość sprawiało wrażenie zepsutego — a strumieniowanie wyjścia token po tokenie naprawia doświadczenie bez czynienia modelu szybszym, zamieniając martwy spinner w żywy, płynący tekst. Piękna część dla deweloperów Railsów jest taka, że masz już idealny prymityw: Turbo Streams zostało zbudowane, by wypychać przyrostowy HTML na żywą stronę, a LLM emitujący tokeny to po prostu nowe źródło fragmentów dla tej samej maszynerii. Konsumuj strumień modelu w zadaniu w tle (bo jest wolny i długowieczny, dokładnie tam, gdzie należy wolne I/O), dołączaj każdy token do elementu docelowego przez broadcast_append_to, akumuluj bufor, by móc utrwalić kompletną odpowiedź, i rozgłoś czyste zakończenie — obsługując przypadek częściowej awarii jawnie jak każde inne wywołanie zewnętrzne. Model jest tej samej szybkości co zawsze; użytkownik po prostu dostaje patrzeć, jak myśli. To cała sztuczka, i to niewielka ilość kodu na wierzchu wzorców, które już znasz.