← Back to DevBytes

Detecting Hallucinations in Production LLM Outputs

Detecting Hallucinations in Production LLM Outputs

Large language models are powerful, but they share one dangerous trait: they can produce fluent, confident, and entirely fabricated information. In production systems—where outputs may drive medical advice, financial decisions, or customer-facing content—hallucinations are not just an inconvenience. They are a liability. This tutorial walks through what hallucinations are, why detecting them matters, and how to build practical detection pipelines you can deploy today.

What Is an LLM Hallucination?

A hallucination occurs when a language model generates text that is syntactically correct and contextually plausible but factually wrong, ungrounded, or fabricated. Hallucinations come in several flavors:

In a retrieval-augmented generation (RAG) setup, the most common and dangerous type is the contextual hallucination: the model drifts away from the retrieved documents and fills gaps with parametric memory.

Why Hallucination Detection Matters in Production

During prototyping, a hallucination might be a funny anecdote. In production, it becomes a business risk. Here is why detection is essential:

Approaches to Hallucination Detection

There is no single silver bullet. Production systems typically combine multiple techniques. Below are the most practical approaches, with code you can adapt.

1. Self-Consistency Checking

The idea is simple: ask the model the same question multiple times (or rephrase it) and compare the answers. If the model gives contradictory responses, the answer is likely ungrounded. This works well for factual queries.

import openai
from collections import Counter

openai.api_key = "your-api-key"

def generate(prompt, temperature=0.7):
    response = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=temperature,
    )
    return response.choices[0].message.content.strip()

def self_consistency_check(question, n=5):
    answers = [generate(question) for _ in range(n)]
    
    # Normalize and count answers
    normalized = [a.lower().strip() for a in answers]
    counts = Counter(normalized)
    most_common, freq = counts.most_common(1)[0]
    
    agreement = freq / n
    return {
        "answers": answers,
        "agreement_score": agreement,
        "is_consistent": agreement >= 0.8,
    }

result = self_consistency_check("What year was the Eiffel Tower completed?")
print(f"Agreement: {result['agreement_score']:.0%}")
print(f"Consistent: {result['is_consistent']}")

The agreement score tells you how stable the answer is. Low agreement signals low confidence and a higher hallucination risk. The tradeoff is cost: you are calling the model n times per query.

2. LLM-as-a-Judge Verification

Use a second model (or the same model with a different prompt) to verify whether the generated answer is supported by the provided context. This is one of the most popular production techniques because it is flexible and relatively cheap.

VERIFIER_PROMPT = """You are a strict fact-checker.

Given a SOURCE CONTEXT and a CLAIM, determine whether the claim
is fully supported by the source context.

Rules:
- If the claim contains any fact not found in the source context,
  mark it as "UNSUPPORTED".
- If the claim contradicts the source context, mark it as
  "CONTRADICTED".
- Only mark "SUPPORTED" if every factual assertion in the claim
  can be traced to the source context.

Source Context:
{context}

Claim:
{claim}

Respond in JSON:
{{"verdict": "SUPPORTED|UNSUPPORTED|CONTRADICTED", "reason": "..."}}
"""

def verify_claim(context, claim):
    prompt = VERIFIER_PROMPT.format(context=context, claim=claim)
    response = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.0,
        response_format={"type": "json_object"},
    )
    import json
    return json.loads(response.choices[0].message.content)

context = """
The Eiffel Tower was completed in 1889 for the World's Fair
celebrating the 100th anniversary of the French Revolution.
It stands 330 meters tall.
"""

claim = "The Eiffel Tower was completed in 1889 and is 330 meters tall."
result = verify_claim(context, claim)
print(result)
# {'verdict': 'SUPPORTED', 'reason': 'Both facts appear in the context.'}

bad_claim = "The Eiffel Tower was completed in 1889 and was designed by Gustave Eiffel."
result = verify_claim(context, bad_claim)
print(result)
# {'verdict': 'UNSUPPORTED', 'reason': 'The designer is not mentioned in the context.'}

