Naar de inhoud
NLEN
Illustratie: Streaming met terugval bij LLM-API's

Streaming met terugval: gedeeltelijke antwoorden netjes afhandelen

Door Ivo Donker — samengesteld met AI-ondersteuning (Claude & Gemini)

Streaming via Server-Sent Events (SSE) is de standaardmethode geworden om de waargenomen wachttijd (Time to First Token) bij interactieve LLM-toepassingen drastisch te verlagen. Waar een complete generatie secondenlang op zich laat wachten, ziet de gebruiker bij streaming direct de eerste woorden op het scherm verschijnen. Deze interactieve snelheid kent echter een aanzienlijke technische keerzijde: zodra de HTTP-verbinding halverwege wegvalt of de provider een foutmelding retourneert, heeft de client al een gedeeltelijk antwoord ontvangen en getoond. Een simpele HTTP-statuscode volstaat op dat moment niet meer om een schone foutafhandeling af te dwingen.

In klassieke stateless API-architecturen kan een mislukt verzoek eenvoudig worden opgevangen met een geautomatiseerde retry naar een secundaire provider. Wie de basisprincipes van foutbestendigheid wil doorgronden, kan retries, timeouts en fallbacks bij LLM-integraties bekijken om te zien hoe circuit breakers en backoff-mechanismen werken bij geblokkeerde calls. Bij streaming breekt dat eenvoudige patroon: de client heeft immers al data geconsumeerd. In dit artikel analyseren we de vier faalmodi van streaming, ontwerpen we state-tracking mechanismen in de applicatielaag en tonen we robuuste fallback-patronen om gedeeltelijke antwoorden gecontroleerd voort te zetten of af te ronden.

De vier faalmodi van streaming verbindingen

Wanneer een LLM-aanroep faalt vóórdat de eerste byte verstuurd is, ontvangt de client een duidelijke HTTP-foutstatus (zoals 429, 500 of 503). Bij een gestreamde respons staat de HTTP-statuscode echter al vast op 200 OK met Content-Type: text/event-stream. Vanaf dat moment kunnen zich vier verschillende faalsituaties voordoen die elk een specifieke detectiemethode vereisen.

De eerste categorie is de abrupte socket-onderbreking. De TCP-verbinding tussen de API-gateway en de provider valt weg door netwerkcongestie of een herstartende reverse proxy. De stream stopt zonder voorafgaand signaal en zonder formeel afsluitend SSE-bericht (zoals data: [DONE]). De applicatie merkt dit uitsluitend doordat er een I/O-leesfout optreedt of doordat een specifieke chunk-timeout verloopt.

De tweede categorie betreft een providerfout halverwege het generatieproces. Sommige infrastructuren sturen halverwege de SSE-stroom een JSON-payload met een foutmelding in plaats van een regulier content-delta-blok. Een server kan bijvoorbeeld na 150 gegenereerde tokens tegen een interne geheugenlimiet aanlopen en een event met {"error": {"type": "server_error", "message": "Inference backend unavailable"}} doorgeven. Als de client deze payload naïef als tekst interpreteert, verschijnt ruwe JSON-code rechtstreeks in de gebruikersinterface.

De derde faalmodus ontstaat door contentfiltering en veiligheidsmoderatie. Providers evalueren zowel de prompt als de gegenereerde output. Wanneer de actieve generatie na enkele zinnen botst met een veiligheidsfilter, stopt het model abrupt en bevat het laatste SSE-blok een finish_reason: "content_filter" in plaats van "stop" of "length". Dit is geen infrastructuurstoring, maar een beleidsmatige onderbreking die niet met een willekeurige fallback opnieuw geprobeerd moet worden.

De vierde categorie is het overschrijden van tokenlimieten. Als de som van de invoertokens en de maximaal gevraagde uitvoertokens het contextvenster overschrijdt, of als de parameter max_tokens bereikt is voordat de gedachte is voltooid, retourneert de stream finish_reason: "length". De zin stopt dan halverwege. Om grip te krijgen op de ruwe structuur van dit soort netwerkstromen kun je de werking van streaming responses bij LLM-API's bestuderen, waarin de payload-specificaties van Server-Sent Events gedetailleerd worden ontleed.

Het UX-dilemma: knipperen, wissen of continueren

