Eine Woche, durchgerechnet
Die fünf Tage des vorigen Kapitels, nachdem jemand sie gegangen ist: jede Zahl, jeder Notiz und jeder Zeile Code ausgeschrieben.
Das vorige Kapitel sagt, was zu tun ist. Dieses Kapitel zeigt ein Team, das es tut. Jeder Tag folgt demselben Raster: Was das Team getan hat, die Datei, die dabei entstand, das Ergebnis in Zahlen, und was es bedeutet. Die Dateien sind dieselben wie in Ein Eval in fünf Dateien. Sie wachsen nur.
Alles hier ist erfunden. Das Team gibt es nicht, und keine Zahl wurde gemessen. Die Zahlen sind aber so gewählt, wie echte Zahlen aussehen: ein Judge, der anfangs schlecht ist, eine Verbesserung, die sich als Rauschen entpuppt, ein Eval-Set, das still veraltet.
Ein Ticket zieht sich durch das ganze Kapitel: T-48213. An Tag 1 ist es eine Notiz, an Tag 3 ein Fall, an Tag 4 ein Label, und es ist der Grund, warum der erste Judge wegmusste.
Die Wörter dieses Kapitels
Zwölf Wörter, jedes in einer Zeile. Alle anderen Begriffe erklärt der Text dort, wo sie vorkommen.
| Wort | Bedeutung |
|---|---|
| Trace | Eine Interaktion, vollständig aufgezeichnet: Ticket, gefundene Doku-Stellen, Entwurf. |
| Fall | Eine eingefrorene Situation als JSON-Datei, plus was daran geprüft wird. Datei case-041.json. |
| Vorfall | Ein Fall, der aus einem echten Ticket stammt, bei dem der Assistent falsch lag. |
| Fixture | Der Stand der Produktdoku an einem bestimmten Tag, als Git-Commit festgehalten. |
| Prüfung | Eine Python-Funktion, die einen Entwurf ansieht und wahr oder falsch zurückgibt. Kein Modell. |
| Judge | Ein zweites Modell, das einen Entwurf liest und eine einzige Frage mit PASS oder FAIL beantwortet. |
| Label | Das Urteil eines Menschen über einen Entwurf: bestanden oder nicht bestanden, mit Begründung. |
| Aufbaustapel | Die Labels, aus denen die Anweisung für den Judge geschrieben wird. |
| Versiegelter Stapel | Die Labels, an denen der Judge gemessen wird. Er sieht sie nie. |
| Trefferquote | Anteil der echten Fehler, die der Judge gefunden hat. 21 von 25 sind 0,84. |
| Richtig-negativ-Rate | Anteil der guten Entwürfe, die der Judge in Ruhe gelassen hat. 50 von 55 sind 0,91. |
| Spanne | Wie weit eine gemessene Zahl vom wahren Wert abweichen kann, weil die Stichprobe klein ist. |
Das System
Ein SaaS für Schichtplanung. Rund 4.000 Firmenkunden, sechs Personen im Support, etwa 2.800 Tickets im Monat auf Deutsch und Englisch. Der Assistent ist ein einziger Modellaufruf: Er nimmt das Ticket, durchsucht die Produktdoku und schreibt einen Antwortentwurf mit Quellenangaben. Ein Mensch liest den Entwurf, korrigiert ihn und schickt ihn ab. Gemessen hat das noch nie jemand.
So sieht ein Trace aus. Drei Teile. Das Ticket, wie der Helpdesk es exportiert:
{
"id": "T-48213",
"created": "2026-03-09T08:41:00+01:00",
"customer": { "account": "acc_7731", "plan": "pro", "seats": 34, "locale": "de" },
"subject": "Zusatzplätze und Nachtschicht-Zuschlag",
"body": "Guten Tag,\n\nwir brauchen ab April 6 weitere Plätze. Was kostet ein zusätzlicher Platz im Monat?\n\nAußerdem: rechnet Ihr System den Nachtschicht-Zuschlag automatisch, wenn eine Schicht um 21:45 beginnt?\n\nViele Grüße\nM. Brandt"
}
Die drei Doku-Stellen, die die Suche gefunden hat:
pricing#plans | Starter 39 € pro Platz/Monat · Pro 45 € pro Platz/Monat
pricing#seats | „Zusätzliche Plätze werden ab dem Tag der Aktivierung
| anteilig berechnet."
rules#night-surcharge | „Der Nachtzuschlag gilt für Schichten, die um 22:00 Uhr
| oder später beginnen."
Der Entwurf des Assistenten:
Guten Tag Herr Brandt,
zusätzliche Plätze kosten 39 € pro Platz und Monat [doc:pricing#plans] und
werden ab dem Tag der Aktivierung anteilig berechnet [doc:pricing#seats].
Viele Grüße
Ihr Support-Team
Zwei Fehler. Der Kunde hat den Tarif Pro, der Preis ist also 45 €, nicht 39 €. Die Zahl steht auf der zitierten Seite, nur in der falschen Zeile. Und die zweite Frage nach dem Nachtzuschlag um 21:45 wird nicht beantwortet. Die Antwort wäre nein gewesen.
Werkzeuge. Kein Framework. Alles ist Python und Dateien in Git.
| Zweck | Werkzeug |
|---|---|
| Tickets | Helpdesk-Export als JSON, eine Datei pro Ticket, im Ordner tickets/ |
| Doku-Stände | Das Doku-Repo auf einem festen Commit, die Fixture |
| Fälle | JSON-Dateien in evals/cases/, mit einem Git-Tag versioniert |
| Prüfungen | Die checks.py aus Ein Eval in fünf Dateien, plus eine Funktion |
| Judge | Ein zweites Modell über seine API; die Anweisung ist eine Textdatei in evals/judges/ |
| Runner | Die run.py aus demselben Kapitel, erweitert um Lader, Cache und zwei Schalter |
| CI | GitHub Actions |
Ein Framework wäre auch eine vernünftige Wahl gewesen. Die Tooling-Landschaft beschreibt, was Frameworks leisten.
Tag 1: 100 Traces lesen
Was das Team tat. Es zog 100 aufeinanderfolgende Tickets aus einer Woche, jedes mit Entwurf und gefundenen Doku-Stellen. Zwei Personen lasen je 50 und schrieben zu jedem Trace einen Satz über das Erste, was falsch war. Keine Kategorien, keine Korrekturen.
Die Datei. Eine Textdatei mit 100 Zeilen. Vier davon:
#7 (T-48213) Sagt, der Pro-Tarif koste 39 € pro Platz. Er kostet 45 €.
Die Zahl steht auf der zitierten Seite, aber in der Starter-Zeile.
Beantwortet außerdem die Nachtschicht-Frage nie.
#23 Kunde hat auf Deutsch geschrieben, Entwurf kam auf Englisch zurück.
Kollege hat alles von Hand neu geschrieben.
#41 Verweist auf „Einstellungen → Schichtvorlagen → Sammelbearbeitung".
Es gibt keine Sammelbearbeitung.
#58 „Ich habe das an unser Abrechnungsteam eskaliert." Er kann gar nichts
eskalieren. Eskaliert wurde niemand.
Ergebnis. Dreieinhalb Stunden pro Person. Beide hatten unabhängig voneinander rund ein Dutzend Mal „erfindet Preise“ geschrieben.
Was es bedeutet. Die abgeschickten Antworten waren in Ordnung, weil der Support die Entwürfe seit Monaten von Hand reparierte. Deshalb gab es keine Beschwerde. Das Maß, das den Fehler früher gezeigt hätte, ist der Abstand zwischen Entwurf und abgeschickter Antwort. Niemand hatte ihn je berechnet.
Tag 2: Notizen zu Fehlermodi
Was das Team tat. Es gab die 100 Notizen in ein Modell und ließ sich Gruppen vorschlagen. Die Hälfte der Gruppen warf es weg, den Rest benannte es um. Jeder Trace zählt einmal, unter dem ersten Fehler. T-48213 zählt also als Preisfehler.
Das Ergebnis. Die Fehlermodi, sortiert nach Häufigkeit:
| Fehlermodus | Traces |
|---|---|
| Preis oder Zahl, die die zitierte Seite nicht hergibt | 19 |
| Auf Englisch geantwortet, obwohl das Ticket deutsch war | 14 |
| Zitiert einen Doku-Abschnitt, den es nicht gibt | 11 |
| Verspricht eine Handlung, die der Assistent nicht ausführen kann | 8 |
| Beantwortet die erste Frage und übergeht die zweite | 7 |
| Markdown, das der Helpdesk-Editor als rohe Zeichen anzeigt | 4 |
| Nichts zu beanstanden | 37 |
Was es bedeutet. Zwei der Zeilen waren gewöhnliche Bugs und wurden am selben Nachmittag behoben. Der Helpdesk verschluckt Markdown-Tabellen, also verbietet der Prompt sie jetzt: 4 Traces erledigt. Der Suchindex war sechs Wochen alt und lieferte Anker auf umbenannte Abschnitte. Nach dem Neuaufbau fielen 11 auf 3. Übrig blieben 51 Traces mit Verhaltensfehlern, die ein Bugfix nicht beseitigt. Das erste Ziel steht fest: Die größte Zeile ist zugleich die, die Geld kostet, sobald ein Kunde danach handelt.
Häufiger Fehler: Die Liste um das herum bauen, was leicht zu beheben ist. Das Team hätte „übergeht die zweite Frage“ fast gestrichen, weil niemand wusste, was man dagegen tun kann. Die Zeile blieb, mit ihrer Zahl, als ungemessener Fehler. Das ist die richtige Behandlung für einen echten Fehler, gegen den man noch nichts hat.
Tag 3: Das Eval-Set und die Prüfungen
Woher die 147 Fälle kommen
Ein Eval-Set muss zwei Fragen beantworten. Wird das besser, von dem wir wissen, dass es kaputt ist? Und geht dabei etwas kaputt, das vorher funktioniert hat? Keine einzelne Quelle liefert beides. Das Team nahm drei.
Quelle 1: 41 Vorfälle, für die erste Frage. Tag 2 endete mit 51 Traces mit Verhaltensfehlern. Zehn davon waren Wiederholungen, etwa vier Kunden, die in vier Formulierungen nach dem Platzpreis fragen. Ein Fall pro Situation reicht. 51 minus 10 ergibt 41 Vorfälle, jeder ein echtes Ticket, eingefroren mit der Doku dieses Tages. T-48213 ist einer davon.
Quelle 2: 78 gewöhnliche Tickets, für die zweite Frage. An Tag 1 war bei 37 der 100 Traces nichts falsch. Ein Set nur aus Fehlern kann nicht sagen, ob eine Korrektur diese 37 kaputt macht. Also zog das Team 78 Tickets aus drei Monaten, im selben Verhältnis, in dem die Ticket-Typen im echten Verkehr vorkommen:
| Ticket-Typ | Anteil am Verkehr | Fälle |
|---|---|---|
| Abrechnung und Tarife | 40 % | 31 |
| Dienstplanregeln | 28 % | 22 |
| Integrationen | 18 % | 14 |
| Konto und Sonstiges | 14 % | 11 |
Quelle 3: 28 von Hand geschriebene Randfälle. Situationen, die die Liste von Tag 2 als schwierig ausweist, die aber in einer Woche Verkehr noch nicht vorgekommen waren:
| Randfall | Fälle |
|---|---|
| Zwei unabhängige Fragen in einem Ticket | 8 |
| Deutsches Ticket mit englischer Fehlermeldung darin | 6 |
| Kunde in einem Alttarif, den die Doku nicht mehr führt | 5 |
| Eine Frage, die die Doku wirklich nicht beantwortet | 5 |
| Ein „Ticket“, das eigentlich ein Bugreport ist, keine Frage | 4 |
41 + 78 + 28 = 147 Fälle, eingecheckt in evals/cases/ und mit support-drafts@v14
getaggt. Das Tag ist an Tag 5 wichtig: Zwei Läufe lassen sich nur vergleichen, wenn beide
dieselben 147 benutzt haben.
Was fehlt: ein Testset, das die CI nie sieht. Das Team legte es erst an Tag 31 an. Datensatz-Design erklärt, warum es früher da sein sollte.
Ein Fall als Datei
So sieht T-48213 als Fall aus, in evals/cases/case-041.json:
{
"id": "case-041",
"source": "Vorfall T-48213, 2026-03-09",
"ticket": "tickets/T-48213.json",
"fixture": "docs@a91f4c7",
"checks": ["zitate_loesen_auf", "keine_zahl_ohne_beleg", "erwartetes_kommt_vor", "passt_ins_helpdesk_feld"],
"expect": { "nennt": ["\\b45\\s?€"] },
"unmeasured": ["zweite_frage_uebergangen"],
"note": "Pro-Kunde. pricing#plans führt 39 und 45; nur 45 gilt."
}
Gegenüber dem Fall aus Ein Eval in fünf Dateien sind drei Felder neu:
| Feld | Was darin steht |
|---|---|
ticket | Pfad zur Ticket-Datei statt des Tickettexts. Der Runner liest sie ein. |
fixture | Der Doku-Commit, gegen den der Fall läuft. Ohne ihn wäre der Fall nach der nächsten Doku-Änderung nicht mehr reproduzierbar. |
unmeasured | Fehler, die das Team in diesem Fall kennt, aber noch mit nichts fangen kann. Hier die übergangene zweite Frage. |
Zwei Regeln stecken in dieser Datei. Erstens: checks steht pro Fall, weil eine Prüfung,
die auf einen Fall nicht passt, sonst still besteht. Vier der Randfälle sind Bugreports, bei
denen die richtige Antwort nichts zitiert. Dort würde zitate_loesen_auf wahr zurückgeben,
ohne etwas geprüft zu haben. Diese vier Fälle führen deshalb nur passt_ins_helpdesk_feld.
Zweitens: expect gibt es nur bei den 41 Vorfällen. Die 78 Verkehrsfälle und die Randfälle
haben keine erwartete Antwort, sie tragen nur checks. Ein Preis in expect verrottet nicht,
weil der Fall gegen die eingefrorene Doku läuft. Eine Prüfung, die den heutigen Preis
behauptet, würde verrotten. Deshalb: Regeln in den Prüfungen, feste Werte nur in Vorfällen.
Die Prüfungen
Drei der vier Prüfungen sind unverändert die aus Ein Eval in fünf Dateien:
zitate_loesen_auf, keine_zahl_ohne_beleg, erwartetes_kommt_vor. Neu ist eine vierte:
# evals/checks.py, was an Tag 3 dazukam
MAX_CHARS = 1200
def passt_ins_helpdesk_feld(entwurf: str, fall: dict) -> bool:
"""Der Helpdesk-Editor rendert keine Tabellen und kürzt ab 1200 Zeichen."""
return len(entwurf) <= MAX_CHARS and "|" not in entwurf
CHECKS = {f.__name__: f for f in (zitate_loesen_auf, keine_zahl_ohne_beleg, erwartetes_kommt_vor, passt_ins_helpdesk_feld)}
Der Runner bekommt zwei Ergänzungen. Ein Lader macht aus dem Pfad unter ticket und der
fixture dieselbe Liste retrieved, die die Prüfungen schon lesen. Und der Modellaufruf
wird auf der Platte gecacht, damit ein wiederholter Lauf ohne Änderung nichts kostet:
# evals/run.py, was an Tag 3 dazukam
CACHE_DIR = pathlib.Path("runs/cache")
def lade_fall(pfad: pathlib.Path) -> dict:
fall = json.loads(pfad.read_text())
ticket = json.loads(pathlib.Path(fall["ticket"]).read_text())
fall["customer"] = ticket["customer"]
fall["ticket"] = ticket["body"]
fall["retrieved"] = retrieve(ticket["body"], fixture=fall["fixture"]) # die Suche des Produkts, gegen die festgenagelte Doku
return fall
def call_model(fall: dict) -> str:
... # der Rumpf von generate() aus den fünf Dateien, unverändert
def generate(fall: dict) -> str:
gecacht = CACHE_DIR / f"{fall['id']}_{MODEL}_{PROMPT_SHA}.txt"
if not gecacht.exists():
gecacht.write_text(call_model(fall))
return gecacht.read_text()
retrieve ist die Suchfunktion des Produkts, aus der Anwendung importiert. Das Eval baut die
Suche nicht nach.
Der erste Lauf
Das Team ließ den Runner noch am selben Nachmittag über alle 147 Fälle laufen:
| Prüfung | Verstöße |
|---|---|
keine_zahl_ohne_beleg | 22 |
zitate_loesen_auf | 9 |
passt_ins_helpdesk_feld | 5 |
erwartetes_kommt_vor | 14 |
Was es bedeutet. 50 Fehler, gefunden von Code, der in unter einer Sekunde läuft und
nichts kostet. Und eine Lücke: Bei T-48213 steht hinter der 39 € eine Quellenangabe, also
besteht keine_zahl_ohne_beleg. Nur erwartetes_kommt_vor fängt den Fall, weil er eine
feste Erwartung trägt. Die 78 Verkehrsfälle tragen keine. Dort fängt nichts den Preis des
falschen Tarifs. Dafür ist Tag 4 da.
Tag 4: Ein Judge, gemessen
Dieselben sechs Schritte wie im vorigen Kapitel, mit den Zahlen dieses Teams.
1. Ein Fehlermodus, eine Frage
Der Fehlermodus ist die größte Zeile von Tag 2: eine Zahl, die die zitierte Seite nicht hergibt. Code fängt nur den einfachen Teil davon, die Zahl ohne Quelle. Den schwierigen Teil, die Zahl mit Quelle, bei der die Quelle etwas anderes sagt, muss jemand lesen. Die Frage an den Judge lautet:
Stützt die zitierte Stelle die Zahl in diesem Entwurf, für diesen Kunden?
2. 120 Entwürfe aus dem Verkehr
Das Team nahm einen Monat Tickets und ließ den Assistenten über alle laufen. Behalten wurde jeder Entwurf mit mindestens einem Geldbetrag, denn nur zu denen hat die Frage etwas zu sagen. Es blieben 120 Entwürfe. Sie kommen nicht aus dem Eval-Set von Tag 3. Sonst stünden die Testfälle später als Beispiele in der Judge-Anweisung.
Die Datei. Jeder Entwurf wird mit dem gespeichert, was der Judge später sehen muss:
Tarif, gefundene Stellen, Entwurf. Eine Zeile je Entwurf in evals/drafts.jsonl:
{"draft_id":"d-0311","source":"T-48213","customer":{"plan":"pro"},"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."}],"draft":"Guten Tag Herr Brandt, zusätzliche Plätze kosten 39 € pro Platz und Monat [doc:pricing#plans] und werden ab dem Tag der Aktivierung anteilig berechnet [doc:pricing#seats]."}
3. Labeln
Die Supportleitung las alle 120 und beantwortete zu jedem nur die eine Frage. Das Ergebnis
ist eine Datei evals/labels.jsonl, eine Zeile pro Entwurf, mit Begründung. Das Feld
pile kommt in Schritt 4 dazu und sagt, zu welchem Stapel der Entwurf gehört:
{"draft_id":"d-0311","source":"T-48213","pile":"build","verdict":"fail","reason":"Nennt 39 € für einen Pro-Kunden. pricing#plans führt 39 € für Starter und 45 € für Pro. Die Zahl ist echt, der Tarif ist falsch."}
{"draft_id":"d-0312","source":"T-48377","pile":"sealed","verdict":"pass","reason":"Sagt, die ersten 5 Plätze seien enthalten, zitiert pricing#included-seats: 'jeder Tarif enthält 5 Plätze'. Deckt sich."}
{"draft_id":"d-0313","source":"T-48401","pile":"build","verdict":"fail","reason":"Nennt 42 € und zitiert pricing#archive-2025. Das war der Preis von 2025. Der aktuelle Abschnitt sagt 45 €."}
Ergebnis. Rund 90 Sekunden pro Entwurf, drei Stunden insgesamt. 45 der 120 Entwürfe fielen durch, 75 bestanden. Zweimal änderte die Supportleitung dabei die Formulierung der Frage und labelte die bereits bearbeiteten Entwürfe neu. Die Begründungen sind das Material, aus dem in Schritt 4 die Anweisung entsteht.
4. Zwei Stapel
| Stapel | Größe | Zusammensetzung | Wofür |
|---|---|---|---|
| Aufbau | 40 | 20 durchgefallen + 20 bestanden, von Hand ausgewählt | Die Anweisung des Judges schreiben |
| Versiegelt | 80 | 25 durchgefallen + 55 bestanden, alles Übrige | Den Judge messen |
Der Aufbaustapel ist ausgeglichen, weil man darin Beispiele für beide Seiten der Unterscheidung sucht. Der versiegelte Stapel ist absichtlich nicht ausgeglichen: 25 zu 55 ist die Mischung, die der Judge später in Produktion sieht. Der Judge bekommt die 80 nie zu sehen, auch nicht als Beispiel.
5. Messen: Fassung eins
Das Messskript. Es liest Labels und Entwürfe, lässt den Judge über die versiegelten 80 laufen und zählt die vier Felder der Matrix aus Kapitel 5:
# evals/measure.py
import json
import math
import pathlib
import sys
from run import judge
HERE = pathlib.Path(__file__).parent
judge_modell, judge_name = sys.argv[1], sys.argv[2]
def zeilen(name: str) -> list[dict]:
return [json.loads(z) for z in (HERE / name).read_text().splitlines()]
entwuerfe = {zeile["draft_id"]: zeile for zeile in zeilen("drafts.jsonl")}
versiegelt = [label for label in zeilen("labels.jsonl") if label["pile"] == "sealed"]
richtig_positiv = falsch_negativ = falsch_positiv = richtig_negativ = 0
for label in versiegelt:
entwurf = entwuerfe[label["draft_id"]]
fall = {"judge": judge_name, "customer": entwurf["customer"], "retrieved": entwurf["retrieved"]}
bestanden = judge(entwurf["draft"], fall, judge_modell)
if label["verdict"] == "fail":
falsch_negativ += bestanden
richtig_positiv += not bestanden
else:
richtig_negativ += bestanden
falsch_positiv += not bestanden
def quote(treffer: int, gesamt: int) -> str:
p = treffer / gesamt
spanne = 1.96 * math.sqrt(p * (1 - p) / gesamt)
return f"{treffer} von {gesamt} = {p:.2f} ±{spanne:.2f}"
print("Trefferquote ", quote(richtig_positiv, richtig_positiv + falsch_negativ))
print("Richtig-negativ-Rate ", quote(richtig_negativ, richtig_negativ + falsch_positiv))
Aufruf und Ausgabe für die erste Fassung der Anweisung, die aus den 40 Entwürfen des Aufbaustapels mit zwei Begründungen als Beispielen entstand:
uv run --with openai python evals/measure.py "<anbieter>/<modell>@2026-06-02" zahl_durch_zitat_gedeckt
Trefferquote 14 von 25 = 0.56 ±0.19
Richtig-negativ-Rate 53 von 55 = 0.96 ±0.05
Dieselben Zahlen als Matrix:
| Judge: nicht bestanden | Judge: bestanden | |
|---|---|---|
| Experte: nicht bestanden (25) | 14 | 11 |
| Experte: bestanden (55) | 2 | 53 |
Trefferquote 0,56, also 14 von 25. Richtig-negativ-Rate 0,96, also 53 von 55. Der Judge fand nur die Hälfte der echten Fehler. Bei T-48213 gab er PASS zurück, mit der Begründung: „Der Abschnitt pricing#plans enthält 39 €, die Zahl ist also gedeckt." Der Judge hatte gelernt: „Steht die Zahl irgendwo auf der Seite?“ Die Frage war aber: „Stützt die Stelle die Zahl für diesen Kunden?“
6. Nachbessern: Fassung zwei
Das Team las die 11 verpassten Fälle. Alle waren Zahlen, die auf der Seite standen, aber zum falschen Gegenstand gehörten. Fassung zwei bekam drei Begründungen aus dem Aufbaustapel als Beispiele, jede für eine andere Art dieses Fehlers.
Kritik 1: richtige Zahl, falscher Tarif
Der Entwurf nennt 39 € pro Platz und zitiert pricing#plans. Dort stehen
39 € für Starter und 45 € für Pro. Der Kundendatensatz sagt Tarif "pro".
Die Zahl steht auf der Seite; sie gehört zum anderen Tarif. FAIL.
Kritik 2: richtige Zahl, falscher Zeitpunkt
Der Entwurf nennt 42 € und zitiert pricing#archive-2025. 42 € war der
Preis von 2025, und dieser Abschnitt ist archiviert. Das aktuelle
pricing#plans sagt 45 €. Eine Zahl aus einem archivierten Abschnitt ist
keine Deckung. FAIL.
Kritik 3: richtige Zahl, falsche Einheit
Der Entwurf nennt 540 € und zitiert pricing#plans, wo 45 € pro Platz und
Monat stehen. 540 € ist 12 × 45, rechnerisch richtig, aber der Kunde hat
nach dem Monatspreis gefragt, und die Stelle nennt keinen Jahresbetrag.
Eine Zahl, die man ausrechnen musste, ist nicht zitiert. FAIL.
Dazu eine Regel, die nach Einschätzung des Teams so viel bewirkte wie die drei Beispiele:
Der Judge muss den stützenden Satz zitieren, bevor er urteilt. Findet er keinen, ist das
Urteil FAIL. Die fertige Anweisung, in evals/judges/zahl_durch_zitat_gedeckt.txt:
Du prüfst eine einzige Sache: Stützt die zitierte Stelle die Zahl im
Entwurf, FÜR DIESEN KUNDEN? Der Tarif des Kunden wird dir mitgegeben.
Zitiere den stützenden Satz aus der Stelle, BEVOR du urteilst. Kannst du
keinen zitieren, lautet das Urteil FAIL.
Eine Zahl ist NICHT gedeckt, wenn sie zwar auf der Seite steht, aber zu
einem anderen Tarif, einem anderen Zeitraum oder einer anderen Einheit
gehört. Drei Beispiele:
<die drei Kritiken von oben, wörtlich>
Ausgabe: {"quote": string | null, "verdict": "PASS" | "FAIL", "reason": string}
Dasselbe Skript, dieselben versiegelten 80, sonst nichts geändert:
Trefferquote 21 von 25 = 0.84 ±0.14
Richtig-negativ-Rate 50 von 55 = 0.91 ±0.08
| Judge: nicht bestanden | Judge: bestanden | |
|---|---|---|
| Experte: nicht bestanden (25) | 21 | 4 |
| Experte: bestanden (55) | 5 | 50 |
Trefferquote 0,84, also 21 von 25. Richtig-negativ-Rate 0,91, also 50 von 55. Der Judge übersieht jetzt 4 echte Fehler und beschuldigt 5 gute Entwürfe. Beide Zahlen sind bekannt, und das ist der Zweck der Übung. Bei 25 Fehlern hat die Trefferquote eine Spanne von rund ±14 Punkten. Das ändert die Reihenfolge der beiden Fassungen nicht, aber es macht die zweite Nachkommastelle bedeutungslos. Bei T-48213 gibt der Judge jetzt FAIL zurück und zitiert die Starter-Zeile.
Eine Zahl, die das Team bewusst nicht berichtet: die Übereinstimmung. Sie stieg von 67 auf 71 von 80. Ein Judge, der zu allem PASS sagt, käme auf 55 von 80, ohne zu lesen. Die Übereinstimmung versteckt genau das, wofür der Judge gebaut wurde.
Die Datei. Der Fall von Tag 3 wächst um eine Zeile, den Namen der Anweisungsdatei:
"judge": "zahl_durch_zitat_gedeckt",
Und der Runner bekommt die Funktion, die den Judge aufruft. Sie zeigt ihm dieselben drei Dinge, die der Fachexperte sah, und liest das Urteil aus:
# evals/run.py, was an Tag 4 dazukam
def judge(entwurf: str, fall: dict, judge_modell: str) -> bool:
anweisung = (HERE / "judges" / f"{fall['judge']}.txt").read_text()
auszuege = "\n".join(f"[doc:{doc['ref']}] {doc['text']}" for doc in fall["retrieved"])
nutzer = f"Tarif des Kunden: {fall['customer']['plan']}\n\nEntwurf:\n{entwurf}\n\nStellen:\n{auszuege}"
antwort = OpenAI().chat.completions.create(
model=judge_modell,
temperature=0,
response_format={"type": "json_object"},
messages=[{"role": "system", "content": anweisung}, {"role": "user", "content": nutzer}],
)
return json.loads(antwort.choices[0].message.content)["verdict"] == "PASS"
Der Judge läuft auf den 41 Vorfällen und auf jedem weiteren Fall, dessen Entwurf eine Zahl enthält. Die übrigen Fälle rufen ihn nie auf. Das hält die Kosten klein.
Derselbe Judge auf Deutsch
Das Team wiederholte Schritt 3 bis 5 mit 80 deutschen Entwürfen und der übersetzten Anweisung:
| Judge: nicht bestanden | Judge: bestanden | |
|---|---|---|
| Experte: nicht bestanden (27) | 17 | 10 |
| Experte: bestanden (53) | 5 | 48 |
Trefferquote 0,63, also 17 von 27. Das liegt deutlich unter den 0,84 auf Englisch. Ob der Unterschied echt ist, lässt sich mit 27 und 25 Fehlern noch nicht sagen; die Spannen beider Zahlen überlappen. Statistik rechnet das vor. Was das Team weiß: Der Judge ist auf Deutsch nicht nachweislich gleich gut, und das reicht, um ihm dort nicht zu trauen, bis mehr Labels da sind. Dass Judges außerhalb des Englischen schlechter abschneiden, ist bekannt. Mehrsprachige Evaluation hat die Belege.
Tag 5: In die CI
Zwei Stufen
| Stufe | Was läuft | Wann | Kosten | Darf blockieren? |
|---|---|---|---|---|
| Smoke | Die vier Prüfungen über alle 147 Fälle | Bei jedem Push | Nichts, die Entwürfe sind gecacht | Ja |
| Regression | Dasselbe plus der Judge auf jedem Fall mit judge | Bei Pull Requests und nachts | Rund 0,65 € pro Lauf | Nein, schreibt einen Kommentar |
Ein Fall gilt als bestanden, wenn alle seine Prüfungen bestehen, in der Regressionsstufe einschließlich des Judges. Der Runner bekommt dafür zwei Schalter:
# evals/run.py, was an Tag 5 dazukam
parser.add_argument("--tier", choices=["smoke", "regression"], default="smoke")
parser.add_argument("--judge") # Modellname, siehe Tag 4
ergebnisse = {name: CHECKS[name](entwurf, fall) for name in fall["checks"]}
if args.tier == "regression" and "judge" in fall:
ergebnisse[fall["judge"]] = judge(entwurf, fall, args.judge)
Die Kostenrechnung stand von Anfang an neben der Konfiguration:
Generierung 147 Fälle × ~2.500 Token Eingabe + ~300 Ausgabe ≈ 0,40 € nur nach Prompt- oder Modelländerung, sonst Cache
Judge ~60 Fälle mit Zahl × ~3.000 Token ≈ 0,25 € bei jedem Regressionslauf
Ein Regressionslauf ≈ 0,65 €
Etwa 8 PRs am Tag plus Nachtlauf ≈ 6 € am Tag, rund 130 € im Monat
Die Beträge veralten mit der Preisliste, die Rechnung nicht. Jeder weitere Judge verdoppelt die zweite Zeile.
Die Datei. Die CI-Konfiguration:
# .github/workflows/evals.yml
name: evals
on:
push:
pull_request:
schedule: [{ cron: "0 3 * * *" }]
env:
MODEL: "<anbieter>/<modell>@2026-07-11"
JUDGE_MODEL: "<anbieter>/<modell>@2026-06-02"
DATASET: "support-drafts@v14"
PROMPT_SHA: "b3f1c02"
DOCS_FIXTURE: "docs@a91f4c7"
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: uv run --with openai python evals/run.py --tier smoke
# nur die deterministischen Prüfungen; Exit ≠ 0 blockiert den Merge
regression:
if: github.event_name != 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: uv run --with openai python evals/run.py --tier regression --judge "$JUDGE_MODEL"
continue-on-error: true # Judge-Werte kommentieren, sie blockieren nicht
Fünf Dinge sind im Block env festgehalten: Modellversion, Judge-Version, Datensatz-Tag,
Hash der Prompt-Vorlage, Doku-Fixture. Alles, was dort nicht steht, kann sich zwischen zwei
Läufen ändern, ohne dass es jemand merkt.
Der erste echte Test
Jemand schrieb den System-Prompt um, damit er strenger mit Quellenangaben umgeht. Die Regressionsstufe meldete: 71,4 % → 74,1 % der Fälle bestanden. Ausliefern?
Nein. Beide Läufe nutzen dieselben 147 Fälle, also vergleicht man Fall für Fall:
n = 147 Fälle 105 bestanden vorher → 109 danach (+4 netto)
Naiver Fehlerbalken für einen Lauf
SE = Wurzel(0,714 × 0,286 / 147) = 0,0373 → ±7,3 Punkte
Beide Läufe nutzen aber dieselben 147 Fälle, also paarweise vergleichen:
129 Fälle unverändert 11 nicht bestanden → bestanden 7 umgekehrt
SE = Wurzel(11 + 7) / 147 = 0,0289 → ±5,7 Punkte
Beobachtete Änderung: +2,7 Punkte. So oder so innerhalb der Spanne.
Was es bedeutet. Plus 2,7 Punkte bei einer Spanne von ±5,7 ist Rauschen. Die Gesamtquote
sagt nichts. Was im selben Lauf kein Rauschen war: Die Verstöße gegen
keine_zahl_ohne_beleg fielen von 22 auf 3, und kein einziger Fall wurde schlechter. Der
strengere Prompt tat genau, was er sollte. Die Gesamtquote war nur zu stumpf, um das zu
zeigen. Wie die Rechnung im Detail geht, steht in Evals in der CI.
Häufiger Fehler: Den Merge an der Gesamt-Bestehensquote blockieren. Sie ist die unempfindlichste Zahl der ganzen Suite. Eine echte Verbesserung an einem Fehlermodus geht zwischen den Modi unter, die sich nicht bewegt haben. Blockieren Sie über die deterministischen Prüfungen. Berichten Sie den Rest mit seiner Spanne, und lesen Sie ihn.
Tag 31: Das Set veraltet
Was das Team tat. Es zog jede Woche 200 Entwürfe aus dem laufenden Betrieb und ließ denselben Judge darüber laufen.
Ergebnis. Im Betrieb fielen 8 % der Entwürfe beim Judge durch. Im Eval-Set waren es 2 %.
Was es bedeutet. Das Marketing hatte in Woche drei eine neue Preisseite ausgeliefert.
Zwölf der 41 Vorfälle verwiesen jetzt auf Abschnitte, die es nicht mehr gab, und die Kunden
fragten nach den neuen Tarifen, zu denen das Set keinen Fall hatte. Das Set war nicht falsch.
Es beschrieb den März. Zwei Entscheidungen von Tag 3 retteten es: Die Prüfungen prüfen
Regeln, also stimmte keine_zahl_ohne_beleg weiterhin. Und die Vorfälle sind gegen eine
Fixture eingefroren, also laufen sie weiter, nur eben gegen alte Doku.
Das Nachziehen dauerte einen Vormittag: 100 frische Traces, derselbe Durchgang wie an Tag 1
und 2, DATASET in der CI hochgezählt. Die 100 frischen Traces wurden zugleich das erste
Testset, das die CI nicht sieht. Der Durchgang steht jetzt monatlich im Kalender.
Häufiger Fehler: Das Eval-Set als fertig betrachten. Wenn es veraltet, bricht nichts. Die CI bleibt grün, die Zahlen kommen weiter. Sie beziehen sich nur nach und nach nicht mehr auf Ihr Produkt.
Was die Woche hervorgebracht hat
Eine Liste von Fehlermodi aus echten Traces. 147 versionierte Fälle mit eingefrorener Doku. Vier Prüfungen, die bei jedem Push kostenlos laufen. Ein Judge mit bekannter Trefferquote in zwei Sprachen. Und eine Prompt-Änderung, über die man mit Zahlen statt mit Eindrücken streiten kann.
Drei Dinge zeigt dieses Beispiel nicht: wie man Handlungen statt Sätze bewertet, wie man eine kaputte Suche von einer kaputten Antwort unterscheidet, und wie man mit feindseligen Eingaben umgeht. Die Schlusstabelle des vorigen Kapitels ordnet diese Lücken den Kapiteln zu.
Das wertvollste Ergebnis war Tag 1: die Entdeckung, dass der Support monatelang still denselben Fehler repariert hatte. Bis ein Kunde die Antwort sah, sah sie gut aus.