Self-hosted evals voor AI-agents bouwen
Bouw een eigen eval-loop voor AI-agents. Gebruik golden cases uit echte traces, snelle deterministische checks en een LLM-judge om per commit het slagingspercentage te meten.
Wat self-hosted evals voor AI-agents zijn
Self-hosted evals voor AI-agents bestaan uit vier onderdelen die u in uw eigen repository beheert: een bestand met opgeslagen cases, een script dat de agent hiermee uitvoert, een set controles die elk antwoord beoordeelt en een tabel met resultaten die u kunt bevragen. Voor geen van deze onderdelen is een externe leverancier nodig. De gehele lus bestaat uit enkele honderden regels Python en één SQLite-bestand.
De agent werkte in de demo omdat u de vijf inputs zelf had gekozen. In de tweede week faalde deze omdat een promptregel, een model of een toolbeschrijving was gewijzigd, zonder dat enige meting dit opving. Een eval-loop verandert "het voelt nu slechter aan" in "het slagingspercentage ging van 58 van de 60 naar 51 van de 60 bij commit 4f1c9ab".
De lus bestaat uit vier stappen en deze handleiding behandelt elke stap in een eigen sectie: verzamel echte traces, promoveer de interessante traces tot cases, beoordeel elke case bij elke wijziging en sla het slagingspercentage op naast de commit die dit resultaat genereerde. Dezelfde lus werkt ongeacht waarop u de agent uitvoert, en de self-hosted agent-frameworks die het waard zijn om te draaien verschillen hoofdzakelijk in de mate waarin zij de trace voor u kant-en-klaar aanleveren.
Waarom de agent na twee weken niet meer functioneert
Een agent bestaat uit een prompt, een model, een set tooldefinities en de context die tijdens runtime wordt opgehaald. Al deze vier onderdelen kunnen veranderen zonder dat uw applicatiecode wordt aangepast. Een normale code-review merkt hierdoor niets op.
De meest voorkomende oorzaak is een aanpassing in de prompt. U voegt één zin toe om een onbeleefd antwoord te voorkomen. Die zin verandert het gedrag bij invoer die door niemand opnieuw is getest. De traces laten dit duidelijk zien: de trace van vorige week voor dezelfde vraag bevat een create_refund tool-aanroep, terwijl die van deze week er geen bevat en het antwoord een beleefd excuus is. Er trad geen fout op, dus er werd geen waarschuwing gegenereerd.
De tweede oorzaak is het model. Leg de exacte model-string vast die u bij elke run meestuurt, claude-haiku-4-5-20251001 in plaats van een afkorting die u in uw hoofd houdt. Een slagingspercentage dat daalt op de dag dat u van model wisselde, is namelijk alleen te diagnosticeren als het model in de logregel staat.
De derde oorzaak zijn de tools. Het herschrijven van een tool-beschrijving verandert het moment waarop het model besluit deze aan te roepen. Als uw tools binnenkomen via MCP-servers die op een VPS draaien, bevindt het schema zich in een ander proces. Het kan dus veranderen zonder dat er een diff in uw repository zichtbaar is. De vierde oorzaak is retrieval: dezelfde vraag bereikt een index die 's nachts opnieuw is opgebouwd, waardoor het antwoord gebaseerd is op het nieuwe document.
Stel de golden set samen op basis van verzamelde traces
Verzin geen testgevallen. Gebruik hiervoor bestaand verkeer. Als u al self-hosted Langfuse tracing voor uw agent gebruikt, wordt elk verzoek opgeslagen met de invoer, de tool-aanroepen en de uitvoer. Dit is precies het basismateriaal dat een testgeval nodig heeft.
Exporteer een venster met root-observaties via de publieke API. Deze maakt gebruik van basisauthenticatie, waarbij uw publieke sleutel als gebruikersnaam dient en uw geheime sleutel als wachtwoord.
export LF_HOST="https://langfuse.example.com"
curl -sS -u "$LF_PUBLIC_KEY:$LF_SECRET_KEY" \
"$LF_HOST/api/public/v2/observations?limit=50&isRootObservation=true&fromStartTime=2026-07-01T00:00:00Z" \
| jq '.data[0]'Lees één record voordat u begint met het schrijven van de parsing-logica. De rijen worden geretourneerd onder data, maar de veldnamen die de vraag en het antwoord bevatten, hangen af van hoe uw agent de spans instrumenteert. Breng daarom in kaart wat u daadwerkelijk ziet in plaats van wat u verwachtte. Schrijf de testgevallen vervolgens handmatig, één JSON-object per regel, in evals/cases.jsonl:
{"id": "refund-double-charge", "tags": ["smoke"], "input": "I was charged twice for order 41822.", "must_call": ["lookup_order", "create_refund"], "must_not_include": ["I cannot help"], "rubric": "The reply confirms exactly one refund for order 41822 and states the amount."}Vijf regels zorgen ervoor dat de set bruikbaar blijft:
- 40 tot 80 testgevallen zijn voldoende om te beginnen. Bij minder dan 20 gevallen zorgt één instabiel testgeval voor een schommeling van 5 punten in het slagingspercentage, waardoor een getal dat zonder reden springt, genegeerd zal worden.
- Elk productieprobleem dat u oplost, wordt op de dag van herstel een testgeval. Deze gewoonte zorgt ervoor dat de set in de juiste richting groeit.
- Eén gedrag per testgeval. Een testgeval dat zowel het restitutiebedrag als de toon controleert, vertelt u niets wanneer het faalt.
- De
idverandert nooit, omdat het id de manier is waarop de run van vandaag wordt vergeleken met die van vorige maand. - Redigeer gegevens voordat u een commit uitvoert. Dit bestand wordt in git opgeslagen, dus verwijder klantnamen en ordernummers die niet van u zijn.
Beoordeel eerst met deterministische controles, omdat deze kosteloos zijn
Alles met een correct antwoord krijgt een eenvoudige assertie. Geen modelaanroep, geen kosten, geen ambiguïteit. Deterministische controles vangen structurele regressies op, en dat zijn de fouten die de systemen rondom uw agent verstoren: de JSON wordt niet geparseerd, de tool werd nooit aangeroepen, de verboden zin kwam terug, of het antwoord citeert geen bron.
Eén functie heeft kennis van uw agent. Al het overige in de testomgeving is generiek.
import json, os, urllib.request
def run_agent(case):
req = urllib.request.Request(
os.environ["AGENT_URL"],
data=json.dumps({"input": case["input"]}).encode(),
headers={"content-type": "application/json"},
)
with urllib.request.urlopen(req, timeout=120) as resp:
return json.load(resp)
def deterministic(case, result):
text = result.get("output", "")
called = [c["name"] for c in result.get("tool_calls", [])]
failures = []
for tool in case.get("must_call", []):
if tool not in called:
failures.append(f"tool not called: {tool}")
for phrase in case.get("must_not_include", []):
if phrase.lower() in text.lower():
failures.append(f"forbidden phrase: {phrase}")
if len(called) > case.get("max_tool_calls", 12):
failures.append(f"too many tool calls: {len(called)}")
return failuresHoud het toolbudget in die lijst. Een agent die vandaag een casus oplost in 3 aanroepen en morgen in 11, is achteruitgegaan, zelfs als het uiteindelijke antwoord correct is, omdat u betaalt voor elke aanroep die de agent doet.
LLM als beoordelaar, en de vier manieren waarop dit misgaat
Alles wat de assertions doorstaat, heeft een beoordelaar nodig die de tekst leest. Een LLM-beoordelaar is een tweede modelaanroep: deze ontvangt de vraag, het antwoord van de agent en één criterium, en geeft vervolgens een oordeel terug. Het is de enige praktische manier om te beoordelen of "het antwoord daadwerkelijk beantwoordt wat de gebruiker vroeg".
Vier regels maken een beoordelaar bruikbaar:
- Binair oordeel, nooit een score van 1 tot 10. Een schaal geeft voor bijna alles een 7 of 8, waardoor het getal nooit verandert en u er niets van leert.
- Eén criterium per aanroep. Vraag naar het terugbetalingsbedrag of naar de toon, maar niet beide tegelijk.
- Geef de beoordelaar het verwachte antwoord wanneer er een correct antwoord bestaat. Beoordelen aan de hand van een referentie is een veel eenvoudigere taak dan abstract beoordelen.
- Forceer de uitvoervorm en parseer deze strikt.
from anthropic import Anthropic
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
def judge_prompt(case, output):
return (
"You grade one answer against one criterion.\n"
"Reply with JSON only, in this exact shape:\n"
'{"verdict": "pass", "confidence": "high", "reason": "one short sentence"}\n'
f"Criterion: {case['rubric']}\n"
f"Question: {case['input']}\n"
f"Answer: {output}\n"
"Length is not a criterion. Judge only the criterion above."
)
def judge(case, output, model):
msg = client.messages.create(
model=model,
max_tokens=200,
messages=[{"role": "user", "content": judge_prompt(case, output)}],
)
return json.loads(msg.content[0].text)Dan nu de faalmodi. Voor elk daarvan kunt u vanmiddag een test uitvoeren, en dat is belangrijk, want een ongecontroleerde beoordelaar produceert getallen die precies lijken, maar niets betekenen.
Lengtebias. Langere antwoorden slagen vaker. Test dit: neem tien antwoorden die de beoordelaar heeft afgekeurd, vul elk aan met twee alinea's zelfverzekerde opvulling die geen nieuwe feiten toevoegen, en beoordeel ze opnieuw. Elk oordeel dat verandert naar 'geslaagd' is lengtebias, en de rubric is wat u moet aanpassen.
Zelfvoorkeur. Een beoordelaar beoordeelt output van de eigen modelfamilie vaak milder dan output van een andere familie. Test dit: beoordeel dezelfde 30 antwoorden met beoordelaars uit twee verschillende families en vergelijk de oordelen per geval. Waar ze het oneens zijn, leest u het geval zelf.
Positiebias. Als u de beoordelaar gebruikt om twee antwoorden, A en B, te vergelijken, wissel de volgorde dan om en voer het opnieuw uit. Een oordeel dat verandert na de wissel betekent dat paarsgewijze vergelijking voor die rubric nog niet veilig is.
Rubric-drift. Vage criteria produceren meegaande beoordelaars. "Is het antwoord behulpzaam" laat bijna alles slagen. "Vermeldt het antwoord het terugbetalingsbedrag in dollars" laat alleen slagen wat u bedoelde. Herschrijf elk criterium totdat het het feitelijke punt benoemt dat gecontroleerd wordt.
Eén beveiliging dekt alle vier de punten. Houd 30 gevallen bij die u handmatig heeft gelabeld, en beoordeel de beoordelaar aan de hand van uw labels telkens wanneer u het beoordelingsmodel of de prompt van de beoordelaar wijzigt. Als de beoordelaar het in meer dan één op de tien gevallen niet met u eens is, corrigeer dan de rubric voordat u vertrouwt op het slagingspercentage dat deze produceert. De beoordelaar is code, dus deze wordt net als code van versies voorzien en beoordeeld.
Beoordeel goedkoop, escaleer naar een frontier-model
Elke casus bij elke commit beoordelen met het duurste model zorgt ervoor dat de kosten voor evaluatie hoger uitvallen dan de kosten voor de agent die wordt getest. Rangschik de beoordelaars op prijs en stop zodra het antwoord duidelijk is.
The data behind this chart
[
{
"label": "Haiku 4.5, Batch API",
"usd_per_1000_judge_calls": "0.90"
},
{
"label": "Haiku 4.5",
"usd_per_1000_judge_calls": "1.80"
},
{
"label": "Sonnet 5",
"usd_per_1000_judge_calls": "3.60"
},
{
"label": "Opus 5",
"usd_per_1000_judge_calls": "9.00"
}
]Deze cijfers gaan uit van ongeveer 1.200 input-tokens en 120 output-tokens per beoordelingsaanroep, wat een realistische omvang is voor één vraag, één antwoord en één criterium. Het beoordelen van 1.000 casussen kost 1.80 Amerikaanse dollar op Claude Haiku 4.5 en 9.00 op Claude Opus 5. Het verschil lijkt verwaarloosbaar totdat u het vermenigvuldigt. Een set van 60 casussen, beoordeeld bij elke commit, bij 40 commits per week, komt neer op 2.400 beoordelingsaanroepen per week, nog voordat iemand de nachtelijke job heeft uitgevoerd.
Twee kortingen zijn uitstekend toepasbaar op evaluatiewerk en ze zijn cumulatief. Evaluatieruns zijn niet interactief, dus de Batch API halveert zowel de input- als outputprijzen in ruil voor asynchrone levering; dit is de eerste rij in de tabel. De rubric en de instructies zijn byte voor byte identiek in elke aanroep, dus prompt caching is effectief: een cache-read kost een tiende van de basis-inputprijs en een cache-write van vijf minuten kost 1,25 keer de basis-input, waardoor de cache zichzelf na één hit terugverdient. Dit zijn de catalogusprijzen van Anthropic per augustus 2026, en Sonnet 5 heeft een introductieprijs tot 31 augustus 2026, dus de derde balk stijgt na die datum.
De ladder, in volgorde:
- Deterministische controles op elke casus. Geen API-kosten.
- Een kleine model-beoordelaar voor de casussen die door die controles zijn gekomen.
- Een frontier-beoordelaar alleen wanneer de kleine beoordelaar 'falen' aangeeft, of 'slagen' met een laag zelfvertrouwen.
- Menselijke beoordeling van een kleine steekproef, één keer per week.
CHEAP = "claude-haiku-4-5-20251001"
STRICT = "claude-opus-5"
def grade(case, result):
hard = deterministic(case, result)
if hard:
return False, "deterministic", "; ".join(hard)
first = judge(case, result["output"], CHEAP)
if first["verdict"] == "pass" and first["confidence"] == "high":
return True, CHEAP, first["reason"]
second = judge(case, result["output"], STRICT)
return second["verdict"] == "pass", STRICT, second["reason"]Dit ruilt enige beoordelingsnauwkeurigheid in voor kosten, dus meet de afweging in plaats van er zomaar vanuit te gaan. Beoordeel één keer per maand de gehele set ook met de strikte beoordelaar en vergelijk de twee kolommen. Als ze het bij meer dan een handvol casussen oneens zijn, is uw rubric te ruim voor het kleine model en moet u de rubric aanpassen. Het beheersen van wat de agent zelf uitgeeft is een aparte taak, die wordt behandeld in kostenbeheersing voor een AI-agent op een VPS.
Volg het slagingspercentage in de loop van de tijd in een systeem dat u beheert
Een slagingspercentage dat u niet kunt koppelen aan een commit is slechts een gevoel. Sla per testgeval en per run één rij op, inclusief de commit en het model in die rij.
CREATE TABLE IF NOT EXISTS results (
run_id TEXT NOT NULL,
ran_at TEXT NOT NULL,
git_sha TEXT NOT NULL,
agent_model TEXT NOT NULL,
case_id TEXT NOT NULL,
passed INTEGER NOT NULL,
graded_by TEXT NOT NULL,
reason TEXT
);SELECT run_id, git_sha, agent_model,
count(*) AS cases,
round(100.0 * sum(passed) / count(*), 1) AS pass_pct
FROM results
GROUP BY run_id
ORDER BY ran_at DESC
LIMIT 10;Laad het schema met sqlite3 evals/results.db < evals/schema.sql en lees vervolgens de trend met sqlite3 -box evals/results.db < evals/passrate.sql. Een jaar aan dagelijkse runs over 60 testgevallen resulteert in ongeveer 22,000 rijen, waardoor de opslag nooit een project op zich wordt. SQLite in productie draaien op een VPS behandelt de instellingen die relevant worden zodra dit bestand tussen machines wordt gedeeld.
De runner toont dezelfde informatie voor een gebruiker:
run 2026-08-05T09:14:22Z sha 4f1c9ab model claude-sonnet-5 58/60 pass (96.7%)
FAIL refund-double-charge deterministic: tool not called: create_refund
FAIL pto-policy-question judge(opus): reply gives no dollar amountVoer de testsuite uit op wijzigingen die een agent kunnen verstoren; dit betreft aanpassingen aan prompts, modelwijzigingen en wijzigingen in tools, in plaats van elke commit in de repository. Een pre-push hook dekt de snelle subset:
cat > .git/hooks/pre-push <<'EOF'
#!/bin/sh
python3 evals/run.py --set smoke || exit 1
EOF
chmod +x .git/hooks/pre-pushVolledige runs zijn trager en horen thuis in een planning. Een nachtelijke systemd service en timer op de VPS voert de volledige set uit tegen de geïmplementeerde prompt. Dit is wat de wijzigingen opvangt die van buiten uw repository komen, zoals een gehoste tool waarvan het gedrag is veranderd.
Menselijke beoordeling, steekproefsgewijs in plaats van uitputtend
De judge wordt gekalibreerd op basis van menselijke labels, dus iemand moet deze aanmaken. Lees elke week een steekproef: elk geval waarin de judge faalde, plus tien willekeurig gekozen geslaagde gevallen. De willekeurige geslaagde gevallen vormen de belangrijkste helft, omdat een judge die stilletjes is begonnen met het goedkeuren van slechte antwoorden er perfect uitziet op elk dashboard dat is gebouwd op basis van zijn eigen oordelen.
Vijftien gevallen van elk drie minuten kosten 45 minuten per week. Dit levert correcties op voor de rubric waar u en de judge van mening verschillen, plus nieuwe gevallen voor faaltypen die niemand had voorzien. Schrijf het menselijke oordeel in dezelfde tabel met graded_by ingesteld op human, zodat overeenstemming tussen de judge en de mens een query wordt in plaats van een herinnering.
Wat er misgaat in de eval-harness zelf
anthropic.RateLimitError bij de eerste volledige run. Zestig cases tegelijkertijd uitvoeren overschrijdt de request- of tokenlimiet van uw tier. Beperk de gelijktijdigheid tot vier workers en verplaats de nachtelijke run naar de Batch API.
json.JSONDecodeError: Expecting value: line 1 column 1 (char 0) van de judge. Het model antwoordde in proza of plaatste de JSON in een code block. Probeer het één keer opnieuw en registreer de case daarna als een fout. Laat een parse-fout nooit als geslaagd tellen, want een suite die fouten omzet in successen stijgt naar 100% terwijl de agent slechter wordt.
Flaky cases. Dezelfde input slaagt in de ene run en faalt in de volgende, omdat de agent zijn output samplest. Voer de flaky case drie keer uit en registreer de fractie in plaats van de case te verwijderen. Een case die twee van de drie keer slaagt, is een reële robuustheidsfout die een klant zal tegenkomen.
Veroudering van de golden set. Iemand past een verwacht antwoord aan om de suite groen te krijgen. Controleer diffs naar evals/cases.jsonl net zo zorgvuldig als diffs naar de agent, omdat dat bestand uw schriftelijke definitie van correctheid is.
Een suite die nooit faalt. Een slagingspercentage dat een maand lang op 100% blijft staan, betekent dat de set het product niet langer volgt. Haal tien recente traces op, zoek de gevallen die de agent slecht afhandelde en voeg deze toe.
FAQ
Hoeveel cases heeft een eval-set voor een AI-agent nodig?
Begin met 40 tot 80 cases en breid de set uit op basis van echte fouten. Bij minder dan 20 cases zorgt één onbetrouwbaar resultaat voor een schommeling van 5 punten in het slagingspercentage, waardoor de waarde van de statistiek vervalt. Boven de paar honderd cases kost elke run aanzienlijk tijd en geld, terwijl de toegevoegde waarde van een extra case marginaal is. De maatstaf die er echt toe doet is niet het aantal: het gaat om het aandeel van uw bekende productiefouten dat ten minste één keer in de set voorkomt.
Kan ik een LLM-judge vertrouwen om mijn agent te beoordelen?
Alleen nadat u deze heeft getoetst aan uw eigen labels. Houd 30 cases bij die u handmatig heeft beoordeeld en toets de judge hieraan telkens wanneer u het model of de prompt van de judge wijzigt. Judges vertonen vaak een lengtebias, waarbij uitgebreidere antwoorden vaker slagen, en een voorkeur voor het eigen model, waarbij output van dezelfde model-familie gunstiger wordt beoordeeld. Beide zijn testbaar: vul een foutief antwoord aan met extra tekst en laat het opnieuw beoordelen, of beoordeel dezelfde antwoorden met een judge uit een andere familie. Als de judge bij meer dan één op de tien cases afwijkt van uw labels, is de rubric te vaag voor gebruik.
Welk model moet de evals beoordelen?
Beoordeel goedkoop en schaal op indien nodig. Deterministische beweringen kosten niets en worden daarom als eerste op elke case uitgevoerd. Een klein model verwerkt de duidelijke successen. Alleen fouten en oordelen met een lage betrouwbaarheid gaan naar een frontier-model. Tegen de catalogusprijzen van augustus 2026 kost het beoordelen van 1.000 cases ongeveer 1.80 US dollar met Claude Haiku 4.5 en ongeveer 9.00 met Claude Opus 5. Omdat eval-runs asynchroon zijn, halveren deze bedragen bij gebruik van de Batch API.
Vervangen evals de monitoring in productie?
Nee, want ze beantwoorden verschillende vragen. Een eval-suite vertelt u of een wijziging die u op het punt staat uit te rollen een vaste set cases verbetert of verslechtert. Tracing en monitoring vertellen u waar echte gebruikers op dit moment tegenaan lopen, inclusief inputs die in geen enkele case voorkomen. Ze vullen elkaar aan: traces leveren de nieuwe cases, en de eval-suite bepaalt of uw oplossing daadwerkelijk heeft gewerkt.