Hallucination & Reliability

Why LLMs hallucinate and the layered defenses that make generation reliable: grounding, forced abstention, source citation and verification (chain-of-verification), structured-output constraints, and a post-generation verification layer that catches unsupported claims before the user sees them.

~3hevaluationreliabilityguardrails
Learning objectives
  • Explain why LLMs hallucinate and distinguish intrinsic (unfaithful to source) from extrinsic (factually wrong) errors.
  • Apply the first-line defenses: grounding and forced abstention.
  • Make claims checkable with source citation and verification (chain-of-verification).
  • Add structured-output constraints and a post-generation verification layer for high-stakes output.
Note

For JavaScript developers: the code composes pieces you've built — a retrieval call, a generation call, and a second "checker" LLM call — plus a JSON-schema constraint. No new syntax; the ideas are the defenses, layered.

Why LLMs hallucinate

A hallucination is confident, fluent output that is wrong or unsupported. It's not a bug to be fully patched — it's inherent to how the model works: an LLM predicts the most probable next token, not the true one. It has no built-in model of truth and no signal for "I don't actually know this," so when its parametric memory is thin, it produces a plausible-sounding guess with the same confidence as a fact.

Two flavors worth separating (they need different fixes):

  • Intrinsic / unfaithful — the output contradicts the provided source (e.g. the context said €49, the answer says €59). A faithfulness failure — fixable with grounding + verification.
  • Extrinsic / factual — the output is wrong about the world with no source involved. Needs grounding (give it a source) or abstention.

Reliability is therefore not one switch but a stack of defenses, each catching what the previous missed.

Defense 1: grounding + abstention

The foundation (from RAG, Track 3): don't rely on parametric memory — retrieve the facts and instruct the model to answer only from them, and to abstain when they're insufficient. Abstention is the single most important reliability instruction: a model that says "I don't know" is infinitely more trustworthy than one that invents.

def grounded_answer(question: str) -> str:
    context = retrieve(question)                          # bring real facts into the prompt (grounding)
    prompt = (
        "Answer using ONLY the context. If the context does not contain the answer, "
        "reply exactly 'I don't know.' Do not use outside knowledge.\n\n"   # forced abstention — the key line
        f"Context:\n{context}\n\nQuestion: {question}"
    )
    return llm(prompt, temperature=0)                      # temp 0 → less improvisation on facts
Tip

Exercise 1 has you take a grounded prompt and confirm it abstains on a question the context can't answer — then weaken the abstention instruction and watch a hallucination reappear.

Defense 2: cite sources & verify (chain-of-verification)

Make claims checkable. First, require the model to cite which context chunk supports each statement — citations turn "trust me" into "check line 3," and they make unsupported claims visible. Second, for high-stakes output, run a verification pass: a second model call that checks each claim against the source and flags anything unsupported. This "chain-of-verification" (generate → verify → revise) measurably cuts hallucination:

def verify_claims(answer: str, context: str) -> dict:
    # A SECOND LLM call acting as a fact-checker against the source (the faithfulness judge from Track 3):
    report = llm(
        "For each claim in the ANSWER, say whether the CONTEXT supports it. "
        'Return JSON: {"unsupported": ["claim", ...]}.\n\n'
        f"CONTEXT:\n{context}\n\nANSWER:\n{answer}",
        temperature=0,
    )
    return json.loads(report)                             # {"unsupported": [...]} — empty means fully grounded

def answer_with_verification(question: str) -> str:
    context = retrieve(question)
    draft = grounded_answer(question)                     # first pass
    issues = verify_claims(draft, context)               # verification pass
    if issues["unsupported"]:                             # if any claim isn't grounded…
        return llm(f"Revise to remove unsupported claims {issues['unsupported']}:\n{draft}")   # …revise it out
    return draft
Tip

Exercise 2 has you add source citation to a grounded answer and then a verification pass that flags an unsupported claim you plant — seeing generate→verify→revise catch it.

Defense 3: constrain the output

You can't hallucinate an invalid category if the format won't allow it. For classification/extraction, constrain generation to a schema or enum (structured outputs / function calling from Track 4). The model physically can't return a label outside the set:

schema = {"type": "object", "properties": {
    "category": {"type": "string", "enum": ["billing", "technical", "sales"]},   # ONLY these three are possible
    "confidence": {"type": "number"},
}, "required": ["category"]}
# With structured-output/enforced-schema mode, the model can't invent a fourth category or malformed output.

Constraints don't help open-ended prose, but for anything structured they eliminate a whole class of "made-up value" errors for free. Pair with a confidence field and route low-confidence cases to a human.

Tip

Exercise 3 has you constrain a classifier to an enum and confirm it can never emit an out-of-set label, then route low-confidence outputs to review.

Defense 4: the verification layer (and human-in-the-loop)

For high-stakes output, wrap generation in a reliability layer that decides whether to ship, revise, or escalate. It composes the defenses: ground → generate → verify → check confidence → gate:

def reliable_answer(question: str):
    context = retrieve(question)
    draft = grounded_answer(question)
    if not context.strip():                              # nothing retrieved → don't answer from memory
        return {"action": "abstain", "text": "I don't have information on that."}
    issues = verify_claims(draft, context)
    if issues["unsupported"]:                            # unsupported claims → escalate, don't silently ship
        return {"action": "escalate_to_human", "text": draft, "issues": issues["unsupported"]}
    return {"action": "answer", "text": draft}           # grounded + verified → safe to return

The pattern: abstain when you can't ground, escalate when verification fails, answer only when both pass. For irreversible actions (medical, legal, financial, sending, deleting), add the LangGraph human-in-the-loop gate (Track 4) so a person approves before anything ships.

Watch out

