Naar de inhoud
NLEN
Illustratie: LLM-integraties regressietesten in CI/CD-pipelines

LLM-integraties regressietesten in CI/CD-pipelines

Door Ivo Donker — samengesteld met AI-ondersteuning (Claude & Gemini) · 20 augustus 2026

Het integreren van taalmodellen in moderne softwarearchitecturen brengt een fundamentele verschuiving met zich mee voor traditionele softwarekwaliteitsborging: niet-deterministische output. Waar klassieke unit- en integratietests vertrouwen op harde, binaire vergelijkingen (zoals assert result == 42), introduceert een LLM een probabilistische component. Een ogenschijnlijk triviale aanpassing in een systeemprompt, een wijziging in de contextopbouw of een stille backend-update bij een modelleverancier kan ertoe leiden dat antwoorden plotseling subtiel afwijken in structuur, lengte, toon of semantische nauwkeurigheid. Zonder geautomatiseerde regressietesten in de CI/CD-pipeline sluipen deze kwaliteitslekken ongemerkt door naar de productieomgeving.

Een doordachte CI/CD-pipeline voor LLM-applicaties balanceert drie tegenstrijdige belangen: snelle feedbackcycli voor ontwikkelaars, beheersbare API-kosten en diepgaande semantische kwaliteitscontrole. Voor wie de fundamenten van lokale mocks en unit-evaluaties wil opzetten, biedt de handleiding over geautomatiseerd testen van LLM-integraties een solide basis om individuele componenten modulair te isoleren. In dit artikel behandelen we de architectuur en operationalisering binnen continue integratie: hoe ontwerp je een gelaagde testsuite die zowel deterministische contractbreuken als statistische kwaliteitsregressies tijdig signaleert en blokkeert?

De specifieke faalmodi van taalmodellen in productie

Software die afhankelijk is van externe taalmodellen faalt op wezenlijk andere manieren dan traditionele microservices. Naast conventionele netwerkfouten en rate limits zijn er vier specifieke faalmodi die een continue testpipeline moet kunnen detecteren:

Gelaagde testarchitectuur in continuous integration

Het direct aanroepen van live model-API's bij elke push of pull request is operationeel onhoudbaar. Het leidt tot trage pipelines, onvoorspelbare build-tijden, willekeurige netwerkfouten (flakiness) en torenhoge tokenkosten. Een effectieve pipeline verdeelt de testlast daarom strikt over drie lagen, elk met een eigen frequentie, trigger en isolatieniveau.

Testlaag Trigger & Frequentie Uitvoeringsmethode Doel & Validatiecriteria
Laag 1: Deterministic Unit & Contract Elke commit / PR-push (Fast Feedback) Lokale mocks, opgeslagen VCR-cassettes, Pydantic Payload-formattering, parsering, schemaconformiteit, foutafhandeling en fallback-paden.
Laag 2: Integration Smoke & Sanity PR merge / Staging deployment Live API met minimale testset (< 15 items) Connectiviteit, API-sleutelpermissies, token-budgettering en basale modelinteractie.
Laag 3: Full Evaluation & Benchmarking Nachtelijke build / Release-tag / Modelupgrade Live API over complete gouden dataset (> 200 items) Statistische kwaliteitsscores, cosine similarity, LLM-as-a-judge scores en kosten-latentieregistratie.

Deterministische contractvalidatie met JSON Schema en Pydantic

Wanneer een LLM fungeert als functionele component in een softwarepijplijn, genereert het vrijwel altijd gestructureerde gegevens voor downstream applicatielogica. Een onverwachte veldnaam of ongeldig type leidt direct tot een onverwerkte runtime-exceptie. Daarom vormt schema-validatie de snelste en meest kritieke verdedigingslinie in Laag 1.

Als je wilt weten hoe je gegarandeerde JSON-schema's direct op protocolniveau afdwingt bij API-aanroepen, bekijk dan de documentatie over betrouwbare structured output uit LLM's om te zien hoe schema-validatie direct op netwerkniveau wordt afgehandeld. Binnen de CI-omgeving valideren we zowel opgeslagen payloads als live gegenereerde modelresponsen tegen strikte Pydantic-modellen.

import json
import pytest
from pydantic import BaseModel, Field, ValidationError