Wanneer een stream na 200 woorden plotseling wegvalt, moet de gebruikersinterface beslissen wat er met de al getoonde tekst gebeurt. In slecht ontworpen applicaties zien we vaak een van twee extremen: de tekst verdwijnt plotsklaps en maakt plaats voor een generieke foutmelding ("Er is iets misgegaan"), of de pagina wordt zonder waarschuwing opnieuw geladen waardoor de tekst verdwijnt en vanaf token nul opnieuw begint te tikken. Beide scenario's zorgen voor frustratie en verwarring bij de eindgebruiker.

Het plotseling wissen van al gegenereerde tekst is zonde van de cognitieve verwerking van de lezer, die de eerste alinea's mogelijk al aan het lezen was. Aan de andere kant brengt het zomaar laten staan van een afgebroken tekst ernstige risico's met zich mee: een onvolledig gegenereerd juridisch advies of een half afgekapt software-algoritme kan feitelijk onjuist of gevaarlijk zijn. In kritische toepassingen moeten gebruikers altijd in staat worden gesteld om de geldigheid van onvolledige modellen te verifiëren; raadpleeg hiervoor de richtlijnen over het systematisch fact-checken van AI-antwoorden om te beoordelen hoe menselijke controleurs halve of inconsistente outputs kunnen valideren.

Een doordachte architectuur hanteert daarom een duidelijke state machine op de frontend. Een antwoord bevindt zich in een van vier toestanden: STREAMING, COMPLETED, INTERRUPTED of FAILED. Bij een onderbreking blijft de reeds getoonde tekst behouden in de toestand INTERRUPTED, vergezeld van een visuele statusbalk die aangeeft dat de verbinding werd onderbroken, inclusief een interactieve actieknop om de generatie vanaf het laatste punt te hervatten.

Architectuur van een state-aware gateway buffer

Om herstel en fallback mogelijk te maken zonder de client met complexe routeringslogica te belasten, plaatsen we een applicatie-gateway tussen de client en de upstream AI-providers. Deze gateway fungeert als een state-aware proxy die elke inkomende chunk inspecteert, accumuleert en doorstuurt.

De gateway houdt voor elk actief streaming-verzoek een tijdelijke sessiebuffer bij in het geheugen (bijvoorbeeld via een lokale bufferstructuur of Redis bij gedistribueerde gateways). Deze buffer slaat drie cruciale waarden op: de geaccumuleerde tekst (de samengevoegde content deltas), het aantal ontvangen chunks en de laatst ontvangen metadata (zoals modelnaam en finish reasons). Mocht de upstream provider tijdens de stream wegvallen, dan weet de gateway exact hoeveel tekst er al met succes naar de client is doorgesluisd.

Wie overweegt om dergelijke routering en failover onder te brengen in een centrale infrastructuurlaag, kan de architectuur van een LLM-aggregator verkennen om te zien hoe multi-provider gateways load-balancing, rate limits en fallback-regels centraal afhandelen. In een streaming-context maakt deze centrale laag het mogelijk om een onderbroken verzoek direct om te leiden naar een alternatieve leverancier.

Herstelstrategie Voordelen Nadelen Typische Latency Impact
Prefix Resume (Voortzetten) Naadloze leeservaring; client hoeft niets opnieuw te renderen. Niet elk model ondersteunt prefix injection; risico op stijlbreuk. Laag (+300ms tot +700ms herstarttijd).
Clean Slate Retry (Volledig opnieuw) Consistente coherentie en volledige context voor het fallback-model. Dubbele tokenkosten; reeds getoonde tekst moet worden vervangen. Hoog (volledige generatieduur opnieuw).
Partial Acceptance (Markeren) Geen extra kosten; directe duidelijkheid voor de gebruiker. Onvolledig antwoord; vereist handmatige actie van de gebruiker. Geen extra latency.

Drie strategieën voor herstel bij afgebroken streams

Wanneer de backend vaststelt dat een stream definitief is afgebroken, zijn er drie fundamentele patronen beschikbaar om het incident op te lossen. De keuze voor een patroon hangt af van het modeltype, de kostenstructuur en de gewenste gebruikerservaring.

1. De Prefix Resume strategie (Voortzetting)

