Evaluierungsframeworks für LLM-Anwendungen aufbauen
Systematische Evaluierungs-Pipelines für LLM-Anwendungen: automatisierte Metriken, menschliche Bewertung, Regressionstests, Prompt-Versionierung.

Du kannst kein LLM-Feature ausliefern, ohne zu wissen, ob es funktioniert. Anders als bei traditioneller Software, wo ein Test entweder besteht oder fehlschlägt, bewegen sich LLM-Ausgaben auf einem Qualitätsspektrum. Derselbe Prompt kann in einer Minute eine exzellente Antwort liefern und in der nächsten eine mittelmäßige. Ohne systematische Evaluierung lieferst du ein Feature aus, dessen Qualität ein Münzwurf ist.
Evaluierungsframeworks für LLM-Anwendungen brauchen automatisierte Metriken für schnelles Feedback, menschliche Evaluierung für differenzierte Qualitätsbeurteilung und Regressionstests, um Verschlechterungen zu erkennen, wenn sich Prompts oder Modelle ändern.
Evaluierungsdimensionen definieren
Verschiedene Anwendungen brauchen verschiedene Qualitätsdimensionen. Ein Zusammenfasser braucht Quellentreue und Prägnanz. Ein Code-Generator braucht Korrektheit und Sicherheit. Definiere diese Dimensionen, bevor du eine einzige Evaluierungsfunktion schreibst.
// ❌ Single-score evaluation — tells you nothing actionable
function evaluate(output: string): number {
// "Quality score" from 0 to 1
return someBlackBoxScore(output);
}
// Is a 0.7 good? Bad? Good summary but wrong facts?// ✅ Multi-dimensional evaluation framework
interface EvalDimension {
name: string;
description: string;
scorer: (input: EvalInput) => Promise<DimensionScore>;
weight: number;
threshold: number; // Minimum acceptable score
}
interface EvalInput {
prompt: string;
context?: string;
expectedOutput?: string;
actualOutput: string;
metadata: Record<string, unknown>;
}
interface DimensionScore {
dimension: string;
score: number; // 0.0 to 1.0
reasoning: string;
evidence: string[];
}
interface EvalResult {
inputId: string;
scores: DimensionScore[];
overallScore: number;
passed: boolean;
failedDimensions: string[];
}
class EvaluationFramework {
private dimensions: EvalDimension[] = [];
addDimension(dimension: EvalDimension): void {
this.dimensions.push(dimension);
}
async evaluate(input: EvalInput): Promise<EvalResult> {
const scores: DimensionScore[] = [];
const failedDimensions: string[] = [];
for (const dim of this.dimensions) {
const score = await dim.scorer(input);
scores.push(score);
if (score.score < dim.threshold) {
failedDimensions.push(dim.name);
}
}
const totalWeight = this.dimensions.reduce(
(sum, d) => sum + d.weight,
0
);
const overallScore = scores.reduce((sum, score, i) => {
return sum + score.score * this.dimensions[i].weight;
}, 0) / totalWeight;
return {
inputId: input.metadata.id as string ?? "unknown",
scores,
overallScore,
passed: failedDimensions.length === 0,
failedDimensions,
};
}
}Automatisierte Scoring-Funktionen
Einige Qualitätsdimensionen lassen sich automatisch bewerten. Sachliche Konsistenz, Formatkonformität und Toxizitätserkennung erfordern nicht bei jeder Evaluierung menschliches Urteilsvermögen.
// Factual consistency: check if output contradicts the source
async function scoreFactualConsistency(
input: EvalInput
): Promise<DimensionScore> {
if (!input.context) {
return {
dimension: "factual_consistency",
score: 1.0,
reasoning: "No source context to check against",
evidence: [],
};
}
// Extract claims from the output
const claims = extractClaims(input.actualOutput);
const supportedClaims: string[] = [];
const unsupportedClaims: string[] = [];
for (const claim of claims) {
const isSupported = await checkClaimAgainstSource(
claim,
input.context
);
if (isSupported) {
supportedClaims.push(claim);
} else {
unsupportedClaims.push(claim);
}
}
const score = claims.length > 0
? supportedClaims.length / claims.length
: 1.0;
return {
dimension: "factual_consistency",
score,
reasoning:
`${supportedClaims.length}/${claims.length} claims ` +
`supported by source`,
evidence: unsupportedClaims.map(
(c) => `Unsupported: "${c}"`
),
};
}
// Format compliance: check structural requirements
function scoreFormatCompliance(
input: EvalInput
): Promise<DimensionScore> {
const checks: { name: string; passed: boolean }[] = [];
const output = input.actualOutput;
const rules = input.metadata.formatRules as FormatRule[];
for (const rule of rules ?? []) {
switch (rule.type) {
case "max_length":
checks.push({
name: `Max length ${rule.value}`,
passed: output.length <= (rule.value as number),
});
break;
case "contains_section":
checks.push({
name: `Contains "${rule.value}"`,
passed: output.includes(rule.value as string),
});
break;
case "json_valid":
try {
JSON.parse(output);
checks.push({ name: "Valid JSON", passed: true });
} catch {
checks.push({ name: "Valid JSON", passed: false });
}
break;
}
}
const passed = checks.filter((c) => c.passed).length;
const score = checks.length > 0 ? passed / checks.length : 1.0;
return Promise.resolve({
dimension: "format_compliance",
score,
reasoning: `${passed}/${checks.length} format checks passed`,
evidence: checks
.filter((c) => !c.passed)
.map((c) => `Failed: ${c.name}`),
});
}
interface FormatRule {
type: "max_length" | "contains_section" | "json_valid";
value: string | number;
}
function extractClaims(text: string): string[] {
// Split into sentences and filter for factual assertions
return text
.split(/[.!?]+/)
.map((s) => s.trim())
.filter((s) => s.length > 20);
}
async function checkClaimAgainstSource(
claim: string,
source: string
): Promise<boolean> {
// Semantic similarity check
const similarity = computeCosineSimilarity(claim, source);
return similarity > 0.6;
}
function computeCosineSimilarity(a: string, b: string): number {
// Placeholder for embedding-based similarity
return 0.8;
}Protokoll für die menschliche Evaluierung
Automatisierte Metriken haben blinde Flecken. Die menschliche Evaluierung erkennt Qualitätsprobleme, die keine Metrik erfassen kann: Tonfall, Hilfreichkeit, Kohärenz und ob die Antwort die Frage tatsächlich beantwortet.
interface HumanEvalTask {
id: string;
input: EvalInput;
dimensions: HumanEvalDimension[];
assignedTo: string;
status: "pending" | "in_progress" | "completed";
results?: HumanEvalResult;
}
interface HumanEvalDimension {
name: string;
description: string;
scale: { min: number; max: number; labels: string[] };
}
interface HumanEvalResult {
evaluatorId: string;
scores: Map<string, number>;
freeformFeedback: string;
completedAt: Date;
timeSpentSeconds: number;
}
class HumanEvalPipeline {
private tasks: Map<string, HumanEvalTask> = new Map();
createSampledBatch(
allOutputs: EvalInput[],
sampleSize: number,
strategyFn: (outputs: EvalInput[]) => EvalInput[]
): HumanEvalTask[] {
// Sample strategically — not randomly
const sampled = strategyFn(allOutputs).slice(0, sampleSize);
return sampled.map((input) => {
const task: HumanEvalTask = {
id: `eval-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
input,
dimensions: [
{
name: "Helpfulness",
description:
"Does the response address the user's actual need?",
scale: {
min: 1,
max: 5,
labels: [
"Not helpful",
"Slightly helpful",
"Moderately helpful",
"Helpful",
"Very helpful",
],
},
},
{
name: "Accuracy",
description:
"Are the factual claims in the response correct?",
scale: {
min: 1,
max: 5,
labels: [
"Incorrect",
"Mostly incorrect",
"Mixed",
"Mostly correct",
"Fully correct",
],
},
},
],
assignedTo: "",
status: "pending",
};
this.tasks.set(task.id, task);
return task;
});
}
}
// Sampling strategy: focus on edge cases and low-confidence outputs
function edgeCaseSampling(outputs: EvalInput[]): EvalInput[] {
return outputs.sort((a, b) => {
// Prioritize longer outputs (more room for errors),
// outputs with low automated scores, and diverse prompts
const aLength = a.actualOutput.length;
const bLength = b.actualOutput.length;
return bLength - aLength;
});
}Regressionstests über Prompt-Versionen hinweg
Wenn du einen Prompt änderst, musst du wissen, ob die Änderung die Qualität über deinen Testdatensatz hinweg verbessert oder verschlechtert hat. Das erfordert versionierte Evaluierung.
interface PromptVersion {
version: string;
template: string;
changedAt: Date;
changelog: string;
}
interface RegressionReport {
baselineVersion: string;
candidateVersion: string;
testSetSize: number;
improvements: DimensionComparison[];
regressions: DimensionComparison[];
neutral: DimensionComparison[];
recommendation: "ship" | "investigate" | "rollback";
}
interface DimensionComparison {
dimension: string;
baselineAvg: number;
candidateAvg: number;
delta: number;
significant: boolean;
}
async function runRegressionTest(
baseline: PromptVersion,
candidate: PromptVersion,
testSet: EvalInput[],
framework: EvaluationFramework
): Promise<RegressionReport> {
const baselineResults: EvalResult[] = [];
const candidateResults: EvalResult[] = [];
for (const testCase of testSet) {
const baselineOutput = await generateWithPrompt(
baseline.template,
testCase
);
const candidateOutput = await generateWithPrompt(
candidate.template,
testCase
);
baselineResults.push(
await framework.evaluate({
...testCase,
actualOutput: baselineOutput,
})
);
candidateResults.push(
await framework.evaluate({
...testCase,
actualOutput: candidateOutput,
})
);
}
return compareResults(
baseline.version,
candidate.version,
baselineResults,
candidateResults
);
}
function compareResults(
baselineVersion: string,
candidateVersion: string,
baseline: EvalResult[],
candidate: EvalResult[]
): RegressionReport {
const dimensions = new Set(
baseline.flatMap((r) => r.scores.map((s) => s.dimension))
);
const comparisons: DimensionComparison[] = [];
for (const dim of dimensions) {
const baseScores = baseline.map(
(r) => r.scores.find((s) => s.dimension === dim)?.score ?? 0
);
const candScores = candidate.map(
(r) => r.scores.find((s) => s.dimension === dim)?.score ?? 0
);
const baseAvg =
baseScores.reduce((a, b) => a + b, 0) / baseScores.length;
const candAvg =
candScores.reduce((a, b) => a + b, 0) / candScores.length;
const delta = candAvg - baseAvg;
comparisons.push({
dimension: dim,
baselineAvg: baseAvg,
candidateAvg: candAvg,
delta,
significant: Math.abs(delta) > 0.05,
});
}
const regressions = comparisons.filter(
(c) => c.significant && c.delta < 0
);
const improvements = comparisons.filter(
(c) => c.significant && c.delta > 0
);
return {
baselineVersion,
candidateVersion,
testSetSize: baseline.length,
improvements,
regressions,
neutral: comparisons.filter((c) => !c.significant),
recommendation:
regressions.length > 0
? "investigate"
: improvements.length > 0
? "ship"
: "investigate",
};
}
async function generateWithPrompt(
template: string,
input: EvalInput
): Promise<string> {
// Placeholder for LLM call
return "";
}Wichtigste Erkenntnisse
LLM-Evaluierung erfordert mehrdimensionales Scoring, weil eine einzelne Qualitätszahl nichts Umsetzbares aussagt: Zerlege die Evaluierung in spezifische Dimensionen wie sachliche Konsistenz, Formatkonformität, Hilfreichkeit und Korrektheit. Automatisiere, was du kannst: Formatprüfungen, die Verifizierung von Behauptungen gegen Quelldokumente und Toxizitätserkennung liefern schnelles Feedback ohne menschlichen Aufwand. Wähle Stichproben für die menschliche Evaluierung strategisch: Konzentriere dich auf Grenzfälle, lange Ausgaben und Vorhersagen mit geringer Konfidenz statt auf Zufallsstichproben. Versioniere deine Prompts und führe bei jeder Änderung Regressionstests gegen einen festen Testdatensatz aus, indem du die Scores Dimension für Dimension vergleichst, um Verschlechterungen zu erkennen. Definiere Qualitätsschwellen pro Dimension, die das Deployment steuern: Eine Antwort, die gut formatiert, aber sachlich falsch ist, sollte nicht ausgeliefert werden. Das Evaluierungsframework ist kein einmaliges Setup: Es entwickelt sich weiter, sobald du neue Fehlermodi in der Produktion entdeckst und sie als neue Testfälle und Scoring-Dimensionen kodierst.