No stack makes an LLM 100% reliable — the goal is to reduce and contain hallucination and fail safe when it happens (abstain/escalate rather than confidently mislead). Match the defense depth to the stakes: a brainstorming tool needs little; a medical assistant needs all of it plus human sign-off.

Tip

Exercise 4 has you assemble the ground→generate→verify→gate layer and trace an answer down each branch (abstain, escalate, answer).

Pitfalls

  • Treating hallucination as fully fixable. It's inherent to next-token prediction — reduce and contain, don't assume a cure.
  • No abstention instruction. Without an explicit "say I don't know," the model fills gaps with confident invention.
  • Citations you never check. Requiring citations helps only if you (or a verifier) actually validate them.
  • Skipping verification on high stakes. For medical/legal/financial output, a generate-only pipeline is negligent — add a verify pass and human gate.
  • Mismatched defense depth. Over-guarding a low-stakes toy wastes effort; under-guarding a high-stakes system is dangerous.

Recap

  • Hallucination is inherent: LLMs predict probable, not true, tokens — separate intrinsic (unfaithful to source) from extrinsic (factually wrong) errors.
  • Layer defenses: grounding + abstention (answer only from context, else "I don't know"), citation + verification (chain-of-verification: generate → verify → revise), structured-output constraints (no invalid values), and a reliability layer that abstains/escalates/answers.
  • Add human-in-the-loop for irreversible, high-stakes actions.
  • You can't reach 100% — fail safe and match defense depth to stakes.
  • Worked examples map to the exercises: grounding+abstention (Ex 1), citation+verification (Ex 2), enum constraints (Ex 3), the reliability layer (Ex 4).

Hallucination & Reliability — Exercises

Reuse your grounded RAG pipeline. Ex 2/4 add a second "checker" LLM call; keep it cheap (gpt-4o-mini, temperature=0).


Exercise 1 — Abstention on/off

Take a grounded prompt with a strong abstention instruction and ask a question your corpus can't answer; confirm it replies "I don't know." Then remove the abstention line and re-ask; observe a hallucination return.

Reference solution
STRONG = ("Answer using ONLY the context. If it's not there, reply exactly \"I don't know.\"\n\n"
          "Context:\n{ctx}\n\nQuestion: {q}")
WEAK   = "Context:\n{ctx}\n\nQuestion: {q}"     # no abstention instruction

q = "What is the CEO's home address?"           # not in the corpus
ctx = "\n\n".join(retrieve(q))
print(llm(STRONG.format(ctx=ctx, q=q)))          # -> "I don't know."
print(llm(WEAK.format(ctx=ctx, q=q)))            # -> often a confident invented answer

The single abstention line is the highest-leverage reliability instruction. Without it the model fills the gap with plausible invention at full confidence — the core hallucination failure mode in miniature.


Exercise 2 — Citation + verification pass

Make a grounded answer cite the chunk index for each claim. Then add a verify_claims pass that returns unsupported claims. Plant an unsupported claim (e.g. by feeding partial context) and confirm it's flagged.

Reference solution
import json
def verify_claims(answer, context):
    r = llm('For each claim in ANSWER, is it supported by CONTEXT? '
            'Return JSON {"unsupported": [...]}.\n\n'
            f"CONTEXT:\n{context}\n\nANSWER:\n{answer}", temperature=0)
    return json.loads(r)

draft = "The Pro plan is €49/month and includes a free laptop."   # laptop is unsupported
print(verify_claims(draft, "The Pro plan is €49/month."))          # {"unsupported": ["...free laptop..."]}

Citations make each claim checkable ("supported by chunk 2"), and the verification pass (the faithfulness judge from Track 3, reused) catches the planted unsupported claim. Chaining generate → verify → revise measurably reduces hallucination on high-stakes output.


Exercise 3 — Constrain to an enum

Constrain a ticket classifier to {billing, technical, sales} via a schema/enum, and confirm it can never emit a fourth label even on a nonsense input. Add a confidence field and route low-confidence to review.

Reference solution
schema = {"type":"object","properties":{
    "category":{"type":"string","enum":["billing","technical","sales"]},
    "confidence":{"type":"number"}},"required":["category","confidence"]}

out = classify_structured("asdkfj random gibberish", schema=schema)   # still returns one of the 3
if out["confidence"] < 0.6:
    route_to_human(out)          # low confidence -> review instead of trusting it

The enum makes an out-of-set or malformed category impossible — a whole class of "made-up value" errors gone for free. Constraints don't help open prose, but for structured output they're a cheap, hard guarantee. Pairing with confidence + human routing handles the genuinely ambiguous cases.


Exercise 4 — The reliability layer

Assemble a reliable_answer that returns one of three actions — abstain (nothing retrieved), escalate (verification found unsupported claims), or answer (grounded + verified). Trace one input down each branch.

Reference solution
def reliable_answer(q):
    ctx = "\n\n".join(retrieve(q))
    if not ctx.strip():
        return {"action": "abstain", "text": "I don't have information on that."}
    draft = grounded_answer(q)
    issues = verify_claims(draft, ctx)
    if issues["unsupported"]:
        return {"action": "escalate", "text": draft, "issues": issues["unsupported"]}
    return {"action": "answer", "text": draft}

print(reliable_answer("unanswerable question"))   # abstain
print(reliable_answer("question that triggers an unsupported claim"))  # escalate
print(reliable_answer("well-grounded question"))  # answer

The rule: abstain when you can't ground, escalate when verification fails, answer only when both pass. For irreversible actions add the LangGraph human-in-the-loop gate (Track 4). No layer is perfect — the goal is to fail safe (abstain/escalate) rather than confidently mislead, with depth matched to the stakes.