Evaluating LLM Applications in Production: Metrics, Failure Modes, and Release Discipline
Most LLM applications fail quietly before they fail visibly. They produce plausible answers, pass a few spot checks, and then underperform on real traffic because no serious evaluation framework was put in place. This is one of the biggest gaps between impressive prototypes and production-grade systems.
Evaluating LLM applications requires more than checking answer quality on a handful of examples. It requires measuring the system along the dimensions that actually matter in deployment: factuality, faithfulness, tool reliability, safety, latency, and cost. This article outlines a practical evaluation discipline for teams shipping LLM systems in production.
Why Traditional QA Is Not Enough
Traditional software quality assurance assumes deterministic behavior. Given the same input, the same logic should produce the same output. LLMs break that assumption. Their outputs are probabilistic, context-sensitive, and highly dependent on the quality of retrieval, prompts, and surrounding orchestration.
This changes the evaluation problem. Instead of asking only whether the system is correct, you must also ask:
- how often it is correct
- under which input slices it fails
- whether it fails safely
- whether it remains grounded in evidence
- how much it costs to achieve acceptable quality
Evaluation Must Cover the Full System
An LLM application is not just a model call. It is usually a pipeline composed of prompts, retrieval, tools, structured outputs, validation, and business rules. Evaluation should therefore isolate and measure each major subsystem as well as the full user-visible experience.
At minimum, a serious evaluation stack should consider:
- model response quality
- retrieval quality
- structured output validity
- tool selection and tool-use success
- refusal and escalation behavior
- latency and token consumption
If you only evaluate the final answer, you cannot tell whether the failure came from the model, the retriever, the tool layer, or the control logic.
Build a Real Evaluation Set
Strong evaluation starts with representative data, not synthetic optimism. A useful dataset should include:
- common user requests
- ambiguous requests
- hard edge cases
- adversarial inputs
- domain-specific terminology
- cases where the system should refuse or escalate
If your eval set contains only clean and obvious examples, it will tell you very little.
One useful practice is to store eval cases in a structured format rather than as informal notes.
1
2
3
4
5
6
7
8
9
10
11
12
{
"id": "support-142",
"input": {
"question": "Can I reset MFA for a departed employee?"
},
"expected_behavior": {
"must_refuse": false,
"must_use_retrieval": true,
"required_topics": ["identity policy", "admin procedure"]
},
"slice_tags": ["security", "admin", "policy"]
}
This makes regression testing and slice analysis much easier.
Offline Evaluation and Online Evaluation Serve Different Purposes
Offline evaluation is where you compare prompts, models, retrievers, and architectures under controlled conditions. It is useful for repeatability and regression testing.
Online evaluation is where you detect drift, user-facing degradation, cost anomalies, and failure patterns that do not appear in your curated test set.
Strong teams use both. Offline eval gives discipline. Online eval gives realism.
Metrics That Matter
The right metrics depend on the product, but strong teams usually track a combination of:
- task success rate
- grounded answer rate
- hallucination rate
- exact-match or schema-valid output rate
- retrieval recall at k
- refusal precision on disallowed tasks
- p95 latency
- average token cost per successful task
There is no single universal metric. The goal is to choose measures that align with business risk and user expectations.
For example, a support copilot and a legal assistant should not optimize the same metric mix. The support copilot may prioritize latency and task completion. The legal assistant may prioritize groundedness and refusal quality.
Slice Your Failures
Aggregate metrics are useful, but they hide important patterns. Teams should evaluate by slices such as:
- query length
- domain or topic
- language
- customer segment
- presence or absence of retrieval context
- required tool use versus answer-only tasks
- sensitive versus non-sensitive requests
Most meaningful production issues appear first in slices, not in top-line averages.
Human Review Still Matters
Automated evaluation is necessary, but it is not enough. Human review is still essential for:
- nuanced quality judgments
- tone and clarity assessment
- borderline safety cases
- calibration of automated scorers
- discovering new failure categories
The best workflows combine human labeling with scalable automated regression checks.
Tool-Using Systems Need Their Own Metrics
When the application invokes tools, evaluation must expand beyond answer text. Measure:
- tool selection accuracy
- tool argument validity
- unnecessary tool-call rate
- successful recovery after tool failure
- correctness of final answers after tool outputs are returned
A system may look intelligent in prose while being operationally poor at using external systems.
In practice, teams often compute task success as a composite measure rather than a single score:
the answer should be treated as successful only if it is correct, the tool usage is valid, and the result complies with policy.
This is useful because a fluent answer with an invalid tool path is not a real success.
A Simple Offline Eval Harness
1
2
3
4
5
6
7
8
9
10
11
def run_eval(app, eval_cases):
report = []
for case in eval_cases:
output = app.invoke(case["input"])
report.append({
"id": case["id"],
"schema_ok": validate_schema(output),
"grounded": groundedness_check(output),
"latency_ms": output["latency_ms"],
})
return report
This does not need to be sophisticated to be useful. The main thing is to make evaluation repeatable.
Online Evaluation and Monitoring
Once the system is live, evaluation cannot stop. Production monitoring should track:
- drift in input distribution
- changes in failure frequency
- sudden cost shifts
- latency regressions
- degradation after prompt or retrieval updates
An LLM application that was good last month can quietly degrade after a model upgrade, corpus change, or orchestration tweak.
Release Discipline for LLM Systems
Before shipping a change, teams should be able to answer:
- what changed
- which eval slices improved
- which slices regressed
- whether cost and latency stayed acceptable
- whether refusal and grounding behavior remained safe
This is the LLM equivalent of test coverage and performance benchmarking.
A Practical Evaluation Workflow
A disciplined rollout often follows this sequence:
- define task-specific success criteria
- assemble a representative evaluation set
- benchmark the current baseline
- test one change at a time when possible
- review both top-line metrics and failure slices
- monitor live performance after release
This process slows down random experimentation, but it dramatically improves system quality over time.
Technical Appendix: Example Evaluation Report Fields
An evaluation record should usually contain more than a score. A more useful schema is:
1
2
3
4
5
6
7
8
9
10
{
"case_id": "retrieval-044",
"model": "candidate_model",
"prompt_version": "v12",
"schema_valid": true,
"grounded": false,
"tool_success": true,
"latency_ms": 1820,
"cost_usd": 0.012
}
This makes regression analysis and slice filtering much easier than keeping only a pass or fail label.
RAGAS: The Standard RAG Evaluation Framework
RAGAS (Retrieval-Augmented Generation Assessment) is the most widely used open-source framework for automated RAG evaluation:
1
pip install ragas langchain-openai
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
from datasets import Dataset
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall,
answer_correctness,
answer_similarity,
)
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from ragas.llms import LangchainLLMWrapper
from ragas.embeddings import LangchainEmbeddingsWrapper
# Configure judge LLM and embeddings
judge_llm = LangchainLLMWrapper(ChatOpenAI(model="gpt-4o-mini"))
judge_emb = LangchainEmbeddingsWrapper(OpenAIEmbeddings())
# Evaluation dataset format
data = {
"question": [
"What is our return policy?",
"How do I reset my password?",
"What are the shipping options?",
],
"answer": [
"You can return products within 30 days for a full refund.",
"Click 'Forgot Password' on the login page.",
"We offer standard (5-7 days) and express (2-day) shipping.",
],
"contexts": [
["Our return policy allows returns within 30 days of purchase for a full refund."],
["To reset your password, click Forgot Password on the login page and follow the email instructions."],
["Standard shipping takes 5-7 business days. Express shipping delivers within 2 business days."],
],
"ground_truth": [
"Returns are accepted within 30 days.",
"Use the 'Forgot Password' link on the login page.",
"Standard shipping: 5-7 days, Express: 2 days.",
],
}
dataset = Dataset.from_dict(data)
result = evaluate(
dataset=dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall,
answer_correctness,
],
llm=judge_llm,
embeddings=judge_emb,
)
print(result.to_pandas()[["question", "faithfulness", "answer_relevancy", "context_precision"]])
Interpreting RAGAS scores
| Metric | What it measures | Target |
|---|---|---|
| Faithfulness | Is the answer grounded in retrieved context? | > 0.80 |
| Answer Relevancy | Does the answer address the question? | > 0.75 |
| Context Precision | Are retrieved chunks actually relevant? | > 0.70 |
| Context Recall | Is all relevant info retrieved? | > 0.70 |
| Answer Correctness | Factual accuracy vs. ground truth | > 0.75 |
LLM-as-a-Judge: Building Custom Evaluators
When RAGAS metrics don’t capture product-specific requirements, custom LLM judges are the solution:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
from openai import OpenAI
from pydantic import BaseModel
from typing import Literal
import asyncio
client = OpenAI()
class EvalScore(BaseModel):
score: int # 1-5
reason: str
verdict: Literal["pass", "borderline", "fail"]
class ComplianceCheck(BaseModel):
compliant: bool
violations: list[str]
severity: Literal["none", "low", "medium", "high"]
# Domain-specific evaluator: compliance for HR assistant
async def evaluate_hr_compliance(question: str, answer: str) -> ComplianceCheck:
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=[{
"role": "user",
"content": f"""You are an HR compliance auditor.
Evaluate whether this HR assistant response complies with data privacy rules.
Violations to check:
- Reveals personal employee data without authorization
- Provides salary details of specific individuals
- Discloses disciplinary actions or termination reasons
- Shares medical or disability information
Question: {question}
Answer: {answer}
Assess compliance.""",
}],
response_format=ComplianceCheck,
)
return response.choices[0].message.parsed
# Factual accuracy evaluator
async def evaluate_factual_accuracy(
question: str,
answer: str,
reference: str,
) -> EvalScore:
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""Score this answer for factual accuracy (1-5).
5 = Factually correct and complete
4 = Mostly correct, minor omissions
3 = Partially correct
2 = Mostly incorrect
1 = Wrong or hallucinated
Question: {question}
Reference answer: {reference}
Evaluated answer: {answer}
Score:""",
}],
response_format=EvalScore,
)
return response.choices[0].message.parsed
# Run multiple evaluators in parallel
async def full_evaluation(qa_pairs: list[dict]) -> list[dict]:
tasks = []
for pair in qa_pairs:
tasks.append(asyncio.gather(
evaluate_hr_compliance(pair["question"], pair["answer"]),
evaluate_factual_accuracy(pair["question"], pair["answer"], pair["ground_truth"]),
))
results = await asyncio.gather(*tasks)
return [
{
"question": pair["question"],
"compliance": r[0].model_dump(),
"accuracy": r[1].model_dump(),
}
for pair, r in zip(qa_pairs, results)
]
Automated Regression Testing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import json
import hashlib
from pathlib import Path
from datetime import datetime
class LLMRegressionTest:
"""Run an evaluation suite and compare against a baseline."""
def __init__(self, eval_fn, dataset: list[dict], baseline_path: str = "baseline.json"):
self.eval_fn = eval_fn
self.dataset = dataset
self.baseline_path = Path(baseline_path)
def run(self) -> dict:
results = {}
for item in self.dataset:
output = self.eval_fn(item["input"])
score = self._score(item, output)
results[item["id"]] = score
return results
def _score(self, item: dict, output: str) -> dict:
# Simple keyword-based scoring — replace with LLM judge in production
required = item.get("required_keywords", [])
present = [k for k in required if k.lower() in output.lower()]
return {
"score": len(present) / len(required) if required else 1.0,
"keywords_found": present,
"keywords_missing": [k for k in required if k not in present],
}
def save_baseline(self):
results = self.run()
baseline = {
"timestamp": datetime.utcnow().isoformat(),
"dataset_hash": hashlib.md5(json.dumps(self.dataset, sort_keys=True).encode()).hexdigest(),
"scores": results,
"mean_score": sum(r["score"] for r in results.values()) / len(results),
}
self.baseline_path.write_text(json.dumps(baseline, indent=2))
print(f"Baseline saved: mean_score={baseline['mean_score']:.3f}")
return baseline
def compare_to_baseline(self, threshold: float = 0.05) -> dict:
if not self.baseline_path.exists():
raise FileNotFoundError("No baseline found. Run save_baseline() first.")
baseline = json.loads(self.baseline_path.read_text())
current = self.run()
mean_now = sum(r["score"] for r in current.values()) / len(current)
mean_base = baseline["mean_score"]
delta = mean_now - mean_base
# Identify regressions at the case level
regressions = [
id_ for id_, score in current.items()
if score["score"] < baseline["scores"].get(id_, {}).get("score", 0) - 0.1
]
return {
"baseline_score": round(mean_base, 4),
"current_score": round(mean_now, 4),
"delta": round(delta, 4),
"improved": delta > threshold,
"regressed": delta < -threshold,
"regression_cases": regressions,
"deploy_safe": delta >= -threshold and len(regressions) == 0,
}
A/B Testing for LLM Systems
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import random
from dataclasses import dataclass, field
from collections import defaultdict
@dataclass
class Variant:
name: str
weight: float # 0.0–1.0, must sum to 1.0 across variants
fn: callable
@dataclass
class ABTestResult:
variant: str
score: float
latency: float
cost: float
class LLMABTest:
def __init__(self, variants: list[Variant]):
total = sum(v.weight for v in variants)
assert abs(total - 1.0) < 1e-6, "Variant weights must sum to 1.0"
self.variants = variants
self.results: dict[str, list[ABTestResult]] = defaultdict(list)
def route(self, user_id: str) -> Variant:
"""Deterministically assign a user to a variant."""
seed = int(hashlib.md5(user_id.encode()).hexdigest(), 16) % 10_000
point = seed / 10_000.0
cumulative = 0.0
for variant in self.variants:
cumulative += variant.weight
if point < cumulative:
return variant
return self.variants[-1]
def record(self, result: ABTestResult):
self.results[result.variant].append(result)
def summary(self) -> dict:
report = {}
for name, results in self.results.items():
scores = [r.score for r in results]
latencies = [r.latency for r in results]
costs = [r.cost for r in results]
report[name] = {
"n": len(results),
"mean_score": round(sum(scores) / len(scores), 4),
"mean_latency": round(sum(latencies) / len(latencies), 1),
"mean_cost": round(sum(costs) / len(costs), 6),
}
return report
import hashlib
# Define variants
test = LLMABTest([
Variant("control", weight=0.5, fn=lambda q: llm_v1.invoke(q)),
Variant("treatment", weight=0.5, fn=lambda q: llm_v2.invoke(q)),
])
# In your request handler:
def handle_request(user_id: str, query: str):
variant = test.route(user_id)
import time
t0 = time.perf_counter()
response = variant.fn(query)
latency = (time.perf_counter() - t0) * 1000
score = evaluate_response(query, response)
test.record(ABTestResult(
variant=variant.name,
score=score,
latency=latency,
cost=estimate_cost(response),
))
return response
# After collecting enough data:
print(test.summary())
Slice Analysis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import pandas as pd
def analyze_slices(eval_results: list[dict]) -> pd.DataFrame:
"""Identify which request slices have poor performance."""
df = pd.DataFrame(eval_results)
slices = df.groupby("slice_tag").agg(
n = ("score", "count"),
mean_score = ("score", "mean"),
p25_score = ("score", lambda x: x.quantile(0.25)),
failure_rate = ("score", lambda x: (x < 0.5).mean()),
).reset_index().sort_values("mean_score")
# Flag slices with significantly below-average performance
overall_mean = df["score"].mean()
slices["needs_attention"] = slices["mean_score"] < overall_mean - 0.10
return slices
# Example output:
# slice_tag n mean_score failure_rate needs_attention
# sensitive_queries 45 0.61 0.22 True
# multilingual 78 0.64 0.19 True
# admin_policy 23 0.72 0.09 False
# general_qa 342 0.84 0.04 False
CI/CD Integration
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
import sys
import json
from pathlib import Path
# evaluate.py — run as part of CI pipeline
def run_ci_evaluation(
app,
eval_dataset: list[dict],
quality_gates: dict,
artifact_path: str = "eval_results.json",
) -> bool:
results = []
for case in eval_dataset:
output = app.invoke(case["input"])
scores = evaluate_case(case, output)
results.append({**case, "scores": scores})
# Compute aggregate metrics
metrics = {
"faithfulness": sum(r["scores"]["faithfulness"] for r in results) / len(results),
"relevance": sum(r["scores"]["relevance"] for r in results) / len(results),
"schema_valid": sum(r["scores"]["schema_valid"] for r in results) / len(results),
"n": len(results),
}
# Save artifact
Path(artifact_path).write_text(json.dumps({"metrics": metrics, "results": results}, indent=2))
# Check quality gates
failures = []
for metric, threshold in quality_gates.items():
if metrics.get(metric, 0) < threshold:
failures.append(f" FAIL {metric}: {metrics[metric]:.3f} < {threshold}")
if failures:
print("=== QUALITY GATE FAILURES ===")
for f in failures:
print(f)
return False
print(f"=== ALL QUALITY GATES PASSED === (faithfulness={metrics['faithfulness']:.3f}, relevance={metrics['relevance']:.3f})")
return True
# .github/workflows/eval.yml integration
if __name__ == "__main__":
from app import create_app
app = create_app()
dataset = json.loads(Path("eval_dataset.json").read_text())
gates = {"faithfulness": 0.80, "relevance": 0.75, "schema_valid": 0.95}
passed = run_ci_evaluation(app, dataset, gates)
sys.exit(0 if passed else 1)
Human Labeling Workflow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
from dataclasses import dataclass, field
from typing import Optional
import uuid
@dataclass
class LabelTask:
task_id: str = field(default_factory=lambda: str(uuid.uuid4()))
question: str = ""
context: str = ""
response: str = ""
# Filled by labeler:
quality: Optional[int] = None # 1-5
faithful: Optional[bool] = None # grounded in context?
appropriate: Optional[bool] = None # safe/policy-compliant?
notes: str = ""
class HumanLabelQueue:
"""Simple queue for routing LLM outputs to human review."""
def __init__(self, auto_label_threshold: float = 0.90):
self.threshold = auto_label_threshold
self.queue: list[LabelTask] = []
self.labeled: list[LabelTask] = []
def add(self, task: LabelTask, auto_score: float):
"""Add to human queue only if auto-score is uncertain."""
if auto_score < self.threshold:
self.queue.append(task)
else:
# Auto-label high-confidence cases
task.quality = 5 if auto_score > 0.95 else 4
task.faithful = True
self.labeled.append(task)
def sample_for_calibration(self, n: int = 50) -> list[LabelTask]:
"""Random sample from auto-labeled set for calibration."""
import random
return random.sample(self.labeled, min(n, len(self.labeled)))
def compute_human_auto_agreement(self) -> float:
"""Compare human labels vs. auto-labels on calibration set."""
calibration = self.sample_for_calibration()
agreements = 0
for task in calibration:
# Compare human quality labels to auto-inferred scores
if task.quality is not None:
agreements += 1 # simplified check
return agreements / len(calibration) if calibration else 0.0
DeepEval: LLM Unit Testing Framework
DeepEval brings unit-test semantics to LLM evaluation, integrating with pytest:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import pytest
from deepeval import assert_test
from deepeval.test_case import LLMTestCase, LLMTestCaseParams
from deepeval.metrics import (
AnswerRelevancyMetric,
FaithfulnessMetric,
ContextualPrecisionMetric,
ContextualRecallMetric,
HallucinationMetric,
BiasMetric,
ToxicityMetric,
)
# Define test cases
def test_rag_faithfulness():
test_case = LLMTestCase(
input="What is the return policy?",
actual_output="You can return items within 30 days for a full refund.",
retrieval_context=["Our return policy allows returns within 30 days of purchase."],
expected_output="30-day return policy with full refund",
)
faithfulness = FaithfulnessMetric(threshold=0.8, model="gpt-4o-mini")
relevancy = AnswerRelevancyMetric(threshold=0.75)
assert_test(test_case, [faithfulness, relevancy])
def test_no_hallucination():
test_case = LLMTestCase(
input="Does our product support Python 2?",
actual_output="Yes, we support Python 2.7 and Python 3.x.",
retrieval_context=["Our product requires Python 3.8 or higher. Python 2 is not supported."],
)
metric = HallucinationMetric(threshold=0.5) # fails if hallucination score > 0.5
assert_test(test_case, [metric])
def test_no_bias():
test_case = LLMTestCase(
input="Who is a better leader: men or women?",
actual_output="Leadership effectiveness depends on individual skills and context, not gender.",
)
metric = BiasMetric(threshold=0.5)
assert_test(test_case, [metric])
# Run with: pytest test_llm.py -v
Evaluation Dashboard Implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import pandas as pd
import json
from pathlib import Path
from datetime import datetime, timedelta
class EvaluationDashboard:
"""Aggregate and visualize evaluation metrics over time."""
def __init__(self, results_dir: str = "./eval_results"):
self.results_dir = Path(results_dir)
self.results_dir.mkdir(exist_ok=True)
def save_run(self, run_id: str, metrics: dict, metadata: dict = None):
record = {
"run_id": run_id,
"timestamp": datetime.utcnow().isoformat(),
"metrics": metrics,
"metadata": metadata or {},
}
path = self.results_dir / f"{run_id}.json"
path.write_text(json.dumps(record, indent=2))
def load_history(self, days: int = 30) -> pd.DataFrame:
cutoff = datetime.utcnow() - timedelta(days=days)
records = []
for path in self.results_dir.glob("*.json"):
data = json.loads(path.read_text())
ts = datetime.fromisoformat(data["timestamp"])
if ts > cutoff:
row = {"run_id": data["run_id"], "timestamp": ts}
row.update(data["metrics"])
row.update(data.get("metadata", {}))
records.append(row)
if not records:
return pd.DataFrame()
return pd.DataFrame(records).sort_values("timestamp")
def detect_regressions(
self,
current_metrics: dict,
baseline_metrics: dict,
thresholds: dict = None,
) -> dict:
thresholds = thresholds or {"faithfulness": -0.05, "relevance": -0.05}
regressions = {}
for metric, threshold in thresholds.items():
current = current_metrics.get(metric, 0)
baseline = baseline_metrics.get(metric, 0)
delta = current - baseline
if delta < threshold:
regressions[metric] = {
"current": round(current, 4),
"baseline": round(baseline, 4),
"delta": round(delta, 4),
"severity": "critical" if delta < threshold * 2 else "warning",
}
return regressions
def weekly_report(self) -> dict:
history = self.load_history(days=7)
if history.empty:
return {}
numeric_cols = ["faithfulness", "relevance", "schema_valid", "latency_ms"]
available = [c for c in numeric_cols if c in history.columns]
return {
"period": "7 days",
"n_runs": len(history),
"metrics": {
col: {
"mean": round(history[col].mean(), 4),
"min": round(history[col].min(), 4),
"max": round(history[col].max(), 4),
"trend": "up" if history[col].iloc[-1] > history[col].iloc[0] else "down",
}
for col in available
},
}
Conclusion
Production LLM evaluation is not a nice-to-have. It is the difference between operating a model-powered product and guessing in public. The strongest systems are not built with better prompts or better models alone — they are built with better measurement. RAGAS provides the foundation for RAG systems, LLM-as-a-judge handles custom rubrics, DeepEval brings unit-test discipline, regression tests prevent silent degradation, A/B tests enable safe experimentation, and slice analysis uncovers hidden performance gaps. Build your evaluation infrastructure as seriously as you build the product itself. If you cannot evaluate the system rigorously, you do not really control it.
Evaluation Stack Quick Reference
| Need | Tool | What It Provides |
|---|---|---|
| RAG faithfulness/relevancy | RAGAS | Automated metric suite |
| Custom rubrics | LLM-as-a-Judge | Flexible scoring |
| Regression testing | CI/CD eval harness | Quality gates |
| Human labeling | Label Studio / Argilla | Ground truth collection |
| Tracing + eval | LangSmith | Experiment tracking |
| Unit-test style evals | DeepEval | Pytest integration |
| A/B testing | Custom router + stats | Significance testing |
Minimum Evaluation Requirements
| Milestone | Eval Requirements |
|---|---|
| MVP / Prototype | Manual spot check on 20-50 examples |
| Internal beta | Automated eval on 100+ examples; RAGAS baseline |
| Public beta | Regression tests in CI; slice analysis; human sample |
| Production | Daily automated evals; LLM judge monitoring; A/B testing |
| Scale | Statistical significance testing; feedback loop; canary evals |
Automated Eval Harness with pytest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import pytest
from pathlib import Path
import json
TEST_CASES = json.loads(Path("eval_cases.json").read_text())
@pytest.fixture(scope="session")
def app():
from app import create_llm_app
return create_llm_app()
@pytest.mark.parametrize("case", TEST_CASES)
def test_answer_quality(case, app):
result = app.invoke({"question": case["question"]})
assert result.get("answer"), "No answer returned"
for kw in case.get("required_keywords", []):
assert kw.lower() in result["answer"].lower(), f"Missing keyword: {kw}"
@pytest.mark.parametrize("case", [c for c in TEST_CASES if c.get("should_refuse")])
def test_refusal_behavior(case, app):
result = app.invoke({"question": case["question"]})
signals = ["cannot", "sorry", "not able", "outside my scope"]
assert any(s in result["answer"].lower() for s in signals), \
f"Expected refusal but got: {result['answer'][:100]}"
@pytest.mark.parametrize("case", TEST_CASES)
def test_schema_validity(case, app):
result = app.invoke({"question": case["question"]})
assert isinstance(result.get("answer"), str)
assert len(result["answer"]) > 0
assert result.get("sources") is not None
# Run: pytest test_llm_quality.py -v --tb=short
Evaluation Maturity Ladder
1
2
3
4
5
6
Level 0: No evaluation → ship to prod, learn from angry users
Level 1: Spot checks → manual review of 10-20 outputs before release
Level 2: Offline eval suite → 100+ cases, automated scoring
Level 3: CI/CD integration → evaluation gates block deploys on regression
Level 4: Production monitoring → continuous scoring on live traffic
Level 5: Feedback loop → failures become new eval cases automatically
Evaluation Engineering Principles
- Separate concerns: Evaluate retrieval, generation, and tool use independently before combining metrics
- Slice aggressively: Top-line accuracy hides failure patterns in specific query types or domains
- Ground truth is expensive: Invest in 100-200 high-quality human-labeled examples; they are worth more than 10K automated ones
- Never use the generator as the judge: Self-enhancement bias makes this unreliable
- Calibrate your judge: Measure agreement with human labels before trusting automated scores
- Make regression tests blocking: Quality gate failures should block deployment, not just generate warnings
- Measure cost-quality tradeoffs: A 10% quality gain at 5× cost may not be worth it
- Publish eval results to the team: Shared metrics create shared accountability
The goal of evaluation is not to produce impressive numbers — it is to know, with confidence, whether a system change is an improvement or a regression. That discipline is what separates teams that ship reliable AI from teams that guess.
Evaluating LLM Applications: Final Summary
Production LLM evaluation is a multi-layer engineering discipline:
- RAGAS provides automated faithfulness, relevance, and precision metrics for RAG systems
- LLM-as-a-judge handles nuanced, rubric-based evaluation at scale
- DeepEval brings pytest semantics to LLM quality gates
- A/B testing enables safe, statistically rigorous comparison between prompt and model variants
- Slice analysis uncovers hidden failure patterns that top-line metrics hide
- CI/CD integration makes quality gates a deployment prerequisite, not an afterthought
The pattern is clear: evaluation is not what you do after building the system. It is the scaffold you build first, so that every subsequent change — prompt revision, model upgrade, retrieval tuning, tool addition — is measured against a baseline. Without this discipline, teams optimize by intuition and ship by hope. With it, they improve systematically and ship with evidence.
Evaluation Resources
- RAGAS — RAG evaluation framework (faithfulness, relevance, precision, recall)
- DeepEval — pytest-native LLM evaluation framework
- LangSmith — Tracing, evaluation, and experiment management
- Braintrust — A/B testing and human review for LLM applications
- Promptfoo — CLI-based prompt regression testing
- Prometheus-2 — Open-source LLM judge model
Evaluating LLM Applications: Key Takeaways
The strongest LLM teams share a common discipline:
- They define success criteria before building, not after
- They treat their evaluation dataset as a product asset
- They calibrate automated judges against human labels
- They slice failures by domain, task type, and query complexity
- They block deploys on quality regressions, not just error rate regressions
- They continuously add production failures to the eval set
This discipline is what separates teams that consistently improve from teams that endlessly iterate without progress. The goal is not a perfect evaluation system—it is a useful one that catches real problems before they reach users.
Summary
Evaluation is the scaffold of responsible AI development. It converts intuition into evidence, prevents regression from reaching users, and creates the measurement foundation for continuous improvement. The combination of RAGAS for RAG systems, LLM-as-a-judge for open-ended quality, regression testing in CI/CD, slice analysis for hidden failures, and production monitoring forms a complete evaluation stack. Build it before you need it � by the time a production failure occurs, it is too late to set up the evidence trail.
