Zum Inhalt springen
EN DE

Challenge 6.2: Deterministic Eval

Nicht jede Bewertung braucht ein LLM — manchmal reicht ein String-Vergleich. Wann ist ein deterministischer Scorer die bessere Wahl, und wann brauchst Du wirklich ein LLM als Richter?

Entscheidungsbaum: Exakte Antwort erwartet? Ja fuehrt zu Deterministic Scorer (Inline Scorer, createScorer, Autoevals), Nein fuehrt zu LLM-as-a-Judge (Challenge 6.3)

Die Entscheidung ist einfach: Gibt es eine eindeutig richtige Antwort? Dann deterministic. Ist die Antwort offen formuliert? Dann LLM-as-Judge (nächste Challenge).

Ohne deterministische Evals: Du benutzt ein LLM, um zu prüfen, ob die Antwort das Wort “Paris” enthaelt. Das kostet Tokens, dauert Sekunden und liefert bei jedem Lauf leicht andere Ergebnisse. Für triviale Checks ist das Verschwendung.

Mit deterministischen Evals: Ein output.includes('Paris') laeuft in Mikrosekunden, kostet nichts und gibt bei jedem Lauf dasselbe Ergebnis. Schnell, guenstig, reproduzierbar — perfekt für alles, was sich als String-Operation ausdruecken laesst.

Der einfachste Weg — ein Objekt mit name, description und scorer-Funktion direkt im scorers-Array:

import { evalite } from 'evalite';
evalite('Capital Cities', {
data: async () => [
{ input: 'What is the capital of France?', expected: 'Paris' },
{ input: 'What is the capital of Germany?', expected: 'Berlin' },
],
task: async (input) => {
// Hier kommt Dein LLM-Call rein
return 'The capital is Paris';
},
scorers: [{
name: 'Contains Paris',
description: 'Prueft ob Paris vorkommt.',
scorer: ({ output }) => { // ← Bekommt output, expected, input
return output.includes('Paris') ? 1 : 0; // ← Score: 0 oder 1
},
}],
});

Der Scorer bekommt ein Objekt mit output (was die task zurueckgegeben hat), expected (der erwartete Wert aus data) und input (der Input aus data). Er muss eine Zahl zwischen 0 und 1 zurueckgeben.

Statt einen festen String zu prüfen, nutze den expected-Wert aus den Daten:

scorers: [{
name: 'Contains Expected',
description: 'Prueft ob der erwartete Wert im Output vorkommt.',
scorer: ({ output, expected }) => {
if (!expected) return 0;
return output.toLowerCase().includes(expected.toLowerCase()) ? 1 : 0;
},
}]

Jetzt funktioniert der Scorer für alle Test-Cases — nicht nur für “Paris”. Die Normalisierung auf Kleinbuchstaben macht den Check robuster.

Schicht 3: createScorer — Wiederverwendbare Scorer

Abschnitt betitelt „Schicht 3: createScorer — Wiederverwendbare Scorer“

Wenn Du denselben Scorer in mehreren Evals brauchst, extrahiere ihn mit createScorer:

import { createScorer } from 'evalite';
const containsExpected = createScorer<string, string, string>({
name: 'Contains Expected',
description: 'Prueft ob der erwartete Wert im Output vorkommt.',
scorer: ({ output, expected }) => {
if (!expected) return 0;
return output.toLowerCase().includes(expected.toLowerCase()) ? 1 : 0;
},
});

Die drei Generics <Input, Output, Expected> typisieren die Parameter. Jetzt kannst Du containsExpected in jeder Eval wiederverwenden:

evalite('Cities', {
data: async () => [
{ input: 'Capital of France?', expected: 'Paris' },
{ input: 'Capital of Germany?', expected: 'Berlin' },
],
task: async (input) => { /* LLM Call */ return ''; },
scorers: [containsExpected], // ← Wiederverwendbar
});

Scores müssen nicht binaer (0 oder 1) sein. Du kannst Abstufungen verwenden:

const titleLength = createScorer<string, string, string>({
name: 'Title Length',
description: 'Bewertet ob der Titel eine gute Laenge hat (10-50 Zeichen).',
scorer: ({ output }) => {
const len = output.length;
if (len >= 10 && len <= 50) return 1; // ← Perfekt
if (len >= 5 && len <= 80) return 0.5; // ← Akzeptabel
return 0; // ← Zu kurz oder zu lang
},
});

Abgestufte Scores geben Dir feinere Kontrolle. Ein Titel mit 60 Zeichen ist nicht so gut wie einer mit 40, aber immer noch besser als einer mit 200.

Die autoevals Library von Braintrust liefert vorgefertigte Scorer. Levenshtein hast Du bereits kennengelernt:

import { Levenshtein } from 'autoevals';
evalite('Exact Match', {
data: [{ input: 'test', expected: 'test result' }],
task: async (input) => input + ' result',
scorers: [Levenshtein],
});

Levenshtein misst die Edit-Distanz — wie viele Zeichen müssen geaendert werden, um vom Output zum Expected zu kommen. Score 1.0 bedeutet identisch, Score 0.0 bedeutet komplett verschieden.

Du kannst mehrere Scorer in einem scorers-Array kombinieren. Jeder bewertet unabhängig:

