DeepEval ships many LLM-as-judge metrics, and they are the right tool for open-ended quality. For a CI gate I wanted something different: metrics that are free, fast and reproducible, so a red build always means a real change in behaviour. This page shows how I build them. It is part of my LLM evaluation framework.
One golden case, one pytest test
Every case of the golden dataset becomes a parametrized pytest test. The test calls the agent over HTTP, builds a DeepEval LLMTestCase with the answer, the tools that were called and the retrieved context, and passes only when all of its metrics pass.
@pytest.mark.parametrize("case", CASES, ids=[c["id"] for c in CASES])
def test_golden_case(case, client):
messages = case.get("messages") or [{"role": "user", "content": case["input"]}]
reply = client.chat(messages)
test_case = LLMTestCase(
input=messages[-1]["content"],
actual_output=reply.content,
retrieval_context=reply.retrieval_context or None,
tools_called=[ToolCall(name=name) for name in reply.tools_called],
expected_tools=[ToolCall(name=name) for name in case["expected_tools"]],
)
metrics = [
ToolCorrectnessMetric(
threshold=1.0, model=NoJudge(), should_exact_match=True,
async_mode=False, include_reason=False,
)
]
if case["must_include"]:
metrics.append(ExpectedFactsMetric(case["must_include"]))
if case["must_not_include"]:
metrics.append(ForbiddenContentMetric(case["must_not_include"]))
if reply.retrieval_context:
metrics.append(GroundednessMetric(threshold=0.8))
assert_test(test_case, metrics, run_async=False)
Two details matter here. The built-in ToolCorrectnessMetric runs in exact-match mode, so the agent must call precisely the tools the case expects, no more and no fewer. And the metric list is built per case: a greeting has no required facts, and a policy answer has retrieved context to check against.
The metrics
| Metric | Type | Question it answers |
|---|---|---|
ToolCorrectnessMetric | Built in, exact match | Did the agent call exactly the tools this request needs? |
ExpectedFactsMetric | Custom BaseMetric | Does the answer contain the facts the policy requires? |
ForbiddenContentMetric | Custom BaseMetric | Did anything leak that must never be shown? |
GroundednessMetric | Custom BaseMetric | Is the answer supported by the retrieved context? |
Expected facts and forbidden content
These two metrics are the workhorses. Expected facts scores the share of required phrases found in the answer. Forbidden content is binary: any leaked phrase gives a zero. Both subclass BaseMetric, so they plug into assert_test and into any DeepEval dataset run exactly like the built-in metrics.
class ExpectedFactsMetric(_SimpleMetric):
"""Share of required phrases that appear in the answer."""
name = "Expected Facts"
def __init__(self, expected: List[str], threshold: float = 1.0):
super().__init__(threshold)
self.expected = expected
def measure(self, test_case: LLMTestCase, *args, **kwargs) -> float:
answer = test_case.actual_output.lower()
missing = [p for p in self.expected if p.lower() not in answer]
self.score = 1.0 if not self.expected else (len(self.expected) - len(missing)) / len(self.expected)
self.success = self.score >= self.threshold
self.reason = "all expected facts found" if not missing else f"missing: {missing}"
return self.score
class ForbiddenContentMetric(_SimpleMetric):
"""Fails when the answer contains text that must never be shown."""
name = "Forbidden Content"
def __init__(self, forbidden: List[str], threshold: float = 1.0):
super().__init__(threshold)
self.forbidden = forbidden
def measure(self, test_case: LLMTestCase, *args, **kwargs) -> float:
answer = test_case.actual_output.lower()
found = [p for p in self.forbidden if p.lower() in answer]
self.score = 0.0 if found else 1.0
self.success = self.score >= self.threshold
self.reason = "no forbidden content" if not found else f"leaked: {found}"
return self.score
Every metric writes a human-readable reason. When a case fails, the report says which fact is missing or which phrase leaked, so triage takes seconds.
Groundedness
Groundedness measures how much of the answer is supported by the retrieved context or by the question itself. It tokenizes both, drops stopwords and source tags, and scores the overlap. It is lexical on purpose: cheap, reproducible and easy to debug.
class GroundednessMetric(_SimpleMetric):
"""Lexical groundedness: how much of the answer is supported by retrieved context or the question."""
name = "Groundedness"
@staticmethod
def _terms(text: str) -> set:
text = re.sub(r"\(source:[^)]*\)", " ", text.lower())
return {w for w in re.findall(r"[a-z0-9.]+", text) if len(w) > 2 and w not in STOPWORDS}
def measure(self, test_case: LLMTestCase, *args, **kwargs) -> float:
answer_terms = self._terms(test_case.actual_output)
support = self._terms(" ".join(test_case.retrieval_context or []) + " " + test_case.input)
self.score = 1.0 if not answer_terms else len(answer_terms & support) / len(answer_terms)
self.success = self.score >= self.threshold
self.reason = f"{self.score:.0%} of answer terms are supported by the retrieved context"
return self.score
Running without a judge model
Some built-in metrics expect an evaluation model even when they never need to call it. I pass a placeholder judge that raises an error if anything tries to use it. If a future change accidentally introduces a model call, the suite fails loudly instead of silently spending tokens.
class NoJudge(DeepEvalBaseLLM):
"""Placeholder judge for built in metrics that never need one in exact match mode."""
def load_model(self):
return None
def generate(self, prompt: str, *args, **kwargs) -> str:
raise RuntimeError("This suite is judge free, the model must not be called")
def get_model_name(self) -> str:
return "no-judge"
What deterministic metrics can and cannot do
- They are a regression gate, not a quality judge. They catch the wrong tool, a missing policy fact and a leaked secret. They cannot tell whether an answer is well written.
- Lexical overlap is a proxy. A paraphrase can score lower than it deserves, and a fluent hallucination that reuses retrieved words can score higher.
- The next step is a judge, next to these metrics and not instead of them. With a real model I would add
GEvalandFaithfulnessMetricbehind a flag, run each case several times and apply tolerance bands to the thresholds.
Run it locally
cd "LLM Testing"
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
scripts/start_agents.sh python # hardened :8000, vulnerable :8001
AGENT_URL=http://127.0.0.1:8000 python -m pytest evals/deepeval
Next: prompt injection and jailbreak testing or the source code on GitHub.