class ExtractedInvoiceItem(BaseModel):
    description: str = Field(..., min_length=2)
    quantity: int = Field(..., gt=0)
    unit_price_cents: int = Field(..., ge=0)
    vat_rate: float = Field(..., ge=0.0, le=1.0)

class InvoiceExtractionResponse(BaseModel):
    invoice_number: str = Field(..., pattern=r"^[A-Z0-9\-]{4,20}$")
    currency: str = Field(..., min_length=3, max_length=3)
    items: list[ExtractedInvoiceItem] = Field(..., min_items=1)
    total_amount_cents: int = Field(..., gt=0)

def parse_and_validate_payload(raw_json: str) -> InvoiceExtractionResponse:
    try:
        data = json.loads(raw_json)
        return InvoiceExtractionResponse.model_validate(data)
    except (json.JSONDecodeError, ValidationError) as err:
        raise AssertionError(f"Contractbreuk in LLM-output: {err}")

def test_invoice_payload_schema_compliance():
    sample_recorded_payload = """
    {
      "invoice_number": "INV-2026-0042",
      "currency": "EUR",
      "items": [
        {"description": "Cloud Hosting", "quantity": 1, "unit_price_cents": 12500, "vat_rate": 0.21}
      ],
      "total_amount_cents": 15125
    }
    """
    validated = parse_and_validate_payload(sample_recorded_payload)
    assert validated.invoice_number == "INV-2026-0042"
    assert len(validated.items) == 1
    assert validated.items[0].unit_price_cents == 12500

Omdat deze tests uitsluitend lokale validatielogica uitvoeren, draaien honderden assertions binnen enkele milliseconden zonder enige externe netwerkafhankelijkheid of kosten.

Mocks, caching en VCR-cassettes in continue integratie

Om integratietests betrouwbaar en snel uit te voeren tijdens reguliere pull requests, maakt de testsuite gebruik van request-recording via bibliotheken zoals vcrpy of pytest-recording. Bij de eerste uitvoering worden het exacte HTTP-verzoek en het bijbehorende antwoord van de LLM-leverancier opgeslagen in een lokaal YAML-bestand (de cassette).

import os
import pytest
from openai import OpenAI

@pytest.fixture
def api_client():
    api_key = os.getenv("OPENAI_API_KEY", "test-key-not-used-when-mocked")
    return OpenAI(api_key=api_key)

@pytest.mark.vcr
def test_summarization_integration_flow(api_client):
    response = api_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Vat de tekst samen in maximaal één zin."},
            {"role": "user", "content": "Kubernetes cluster auto-scaling vereist accurate metingen van resourcegebruik."}
        ],
        temperature=0.0
    )
    content = response.choices[0].message.content.strip()
    assert len(content) > 10
    assert "Kubernetes" in content

In de standaard CI-stap draait pytest met --vcr-record=none. Als een ontwikkelaar de prompt-tekst of modelparameters in de code aanpast, matcht het uitgaande HTTP-verzoek niet meer met de bestaande cassette, waardoor de test direct faalt. Dit dwingt de ontwikkelaar om bewust nieuwe testcassettes te genereren en deze te reviewen in de pull request.

Samenstelling en beheer van gouden evaluatiedatasets

Voor de diepere evaluaties in Laag 3 is een zogenaamde gouden dataset (golden dataset) onmisbaar. Deze dataset bestaat uit een representatieve steekproef van productiescenario's met vooraf gevalideerde invoer, context en gewenste uitvoercriteria.

Een complete gouden dataset voor CI/CD-regressietesten bevat vier specifieke categorieën:

Bewaar deze datasets als geversionneerde JSONL-bestanden binnen de repository (bijvoorbeeld in tests/fixtures/eval_golden_set.jsonl). Hierdoor doorlopen wijzigingen in testdata exact dezelfde peer-reviews en git-historie als de productiecode.

Meetmethodes voor semantische evaluaties: omgaan met niet-determinisme

Omdat taalmodellen zelfs bij temperature=0.0 kleine variaties kunnen vertonen in woordkeuze en zinsopbouw, leiden strikte stringvergelijkingen tot een onwerkbare hoeveelheid vals-positieve testresultaten. Voor kwalitatieve output hanteren we daarom een combinatie van deterministische heuristieken, embeddings-vergelijkingen en modelgebaseerde beoordelaars.

