方寸 Portal

AI Engineer Portal

Your personal operating system for career transition.

Private mode
Back to Learning Paths
Lesson

Evaluation metrics for LLM outputs

Choose the right metric for the right failure mode: accuracy, faithfulness, relevance, toxicity, and when LLM-as-judge beats deterministic scoring.

40 min
evaluation-and-observabilityphase-1portfolio

Evaluation metrics for LLM outputs

Why this matters

Every LLM feature you build needs a measurement plan. The engineering challenge is that LLM quality is not binary -- a response can be partially correct, well-written but unfaithful, or accurate but harmful. Single-number accuracy scores hide the structure of failures. This lesson gives you the vocabulary and tooling to decompose quality into measurable, actionable dimensions.

For a senior engineer transitioning into AI roles, evaluation fluency is a visible differentiator. Candidates who can specify a metric before writing a prompt signal production thinking.

Core concepts

Accuracy and task success. For tasks with deterministic correct answers -- classification, structured extraction, code generation -- exact-match or schema-validation accuracy is your first metric. It is cheap to compute and easy to track over time.

Faithfulness (groundedness). When your system retrieves context before answering -- as in RAG -- faithfulness measures whether the generated answer is supported by the retrieved documents. A faithful answer does not invent facts; every claim traces back to a source chunk. Faithfulness is distinct from correctness: a faithfulness score of 1.0 means the answer is grounded, not that the context was correct.

Relevance. Answer relevance measures whether the response actually addresses the user's question. A response can be faithful (grounded in context) but irrelevant (the context did not contain what the user needed). Context relevance measures how much of the retrieved context was actually useful for generating the answer.

Toxicity and safety. Toxicity scoring uses classifiers (Perspective API, custom fine-tuned models, or LLM-based judges) to detect harmful or inappropriate outputs. In production, you typically score a sample of outputs and alert when toxicity rates exceed a threshold.

Custom domain metrics. Most production systems need at least one metric specific to the task. A coding assistant might track whether generated code passes a test suite. A summarization feature might track compression ratio and coverage of key entities. Define these before you ship.

LLM-as-judge. When the correct answer is not enumerable -- open-ended generation, nuanced reasoning, style evaluation -- a language model grades the output. The judge receives the question, the response, and a rubric, then returns a score and rationale. Two main patterns:

  • Reference-based: the judge compares the response to a gold reference answer.
  • Reference-free: the judge scores the response against criteria only (coherence, helpfulness, groundedness).

LLM-as-judge scales to tasks where human annotation is prohibitively expensive, but it inherits model biases. Judges tend to prefer longer answers and their own style. Mitigate this with structured rubrics, calibration against human labels, and cross-model judging (do not use GPT-4o to judge GPT-4o outputs uncritically).

Deterministic scoring. For structured outputs -- JSON extraction, SQL generation, date parsing -- deterministic scoring is faster, cheaper, and more reproducible than LLM judges. Parse the output, validate against a schema, and compute field-level accuracy.

When to use which approach:

ScenarioUse
Classification, extraction, structured outputDeterministic exact-match or schema validation
RAG answer qualityFaithfulness + answer relevance (LLM judge or RAGAS)
Open-ended generation qualityLLM-as-judge with explicit rubric
Safety screeningClassifier before LLM call
Regression detectionDeterministic for structured, embedding similarity for text

Working example

A self-contained evaluation runner that handles both deterministic and LLM-as-judge scoring:

from dataclasses import dataclass, field
from typing import Callable, Literal
import json


@dataclass
class EvalCase:
    case_id: str
    input: str
    expected: str
    metadata: dict = field(default_factory=dict)


@dataclass
class EvalResult:
    case_id: str
    score: float
    passed: bool
    rationale: str
    metric: str