Bij deze strategie probeert de gateway de generatie naadloos te laten doorlopen vanaf het punt van uitval. De gateway neemt de oorspronkelijke systeemprompt en gebruikersinput, en voegt de reeds gegenereerde tekst toe als een voorlopige assistent-respons (ook wel bekend als assistant prefill of prefix prompting). Vervolgens wordt het verzoek gestuurd naar een secundaire provider met de instructie om de respons vanaf dat exacte punt af te maken.

De nieuwe chunks van de secundaire provider worden vervolgens direct achter de reeds geopende SSE-stroom naar de client geplakt. Voor de eindgebruiker lijkt het alsof er slechts een korte pauze van een halve seconde in het typen zat, waarna de tekst gewoon verder loopt. De beperking hierbij is dat niet alle commerciële API's prefilling toestaan; bij modellen die dit niet ondersteunen, moet de geaccumuleerde tekst in een herhaalde gebruikersboodschap worden geïnjecteerd met een expliciete instructie ("Ga direct verder met onderstaande tekst zonder inleiding: ...").

2. De Clean Slate Retry strategie (Schone herstart)

Als het kwaliteitsverschil tussen modellen te groot is om stijlen halverwege een alinea te mengen, of als het om gestructureerde code gaat, kiest men voor een volledige herstart bij een secundaire provider. De gateway stuurt een speciaal SSE-besturingsevent naar de client, bijvoorbeeld event: reset_stream met payload {"reason": "provider_fallback"}.

De client-applicatie weet door dit event dat de eerdere buffer moet worden gewist of visueel gemarkeerd als 'herstart', waarna de nieuwe stream vanaf het begin wordt opgebouwd. Dit garandeert logische consistentie over het gehele antwoord, maar brengt hogere tokenkosten en een langere totale wachttijd met zich mee.

3. De Graceful Degradation strategie (Gedeeltelijke acceptatie)

Wanneer het budget beperkt is of wanneer herhaalde calls ongewenst zijn, sluit de gateway de stream direct netjes af met een event: partial_complete. De getoonde tekst blijft op het scherm staan met een waarschuwing: "De verbinding werd onderbroken. Dit antwoord is mogelijk incompleet." Onder het blok verschijnt een knop "Ga verder met genereren", waarmee de gebruiker zelf expliciet een nieuw verzoek kan initiëren dat de eerdere context meeneemt.

Implementatie: Een robuuste SSE-proxy met fallback

Onderstaande Python-implementatie toont hoe een applicatielaag een upstream streaming-respons consumeert, event-fouten detecteert en bij een storing overschakelt naar een secundair model zonder de verbinding met de browser te verbreken.

import asyncio
import json
import httpx
from typing import AsyncGenerator

