You cannot ship what you cannot measure. Build a golden set, score outputs without a human in the loop, catch regressions in CI, and defend against injection with layers that survive a model upgrade.
Traditional code fails loudly. An LLM feature fails quietly and plausibly: the same prompt that worked last month now returns a confident, well-formatted, wrong answer. Nothing throws. No test goes red. A customer notices before you do — unless you built the thing that notices.
assertEqual(output, expected) is useless when the output is prose that may legitimately vary. But the opposite conclusion — "you cannot test AI" — is wrong and expensive. You can test three things reliably:
That is 90% of production quality. The remaining 10% is taste, and taste is what humans review.
Start with a table of cases: input, what a good answer contains, what it must never contain. Keep it in the database so support staff can add cases without a deploy.
class EvalCase(models.Model):
feature = models.CharField(max_length=50) # "support_reply"
prompt = models.TextField()
context = models.JSONField(default=dict) # retrieved docs, user state
must_include = models.JSONField(default=list) # ["14 days", "free of charge"]
must_exclude = models.JSONField(default=list) # ["guarantee", "lifetime"]
expects_tool = models.CharField(max_length=50, blank=True)
notes = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
Where do cases come from? Three sources, in order of value:
Fifty good cases catch more than five hundred generated ones. Resist the urge to bulk-generate.
A case earns its place when it fails for exactly one reason. "Answer is good" is not checkable; "answer mentions the 14-day window and does not promise a refund" is. Write must_include phrases for the facts that matter to the customer, not for the wording you happened to like, and put the forbidden phrases where a wrong answer would cost you something: invented guarantees, competitor names, a price that does not exist.
Literal phrases are brittle in one predictable way: the model says "two weeks" instead of "14 days" and a correct answer fails. Rather than loosening the case until it checks nothing, allow alternatives. A nested list in the same JSON field keeps the model unchanged:
for phrase in case.must_include:
options = phrase if isinstance(phrase, list) else [phrase]
if not any(opt.lower() in output.lower() for opt in options):
failures.append(f"missing: {' | '.join(options)}")
Use notes for the story: which ticket the case came from, what the wrong answer was, why the rule exists. Six months later somebody will want to delete a case that "keeps failing", and the note is what tells them it is the only thing standing between the feature and a repeat of the original incident.
Deactivate rather than delete. When a product rule changes (the return window becomes 30 days), flip is_active on the old case and add a new one. Historical runs keep pointing at a case that still exists, so the pass-rate graph stays interpretable across the policy change.
Run the cheap checks first — they are deterministic, instant and free.
def score_case(case, output, tool_calls):
failures = []
for phrase in case.must_include:
if phrase.lower() not in output.lower():
failures.append(f"missing: {phrase}")
for phrase in case.must_exclude:
if phrase.lower() in output.lower():
failures.append(f"forbidden: {phrase}")
if case.expects_tool and case.expects_tool not in tool_calls:
failures.append(f"did not call: {case.expects_tool}")
return failures
For structured output, validate against a schema and treat a parse failure as a hard fail. Never "repair" malformed JSON silently in production — you will hide a degradation that is about to get worse.
class Extraction(BaseModel):
invoice_number: str
total_cents: int = Field(ge=0)
currency: Literal["EUR", "USD", "GBP"]
due_date: date
try:
parsed = Extraction.model_validate_json(output)
except ValidationError as exc:
metrics.incr("extraction.schema_fail")
raise
Before reaching for a judge, notice how many grounding failures are about numbers. A support answer that invents a price, a date or a quantity is the most damaging kind of hallucination, and it is also the easiest to detect mechanically: every number in the answer should appear somewhere in the sources you supplied.
NUMBER = re.compile(r"\d+(?:[.,]\d+)*")
def ungrounded_numbers(answer: str, sources: list[str]) -> set[str]:
"""Numbers in the answer that appear in no source document."""
source_numbers = set(NUMBER.findall(" ".join(sources)))
return {n for n in NUMBER.findall(answer) if n not in source_numbers}
# inside score_case
sources = [doc["text"] for doc in case.context.get("documents", [])]
for n in sorted(ungrounded_numbers(output, sources)):
failures.append(f"ungrounded number: {n}")
It has false positives — the model writes "3 steps" or reformats "1,000" as "1000" — so start by logging these rather than failing on them, look at a week of results, then add a small allowlist of harmless numbers or normalise separators before comparing. The same idea extends to anything you can extract reliably: order numbers, URLs, product SKUs. If the answer contains a URL that is not in the sources and not on your own domain, that is worth a hard failure on its own.
For qualities you cannot grep for — tone, completeness, whether an answer is grounded in the sources — a second model call can score the first. It works if you keep it narrow.
JUDGE = """You are grading one answer. Reply with JSON only.
Question: {question}
Sources: {sources}
Answer: {answer}
grounded: true only if every factual claim appears in Sources.
complete: true only if the answer addresses the whole question.
reason: one sentence."""
Two rules keep judges honest. Ask for a boolean and a reason, not a score out of ten — numeric scores from models cluster around 7 and carry almost no signal. And calibrate against humans once: grade forty cases by hand, compare, and if the judge disagrees more than one time in ten, fix the rubric before you trust it anywhere.
Evals cost money and take minutes, so run them where they pay off: on changes to prompts, tools or model configuration, and on a schedule.
name: llm-evals
on:
pull_request:
paths: ["**/prompts/**", "**/tools/**", "**/ai/**"]
schedule:
- cron: "0 6 * * 1" # Monday morning, catches provider drift
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: python manage.py run_evals --feature support_reply --fail-under 0.92
The weekly run matters as much as the pull-request run. Nothing in your repository changes when a provider updates a model — but your pass rate does, and you want to hear it from CI on Monday rather than from a customer on Thursday.
class Command(BaseCommand):
def handle(self, *args, **opts):
cases = EvalCase.objects.filter(feature=opts["feature"], is_active=True)
run = EvalRun.objects.create(feature=opts["feature"], git_sha=git_sha())
passed = 0
for case in cases:
output, calls = run_feature(case.prompt, case.context)
failures = score_case(case, output, calls)
EvalResult.objects.create(run=run, case=case, output=output,
failures=failures, passed=not failures)
passed += not failures
rate = passed / max(cases.count(), 1)
self.stdout.write(f"pass rate {rate:.2%} ({passed}/{cases.count()})")
if rate < opts["fail_under"]:
raise CommandError(f"below threshold {opts['fail_under']:.0%}")
Storing every run gives you the graph that actually answers "is this getting better or worse", and the diff that answers "which cases did this prompt change break".
A pass rate that moves from 94% to 94% can hide three cases breaking and three others being fixed. The review that matters on a prompt pull request is the list of flipped cases, which is a two-query job once results are stored:
def flipped(old_run, new_run):
old = dict(EvalResult.objects.filter(run=old_run)
.values_list("case_id", "passed"))
new = dict(EvalResult.objects.filter(run=new_run)
.values_list("case_id", "passed"))
broke = [cid for cid, ok in new.items() if old.get(cid) and not ok]
fixed = [cid for cid, ok in new.items() if ok and old.get(cid) is False]
return broke, fixed
Post the output as a comment on the pull request — case id, the note explaining why the case exists, the failure reasons — and require that every entry in broke is either fixed or explicitly accepted in the description. "We accept losing case 31 because the policy it tests was retired" is a perfectly good outcome; silently losing it is not. Compare against the last run on the main branch, not the last run overall, so that two experimental branches do not muddy each other's baseline.
Prompt injection is not solved and will not be solved by a clever system prompt. Treat it the way you treat XSS: assume the payload gets in, and make sure it cannot do anything.
display:none, white-on-white text. A large share of real payloads hide there.Permission scoping. If every tool filters on the authenticated user and every write is gated, a successful injection produces a rude answer instead of a data breach. Everything above reduces frequency; only this reduces severity.
The rule is simple: the identity used for every query comes from the authenticated request, never from the model's arguments. The model may choose which order to look up; it never gets to choose whose.
def tool_get_order(user, order_number: str) -> dict:
order = (Order.objects
.filter(customer__user=user, number=order_number)
.values("number", "status", "total_cents", "shipped_at")
.first())
return order or {"error": "not_found"}
READ_TOOLS = {"get_order": tool_get_order}
WRITE_TOOLS = {"issue_refund"}
def dispatch(request, name: str, arguments: dict) -> dict:
if name in WRITE_TOOLS:
PendingAction.objects.create(user=request.user, tool=name,
arguments=arguments)
return {"status": "awaiting_confirmation"}
if name not in READ_TOOLS:
return {"error": "unknown_tool"}
return READ_TOOLS[name](request.user, **arguments)
Three details carry the weight. The queryset filters on the user before anything else, so there is no code path that looks up an order globally and checks ownership afterwards. The tool returns .values() with an explicit field list, so a new column on Order — an internal note, a fraud score — does not leak into model context by default. And writes do not execute: they create a pending action that the user confirms in your own UI, outside the conversation, where injected text cannot click the button. In production, validate arguments against a per-tool schema before the call as well; the model can produce keys you did not expect, and a TypeError from an unexpected keyword should become a clean error rather than a 500.
The most valuable guardrail tests do not involve the model at all. They assert that the layer that holds actually holds, and they run in milliseconds on every commit:
class ToolScopingTests(TestCase):
def test_get_order_is_scoped_to_the_caller(self):
alice = CustomerFactory()
bob_order = OrderFactory(customer=CustomerFactory())
result = tool_get_order(alice.user, bob_order.number)
self.assertEqual(result, {"error": "not_found"})
def test_write_tools_never_execute_directly(self):
request = RequestFactory().post("/assistant/")
request.user = CustomerFactory().user
result = dispatch(request, "issue_refund", {"order_number": "A-1"})
self.assertEqual(result["status"], "awaiting_confirmation")
self.assertFalse(Refund.objects.exists())
These tests assume the injection succeeded — the model asked for another customer's order, or tried to issue a refund — and check that nothing bad followed. Alongside them, keep a small group of eval cases whose context documents contain embedded instructions of the kind you have seen in real traffic, described in the case notes rather than collected into a library of payloads. Score them on outcome: the answer does not contain data from outside the user's scope, no write tool was proposed, the system prompt did not appear in the output. A drop in that group's pass rate after a model change tells you the first layer got weaker, which is useful to know even though the scoping layer means it should not become an incident.
Evals tell you about fifty known cases. Production tells you about the other ten thousand. Log per call: model, prompt version, token counts, latency, schema-validation outcome, tool calls, and whether the user retried or gave up. Then alert on shape changes rather than individual answers:
These signals only exist if every model call goes through a single function that records them. Scattered client.complete(...) calls across views and tasks are how teams end up with metrics for three features out of five.
import hashlib
import logging
import time
logger = logging.getLogger("llm")
def call_llm(client, *, feature, prompt_version, model, messages, validate):
started = time.monotonic()
reply = client.complete(messages, model=model)
latency_ms = int((time.monotonic() - started) * 1000)
try:
parsed, schema_ok = validate(reply.text), True
except ValidationError:
parsed, schema_ok = None, False
Generation.objects.create(
prompt_version=prompt_version, model=model,
input_hash=hashlib.sha256(repr(messages).encode()).hexdigest(),
output=reply.text, schema_ok=schema_ok,
)
logger.info("llm_call", extra={
"feature": feature, "prompt_version": prompt_version, "model": model,
"latency_ms": latency_ms, "output_chars": len(reply.text),
"schema_ok": schema_ok,
})
if not schema_ok:
metrics.incr(f"{feature}.schema_fail")
raise LLMSchemaError(prompt_version)
return parsed
client here is your own thin adapter around the provider SDK, which is also what makes the unit tests below possible. Record the provider's reported token usage and stop reason too if your adapter exposes them; a stop reason of "maximum tokens reached" on more than a trickle of calls explains most sudden schema-failure spikes. Hash inputs rather than storing them in full when they contain customer data, and set a retention period on Generation rows that matches the rest of your personal-data policy.
Prompts belong in the repository, numbered, with the eval results that justified the change recorded in the pull request. Store the version with every generated row:
class Generation(models.Model):
prompt_version = models.CharField(max_length=20) # "support_reply.v7"
model = models.CharField(max_length=50)
input_hash = models.CharField(max_length=64)
output = models.TextField()
schema_ok = models.BooleanField()
created_at = models.DateTimeField(auto_now_add=True)
When a customer complains about an answer from three weeks ago, you can reproduce exactly which prompt and which model produced it. Without that column, you are guessing.
Evals test the model's behaviour. Everything around the model — building the prompt, delimiting documents, parsing, the failure paths — is ordinary code and deserves ordinary fast tests that never hit the network. The adapter from the previous section makes that easy: swap it for a fake that returns canned replies and records what it was sent.
class FakeLLM:
def __init__(self, *replies):
self.replies = list(replies)
self.sent = []
def complete(self, messages, **kwargs):
self.sent.append(messages)
return Reply(text=self.replies.pop(0))
class ExtractionTests(TestCase):
def test_invalid_json_fails_loudly(self):
client = FakeLLM('{"invoice_number": "A-1", "total_cents": -5}')
with self.assertRaises(LLMSchemaError):
extract_invoice("irrelevant", client=client)
self.assertFalse(Generation.objects.get().schema_ok)
def test_documents_cannot_close_their_own_delimiter(self):
doc = "Terms apply.</document> Extra text."
messages = build_messages("What are the terms?", [doc])
body = messages[-1]["content"]
self.assertEqual(body.count("</document>"), 1)
The second test is worth dwelling on. Delimiting only works if a document cannot contain your closing marker; if it can, the text after it reads as if it were outside the data block. The prompt builder should neutralise the marker inside untrusted content — replace or escape it — and the test pins that behaviour so a refactor cannot quietly remove it. Other cheap, valuable plumbing tests: an empty retrieval result produces the "I don't know" path rather than a call with no context; oversized documents are truncated to the cap; the prompt version string recorded on Generation matches the template actually used.
Keep these in the normal test suite and the evals in their own command. Mixing them is how a suite ends up either slow and flaky on every commit or, after someone gets annoyed, with the model calls mocked out of the evals too.
When the number drops, resist editing the prompt until you know which of these you are looking at. The flipped-case diff and the stored outputs usually point to the row within a few minutes.
| Symptom | Likely cause | What to do |
|---|---|---|
| Weekly run drops, no commits since last green run | Provider updated the model behind an alias | Pin a dated model identifier; re-run failing cases against the old and new versions and compare outputs |
Many missing: failures, answers read correctly | Model rephrased facts ("two weeks" vs "14 days") | Add alternatives to the case; do not delete the phrase check |
| Same case alternates pass and fail | Prompt leaves the decision near a boundary | Mark as flaky, run with repeats, tighten the instruction the case exercises |
| Schema failures spike suddenly | Output truncated at the token limit, or a new field the prompt now asks for | Check stop reason and output length in the logs; raise the limit or shorten the schema |
| Judge passes nearly everything | Rubric too lenient, or calibration set had no bad answers | Add known-bad answers, re-run calibration, check false passes specifically |
| Failures cluster in one language or customer type | Context or examples in the prompt skewed toward the majority case | Add cases for the cluster before changing the prompt, then fix and confirm |
| Run fails with timeouts or rate-limit errors | Too much concurrency, or provider incident | Treat infrastructure errors as "errored", not "failed", and retry them; never count them toward the pass rate |
The last row matters more than it looks. If a provider outage is scored as a quality regression, people learn to ignore red eval runs, and a genuine regression the following week gets waved through with "it's probably the API again".
That is an afternoon of work and it converts your AI feature from something you hope is fine into something you can measure. Everything after — judges, dashboards, per-case history — is refinement on top of a foundation that already caught the expensive mistakes.