This approach is especially powerful in RAG pipelines. You pass the retrieved chunks as context and the model's answer as the claim. The verifier acts as a gatekeeper before the response reaches the user.

3. Token-Level Confidence via Logprobs

Some API providers return log probabilities for generated tokens. Low logprob values indicate the model was uncertain. While not a direct hallucination signal, aggregated low-confidence tokens correlate with ungrounded content.

def generate_with_logprobs(prompt):
    response = openai.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.0,
        logprobs=True,
        top_logprobs=1,
    )
    return response.choices[0]

def confidence_score(choice):
    import math
    logprobs = choice.logprobs.content
    if not logprobs:
        return None
    
    probabilities = []
    for token in logprobs:
        if token.logprob is not None:
            probabilities.append(math.exp(token.logprob))
    
    if not probabilities:
        return None
    
    # Geometric mean of token probabilities
    log_sum = sum(math.log(p) for p in probabilities)
    return math.exp(log_sum / len(probabilities))

choice = generate_with_logprobs("What is the capital of France?")
score = confidence_score(choice)
print(f"Confidence: {score:.4f}")
print(f"Answer: {choice.message.content}")

if score and score < 0.3:
    print("WARNING: Low confidence — possible hallucination")

Use this as a signal, not a verdict. A model can be confidently wrong, so pair logprob analysis with content-based verification for robust detection.

4. NLI-Based Grounding Detection

Natural Language Inference (NLI) models classify the relationship between a premise (your source context) and a hypothesis (the model's claim) as entailment, contradiction, or neutral. This is a fast, cheap, and deterministic alternative to LLM-as-a-judge.

from transformers import pipeline

# Load an NLI model fine-tuned for fact-checking / grounding
nli = pipeline(
    "text-classification",
    model="roberta-large-mnli",
    return_all_scores=True,
)

def check_grounding(context, claim):
    # NLI models expect premise-hypothesis pairs
    result = nli(f"{context}  {claim}")
    scores = {r["label"].lower(): r["score"] for r in result[0]}
    
    if scores.get("entailment", 0) > 0.7:
        return "GROUNDED", scores
    elif scores.get("contradiction", 0) > 0.7:
        return "CONTRADICTED", scores
    else:
        return "UNVERIFIED", scores

context = "The Apollo 11 mission landed on the Moon on July 20, 1969."
claim = "Apollo 11 landed on the Moon in 1969."

label, scores = check_grounding(context, claim)
print(f"Label: {label}")
print(f"Scores: {scores}")

NLI models are lightweight enough to run on CPU and can process thousands of claims per second on a modest GPU. This makes them ideal for high-throughput production environments where calling a second LLM for every response is too expensive.

5. Building a Combined Detection Pipeline

No single method catches everything. A robust production system layers multiple checks. Here is a simplified pipeline that combines NLI grounding, confidence scoring, and LLM verification:

import math
import json
from transformers import pipeline

nli = pipeline("text-classification", model="roberta-large-mnli",
               return_all_scores=True)

class HallucinationDetector:
    def __init__(self, nli_threshold=0.7, confidence_threshold=0.3):
        self.nli_threshold = nli_threshold
        self.confidence_threshold = confidence_threshold
    
    def check_nli(self, context, claim):
        result = nli(f"{context}  {claim}")
        scores = {r["label"].lower(): r["score"] for r in result[0]}
        return scores
    
    def check_confidence(self, choice):
        logprobs = choice.logprobs.content if choice.logprobs else None
        if not logprobs:
            return 1.0  # assume confident if unavailable
        probs = [math.exp(t.logprob) for t in logprobs if t.logprob]
        if not probs:
            return 1.0
        log_sum = sum(math.log(p) for p in probs)
        return math.exp(log_sum / len(probs))
    
    def llm_verify(self, context, claim):
        prompt = f"""Is this claim fully supported by the context?
Context: {context}
Claim: {claim}
Respond JSON: {{"supported": true/false, "reason": "..."}}"""
        resp = openai.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{"role": "user", "content": prompt}],
            temperature=0.0,
            response_format={"type": "json_object"},
        )
        return json.loads(resp.choices[0].message.content)
    
    def evaluate(self, context, claim, choice=None):
        nli_scores = self.check_nli(context, claim)
        confidence = self.check_confidence(choice) if choice else 1.0
        
        # Fast path: strong NLI signal
        if nli_scores.get("entailment", 0) > self.nli_threshold:
            return {"verdict": "PASS", "method": "nli", "scores": nli_scores}
        
        if nli_scores.get("contradiction", 0) > self.nli_threshold:
            return {"verdict": "FAIL", "method": "nli", "scores": nli_scores}
        
        # Slow path: fall back to LLM verification
        llm_result = self.llm_verify(context, claim)
        verdict = "PASS" if llm_result["supported"] else "FAIL"
        
        return {
            "verdict": verdict,
            "method": "llm_verify",
            "nli_scores": nli_scores,
            "confidence": confidence,
            "llm_reason": llm_result.get("reason"),
        }

# Usage
detector = HallucinationDetector()
context = "Photosynthesis converts light energy into chemical energy stored in glucose."
claim = "Photosynthesis produces glucose from light energy."
result = detector.evaluate(context, claim)
print(json.dumps(result, indent=2))

This pipeline uses cheap NLI checks first and only escalates to an LLM call when the NLI model is uncertain. This keeps latency and cost low while maintaining accuracy.

Best Practices for Production

Log Everything for Observability

Store every input, output, context, and detection result. Over time, this data becomes a goldmine for improving your system and training custom classifiers.

import logging
from datetime import datetime

logger = logging.getLogger("hallucination_monitor")

def log_evaluation(query, context, output, result):
    logger.info(json.dumps({
        "timestamp": datetime.utcnow().isoformat(),
        "query": query,
        "context": context[:500],
        "output": output[:500],
        "verdict": result["verdict"],
        "method": result.get("method"),
        "scores": result.get("scores") or result.get("nli_scores"),
    }))

Set Thresholds Based on Use Case

A medical chatbot needs stricter thresholds than a creative writing assistant. Tune your nli_threshold and confidence_threshold based on the cost of false negatives versus false positives. Measure precision and recall against a labeled evaluation set.

Handle Failures Gracefully

When detection flags a potential hallucination, do not silently fail. Options include:

Use Specialized Tools and Frameworks

Several open-source libraries are purpose-built for this:

# Example using Ragas for faithfulness evaluation
from ragas import evaluate
from ragas.metrics import faithfulness
from datasets import Dataset

eval_data = Dataset.from_dict({
    "question": ["What is the speed of light?"],
    "answer": ["The speed of light is 299,792,458 meters per second."],
    "contexts": [["Light travels at 299,792,458 m/s in a vacuum."]],
})

results = evaluate(eval_data, metrics=[faithfulness])
print(results)
# {'faithfulness': 1.0}

Continuously Evaluate and Retune

Hallucination patterns shift as you change prompts, models, or retrieval strategies. Set up a weekly evaluation job that runs your detector against a golden dataset and tracks metrics over time. Alert on regressions.

Conclusion

Detecting hallucinations in production LLM outputs is not a one-time fix but an ongoing discipline. By combining self-consistency checks, LLM-as-a-judge verification, logprob confidence signals, and NLI-based grounding detection, you can build a layered defense that catches most fabrications before they reach users. The key is to treat hallucination detection as a first-class part of your architecture—with its own logging, metrics, thresholds, and fallback strategies—rather than an afterthought. Start simple with an NLI grounding check, measure its performance on your real traffic, and iteratively add layers until your false-negative rate meets the risk tolerance of your application. Your users, your compliance team, and your future self will thank you.

— Ad —

Google AdSense will appear here after approval

← Back to all articles