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.
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
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 / cases.count()
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".
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.
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:
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.
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.