Ein Eval in fünf Dateien
Das kleinste vollständige Eval: drei Fälle, drei Prüfungen, ein Runner, ein Befehl, und was er ausgibt.
Ein Eval ist keine Tabelle und keine Zeile Code. Es sind vier Dinge in einem Verzeichnis: Falldateien, Prüffunktionen, ein Runner und eine Ergebnisdatei je Lauf. Diese Seite zeigt das kleinste vollständige Beispiel, im selben Produkt wie das Praxisbeispiel: ein Assistent, der Antwortentwürfe für Support-Tickets schreibt. Alles hier ist gewöhnliches Python und JSON. Sie können es abtippen, ausführen und die Ausgabe lesen, bevor Sie ein anderes Kapitel lesen. Kein Modell bewertet hier die Antworten. Das kommt in Kapitel 5, und aus gutem Grund erst dann.
evals/
cases/
case-001.json
case-002.json
case-003.json
prompt.txt Die Anweisung an das Modell. Ihr Hash landet in jeder Ergebniszeile.
checks.py Drei Prüffunktionen. Kein Modellaufruf.
run.py Der Runner: lädt Fälle, ruft das Modell, prüft, schreibt Ergebnisse.
runs/
2026-09-03T14-22_650de45.jsonl Entsteht beim Ausführen. Eine Zeile je Fall.
Ein Fall
Ein Fall ist eine eingefrorene Situation plus die Angabe, was daran geprüft wird:
{
"id": "case-001",
"source": "Ticket T-48213 vom 2026-03-09",
"customer": { "plan": "pro", "locale": "de" },
"ticket": "Guten Tag, wir brauchen ab April 6 weitere Plätze. Was kostet ein zusätzlicher Platz im Monat? Außerdem: rechnet Ihr System den Nachtschicht-Zuschlag automatisch, wenn eine Schicht um 21:45 beginnt?",
"retrieved": [
{ "ref": "pricing#plans", "text": "Starter: 39 € pro Platz und Monat. Pro: 45 € pro Platz und Monat." },
{ "ref": "pricing#seats", "text": "Zusätzliche Plätze werden ab dem Tag der Aktivierung anteilig berechnet." },
{ "ref": "rules#night-surcharge", "text": "Der Nachtzuschlag gilt für Schichten, die um 22:00 Uhr oder später beginnen." }
],
"checks": ["zitate_loesen_auf", "keine_zahl_ohne_beleg", "erwartetes_kommt_vor"],
"expect": { "nennt": ["\\b45\\s?€"] },
"note": "Pro-Kunde. pricing#plans nennt 39 und 45; nur 45 gilt. Muster mit Wortgrenze, sonst passt die 45 in „21:45“."
}
| Feld | Was darin steht |
|---|---|
id | Der Name des Falls. Jede Ergebniszeile hängt daran, deshalb ändert er sich nie. |
source | Woher der Fall stammt. Hier ein echtes Ticket mit Datum. |
customer | Was das System über den Kunden weiß. Hier entscheidet der Tarif über richtig und falsch. |
ticket | Die Kundenanfrage, wörtlich. |
retrieved | Was die Suche geliefert hat, eingefroren. Der Fall läuft nie gegen die heutige Doku. |
checks | Welche der Funktionen aus checks.py auf diesen Fall angewendet werden. |
expect | Muster, die im Entwurf vorkommen müssen. Reguläre Ausdrücke, siehe note. |
note | Für den, der um fünf Uhr nachmittags einen Fehlerbericht liest. |
Die beiden anderen Fälle haben dieselben Felder. case-002 fragt nach anteiliger
Abrechnung und erwartet das Muster anteilig. case-003 fragt nach dem aktuellen Preis,
und die Suche liefert zusätzlich den Archivpreis von 2025. Erwartet wird wieder \b45\s?€.
Die Prüfungen
Drei Funktionen mit derselben Signatur. Keine nennt einen Preis; sie prüfen die Form der
Antwort, nicht ihren Inhalt. Nur erwartetes_kommt_vor liest den Fall.
# evals/checks.py
import re
CITATION = re.compile(r"\[doc:([a-z0-9-]+#[a-z0-9-]+)\]")
MONEY = re.compile(r"\d[\d.,]*\s?(?:€|EUR)|(?:€|EUR)\s?\d[\d.,]*")
def zitate_loesen_auf(entwurf: str, fall: dict) -> bool:
"""Jede Quellenangabe zeigt auf einen Abschnitt, den die Suche geliefert hat."""
verfuegbar = {doc["ref"] for doc in fall["retrieved"]}
zitiert = CITATION.findall(entwurf)
return bool(zitiert) and all(ref in verfuegbar for ref in zitiert)
def keine_zahl_ohne_beleg(entwurf: str, fall: dict) -> bool:
"""Hinter jedem Geldbetrag steht innerhalb von 120 Zeichen eine Quellenangabe."""
return all(CITATION.search(entwurf[t.end() : t.end() + 120]) for t in MONEY.finditer(entwurf))
def erwartetes_kommt_vor(entwurf: str, fall: dict) -> bool:
"""Jedes Muster unter expect.nennt kommt im Entwurf vor."""
return all(re.search(muster, entwurf) for muster in fall["expect"]["nennt"])
CHECKS = {f.__name__: f for f in (zitate_loesen_auf, keine_zahl_ohne_beleg, erwartetes_kommt_vor)}
Die Anweisung an das Modell
Eine Textdatei, damit ihr Hash in jedem Ergebnis steht und ein geänderter Prompt als geänderter Prompt erkennbar bleibt.
Du bist der Support-Assistent eines Dienstplan-Tools. Du bekommst ein Kundenticket,
den Tarif des Kunden und Auszüge aus der Dokumentation.
Regeln:
- Beantworte jede Frage im Ticket. Fehlt die Information, sag das.
- Nutze nur die Dokumentation. Erfinde nichts.
- Schreib direkt hinter jede Zahl die Quelle in der Form [doc:abschnitt].
- Antworte in der Sprache des Kunden, höchstens 120 Wörter, ohne Tabellen.
Der Runner
Das ist das Stück, das im Praxisbeispiel „ein Skript“ heißt. Es sind sechzig Zeilen.
# evals/run.py
import argparse
import datetime
import hashlib
import json
import os
import pathlib
import sys
from checks import CHECKS
HERE = pathlib.Path(__file__).parent
SYSTEM_PROMPT = (HERE / "prompt.txt").read_text()
PROMPT_SHA = hashlib.sha1(SYSTEM_PROMPT.encode()).hexdigest()[:7]
MODEL = os.environ["EVAL_MODEL"]
def generate(fall: dict) -> str:
from openai import OpenAI # liest OPENAI_API_KEY und OPENAI_BASE_URL aus der Umgebung
auszuege = "\n".join(f"[doc:{doc['ref']}] {doc['text']}" for doc in fall["retrieved"])
nutzer = (
f"Tarif des Kunden: {fall['customer']['plan']}\n\n"
f"Ticket:\n{fall['ticket']}\n\n"
f"Dokumentation:\n{auszuege}"
)
antwort = OpenAI().chat.completions.create(
model=MODEL,
temperature=0,
messages=[{"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": nutzer}],
)
return antwort.choices[0].message.content
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--cases", default=str(HERE / "cases"))
parser.add_argument("--out", default="runs")
args = parser.parse_args()
faelle = [json.loads(p.read_text()) for p in sorted(pathlib.Path(args.cases).glob("*.json"))]
run_id = datetime.datetime.now().strftime("%Y-%m-%dT%H-%M") + "_" + PROMPT_SHA
out = pathlib.Path(args.out) / f"{run_id}.jsonl"
out.parent.mkdir(parents=True, exist_ok=True)
fehler = 0
with out.open("w") as f:
for fall in faelle:
entwurf = generate(fall)
ergebnisse = {name: CHECKS[name](entwurf, fall) for name in fall["checks"]}
bestanden = all(ergebnisse.values())
fehler += not bestanden
zeile = {
"run_id": run_id,
"model": MODEL,
"prompt_sha": PROMPT_SHA,
"case": fall["id"],
"passed": bestanden,
"checks": ergebnisse,
"draft": entwurf,
}
f.write(json.dumps(zeile, ensure_ascii=False) + "\n")
gescheitert = ", ".join(name for name, ok in ergebnisse.items() if not ok)
print(f"{'PASS' if bestanden else 'FAIL'} {fall['id']} {gescheitert}")
print(f"\n{len(faelle) - fehler}/{len(faelle)} bestanden -> {out}")
sys.exit(1 if fehler else 0)
if __name__ == "__main__":
main()
Ausführen
Drei Umgebungsvariablen, ein Befehl. Der Endpunkt ist jeder OpenAI-kompatible Endpunkt. Das Modell wird mit Datum festgenagelt, wie es das Harness-Kapitel verlangt.
export OPENAI_API_KEY="…"
export OPENAI_BASE_URL="https://<endpunkt>/v1"
export EVAL_MODEL="<anbieter>/<modell>@2026-07-11"
uv run --with openai python evals/run.py
Was Sie danach sehen
Im Terminal:
FAIL case-001 erwartetes_kommt_vor
PASS case-002
PASS case-003
2/3 bestanden -> runs/2026-09-03T14-22_650de45.jsonl
Und die erste Zeile der Ergebnisdatei:
{"run_id": "2026-09-03T14-22_650de45", "model": "<anbieter>/<modell>@2026-07-11", "prompt_sha": "650de45", "case": "case-001", "passed": false, "checks": {"zitate_loesen_auf": true, "keine_zahl_ohne_beleg": true, "erwartetes_kommt_vor": false}, "draft": "Guten Tag, zusätzliche Plätze kosten 39 € pro Platz und Monat [doc:pricing#plans] und werden anteilig berechnet [doc:pricing#seats]. Der Nachtzuschlag gilt erst ab 22:00 Uhr [doc:rules#night-surcharge], für 21:45 also nicht."}
So lesen Sie das. Jede Terminalzeile ist ein Fall. Steht dahinter ein Funktionsname,
ist das die Fehlerklasse. case-001 hat alle Quellen korrekt gesetzt und jeden Betrag
belegt, und trotzdem den Starter-Preis genannt. Die beiden Formprüfungen können das nicht
sehen. Nur die feste Erwartung hat es gefangen, und ein Fall aus dem echten Verkehr trägt
keine. Für solche Fälle kommt später ein Judge dazu. Den Entwurf selbst lesen
Sie in der Ergebniszeile, nicht im Terminal. Der Exit-Code 1 ist das, was in der
CI den Merge blockiert. Zwei Läufe vergleichen Sie über ihre beiden Dateien,
Fall für Fall über case. Die Rechnung dafür steht im CI-Kapitel.
Was dieses Eval nicht hat
Keinen Cache für Modellantworten (das Harness). Keinen Judge (Kapitel 5). Keinen gepaarten Vergleich zweier Läufe (CI). Keinen versiegelten Stapel (Datensatz-Design). Alles in diesen Kapiteln baut auf den fünf Dateien auf und ersetzt keine davon. Das Praxisbeispiel zeigt, was ein Team in einer Woche daraus macht.
Häufiger Fehler: Eine Erwartung als Teilstring. Die erste Fassung von
case-001erwartete"45". Ein Entwurf mit dem falschen Preis 39 € bestand trotzdem, weil die Uhrzeit21:45aus dem Ticket in der Antwort vorkommt. Eine Prüfung, die grün wird, wo sie rot sein müsste, ist schlimmer als keine Prüfung. Lassen Sie jeden Fall einmal mit einer Antwort laufen, von der Sie wissen, dass sie falsch ist, bevor Sie ihm trauen.