← Previous Chapter | Table of Contents | Next Chapter →
中文: 中文
Chapter 12: Evaluation -- The Most Underestimated Link¶
"If you can't measure it, you can't improve it. If you don't measure it, you'll definitely break it."
At this point, we have discussed how models "think," the boundaries of their capabilities, prompts, knowledge injection, and agents. These are all tools for building systems. But there is one question we have been avoiding:
How do you know that what you have built is good?
This is the easiest step to skip in LLM engineering. A common pattern: an engineer tunes a prompt, does a "vibe check," decides it is good enough, ships it, and three days later users report all kinds of failures. The engineer has no idea which change introduced the problem, because there was never a baseline.
The nondeterminism, open output space, and long-tail failure modes of LLM systems make evaluation much harder than in traditional software. But precisely because it is hard, the people who do it have a significant advantage.
The core arguments of this chapter:
- An LLM system without evals is just a demo -- it can be shown, but it cannot be iterated on reliably
- Vibe checks are not enough, and benchmarks are not enough either -- what you need is task-specific evals
- LLM-as-judge is a double-edged sword -- it can scale evaluation, but it has structural biases
- Evaluation should drive development -- write evals first, then tune the system
After reading this chapter, you will have a practical evaluation methodology: choosing metrics, building an eval set, and embedding evals into CI to prevent regressions.
12.1 Why LLM Evaluation Is So Hard¶
Traditional Software vs LLM Systems¶
Traditional software:
input -> function -> output
correctness = whether the output conforms to the spec
evaluation = unit tests
LLM system:
input -> LLM -> output
correctness = ???
evaluation = ???
LLM evaluation is difficult for three reasons:
1. The output is an open space
For an ordinary function: input 1+1 -> the output must be 2. For an LLM: input "summarize this article" -> there are many possible "correct" summaries.
You cannot test this with assertEqual(output, expected), because there is no single expected output.
2. Nondeterminism
When Temperature > 0, the same input produces different outputs every time. Even when temperature = 0, model version upgrades, batching changes, and hardware floating-point differences can all change the output.
3. Long-tail failures
An LLM performs well 95% of the time and fails the remaining 5% in unexpected ways. Random spot checks will not find this 5%, but real users in production will trigger it with precision.
flowchart LR
subgraph Traditional["Traditional Software"]
T1["Limited failure modes"] --> T2["Write tests to cover them"]
end
subgraph LLM["LLM System"]
L1["Infinite failure modes<br>and impossible to enumerate"] --> L2["Need distributed monitoring<br>+ continuous evals"]
end
style T1 fill:#c8e6c9
style L1 fill:#ffcdd2
Three Anti-Patterns¶
The most common forms of "fake evaluation" I have seen:
Anti-pattern 1: Vibe check
Problem: the few examples you tested are likely easy cases where the model would not make mistakes anyway. You will not think of the real boundary cases.
Anti-pattern 2: Relying on general benchmarks
Problem: benchmarks like MMLU, GPQA, and HumanEval measure the model's own capabilities, not the performance of your specific application. A model with a high MMLU score can still fail completely in your customer support scenario, for example, by being too academic or too verbose.
Anti-pattern 3: "End users will tell us"
Problem: user feedback is noisy and delayed. By the time you collect a statistically significant signal, you may already have lost many users. And users will not tell you about dissatisfaction they never voiced -- they will quietly switch to someone else.
12.2 Levels of Evaluation¶
Do not lump all evaluation together. Evaluation has multiple levels, and each level has a different goal.
flowchart TD
L1["L1: Unit evaluation<br>single model call"] --> L2["L2: Component evaluation<br>RAG, tool use, single-step agent"]
L2 --> L3["L3: System evaluation<br>end-to-end user journey"]
L3 --> L4["L4: Production monitoring<br>real users, real traffic"]
style L1 fill:#c8e6c9
style L2 fill:#fff9c4
style L3 fill:#bbdefb
style L4 fill:#f8bbd0
| Level | Evaluation Target | Frequency | Degree of Automation |
|---|---|---|---|
| L1 Unit evaluation | Single prompt / single call | Every prompt change | Fully automatic |
| L2 Component evaluation | RAG retrieval accuracy, tool call success rate | Every component change | Fully automatic |
| L3 System evaluation | End-to-end task completion rate | Every release | Partly automatic + human |
| L4 Production monitoring | Metrics on real traffic | Continuous | Automatic + sampled human review |
Many teams only do L1, or not even that, and then ship straight to L4: production. Without the L2 and L3 layers, no one knows whether the overall system got better or worse after a prompt change.
12.3 Building an Eval Set: The Most Important Step¶
What Is an Eval Set?¶
An eval set is a collection of representative inputs + corresponding judgment criteria. It is your "ground truth."
eval_set = [
{
"input": "Summarize this article about climate change: ...",
"judge": {
"type": "llm_judge",
"criteria": ["covers the main arguments", "no more than 100 words", "neutral tone"],
}
},
{
"input": "Where is my order #12345?",
"judge": {
"type": "exact_match",
"expected": "Order #12345 has shipped and is expected to arrive tomorrow.",
}
},
...
]
Building a good eval set usually takes more time than writing code. But it is the foundation for everything that follows.
How to Collect Inputs for an Eval Set¶
Several sources are useful, roughly in order of quality:
1. Real user inputs (highest quality)
The best inputs come from actual production traffic. They reflect the real distribution and include real boundary cases.
# Sample from production logs
sampled = random.sample(production_logs, 200)
# Manually filter/label, remove PII
eval_inputs = clean_and_label(sampled)
If you have not launched yet, you can run an internal alpha: let people inside the company act as users and collect real inputs.
2. Real failure cases (high value)
Every time production has an incident, add that input to the eval set. That way, the next time you change the system, this case is automatically tested so you can avoid regressions.
# Standard process after a user reports a bug
def add_failure_to_eval(input, expected_behavior):
eval_set.append({
"input": input,
"judge": {"criteria": expected_behavior},
"added_reason": "regression: bug from 2026-04-15",
})
3. Adversarial construction (boundary coverage)
Deliberately construct inputs that models are prone to get wrong:
- Vague / ambiguous questions
- Questions containing contradictory information
- Very long context
- Rare topics
- Different languages, colloquial speech / dialects
- Prompt injection attempts
- Jailbreak attempts
4. Synthetic data (quantity, but be careful with quality)
Ask an LLM to generate eval inputs. It is cheap and fast, but be cautious: synthetic data reflects the biases of the generator LLM, not real users.
prompt = f"""Generate 50 different types of user questions for a customer support chatbot.
Requirements:
- Cover inquiries, complaints, refunds, and technical issues
- Include both polite and angry questions
- Include both clear and vague questions
- Include both standard written language and colloquial language
"""
Synthetic data is useful as a starting point, but it should be replaced by real data as soon as possible.
Eval Set Size¶
How large is enough? Some rules of thumb:
| Stage | Recommended Size | Use |
|---|---|---|
| Early development | 20-50 | Iterate quickly and find the right direction |
| Before launch | 200-500 | Systematic testing |
| Stable production | 1000+ | Regression prevention + long-tail coverage |
Note: quality > quantity. 100 carefully selected inputs that cover a variety of cases are far more useful than 10,000 homogeneous random samples.
12.4 How to "Judge" Whether an Output Is Good¶
Once inputs have been collected, the next step is defining "what counts as a correct output." This is the genuinely hard part of LLM evaluation.
Here are several judging methods, listed from simple to complex:
Judge Method 1: Exact Match¶
Suitable for: small, well-defined output spaces, such as classification and extraction tasks with only a limited number of possible answers.
Not suitable for: open generation. Even when the semantics are correct, the wording may be completely different.
Judge Method 2: Numeric / Format Validation¶
def is_valid_json(output):
try:
json.loads(output)
return True
except:
return False
def matches_schema(output, schema):
try:
jsonschema.validate(json.loads(output), schema)
return True
except:
return False
Suitable for: structured output. This is the most underrated eval in engineering: it is simple, cheap, and able to catch many basic errors.
Judge Method 3: Contains Key Facts¶
def contains_required_facts(output, required):
"""Check whether the output mentions all required facts"""
return all(fact.lower() in output.lower() for fact in required)
# Example
eval_item = {
"input": "Roger has 5 tennis balls and buys 2 more cans with 3 balls each. How many does he have in total?",
"judge": {
"type": "contains",
"required": ["11", "tennis balls"],
}
}
Suitable for: QA and reasoning tasks, where you care whether the answer contains the correct facts.
Trap: this can misjudge outputs. For example, "the answer is not 11 but 12" also contains "11." You need something more precise.
Judge Method 4: Structured Extraction, Then Comparison¶
def evaluate_qa(output, expected_answer):
# Use a simple LLM call to extract the final answer
extracted = llm.generate(f"""
Extract the final numerical answer from the following response:
{output}
""").strip()
return extracted == expected_answer
Split "judging whether the model output is correct" into two steps: first extract the key information, then exact match. This is more reliable than direct LLM judging.
Judge Method 5: LLM-as-Judge¶
Ask another LLM to judge:
def llm_judge(input, output, criteria):
judge_prompt = f"""
User question: {input}
System answer: {output}
Evaluate this answer according to the following criteria:
{criteria}
Output JSON:
{{
"score": 1-5,
"reasons": "...",
"passes": true/false
}}
"""
return llm.generate(judge_prompt, model="claude-opus-4-7")
Suitable for: open generation, subjective evaluation, and complex multidimensional judgments.
But the next section is devoted to its traps.
Judge Method 6: Human Evaluation¶
Suitable for: the gold standard. Calibration of new metrics, disputed cases, and final acceptance.
Cost: slow, expensive, and subject to inter-annotator agreement issues.
Selection Guide¶
flowchart TD
Out{"Output type?"}
Out -->|"closed/classification"| EM["Exact match<br>(cheap and reliable)"]
Out -->|"structured"| SC["Schema validation<br>+ field checks"]
Out -->|"factual QA"| Fact["Key fact checks<br>+ extraction then matching"]
Out -->|"open generation"| Q2{"Quality bar?"}
Q2 -->|"fast iteration"| LJ["LLM-as-judge<br>(watch for bias)"]
Q2 -->|"high stakes"| HJ["Human evaluation<br>(mix when necessary)"]
style EM fill:#c8e6c9
style SC fill:#c8e6c9
style Fact fill:#fff9c4
style LJ fill:#fff9c4
style HJ fill:#bbdefb
12.5 LLM-as-Judge: Power and Traps¶
Why This Paradigm Matters¶
LLM-as-judge solves a core bottleneck: scaling evaluation.
Human evaluation is expensive; an annotator can judge only a few dozen samples per hour. But if you use an LLM as the judge:
- Speed improves by 100x
- Cost drops to a fraction
- It can cover more dimensions, evaluating factuality, fluency, usefulness, and safety at the same time
Many evaluation pipelines use LLM-as-judge: MT-Bench, AlpacaEval, partial automation in Chatbot Arena, and internal evals at many companies.
Known Biases¶
But an LLM judge is not perfect. Research and practice have already identified several systematic biases:
Bias 1: Position Bias
If a model is asked to compare two answers, A and B, it tends to favor the first or the second. The tendency differs across models, but it exists in all of them.
# Fix: run every pair twice, A vs B and B vs A, then average
score_AB = judge(A, B)
score_BA = judge(B, A)
final = (score_AB + (1 - score_BA)) / 2
Bias 2: Length Bias
LLM judges tend to prefer longer answers, even when longer does not mean better quality.
# Fix: explicitly tell the judge not to score based on length
judge_prompt = """
...Please score only based on answer quality. Do not give a higher score just because an answer is longer.
A concise good answer should receive the same score as a detailed good answer.
"""
Bias 3: Self-Preference
When GPT-4 acts as judge, it tends to prefer GPT-4 outputs. When Claude acts as judge, it tends to prefer Claude outputs.
# Fix: use a model from a different family as the judge
# Evaluating Claude outputs -> use GPT as judge
# Or use a third-party model, such as an open-source model
Bias 4: Style Over Substance
LLM judges are easily fooled by attractive formatting: bullet points, structured answers, and a confident tone can score highly even when the content is wrong.
Bias 5: Rubric Interpretation Drift
Over time and across different prompt wordings, the judge's interpretation of the same rubric can vary. Calibration is needed.
How to Use It Relatively Reliably¶
def reliable_llm_judge(input, output):
# 1. Use a strong model; do not use a cheap model as the judge
judge_model = "claude-opus-4-7"
# 2. Give an explicit rubric; do not vaguely ask whether it is "good"
rubric = """
Evaluate the following dimensions (0-2 points each):
- Factual accuracy: Is the information correct?
- Completeness: Did it answer the full question?
- Conciseness: Is it free of redundancy?
- Safety: Did it avoid harmful content?
"""
# 3. Require the judge to give reasons before scores, avoiding snap judgments
judge_prompt = f"""...first give reasons for each item, then give scores..."""
# 4. Sample multiple times and take the mean
scores = [judge(input, output, rubric) for _ in range(3)]
return mean(scores)
# 5. For critical decisions, validate judge reliability with human sampling
The most important rule: the LLM judge itself must be evaluated. Regularly sample 10-20% of cases for human evaluation and compare judge-human agreement. If agreement is significantly low (< 80%), the judge has a problem.
12.6 Designing Evaluation Metrics¶
Different tasks require different metrics. Typical metrics for common tasks:
RAG Systems¶
flowchart LR
R1["Retrieval evaluation"] --> R2["Generation evaluation"]
R1 -.->|"accuracy metrics"| M1["Recall@k<br>Precision@k<br>MRR"]
R2 -.->|"quality metrics"| M2["Faithfulness<br>(is the answer faithful to retrieved content?)<br><br>Answer Relevance<br>(does the answer address the question?)<br><br>Context Relevance<br>(is the retrieved content relevant?)"]
| Metric | Definition | How to Measure |
|---|---|---|
| Recall@k | Proportion of truly relevant documents included in top-k | Requires labeled relevant documents |
| Precision@k | Proportion of relevant documents in top-k | Requires labels |
| MRR | Reciprocal rank of the first relevant document | Requires labels |
| Faithfulness | Whether all facts in the answer come from retrieved content | LLM-judge or fact decomposition |
| Answer Relevance | Whether the answer addresses the question | LLM-judge |
| Context Relevance | Whether retrieval results are relevant | LLM-judge or human |
Agent Systems¶
| Metric | Definition |
|---|---|
| Task Success Rate | Whether the task was completed |
| Steps to Completion | Number of steps used to complete the task (fewer = more efficient) |
| Tool Call Accuracy | Whether the correct tool was called |
| Tool Argument Validity | Whether tool arguments were valid |
| Cost per Task | Total API cost to complete one task |
| Latency P50/P95 | Distribution of user-perceived latency |
Classification / Extraction Tasks¶
Classic ML metrics still apply:
- Accuracy, Precision, Recall, F1
- Confusion matrix
- Per-class metrics (do not let macro averages hide problems in small classes)
Open Generation¶
This is the hardest scenario for defining metrics. Common approaches:
- Pairwise comparison: compare outputs from two versions in an A/B setting and see which is better (more reliable than single-point scoring)
- Multi-dimension rubric: evaluate by dimension (fluency, relevance, safety, usefulness...)
- Win rate: against a baseline, the proportion of cases where the new version wins
Safety and Compliance¶
- Refusal rate (appropriate refusal rate)
- False refusal rate (refusals when the system should not refuse)
- Harmful content rate
- PII leakage
- Prompt injection success rate
Do not forget bidirectional metrics: you need to test both "it refused when it should refuse" and "it did not refuse when it should not refuse." The latter is often neglected, which leads to overly conservative systems.
12.7 Eval-Driven Development¶
Reversing the Order¶
The usual order in traditional ML / engineering development:
LLM systems should reverse this:
This is eval-driven development. Its benefits:
- Define "what is good" first, avoiding judgment by feel alone
- Know immediately whether a prompt change made things better or worse
- Quantitatively compare the effects of different changes
- Avoid breaking other things while fixing one problem (regression)
Practical Workflow¶
flowchart TD
S1["Define evaluation metrics + collect eval set"] --> S2["Run baseline<br>(the simplest prompt)"]
S2 --> S3["Analyze baseline failure cases"]
S3 --> S4["Hypothesis: what change might help?"]
S4 --> S5["Implement change"]
S5 --> S6["Run eval"]
S6 --> S7{"Did metrics improve?"}
S7 -->|"yes"| S8["Keep change<br>look at next failure case"]
S7 -->|"no"| S9["Drop change<br>try something else"]
S8 --> S3
S9 --> S4
style S1 fill:#c8e6c9
style S6 fill:#fff9c4
style S7 fill:#bbdefb
Run evals on every change, and base every decision on data. This loop looks slow, but in practice it is much faster than "change the prompt and judge by feel" because it eliminates cycles where you think something improved but actually made things worse.
Error Analysis Matters More Than Metrics¶
After running evals, a number like "78% pass rate" contains very little information by itself. What matters is: what do the failing 22% look like?
# Standard process for error analysis
failures = [item for item in eval_results if not item.passed]
# 1. Categorize by failure type
failure_types = classify_failures(failures)
# For example: {
# "factual error": 8,
# "wrong format": 5,
# "did not understand question": 4,
# "tool call failure": 3,
# "refused to answer": 2,
# }
# 2. Look at representative cases in each category
for failure_type, count in failure_types.items():
print(f"\n=== {failure_type} ({count}) ===")
for case in failures_of_type(failure_type)[:3]:
print(case.input, "->", case.output)
Error analysis tells you:
- What to change next: which failure category is largest and easiest to fix
- Which category a prompt change will fix, and which it might introduce
- Whether the issue is with the prompt or with the model's underlying capabilities
12.8 Regression Testing: Preventing Degradation¶
Changing Prompts Is Like Changing Regular Expressions¶
Anyone who has modified a complex regular expression knows the problem: change one character, and something that used to match may stop matching; something that did not match may start matching. Prompt fragility is similar, and perhaps worse, because it affects an open language space.
# Imagine this scenario
original prompt: "Please answer concisely."
changed prompt: "Please answer concisely and politely."
# A seemingly harmless modification
Actual effects:
- Previously concise answers -> became longer ("polite" added pleasantries)
- Previously refused boundary cases -> became overly polite and sometimes complied with requests that should not be answered
- Overall token usage +20%
You will not know about these changes unless you run evals.
CI Integration¶
Run evals in CI:
# .github/workflows/eval.yml
name: LLM Eval
on:
pull_request:
paths:
- 'prompts/**'
- 'src/**'
jobs:
eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: python eval/run.py --baseline main --candidate ${{ github.head_ref }}
- run: python eval/compare.py --threshold 0.95
# If the new branch's key metrics fall below 95% of main, CI fails
This way, regression checks run automatically before prompt changes are merged.
Evolution of the Eval Set Itself¶
An eval set is not something you write once and forget. It needs continuous maintenance:
- Every time a new failure mode is found -> add it to the eval set
- Business changes -> modify judgment criteria
- Model upgrades -> recalibrate the LLM judge
- User behavior changes -> replace some inputs with new representative samples
Rule of thumb: the eval set should be updated about as often as the code. An unmaintained eval set will drift away from the real distribution within a few months and give you a false sense of safety.
12.9 A Complete RAG Eval Pipeline Example¶
To bring the chapter together, here is a complete example using a RAG system:
import json
from dataclasses import dataclass
@dataclass
class EvalItem:
question: str
relevant_doc_ids: list # Labeled relevant document IDs
expected_answer_facts: list # Facts the answer should contain
@dataclass
class EvalResult:
item: EvalItem
retrieved_doc_ids: list
answer: str
metrics: dict
def evaluate_rag_system(eval_set, rag_system):
results = []
for item in eval_set:
# Run the system
retrieved = rag_system.retrieve(item.question)
answer = rag_system.generate(item.question, retrieved)
# Multidimensional evaluation
metrics = {
# Retrieval metrics (deterministic)
"recall@5": len(set(retrieved[:5]) & set(item.relevant_doc_ids)) / len(item.relevant_doc_ids),
"precision@5": len(set(retrieved[:5]) & set(item.relevant_doc_ids)) / 5,
# Answer metrics (partly using LLM judge)
"fact_coverage": fact_coverage(answer, item.expected_answer_facts),
"faithfulness": llm_judge_faithfulness(answer, retrieved),
"relevance": llm_judge_relevance(answer, item.question),
# System metrics
"latency_ms": rag_system.last_latency,
"cost_usd": rag_system.last_cost,
}
results.append(EvalResult(item, retrieved, answer, metrics))
# Aggregate
return summarize(results)
def summarize(results):
return {
"n": len(results),
"avg_recall@5": mean(r.metrics["recall@5"] for r in results),
"avg_faithfulness": mean(r.metrics["faithfulness"] for r in results),
"avg_relevance": mean(r.metrics["relevance"] for r in results),
"p50_latency": median(r.metrics["latency_ms"] for r in results),
"p95_latency": percentile(r.metrics["latency_ms"], 95),
"total_cost": sum(r.metrics["cost_usd"] for r in results),
# Distribution analysis
"low_recall_examples": [r for r in results if r.metrics["recall@5"] < 0.5][:5],
"low_faithfulness_examples": [r for r in results if r.metrics["faithfulness"] < 3][:5],
}
Notice a few characteristics:
- Multi-level metrics: retrieval, generation, and system-level metrics are all covered
- Mixed judges: deterministic metrics (recall) + LLM judge (faithfulness)
- Not just averages: pull out failure cases so you can do error analysis
- Can be added to CI: run it every time the RAG system changes
12.10 The Boundaries and Future of LLM Evaluation¶
This final section acknowledges the limitations of evaluation methods:
1. You Cannot Measure "Unknown Unknowns"¶
Any eval set is a collection of known failure modes. The failures that cause serious production damage are often boundary cases you never imagined.
Response: production monitoring + continuous red teaming -- have people actively try to break the system.
2. LLM Judges Have an Upper Bound¶
When the system being evaluated exceeds the judge's capabilities, the judge becomes unreliable. For example, using GPT-4 to judge GPT-5 outputs may produce unreliable results.
Response: use a model stronger than the system being evaluated as the judge, or use human evaluation.
3. Benchmark Gaming¶
If you iterate against any fixed eval set for long enough, the model/system will eventually "overfit" to it. The metric looks like it is improving, but generalization has not improved.
Response: hold-out set (keep a test set that is never used for iteration) + regularly update the eval set.
4. The Marginal Cost of Evaluation¶
A full eval may cost dozens of dollars and take several hours. If you run it for every small change, iteration slows down.
Response: tiered evals: fast sanity checks (dozens of samples, seconds) + full regression runs (hundreds to thousands of samples, before release).
Summary¶
| Question | Answer |
|---|---|
| Why is LLM evaluation hard? | Open outputs, nondeterminism, long-tail failures |
| Are vibe checks enough? | No. They can validate simple cases, but cannot prevent regressions or quantify comparisons |
| Are general benchmarks enough? | No. They measure model capability, not your specific application |
| Where does an eval set come from? | Real traffic > failure cases > adversarial construction > synthetic data |
| How should outputs be judged? | Prefer deterministic metrics; calibrate LLM-judge; use humans for high-stakes cases |
| What are the pitfalls of LLM-judge? | Position bias, length bias, self-preference, style over substance |
| When should evals be built? | Before writing the first line of system code: eval-driven development |
| How do you prevent prompt degradation? | Put evals in CI and run automated regression checks on every PR |
In the next chapter, we enter Part IV: frontier topics. From the "black box" of evaluation, we move toward interpretability: opening the model and seeing what it is actually computing internally.
Further Reading¶
- Zheng et al., 2023: Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena — a systematic study of LLM-judge
- Es et al., 2023: RAGAS: Automated Evaluation of RAG — a standard evaluation framework for RAG systems
- Chiang & Lee, 2023: Can Large Language Models Be an Alternative to Human Evaluations? — comparing LLM judges with human evaluation
- Liu et al., 2023: G-Eval: NLG Evaluation using GPT-4 with Better Human Alignment — using GPT-4 for automatic evaluation better aligned with human judgments
- Hendrycks et al., 2021: Measuring Massive Multitask Language Understanding (MMLU) — a representative general-capability benchmark
- Liang et al., 2022: Holistic Evaluation of Language Models (HELM) — a multidimensional LLM evaluation framework
- Chatbot Arena — an open leaderboard evaluated with human ELO