Om te voorkomen dat de algehele nauwkeurigheid en stijl stilletjes afbrokkelen over opeenvolgende iteraties, lees je in het artikel over regressietesten voor prompts en het voorkomen van kwaliteitsverlies hoe je degradatie over meerdere modelgeneraties kwantificeert. Binnen continuous integration vertalen we deze meetmethodes naar geautomatiseerde slagingsdrempels.

Evaluatiemethode Doelwit & Toepassing Meetmethode & Formule Typische Slagingsdrempel in CI
Exact Match & Regex Statuscodes, datums, categorieën String matching / Regex patterns 100% (geen enkele afwijking toegestaan)
Embedding Cosine Similarity Korte samenvattingen, antwoordintentie Cosine hoek tussen vectoren van output en referentie Gemiddelde score ≥ 0.88; geen enkel item < 0.75
ROUGE-L / BERTScore Documentextractie, parafrasering Langste gemeenschappelijke deelsequentie / Token-alignments ROUGE-L F1 ≥ 0.72 ten opzichte van baseline
LLM-as-a-Judge (Rubric Scoring) Complexe redeneringen, toonzetting, feitelijkheid Gestructureerde beoordelingsprompt op schaal 1-5 Gemiddelde ≥ 4.2 / 5.0; 0 faalscores (score 1)

Implementatie van een LLM-as-a-Judge evaluatiescript

Voor niet-triviale taken (zoals juridische samenvattingen of klantenservice-afhandeling) biedt een krachtig referentiemodel als evaluator de meest betrouwbare kwaliteitsmeting. Hieronder staat een compleet evaluatiescript dat in de nachtelijke CI-pipeline draait:

import json
import sys
from openai import OpenAI

JUDGE_SYSTEM_PROMPT = """
Je bent een onafhankelijke softwarekwaliteitsbeoordelaar. Beoordeel het gegenereerde antwoord 
op basis van de verstrekte gebruikersvraag, de broncontext en het referentie-antwoord.
Geef een score van 1 tot 5 op basis van feitelijke correctheid en volledigheid.

Antwoord uitsluitend in valide JSON:
{
  "score": <int 1-5>,
  "reasoning": "<korte motivatie in één zin>",
  "contains_hallucination": <true/false>
}
"""

def evaluate_golden_dataset(dataset_path: str) -> bool:
    client = OpenAI()
    total_score = 0
    failures = 0
    test_cases = []

    with open(dataset_path, "r", encoding="utf-8") as f:
        for line in f:
            if line.strip():
                test_cases.append(json.loads(line))

    for item in test_cases:
        eval_prompt = f"""
        Gebruikersvraag: {item['input']}
        Referentie-antwoord: {item['reference']}
        Gegenereerd modelantwoord: {item['candidate']}
        """

        response = client.chat.completions.create(
            model="gpt-4o",
            messages=[
                {"role": "system", "content": JUDGE_SYSTEM_PROMPT},
                {"role": "user", "content": eval_prompt}
            ],
            response_format={"type": "json_object"},
            temperature=0.0
        )

        result = json.loads(response.choices[0].message.content)
        total_score += result["score"]

        if result["score"] <= 2 or result["contains_hallucination"]:
            failures += 1
            print(f"FAIL [Case {item.get('id', 'N/A')}]: Score {result['score']} - {result['reasoning']}")

    avg_score = total_score / len(test_cases)
    print(f"\n--- Evaluatieresultaat ---")
    print(f"Totaal geëvalueerd: {len(test_cases)}")
    print(f"Gemiddelde score: {avg_score:.2f} / 5.0")
    print(f"Kritieke fouten: {failures}")

    # CI criteria: gemiddelde minimaal 4.0 en geen kritieke hallucinaties
    if avg_score < 4.0 or failures > 0:
        return False
    return True

if __name__ == "__main__":
    success = evaluate_golden_dataset("tests/fixtures/eval_golden_set.jsonl")
    if not success:
        sys.exit(1)
    sys.exit(0)

Volledige GitHub Actions workflowconfiguratie

Onderstaande configuratie demonstreert hoe de verschillende testlagen worden georkestreerd in GitHub Actions. Snelle controles draaien op elk pull request, terwijl intensieve evaluatieruns worden voorbehouden aan geplande nachtelijke builds of pull requests met een specifiek label.

name: LLM CI/CD Quality & Regression Gate

