DeepEval Custom Metrics: Deterministic LLM Evaluation

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

MetricTypeQuestion it answers
ToolCorrectnessMetricBuilt in, exact matchDid the agent call exactly the tools this request needs?
ExpectedFactsMetricCustom BaseMetricDoes the answer contain the facts the policy requires?
ForbiddenContentMetricCustom BaseMetricDid anything leak that must never be shown?
GroundednessMetricCustom BaseMetricIs 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 GEval and FaithfulnessMetric behind 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.