def exact_match_scorer(response: str, expected: str) -> EvalResult:
    norm_r = response.strip().lower()
    norm_e = expected.strip().lower()
    score = 1.0 if norm_r == norm_e else 0.0
    return EvalResult(case_id='', score=score, passed=score == 1.0,
                      rationale=f'matched: {score == 1.0}', metric='exact_match')


def llm_judge_scorer(
    question: str,
    response: str,
    context: str,
    judge_fn: Callable[[str], str],
    metric: Literal['faithfulness', 'relevance', 'helpfulness'] = 'helpfulness',
) -> EvalResult:
    rubrics = {
        'faithfulness': 'Does the response make only claims supported by the context? Score 1.0 if fully grounded, 0.5 if partial, 0.0 if not.',
        'relevance': 'Does the response directly address the question? Score 1.0 if fully on-topic, 0.5 if partial, 0.0 if off-topic.',
        'helpfulness': 'Would a real user find this helpful? Score 1.0 if clearly helpful, 0.5 if partly useful, 0.0 if not.',
    }
    judge_prompt = (
        f'Evaluate this response.\nQuestion: {question}\nContext: {context}\nResponse: {response}\n\n'
        f'Rubric: {rubrics[metric]}\n\nReturn JSON: {{"score": float, "rationale": str}}'
    )
    raw = judge_fn(judge_prompt)
    try:
        result = json.loads(raw)
        score = max(0.0, min(1.0, float(result['score'])))
        rationale = result.get('rationale', '')
    except (json.JSONDecodeError, KeyError, ValueError):
        score, rationale = 0.5, f'Parse error: {raw[:100]}'
    return EvalResult(case_id='', score=score, passed=score >= 0.7, rationale=rationale, metric=metric)


def run_eval(
    cases: list[EvalCase],
    generate_fn: Callable[[str], str],
    score_fn: Callable[[str, EvalCase], EvalResult],
) -> dict:
    results = []
    for case in cases:
        response = generate_fn(case.input)
        result = score_fn(response, case)
        result.case_id = case.case_id
        results.append(result)
    total = len(results)
    passed = sum(1 for r in results if r.passed)
    avg_score = sum(r.score for r in results) / total if total > 0 else 0.0
    return {
        'total': total, 'passed': passed,
        'pass_rate': passed / total if total > 0 else 0.0,
        'avg_score': avg_score,
        'failures': [{'case_id': r.case_id, 'score': r.score} for r in results if not r.passed],
    }

The key design: scorers are pure functions that take a response and return a typed result. The runner separates generation from scoring, so you can swap scorers without touching the generation logic.

Common mistakes

  1. Using one metric for everything. A single accuracy score averaged across all cases hides the pattern of failures. A system that fails 100% of multi-hop questions and passes 100% of simple lookups looks like 50% accuracy -- which tells you nothing actionable.

  2. LLM judge without a rubric. Asking a model to rate quality without specifying criteria produces inconsistent, biased results. Rubrics should be specific enough that two humans reading them would make the same scoring decision.

  3. Not calibrating your judge. Before trusting LLM-as-judge scores, label 30-50 cases manually and compare. If they disagree on more than 20% of cases, your rubric needs work.

  4. Ignoring the cost of scoring. Running GPT-4o as a judge on every production response is expensive. Score 5-10% of traffic with the expensive judge; run cheaper deterministic checks on everything.

  5. Treating toxicity as a binary flag. Toxicity exists on a spectrum. Set thresholds relative to your use case.

Try it yourself

Implement a faithfulness_scorer function that takes a response string and a list of context chunks. Using an LLM judge, score whether every factual claim in the response is supported by at least one context chunk. Return a FaithfulnessResult dataclass with: a score (0.0-1.0), a list of claims the judge identified, and a list of unsupported claims. Test it on three cases: one fully faithful response, one with one hallucinated fact, and one that ignores the context entirely.

Lesson Deep Dive

Ask a follow-up question about this lesson and get an AI-powered explanation.

Loading previous questions...