Zelf AI-agents evalueren in eigen beheer
Bouw een eigen evaluatieloop met echte traces, deterministische checks en een LLM-judge. Volg uw slagingspercentage per commit en voorkom regressies in uw AI-agent pipeline.
Wat self-hosted evals voor AI-agents inhouden
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 op deze cases uitvoert, een set controles die elk antwoord beoordeelt, en een resultatenoverzicht dat u kunt bevragen. Voor geen van deze onderdelen is een externe leverancier nodig. De gehele cyclus beslaat slechts enkele honderden regels Python en één SQLite-bestand.
De agent werkte tijdens de demo omdat u de vijf invoerwaarden zelf had geselecteerd. In de tweede week traden er problemen op 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 daalde van 58 van de 60 naar 51 van de 60 bij commit 4f1c9ab".
De cyclus bestaat uit vier stappen, waarbij deze handleiding per stap één sectie beslaat: verzamel echte traces, promoveer de interessante traces naar cases, beoordeel elke case bij elke wijziging, en sla het slagingspercentage op naast de commit die dit resultaat genereerde. Deze cyclus werkt ongeacht waarop u de agent draait, en de self-hosted agent-frameworks die het overwegen waard zijn verschillen voornamelijk in de mate waarin zij de trace automatisch voor u beschikbaar stellen.
Waarom de agent in de tweede week faalt
Een agent bestaat uit een prompt, een model, een set tooldefinities en alle context die tijdens runtime wordt opgehaald. Al deze vier onderdelen kunnen veranderen zonder dat uw applicatiecode wijzigt, waardoor een normale codereview geen problemen detecteert.
De meest voorkomende oorzaak is een wijziging 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, en de traces laten dit duidelijk zien: de trace van vorige week voor dezelfde vraag bevat een create_refund tool-aanroep, die van deze week bevat er geen, en het antwoord is in plaats daarvan een beleefd excuus. Er werd geen fout gegenereerd, dus er ging geen alarm af.
De tweede oorzaak is het model. Leg de exacte model-string vast die u bij elke run heeft verzonden, claude-haiku-4-5-20251001 in plaats van een afkorting die u in uw hoofd houdt, omdat een slagingspercentage dat daalt op de dag dat u van model wisselde, alleen diagnoseerbaar is als het model in de rij staat.
De derde oorzaak zijn tools. Het herformuleren van een toolbeschrijving 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, waardoor het kan 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, en het antwoord volgt het nieuwe document.
Bouw de golden set op basis van traces die u al verzamelt
Verzin geen evaluatiegevallen. 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 basic authentication, 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 gevallen 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 waardevol blijft om uit te voeren:
- 40 tot 80 gevallen zijn voldoende om te beginnen. Bij minder dan 20 gevallen zorgt één onbetrouwbaar testgeval voor een schommeling van 5 punten in het slagingspercentage, waardoor een cijfer dat zonder reden springt, wordt genegeerd.
- Elke productiebug die 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 geval dat zowel het restitutiebedrag als de toon controleert, vertelt u niets wanneer het faalt.
- De
idverandert nooit, omdat de id de manier is waarop de run van vandaag wordt vergeleken met die van vorige maand. - Anonimiseer gegevens voordat u ze commit. 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 model-aanroep, 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 tool-budget in die lijst. Een agent die vandaag een casus in 3 aanroepen oplost en morgen in 11, is achteruitgegaan, zelfs als het uiteindelijke antwoord correct is, omdat u betaalt voor elke aanroep die wordt gedaan.
LLM als beoordelaar, en de vier manieren waarop dit misgaat
Alles wat de assertions overleeft, 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. Dit 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 niets leert.
- Eén criterium per aanroep. Vraag naar het terugbetalingsbedrag of naar de toon, 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)Nu de faalmodi. Voor elk daarvan kunt u vanmiddag een test uitvoeren, en dat is belangrijk, want een ongecontroleerde beoordelaar produceert getallen die nauwkeurig 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 in 'geslaagd' is lengtebias, en de rubric is wat u moet aanpassen.
Zelfvoorkeur. Een beoordelaar beoordeelt output van zijn 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 dan de volgorde en voer het opnieuw uit. Een oordeel dat verandert na de wissel betekent dat paarsgewijze vergelijking nog niet veilig is voor die rubric.
Rubric-drift. Vage criteria produceren toegeeflijke 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 feit benoemt dat wordt gecontroleerd.
Eén beveiliging dekt alle vier de punten. Houd 30 gevallen bij die u handmatig heeft gelabeld, en scoor de beoordelaar tegen uw labels telkens wanneer u het beoordelaarsmodel 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 versiebeheerd en beoordeeld zoals code.
Beoordeel goedkoop, escaleer naar een frontier-model
Elk geval bij elke commit beoordelen met het duurste model zorgt ervoor dat de kosten voor evaluatie hoger uitvallen dan 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 gevallen kost 1.80 Amerikaanse dollar op Claude Haiku 4.5 en 9.00 op Claude Opus 5. Het verschil lijkt triviaal totdat u het doorberekent. Een set van 60 gevallen, beoordeeld bij elke commit, bij 40 commits per week, komt neer op 2.400 beoordelingsaanroepen per week voordat iemand de nachtelijke job heeft uitgevoerd.
Twee kortingen zijn uitstekend toepasbaar op evaluatiewerk en deze 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 geschikt: 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 elk geval. Geen API-kosten.
- Een beoordeling door een klein model op de gevallen die door die controles zijn gekomen.
- Een frontier-beoordelaar alleen wanneer het kleine model een 'fail' geeft, of een 'pass' met een laag zelfvertrouwen.
- Menselijke beoordeling op een kleine steekproef, eenmaal 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 deze afweging in plaats van er zomaar vanuit te gaan. Beoordeel eenmaal per maand de gehele set ook met de strikte beoordelaar en vergelijk de twee kolommen. Als ze het bij meer dan een handvol gevallen oneens zijn, is uw rubric te ruim voor het kleine model, en de rubric is wat u moet 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 run één rij per testgeval op, inclusief de commit en het model in de 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 draaien in productie op een VPS behandelt de instellingen die relevant worden zodra dit bestand tussen machines wordt gedeeld.
De runner print 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 suite uit op wijzigingen die een agent kunnen breken; dit betekent prompt-aanpassingen, modelwijzigingen en tool-wijzigingen, in plaats van bij 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 dagelijkse 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 aan de hand 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 het oneens zijn, plus nieuwe gevallen voor faaltypes 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 evaluatieomgeving 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) door de judge. Het model antwoordde in proza of plaatste de JSON in een codeblok. 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 presteert.
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 ook zal vinden.
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 meer volgt. Haal tien recente traces op, zoek de gevallen die de agent slecht afhandelde en voeg deze toe. Breek vervolgens opzettelijk iets en bevestig dat de run rood wordt; dit is de controle die mutation testing toepast op een testsuite en de enige manier om te weten of uw set nog steeds effectief is.
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 werkelijke fouten. Bij minder dan 20 cases zorgt één onbetrouwbaar resultaat voor een schommeling van 5 punten in het slagingspercentage, waardoor het getal geen betekenisvolle informatie meer bevat. Boven de paar honderd cases kost elke run aanzienlijk tijd en geld, terwijl de marginale toevoeging aan dekking klein is. De maatstaf die ertoe doet is niet het aantal: het is het aandeel van uw bekende productiefouttypen 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 judge-model of de judge-prompt wijzigt. Judges vertonen een lengte-bias, waarbij langere antwoorden vaker slagen, en een zelfvoorkeur, waarbij output van hun eigen model-familie gunstiger wordt beoordeeld. Beide zijn testbaar: vul een foutief antwoord aan 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. Deterministische beweringen kosten niets, dus voer deze als eerste uit op elke case. Een klein model verwerkt de duidelijke successen. Alleen fouten en oordelen met een laag vertrouwen gaan naar een frontier-model. Tegen de catalogusprijzen van augustus 2026 kost het beoordelen van 1.000 cases ongeveer 1.80 Amerikaanse dollar met Claude Haiku 4.5 en ongeveer 9.00 met Claude Opus 5. Omdat eval-runs asynchroon zijn, halveren beide 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 brengen 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.