Geautomatiseerd testen van LLM-integraties
Wanneer een applicatie afhankelijk is van een Large Language Model (LLM), verandert de manier waarop software getest moet worden fundamenteel. In traditionele applicaties zijn externe afhankelijkheden vaak voorspelbaar: een database of een interne microservice geeft bij dezelfde invoer steevast dezelfde uitvoer. Een LLM-dienst is daarentegen een extern beheerd, stochastisch systeem dat op elk moment kan wijzigen, vertragen of fouten kan retourneren. Om een robuuste applicatie te bouwen, is een doordachte teststrategie noodzakelijk.
In dit artikel behandelen we hoe je geautomatiseerde tests opzet voor software die communiceert met LLM-API's. We ontleden de architectuur van de testsuite, bouwen een doeltreffende mock- en replay-laag op, simuleren de belangrijkste foutpaden en leggen uit hoe je het testen van applicatiecode scheidt van het evalueren van modelgedrag.
Het essentiële onderscheid: integratie testen versus model testen
De meest voorkomende denkfout bij het opzetten van automatisering voor LLM-applicaties is het behandelen van het model en de integratie als één enkel te testen onderdeel. In de praktijk betreft het twee volstrekt verschillende testsoorten met uiteenlopende doelen, snelheden en stabiliteitseisen.
Het testen van de integratie richt zich op jouw eigen applicatiecode. Dit omvat de logica die de prompt opbouwt, de HTTP-verzoeken verzendt, netwerkfouten afhandelt, de ontvangen data parseert en verwerkt in de rest van het systeem. Deze tests moeten snel, goedkoop en honderd procent deterministisch zijn. Ze mogen niet afhankelijk zijn van een actieve internetverbinding of de werkelijke API van een modelleverancier.
Het testen van het model (of de evaluatie van de prompt) richt zich op de inhoudelijke kwaliteit, relevantie en veiligheid van de gegenereerde tekst. Dit type test draait wel tegen een live API of lokaal model, kost geld, duurt langer en levert niet-deterministische resultaten op. Door deze twee lagen strikt gescheiden te houden, voorkom je dat een instabiele API-verbinding het testen van je interne bedrijfslogica blokkeert.
Waarom de klassieke testpiramide niet voldoet
De traditionele testpiramide gaat uit van een brede basis van snelle unit-tests, een smallere laag integratietests en een heel dunne top van end-to-end (E2E) tests. Bij deze klassieke verdeling wordt aangenomen dat de interne code het vaakst verandert en dat externe afhankelijkheden stabiel zijn. Bij het werken met LLM-API's is deze veronderstelling onjuist.
Let op: Bij LLM-integraties is de externe dienst de meest onstabiele factor in de keten. Providers voeren onaangekondigde updates uit, hanteren dynamische rate limits en veranderen af en toe de structuur van foutmeldingen of metadata.
Omdat de externe afhankelijkheid continu kan fluctueren, raakt de klassieke piramide uit balans. Als je vertrouwt op integratietests die bij elke build een live API aanroepen, krijg je te maken met:
- Hoge kosten: Honderden builds per dag die tokens verbruiken maken het testproces onnodig duur.
- Trage feedbacklussen: Een testsuite die minutenlang moet wachten op HTTP- antwoorden van een LLM vertraagt de continuous integration (CI) pipeline.
- Flaky tests: Netwerkschommelingen of kleine variaties in het gegenereerde antwoord laten de pijplijn onterecht falen, waardoor ontwikkelaars het vertrouwen in de tests verliezen.
De oplossing hiervoor is een gelaagde testopzet waarbij het overgrote deel van de applicatietests wordt uitgevoerd tegen een lokale, opgeslagen representatie van de API-antwoorden.
De mock- en replay-laag: testen met opgeslagen antwoorden
Om je integratielogica snel en deterministisch te testen, maak je gebruik van een mock- of replay-mechanisme. Hierbij sla je de werkelijke HTTP-respons van een succesvolle API-aanroep op in een lokaal bestand (vaak een JSON-fixture). Tijdens het uitvoeren van de testsuite onderschept je testframework de netwerkaanroep en speelt deze opgeslagen respons direct af.
Door te testen met opgeslagen antwoorden kun je de volgende onderdelen van je applicatie isoleren en valideren:
- Prompt-constructie: Worden de dynamische variabelen uit de database correct geïnjecteerd in de systeem- en gebruikersprompts?
- Request-opbouw: Worden de juiste parameters, zoals de temperatuur en JSON-schema's, correct meegegeven in de payload naar de API?
- Response parsing: Kan de applicatie de ontvangen JSON-payload foutloos verwerken en omzetten naar interne domeinobjecten? Zorg dat je voor de data-extractie gebruikmaakt van duidelijke afspraken, zoals beschreven in het artikel over structured output.
- Vervolgstappen: Wordt de geparseerde informatie correct opgeslagen in de database of doorgegeven aan de gebruikersinterface?
Het verouderen van opgeslagen antwoorden voorkom
Een risico van opgeslagen API-antwoorden is dat ze kunnen verouderen. LLM-providers passen regelmatig hun API-headers, responsstructuren of foutcodes aan. Als je testsuite uitsluitend draait op verouderde JSON-fixtures, slaagt je CI-pijplijn glansrijk, terwijl de applicatie in productie vastloopt.
Om dit te voorkomen richt je een periodieke taak in (bijvoorbeeld een nachtelijke build) die de opgeslagen fixtures opnieuw opneemt tegen de echte API. Als de provider het responsformaat heeft gewijzigd, zal deze opnametest falen of gewijzigde bestanden opleveren. Zo merk je wijzigingen bij de provider op voordat je gebruikers er hinder van ondervinden in productie.
Foutpaden expliciet nabootsen
Een robuuste integratie onderscheidt zich door de manier waarop fouten worden opgevangen. Aangezien live LLM-API's regelmatig storingen vertonen, moet je integratietestsuite de belangrijkste faalscenario's expliciet simuleren. Dit doe je door je mock-laag zo in te stellen dat deze specifieke HTTP-foutcodes en afwijkende netwerk payloads retourneert.
| Foutscenario | Gesimuleerd API-gedrag | Verwacht applicatiegedrag |
|---|---|---|
| Tijdslimiet (Timeout) | Verbinding blijft openstaan en geeft pas na X seconden een foutmelding. | De applicatie verbreekt de verbinding tijdig en gooit een specifieke timeout-exceptie. |
| Rate limiting | Retourneert HTTP status 429 met een Retry-After header. | De applicatie vangt de 429 op en activeert het interne retry-mechanisme. |
| Ongeldige JSON | Retourneert HTTP status 200, maar de tekstuele inhoud bevat afgebroken JSON. | De parser vangt de fout op zonder dat de applicatie crasht en meldt een parsing-fout. |
| Half afgebroken stream | Verzendt drie netwerkblocks en verbreekt abrupt de TCP-verbinding. | De streaming-client detecteert de onderbreking en ruimt de geopende bronnen op. |
| Leeg antwoord | Retourneert HTTP status 200 met een lege content string of 0 gegenereerde tokens. | De applicatie herkent het lege antwoord als ongeldig en volgt het fallback-pad. |
Het correct afhandelen van netwerk- en tijdsfouten vereist specifieke patronen in je code. Lees meer over hoe je onderbrekingen op de juiste manier afhandelt in het overzicht over timeouts en cancellation, en bekijk de strategieën voor het opnieuw proberen van mislukte verzoeken op de pagina over retries en backoff.
Streaming-functionaliteit geautomatiseerd testen
Veel moderne LLM-toepassingen maken gebruik van streaming (Server-Sent Events) om gegenereerde tokens direct naar de gebruiker te tonen. Het testen van streaming-integraties is complexer dan het testen van een enkele HTTP-POST-respons, omdat de factor tijd en netwerkinstabiliteit een grotere rol spelen.
Bij het testen van streaming moet je twee situaties onderscheiden:
- De volledige stroom: De server stuurt alle datablokken netjes achter elkaar, afgesloten met een expliciet eindsignaal (zoals
[DONE]). Je test valideert of de frontend of verwerkingslaag de losse tokens correct aan elkaar plakt tot een samenhangend geheel. - De voortijdig afgebroken stroom: De server stuurt enkele datablokken en stopt er daarna mee, of de verbinding valt weg door een netwerkfout.
Het tweede geval moet je bewust nabootsen in je testomgeving. Test of de applicatie in staat is de al ontvangen tokens op te slaan of netjes weg te gooien, de gebruikersinterface bij te werken naar een duidelijke foutstatus en de geopende HTTP-verbinding op de juiste manier te sluiten. Als dit niet goed getest is, kunnen afgebroken streams leiden tot geheugenlekken of 'hangende' UI-elementen in de applicatie.
De live testlaag: testen tegen het echte model
Naast de snelle, gemockte integratietests heb je een kleine, gescheiden testsuite nodig die wél verbinding maakt met het echte model. Het doel van deze laag is niet het testen van je code, maar het controleren of de veronderstellingen over het modelgedrag nog klappen.
Deze live testsuite heeft specifieke kenmerken:
- Klein en gericht: Bevat alleen essentiële kernscenario's (bijvoorbeeld de belangrijkste bedrijfsprompt).
- Niet-blokkerend: Draait niet bij elke lokale commit of pull request, maar bijvoorbeeld eenmaal per dag of voor een release.
- Geïsoleerd: Een fout in deze suite wijst op een verandering in het model of de provider, niet per se op een bug in jouw software.
Omdat LLM's niet-deterministisch zijn, kun je een antwoord van een live model zelden testen met een exacte tekstvergelijking (zoals assert response == "Hallo"). In plaats daarvan evalueer je het antwoord op basis van eigenschappen.
Toetsen op eigenschappen (Property-based testing)
Bij het toetsen op eigenschappen controleer je of de uitvoer van het model voldoet aan een reeks structurele en inhoudelijke randvoorwaarden. Voorbeelden van eigenschapstoetsen zijn:
- Schema-validatie: Als de prompt vraagt om een JSON-structuur, bevat het antwoord dan alle verplichte sleutels en zijn de waarden van de verwachte datatypen?
- Lengte- en vormbeperkingen: Blijft het gegenereerde antwoord binnen de gestelde limieten (bijvoorbeeld maximaal drie zinnen of een lijst met precies vijf items)?
- Contextuele opsluiting: Bevat het antwoord geen beweringen die strijdig zijn met de meegegeven context (beperking van hallucinaties)?
- Negatieve randvoorwaarden: Blijven vertrouwelijke instructies of verboden termen afwezig in de uitvoer?
Het opzetten van deze inhoudelijke evaluaties overlap deels met kwaliteits- en regressietesten op prompt-niveau. Voor een gedetailleerde uitdieping van het meten van promptkwaliteit kun je het artikel over regressietesten van prompts raadplegen. Daarnaast vind je praktisch advies over het opzetten van testsets op het community-platform via de gids over prompt testen voor productie.
Het misverstand over temperatuur nul en zaadwaarden
Een hardnekkige mythe bij het ontwikkelen van LLM-applicaties is dat het instellen van de parameter temperature: 0 of het meegeven van een vaste zaadwaarde (seed) zorgt voor volledig deterministisch gedrag. Dit is technisch onjuist.
Hoewel een lage temperatuur de kansverdeling van de te genereren tokens versmalt, blijven er factoren die voor variatie zorgen:
- Floating-point afrondingen: Parallelle verwerking op GPU-clusters kan door piepkleine verschillen in de volgorde van berekeningen tot net iets andere tokenkansen leiden.
- Model-updates op de achtergrond: Providers voeren kleine optimalisaties door op hun infrastructuur zonder het versienummer van het model te wijzigen.
- Infrastructuur-routing: Een verzoek kan op een ander fysiek systeem worden afgehandeld met een licht afwijkende hardware-architectuur.
Het gevolg hiervan voor je testsuite is helder: bouw je asserties nooit op de aanname dat temperature: 0 elke keer exact dezelfde string oplevert. Vertrouw uitsluitend op eigenschapstoetsen, schema-validaties en parser-controles.
Kosten en doorlooptijd als ontwerpprobleem
Een effectieve testpijplijn vereist een duidelijke scheiding van testtypen op basis van uitvoeringstijd en kosten. Als elke ontwikkelaar bij elke kleine code-wijziging honderden live API-verzoeken moet uitvoeren, ontstaat er een trage en dure workflow. De testsuite moet daarom bewust worden gestructureerd.
In de onderstaande tabel staat een advies voor de verdeling van testtypen over de ontwikkelcyclus:
| Testtype | Afhankelijkheid | Frequentie | Snelheid | Primaire focus |
|---|---|---|---|---|
| Unit- & Integratietests | Mocks & JSON-fixtures | Bij elke commit / Pull Request | Milliseconds tot seconden | Code-kwaliteit, parsing, foutafhandeling. |
| Fixture-verversings-tests | Echte API (Live) | Nachtelijk / Wekelijks | Nkele minuten | Detecteren van API-formaatwijzigingen bij de provider. |
| Model- & Eigenschapstests | Echte API (Live) | Voor elke release / Gepland | Minuten tot uren | Valideren van prompt-kwaliteit en modelgedrag. |
Door deze lagen strikt te scheiden, blijven de ontwikkellussen snel en worden kosten beheersbaar gehouden. Meer achtergrond over het monitoren van de prestaties van je integratie in productie vind je in de handleiding over observability en logging.
Regressie op gedrag scheiden van regressie op code
Als een geautomatiseerde test faalt, moet het voor een ontwikkelaar direct duidelijk zijn waar de oorzaak ligt. Falende tests moeten daarom een ondubbelzinnig signaal afgeven. Wanneer het testen van de code en het evalueren van het gedrag op één hoop worden gegooid, ontstaat er verwarring:
Is de test rood omdat de JSON-parser een bug bevat (code-fout), of omdat het model een synoniem gebruikte dat de test niet verwachtte (gedragsverandering)?
Om deze verwarring te voorkomen, moeten meldingen gescheiden zijn:
- Code-regressie (Build Failure): De CI-pijplijn breekt af. Dit betekent dat een parser niet meer werkt, een HTTP-header ontbreekt of de interne logica faalt op basis van bekende fixtures. Dit moet direct door de ontwikkelaar worden hersteld.
- Gedrags-regressie (Alert / Dashboard): De live evaluatiesuite merkt dat het model anders reageert op een bestaande prompt. Dit leidt niet tot een gebroken build, maar tot een melding naar de prompt-engineer of het productteam om de instructies aan te passen of te herbalanceren.
Een overzicht van het ontwerpen van een stabiele architectuur die bestand is tegen dit soort wijzigingen lees je in het hoofdartikel over robuuste integraties.
De verplichte context van een testopzet
Om een testuitslag (of het nu een mock of een live test betreft) waardevol en reproduceerbaar te maken, moet de exacte context van de aanroep worden vastgelegd. Een testresultaat is waardeloos als niet exact bekend is onder welke condities de test is uitgevoerd.
Leg in elke testopstelling en fixture-metadata minimaal de volgende drie factoren expliciet vast:
- Exacte modelversie: Gebruik nooit algemene aliassen zoals
latestofstablein je testconfiguraties. Specificeer altijd de exacte versie-tag (bijvoorbeeld inclusief datumstempel van de release). Model-aliassen veranderen op de achtergrond, waardoor testresultaten van gisteren niet meer te vergelijken zijn met die van vandaag. - Promptversie: Sla prompts op als geversioneerde bronbestanden in je versiebeheersysteem. Een test moet altijd gekoppeld zijn aan een specifiek commit-hash of versienummer van de prompt-sjabloon.
- Aanroep-parameters: Sla alle meegegeven parameters exact op. Denk hierbij aan de ingestelde
temperature,top_p,max_tokens, de meegegevenstop_sequencesen eventuele schema-definities voor gestructureerde uitvoer.
Wanneer een test faalt, stelt deze context je in staat om exact te reconstrueren wat er is gebeurd, de aanroep handmatig te herhalen en te bepalen of het probleem zich bevindt in jouw applicatielogica, de prompt-instructies of bij de externe provider.
