LLM-antwoorden streamen in Rails: stop met gebruikers naar een laadspinner laten staren
Afgelopen oktober belde een klant met een klacht die eigenlijk een compliment had moeten zijn. Ze hadden een GPT-4o-aangedreven documentanalysator in hun Rails-app geïntegreerd — iets wat ik mee had gebouwd. Gebruikers klikten op “Analyseer” en staarden vervolgens twaalf seconden naar een leeg wit vlak totdat het volledige antwoord verscheen. “Gebruikers denken dat het stuk is,” vertelde de CTO me. “De helft van hen laadt de pagina opnieuw.”
De analyse was oprecht goed. De gebruikerservaring was oprecht slecht. De oplossing kostte twee uur werk.
Het probleem was synchrone afhandeling van de response. De OpenAI API stuurde tokens zo snel als hij ze kon genereren, maar de Rails controller wachtte op de volledige response, sloeg die op in de database en renderde hem pas dan. Dat is de juiste architectuur voor batchverwerking. Het is de verkeerde architectuur voor iets waar een mens in real time naar zit te kijken.
Streaming lost het op. Het eerste token verschijnt binnen een seconde op het scherm. Gebruikers zien voortgang. Ze laden de pagina niet meer opnieuw.
De transportopties
Drie patronen om serverdata in real time naar een browser te sturen:
WebSockets — bidirectioneel, stateful, permanente verbinding. Prima voor chat met meerdere gebruikers. Te zwaar voor “toon LLM-output aan één gebruiker.”
Long polling — de browser doet een verzoek, de server houdt het open en antwoordt zodra er data is. Werkt overal, lastig om netjes te implementeren, en niet echt streaming.
Server-Sent Events (SSE) — eenrichtingsverkeer, HTTP-gebaseerd, ingebouwd in de browser. De browser opent een verbinding en de server stuurt events zodra ze beschikbaar zijn. Perfect voor LLM-streaming, waarbij alle data één kant op stroomt.
SSE sluit ook naadloos aan op hoe de streaming-API van OpenAI zelf werkt. Het mentale model is een directe vertaling.
Action Controller::Live
Rails heeft SSE-ondersteuning ingebouwd via ActionController::Live, al sinds versie 4.0. Het is nooit populair geworden — de hype rondom async web trok door naar JavaScript-frameworks — maar het is goed onderhouden, productie-getest en vereist nul extra infrastructuur.
Het basispatroon:
class AnalysisController < ApplicationController
include ActionController::Live
def stream
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no" # cruciaal voor nginx
sse = ActionController::Live::SSE.new(response.stream, retry: 300, event: "message")
begin
sse.write({ status: "started" })
# ... stuur hier je data
rescue ActionController::Live::ClientDisconnected
# Gebruiker is weggenavigeerd — normaal, geen fout
ensure
sse.close
end
end
end
De X-Accel-Buffering: no header is makkelijk om over het hoofd te zien. Zonder die header buffert nginx je volledige response voordat hij hem doorstuurt naar de client. Je “streaming”-feature streamt dan niet.
OpenAI streaming koppelen
De ruby-openai gem ondersteunt streaming via een stream-parameter die een proc accepteert. Elk token dat aankomt vanuit de API roept jouw proc direct aan:
class AnalysisController < ApplicationController
include ActionController::Live
def stream
response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
response.headers["X-Accel-Buffering"] = "no"
document = Document.find(params[:id])
sse = ActionController::Live::SSE.new(response.stream, retry: 300, event: "message")
begin
client = OpenAI::Client.new(
access_token: Rails.application.credentials.openai_api_key
)
client.chat(
parameters: {
model: "gpt-4o",
messages: [
{ role: "system", content: "You are a precise document analyst." },
{ role: "user", content: "Analyze this document:\n\n#{document.content}" }
],
stream: proc { |chunk, _bytesize|
token = chunk.dig("choices", 0, "delta", "content")
sse.write({ token: token }) if token
}
}
)
sse.write({ status: "done" })
rescue ActionController::Live::ClientDisconnected
# Normaal — gebruiker is weggenavigeerd
rescue => e
sse.write({ error: "Analyse mislukt. Probeer het opnieuw." })
Rails.logger.error("Streaming analyse mislukt: #{e.message}")
ensure
sse.close
end
end
end
Voeg het toe aan je routes:
# config/routes.rb
resources :documents do
member do
get :stream
end
end
De frontend
De native EventSource API van de browser verwerkt SSE zonder extra bibliotheken:
function startAnalysis(documentId) {
const output = document.getElementById("analysis-output");
output.textContent = "";
const source = new EventSource(`/documents/${documentId}/stream`);
source.addEventListener("message", (event) => {
const data = JSON.parse(event.data);
if (data.token) {
output.textContent += data.token;
}
if (data.status === "done") {
source.close();
}
if (data.error) {
output.textContent = data.error;
source.close();
}
});
source.onerror = () => {
output.textContent += "\n\n[Verbinding verbroken. Vernieuw de pagina om opnieuw te proberen.]";
source.close();
};
}
Als je Turbo gebruikt, kun je tokens toevoegen aan een <turbo-frame> in plaats van textContent direct aan te passen — maar voor streaming output is gewone DOM-manipulatie overzichtelijker. Turbo morphing en token-voor-token streaming gaan niet goed samen zonder zorgvuldige afhandeling.
Het resultaat opslaan
Streaming naar de browser lost het UX-probleem op, maar je wilt de voltooide analyse waarschijnlijk ook ergens opslaan. Bouw de volledige response op in de controller en sla hem op nadat de stream klaar is:
full_response = +"" # mutable string — let op de +
client.chat(
parameters: {
model: "gpt-4o",
messages: messages,
stream: proc { |chunk, _bytesize|
token = chunk.dig("choices", 0, "delta", "content")
if token
full_response << token
sse.write({ token: token })
end
}
}
)
document.update!(analysis: full_response)
sse.write({ status: "done" })
De +"" geeft je een mutable String. Als je # frozen_string_literal: true bovenaan het bestand hebt staan, gooit de shovel operator op een bevroren string een FrozenError. Makkelijk te vergeten, frustrerend om ‘s nachts te debuggen.
Productie: Puma-threads
Dit is wat mensen in productie overrompelt: ActionController::Live houdt een Puma-thread bezet voor de volledige duur van de stream. Een tien seconden durende OpenAI-response bezet één thread gedurende tien seconden.
De standaard threadpool van Puma is min: 5, max: 5. Bij vijf gelijktijdige gebruikers die LLM-streams triggeren, zijn alle threads bezet. Verzoek nummer zes moet wachten. De responstijden van je gehele applicatie verslechteren.
Opties:
Vergroot de threadpool. Prima tot op zekere hoogte. Geheugengebruik groeit met circa 100–200MB per Ruby-thread in een typisch Rails-proces:
# config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS", 20)
threads threads_count, threads_count
Dedicated streaming-proces. Routeer /documents/:id/stream naar een apart Puma-proces of dyno met een grotere threadpool. Heroku, Render en Fly ondersteunen allemaal meerdere procestypes binnen dezelfde app. Je hoofdapp blijft responsief; het streaming-proces absorbeert de blokkerende threads.
Polling via achtergrondtaken. Een Solid Queue- of Sidekiq-taak roept de LLM aan, slaat tokens op in Redis en een lichtgewicht polling-endpoint leegt ze in een SSE-response. Meer infrastructuur, meer complexiteit — de moeite waard bij hoog volume (honderden gelijktijdige streams).
Voor de meeste applicaties — minder dan 100 gelijktijdige LLM-streams — is de pragmatische aanpak: vergroot de Puma-threadpool naar 15–20 en draai op hardware met voldoende RAM. Houd het eenvoudig totdat de cijfers anders zeggen.
nginx-configuratie
Zet X-Accel-Buffering: no in de controller-header én in je nginx-configuratie. Proxy-buffering heeft de neiging zichzelf opnieuw te activeren op configuratieniveau, ook al heb je de header gezet:
location /documents {
proxy_buffering off;
proxy_cache off;
proxy_pass http://rails_app;
proxy_read_timeout 120s;
}
proxy_read_timeout is de instelling die mensen altijd missen. nginx hanteert standaard 60 seconden. Een lange documentanalyse of een meerstaps redenering kan langer duren. Zonder deze instelling sluit nginx de verbinding halverwege de stream en krijgt de gebruiker een afgekapt antwoord zonder enige foutmelding.
Rate limiting
Zodra streaming werkt, begrens je het. Een LLM-aanroep die 15 seconden streamt terwijl hij een Puma-thread bezet, is een veel zwaarder belastende resource dan een snelle JSON-endpoint. Rack::Attack:
# config/initializers/rack_attack.rb
Rack::Attack.throttle("llm_stream/ip", limit: 5, period: 60) do |req|
req.ip if req.path.include?("/stream")
end
Vijf streaming-verzoeken per minuut per IP is ruim genoeg voor normaal gebruik en strak genoeg om misbruik te voorkomen. Pas aan op basis van je werkelijke gebruikspatronen.
Wanneer je dit niet gebruikt
Streaming is niet altijd het juiste antwoord. Als je een PDF-rapport genereert, valt er niets te streamen — je hebt de volledige response nodig voordat je er iets mee kunt. Als je batch-verrijkingen uitvoert in een achtergrondtaak, slaat streamen naar de browser nergens op.
Streaming telt wanneer een mens actief kijkt en wacht. Documentanalyse, AI-schrijfhulp, codereview, vraagbeantwoording — alles waarbij waargenomen responsiviteit bepaalt of gebruikers het systeem vertrouwen. Toen ik synchrone afhandeling verving door streaming voor die documentanalysator, stopten de “het voelt stuk” supportverzoeken. De werkelijke latency was identiek. De ervaring niet.
Na negentien jaar Rails-applicaties bouwen, kom ik steeds op dezelfde les uit: gebruikers accepteren trage processen die ze kunnen volgen. Ze accepteren geen snelle processen die eruit zien als bevroren.
Veelgestelde vragen
Werkt dit ook met Anthropic Claude of andere providers?
Ja. De anthropic Ruby gem ondersteunt streaming met hetzelfde proc-gebaseerde patroon:
client = Anthropic::Client.new(
api_key: Rails.application.credentials.anthropic_api_key
)
client.messages(
model: "claude-opus-4-6",
max_tokens: 2048,
messages: messages,
stream: proc { |event|
token = event.dig("delta", "text")
sse.write({ token: token }) if token
}
)
De meeste LLM-providers die een streaming-API aanbieden, gebruiken hetzelfde token-voor-token leveringsmodel.
Kan ik Hotwire Turbo Streams gebruiken in plaats van EventSource?
Dat kan, maar het is onhandig. Turbo Streams verwachten complete HTML-fragmenten, terwijl token-streaming continu deelstrings aanlevert. Het schonere patroon is EventSource gebruiken voor de stream, tokens direct aan een DOM-element toevoegen en Turbo alleen inzetten voor de laatste stap — opslaan en verversen — nadat de stream klaar is.
Wat gebeurt er als de gebruiker halverwege de stream het tabblad sluit?
ActionController::Live gooit ActionController::Live::ClientDisconnected. Vang dit op — maar log het niet als fout, want het is volkomen normaal. De LLM API-aanroep loopt aan de providerkant gewoon door. Met de huidige ruby-openai gem is er geen manier om een actieve streaming-aanroep te annuleren. Je betaalt dus voor het volledige aantal tokens, of de gebruiker nu wacht of niet. Goed om te weten voordat je features bouwt die dure langdurige generaties triggeren.
Wordt Server-Sent Events ondersteund door alle browsers?
Alle grote browsers ondersteunen SSE al sinds 2012. De enige uitzondering van betekenis is IE11, dat in 2022 het einde van zijn levensduur bereikte. Als je in 2026 nog IE11-ondersteuning nodig hebt, heb je grotere problemen dan het streamen van LLM-antwoorden.
AI-features integreren in jouw Rails-applicatie? TTB Software heeft meerdere productie-LLM-integraties op Rails gebouwd — documentanalyse, RAG-pipelines, AI-ondersteunde workflows. We weten waar de grenzen liggen. Neem contact op.
About the Author
Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation