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:
- Użytkownik wysyła pytanie. Kontroler kolejkuje zadanie i natychmiast zwraca, renderując pusty kontener odpowiedzi i subskrybując go do Turbo Stream.
- Zadanie uruchamia strumieniujące wywołanie modelu, rozgłaszając każdy token do tego strumienia, gdy przybywa.
- 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.