on:
  pull_request:
    branches: [ main ]
  schedule:
    - cron: '0 2 * * *' # Elke nacht om 02:00 UTC
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  fast-deterministic-checks:
    name: Schema & Mocked Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      - name: Install dependencies
        run: pip install -r requirements-test.txt
      - name: Run Schema & Mock Tests (VCR Mode: None)
        run: pytest tests/unit tests/contracts --vcr-record=none -v

  live-evaluation-benchmark:
    name: Live Model Evaluation Run
    needs: fast-deterministic-checks
    if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'run-eval')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.12'
          cache: 'pip'
      - name: Install dependencies
        run: pip install -r requirements-test.txt
      - name: Execute Golden Dataset Benchmark
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: python scripts/run_eval_benchmark.py --dataset tests/fixtures/eval_golden_set.jsonl --output eval-summary.json
      - name: Archive Evaluation Report
        uses: actions/upload-artifact@v4
        with:
          name: evaluation-summary
          path: eval-summary.json
          retention-days: 14

Koppeling met promptversiebeheer en rollout-strategieën

Een regressietestsuite heeft pas echt waarde wanneer deze direct gekoppeld is aan het versiebeheer van prompts en parameters. Als prompts als losse strings door de codebase verspreid staan, is het vrijwel onmogelijk om te herleiden welke codewijziging verantwoordelijk is voor een kwaliteitsverschuiving.

Voor een gestructureerd framework om prompts als versionable software-artefacten te behandelen, zie de gids over promptversiebeheer en wijzigingen deployen zonder breuk om te zien hoe semantische versienummering wordt toegepast. Door promptdefinities los te koppelen van applicatielogica en te voorzien van een strikt versienummer (zoals prompt-extract-invoice:v2.1.0), kan de CI-pipeline automatisch een differentiële evaluatie draaien: de prestaties van de nieuwe versie worden direct procentueel vergeleken met de actieve productie-baseline.

Kosten, rate limits en budgetbeheer in testomgevingen

Het structureel draaien van live evaluatietests op grote datasets brengt reële operationele kosten met zich mee. Zonder duidelijke vangrails kunnen geautomatiseerde loops of parallelle testrunners binnen korte tijd aanzienlijke budgetten consumeren.

Strategie Mechanisme Kosten- en Risicoreductie
Stratified Sampling Selecteer bij PR-runs een willekeurige steekproef van 10% per categorie uit de gouden dataset. Verlaagt de directe API-kosten per pull request met circa 90% terwijl grove regressies zichtbaar blijven.
Asynchrone Batch API's Voer nachtelijke evaluaties uit via de batch-endpoints van providers (zoals OpenAI/Anthropic Batch). Biedt 50% korting op tokenkosten en omzeilt strikte gelijktijdige rate limits van realtime endpoints.
Dedicated CI API-Keys Gebruik specifieke API-sleutels voor de CI-omgeving met een harde maandelijkse bestedingslimiet. Voorkomt dat een falende testloop ongemerkt het productiebudget of operationele tegoeden uitput.

Beperkingen en valkuilen van geautomatiseerde LLM-evaluaties

Het inrichten van regressietesten voor taalmodellen kent fundamentele uitdagingen die men helder voor ogen moet houden:

Stappenplan voor implementatie

Het opzetten van een effectieve testpipeline hoeft niet in één keer volledig gerealiseerd te worden. We onderscheiden een logisch groeipad in vier fasen:

  1. Fase 1 (Dag 1): Introduceer Pydantic-contractvalidatie op alle JSON-outputs en neem VCR-cassettes op voor bestaande unit-tests. Dit kost minimale tijd en sluit 80% van de directe applicatiecrashes uit.
  2. Fase 2 (Week 1): Stel een compacte gouden dataset samen van 30 tot 50 representatieve voorbeelden (inclusief bekende eerdere productiefouten) en sla deze op als JSONL in de codebase.
  3. Fase 3 (Maand 1): Bouw een geautomatiseerd evaluatiescript met een referentiemodel en configureer een nachtelijke GitHub Actions-workflow die kwaliteitsrapporten genereert als build-artefact.
  4. Fase 4 (Kwartaal 1): Koppel de evaluatiestap aan promptversiebeheer en stel automatische regressiedrempels in die voorkomen dat een PR gemerged kan worden wanneer de gemiddelde kwaliteitsscore daalt.