async def stream_met_fallback(
    prompt: str,
    primaire_url: str,
    secundaire_url: str,
    api_sleutel_primair: str,
    api_sleutel_secundair: str,
    chunk_timeout: float = 3.5
) -> AsyncGenerator[str, None]:
    geaccumuleerde_tekst = []
    stream_succesvol = False
    
    # Poging 1: Primaire provider aanroepen
    headers_primair = {
        "Authorization": f"Bearer {api_sleutel_primair}",
        "Content-Type": "application/json"
    }
    payload_primair = {
        "model": "model-alpha",
        "messages": [{"role": "user", "content": prompt}],
        "stream": True
    }
    
    try:
        async with httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=chunk_timeout, write=5.0, pool=5.0)) as client:
            async with client.stream("POST", primaire_url, headers=headers_primair, json=payload_primair) as response:
                if response.status_code != 200:
                    raise httpx.HTTPStatusError("Primaire provider gaf foutcode", request=response.request, response=response)
                
                async for regel in response.aiter_lines():
                    if not regel.startswith("data: "):
                        continue
                    data_str = regel[6:].strip()
                    if data_str == "[DONE]":
                        stream_succesvol = True
                        yield "data: [DONE]\n\n"
                        break
                    
                    data = json.loads(data_str)
                    # Controleer op expliciete foutpayloads binnen de 200 OK stream
                    if "error" in data:
                        raise RuntimeError(f"Fout in stream payload: {data['error']}")
                    
                    delta = data.get("choices", [{}])[0].get("delta", {}).get("content", "")
                    if delta:
                        geaccumuleerde_tekst.append(delta)
                        yield f"data: {json.dumps({'content': delta})}\n\n"
                        
    except (httpx.TransportError, httpx.HTTPStatusError, asyncio.TimeoutError, RuntimeError):
        # Primaire stream faalde halverwege; we schakelen over naar de fallback
        pass

    if stream_succesvol:
        return

    # Poging 2: Terugval naar secundaire provider met geaccumuleerde context
    reeds_gegenereerd = "".join(geaccumuleerde_tekst)
    headers_secundair = {
        "Authorization": f"Bearer {api_sleutel_secundair}",
        "Content-Type": "application/json"
    }
    
    # We instrueren het secundaire model om exact aan te sluiten
    berichten = [
        {"role": "user", "content": prompt}
    ]
    if reeds_gegenereerd:
        berichten.append({"role": "assistant", "content": reeds_gegenereerd})
        berichten.append({"role": "user", "content": "Ga direct verder vanaf het exacte punt waar je vorige bericht stopte."})

    payload_secundair = {
        "model": "model-beta",
        "messages": berichten,
        "stream": True
    }

    try:
        async with httpx.AsyncClient(timeout=httpx.Timeout(connect=5.0, read=chunk_timeout, write=5.0, pool=5.0)) as client:
            async with client.stream("POST", secundaire_url, headers=headers_secundair, json=payload_secundair) as response:
                if response.status_code != 200:
                    yield f"event: error\ndata: {json.dumps({'message': 'Beide providers onbereikbaar'})}\n\n"
                    return
                
                async for regel in response.aiter_lines():
                    if not regel.startswith("data: "):
                        continue
                    data_str = regel[6:].strip()
                    if data_str == "[DONE]":
                        yield "data: [DONE]\n\n"
                        break
                    
                    data = json.loads(data_str)
                    delta = data.get("choices", [{}])[0].get("delta", {}).get("content", "")
                    if delta:
                        yield f"data: {json.dumps({'content': delta, 'fallback': True})}\n\n"
    except Exception as e:
        yield f"event: error\ndata: {json.dumps({'message': 'Onherstelbare streamingfout'})}\n\n"

Sleutelbeheer en authenticatie bij cross-provider switching

Een naadloze overstap tijdens een actieve stream vereist dat secundaire API-credentials direct klaarstaan in het runtime-geheugen. Als een gateway eerst een trage database-aanroep of een externe secret-manager moet raadplegen op het moment dat een stream faalt, loopt de client tegen een merkbare hapering van honderden milliseconden aan. Hierdoor overschrijdt de client vaak zijn eigen socket-timeout.

De gateway dient daarom alle benodigde tokens voor zowel primaire als secundaire aanbieders voorverwarmd in een beveiligde cache te houden. Bij het opzetten van dergelijke infrastructuren is het essentieel om strikte scheiding van rechten toe te passen; lees meer over hoe je API-sleutels voor LLM's veilig kunt beheren zonder risico op cross-tenant datalekken of onbevoegde toegang. Roteer sleutels automatisch op de achtergrond en zorg dat falende authenticatie op een secundaire sleutel direct een alarmbel triggert in de logging.

Timeouts en deadline-budgetten bij streaming

Bij normale RPC-calls hanteert men doorgaans één totale timeout (bijvoorbeeld 10 seconden). Bij streaming werkt dit fundamenteel anders: een totale responstijd van 30 seconden kan volkomen acceptabel zijn zolang er elke 50 milliseconde een nieuw woord binnenkomt. Daarom moeten er twee afzonderlijke timeouts worden geconfigureerd:

Ten eerste is er de Time to First Token (TTFT) timeout. Als de provider na het accepteren van de verbinding niet binnen bijvoorbeeld 3 tot 5 seconden de allereerste chunk stuurt, wordt de call geannuleerd en wordt er direct omgeschakeld naar de fallback-provider. Er is immers nog niets naar de eindgebruiker gestuurd, waardoor een schone failover triviaal is.

Ten tweede is er de Inter-chunk timeout (of idle read timeout). Zodra de stream loopt, mag de tijd tussen twee opeenvolgende chunks zelden langer duren dan 2 tot 4 seconden. Overschrijdt de stream deze drempel, dan bevindt de upstream server zich waarschijnlijk in een 'hanging socket'-toestand en moet de gateway de verbinding actief verbreken. Om te zien hoe je deadline-budgetten wiskundig verdeelt over gedistribueerde subsystemen, verwijzen we naar timeouts, annulering en deadline-budgetten bij LLM-calls.

