Kapitel

kontinent / evals

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“."
}
FeldWas darin steht
idDer Name des Falls. Jede Ergebniszeile hängt daran, deshalb ändert er sich nie.
sourceWoher der Fall stammt. Hier ein echtes Ticket mit Datum.
customerWas das System über den Kunden weiß. Hier entscheidet der Tarif über richtig und falsch.
ticketDie Kundenanfrage, wörtlich.
retrievedWas die Suche geliefert hat, eingefroren. Der Fall läuft nie gegen die heutige Doku.
checksWelche der Funktionen aus checks.py auf diesen Fall angewendet werden.
expectMuster, die im Entwurf vorkommen müssen. Reguläre Ausdrücke, siehe note.
noteFü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-001 erwartete "45". Ein Entwurf mit dem falschen Preis 39 € bestand trotzdem, weil die Uhrzeit 21:45 aus 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.