evalite('Multi-Scorer', {
data: async () => [
{ input: 'Capital of France?', expected: 'Paris' },
],
task: async (input) => 'The capital of France is Paris.',
scorers: [
containsExpected, // ← Enthaelt "Paris"? → 1
Levenshtein, // ← Wie nah an "Paris"? → niedrig (weil viel Extra-Text)
{
name: 'Short Answer',
description: 'Prueft ob die Antwort unter 50 Zeichen ist.',
scorer: ({ output }) => output.length < 50 ? 1 : 0,
},
],
});

Im Dashboard siehst Du dann drei separate Scores pro Test-Case. Das gibt Dir ein differenziertes Bild: Die Antwort enthaelt das richtige Wort, aber ist zu lang.

Aufgabe: Erstelle einen eigenen “Contains Keyword” Scorer und teste ihn mit Hauptstadt-Fragen.

Erstelle die Datei capitals.eval.ts und fuehre sie mit pnpm eval:dev aus.

capitals.eval.ts
import { evalite } from 'evalite';
import { createScorer } from 'evalite';
import { Levenshtein } from 'autoevals';
// TODO 1: Erstelle einen createScorer namens 'containsKeyword'
// - Prueft ob output.toLowerCase() den expected-Wert enthaelt
// - Gibt 1 zurück wenn ja, 0 wenn nein
// TODO 2: Erstelle eine evalite() mit dem Namen 'Capital Cities'
// - data: 5 Hauptstadt-Fragen (input: Frage, expected: Stadtname)
// - task: Gibt eine Antwort im Format "The capital is [Stadt]." zurück
// (simuliere erstmal ohne LLM — gib feste Antworten zurück)
// - scorers: [containsKeyword, Levenshtein]
// TODO 3: Vergleiche die Scores beider Scorer im Dashboard
// - Warum unterscheiden sich die Scores?

Checkliste:

  • createScorer mit containsKeyword implementiert
  • data mit 5 Test-Cases
  • task gibt Antworten zurück
  • Beide Scorer (containsKeyword und Levenshtein) im Array
  • Dashboard zeigt unterschiedliche Scores für beide Scorer
Lösung anzeigen
capitals.eval.ts
import { evalite } from 'evalite';
import { createScorer } from 'evalite';
import { Levenshtein } from 'autoevals';
const containsKeyword = createScorer<string, string, string>({
name: 'Contains Keyword',
description: 'Prueft ob der erwartete Wert im Output vorkommt (case-insensitive).',
scorer: ({ output, expected }) => {
if (!expected) return 0;
return output.toLowerCase().includes(expected.toLowerCase()) ? 1 : 0;
},
});
evalite('Capital Cities', {
data: async () => [
{ input: 'What is the capital of France?', expected: 'Paris' },
{ input: 'What is the capital of Germany?', expected: 'Berlin' },
{ input: 'What is the capital of Japan?', expected: 'Tokyo' },
{ input: 'What is the capital of Italy?', expected: 'Rome' },
{ input: 'What is the capital of Spain?', expected: 'Madrid' },
],
task: async (input) => {
// Simulierte Antworten — später durch LLM ersetzen
const answers: Record<string, string> = {
'What is the capital of France?': 'The capital of France is Paris.',
'What is the capital of Germany?': 'The capital of Germany is Berlin.',
'What is the capital of Japan?': 'The capital of Japan is Tokyo.',
'What is the capital of Italy?': 'The capital of Italy is Rome.',
'What is the capital of Spain?': 'The capital of Spain is Madrid.',
};
return answers[input] ?? 'I do not know.';
},
scorers: [containsKeyword, Levenshtein],
});

Erwarteter Output: containsKeyword gibt 1.0 für alle 5 Test-Cases. Levenshtein gibt niedrigere Scores (~0.2-0.4) weil “The capital of France is Paris.” viel laenger ist als “Paris”.

Erklärung: containsKeyword gibt 1.0 für alle Test-Cases zurück — “Paris” kommt in “The capital of France is Paris.” vor. Levenshtein gibt niedrigere Scores, weil der Output viel laenger ist als der erwartete Wert “Paris”. Beide Perspektiven sind nuetzlich: Enthaelt die Antwort das Richtige? UND: Wie knapp ist die Antwort?

data (6.1) fliesst durch task() zu Output, der zusammen mit expected durch containsKeyword und Levenshtein bewertet wird und als Scores im Dashboard endet

Uebung: Erweitere Deine Eval aus Challenge 6.1 um den containsKeyword Scorer. Nutze jetzt einen echten LLM-Call als task statt einer simulierten Antwort.

  1. Ersetze die simulierte task durch generateText mit traceAISDKModel
  2. System Prompt: 'Answer concisely with only the city name.'
  3. Scorer: containsKeyword UND Levenshtein
  4. Vergleiche: Welcher Scorer ist strenger? Welcher ist informativer?

Optional Stretch Goal: Baue einen startsWithCapital Scorer, der prüft, ob die Antwort mit einem Grossbuchstaben beginnt. Simpel, aber ein guter Formatierungs-Check.

Part of AI Learning — free courses from prompt to production. Jan on LinkedIn