Bijzonderheden bij gestructureerde JSON-streams

Het opvangen van afgebroken streams wordt aanzienlijk complexer wanneer het model geen vrije tekst genereert, maar gestructureerde JSON-data of function calls via SSE verstuurt. Als een JSON-stream halverwege wordt afgebroken, blijft de client achter met een ongeldig gegevensfragment, zoals:

{"klant_id": 4921, "analyse": "De jaarcijfers tonen een stijging", "risico_factoren": ["liquiditeit", "valuta

Standaard JSON-parsers zoals JSON.parse() in JavaScript of json.loads() in Python zullen direct crashen op deze ongebalanceerde haakjes en niet-afgesloten strings. Voor gestructureerde streaming-toepassingen zijn daarom gespecialiseerde herstelmaatregelen noodzakelijk:

In de eerste plaats kan gebruik worden gemaakt van tolerante streaming-parsers (zoals streaming JSON tokenizers) die gedeeltelijke objecten dynamisch kunnen afsluiten. Deze bibliotheken vullen ontbrekende aanhalingstekens, accolades en rechte haken virtueel aan, zodat de applicatie de reeds gevalideerde velden (zoals klant_id) alvast kan renderen in formulieren of tabellen.

In de tweede plaats geldt voor function calling dat een afgebroken tool call nooit gedeeltelijk mag worden uitgevoerd. Als een model bezig was met het genereren van de argumenten voor een functie voer_betaling_uit({"bedrag": 500, "ontvanger": ...}) en de verbinding verbreekt voordat de volledige JSON-structuur gevalideerd is, moet de gateway de gehele call ongeldig verklaren en weigeren de backend-functie aan te roepen. Pas na een succesvolle, volledige retry mag de tool-executie plaatsvinden.

Productiechecklist voor streaming-robuustheid

Voordat een streaming-integratie in productie wordt genomen, is het raadzaam om de implementatie langs onderstaande technische criteria te leggen:

1. Configureer agressieve chunk-timeouts: Vertrouw nooit op de standaard TCP-timeouts van het besturingssysteem, die minutenlang kunnen wachten op een dode socket. Hanteer een inter-chunk timeout van maximaal 3 tot 5 seconden.

2. Schakel compressiebuffering uit op proxies: Zorg ervoor dat tussenliggende reverse proxies (zoals Nginx, Cloudflare of Traefik) response buffering direct uitschakelen (bijvoorbeeld via de HTTP-header X-Accel-Buffering: no). Als een proxy chunks opspaart om ze gecomprimeerd per 4KB te versturen, wordt de stream vertraagd en kan de client-timeout ten onrechte afgaan.

3. Hanteer expliciete SSE-foutevents: Stuur bij een backend-storing altijd een formeel SSE-event met event: error en een JSON-body in plaats van de TCP-verbinding abrupt te sluiten. Hierdoor weet de frontend exact wat er aan de hand is en kan er gerichte foutinformatie worden getoond.

4. Houd rekening met model-inconsistentie bij prefixing: Test vooraf of het fallback-model soepel kan doorbouwen op tekst die door een ander basismodel is gestart. Sommige modellen herhalen de laatste drie woorden van de prefix of reageren met een afwijkende aanspreekvorm (tutoyeren versus vousvoyeren). Een normalisatielaag in de gateway kan kleine stijlbreuken wegfilteren.

Afwegingen en conclusie

Streaming biedt een ongeëvenaarde interactieve gebruikerservaring, maar transformeert een eenvoudige request-response interactie in een gedistribueerde stateful transactie. Door op gateway-niveau chunks te bufferen, inter-chunk timeouts strikt te handhaven en doordachte fallback-strategieën zoals prefix-voortzetting in te zetten, blijven applicaties betrouwbaar functioneren wanneer een AI-aanbieder hapert.

De keuze tussen het volledig opnieuw starten van een antwoord of het gecontroleerd voortzetten met een secundair model is uiteindelijk een balans tussen tokenkosten, latency en redactionele consistentie. Door de faalmodi expliciet in te bouwen in de state machine van de frontend, zien eindgebruikers nooit meer een raadselachtig knipperend scherm, maar een stabiele, professionele applicatie die transparant omgaat met netwerkfouten.