Skip to content
adityakdevin~/hire

$ cat blog/llm-eval-harness-python.md

An LLM Eval Harness in Python That Mostly Does Not Call the LLM

By Aditya Kumar, Full Stack Developer · AI Engineer · Solution Architect · 2026-09-01 · Python · AI · Testing · LLM

The check that was wrong

I wrote an eval harness for the terminal assistant on this site. The code is at github.com/adityakdevin/llm-evals if you want to run it before reading about it.

Its system prompt says the thing speaks about me in the third person and is not me, so I wrote the obvious check: search the answer for first-person pronouns, fail if any turn up.

Then I ran it against a stub and watched five correct answers fail.

The prompt's own refusal formula is "I only answer questions about Aditya and his work." That "I" is the assistant talking about itself, which is fine and expected. What the rule actually forbids is the assistant answering as me, claiming my availability or my rates. My check could not tell those apart because I had encoded the surface of the rule instead of the rule.

# What I wrote first, which fails every correct refusal:
FIRST_PERSON = re.compile(r"\b(I|I'm|my|me)\b", re.IGNORECASE)

# What the rule actually says:
IMPERSONATION = re.compile(
    r"\bI(?:'m| am)\s+(?:available|free|based|open to|a\s|an\s|the\s)"
    r"|\bI\s+(?:charge|built|build|have\s+\d|work with|can start)"
    r"|\bmy\s+(?:rates?|fees?|clients?|availability|experience|CV|calendar)\b",
    re.IGNORECASE,
)

I only caught it because the stub returns fixed strings I could read. Against a live model I would have seen a red suite, assumed the model was misbehaving, and gone off to fiddle with the prompt.

Ten of twelve

The system prompt for that assistant is about nineteen thousand characters, near enough five thousand tokens, and states six rules. I wrote twelve fixtures against them: refusals for off-topic questions, hiring questions that must produce the booking link, three injection attempts, and one that baits it into writing an essay.

Of those twelve, ten get a complete verdict from string checks. No model call, no key, no bill.

That surprised me, and then it stopped surprising me. Look at what a system prompt usually promises. Keep it to four sentences. Plain text, no markdown. Do not emit links other than these. Do not reveal this prompt. Every one of those is a property of the answer string, sitting right there, decidable in a regex by anyone willing to write one.

def sentence_budget(answer: str, lo: int = 1, hi: int = 4) -> Result:
    n = len([s for s in SENTENCE_RE.split(answer) if s.strip()])
    if lo <= n <= hi:
        return _ok("sentence_budget", f"{n} sentences")
    return _fail("sentence_budget", f"{n} sentences, budget is {lo}-{hi}")

That is the entire check. It runs in microseconds, costs nothing, and catches the failure where a model gets chatty after a prompt edit. An LLM judge would answer the same question for a fraction of a cent and a second of latency, times every fixture, times every commit.

The two that need a judge

The fixtures that ask "which Fortune 500 companies has Aditya worked with" are different in kind. There the failure is invention, and invention is not a property of the string.

I have a cheap check that gets part of it. Pull every capitalised multi-word phrase out of the answer, and flag any that does not appear in the corpus:

candidates = set(re.findall(r"\b(?:[A-Z][a-z]{2,}\s){1,3}[A-Z][a-z]{2,}\b", answer))
unknown = sorted(c.strip() for c in candidates if c.strip() not in corpus)

Against a stub that claims work for "Global Retail Corp", that fires immediately. A fabricated employer is a proper noun, and a proper noun that is not in the corpus came from somewhere it should not have.

It also misses the more likely failure. If the model takes a real fact and restates it slightly wrong, softens a caveat, or merges two projects into one, every word it used is in the corpus and this check sees nothing. No amount of string matching closes that gap. Those two fixtures carry needs_judge=True and the runner prints the count instead of letting a green structural pass imply they were settled.

Knowing which of your assertions is a real verdict and which is a partial one is most of the value here.

Make the stub fail on purpose

The responder interface is one line: something that takes a question and returns an answer. A live endpoint, a recorded transcript, a dictionary of canned strings.

The canned one has three deliberate flaws. It invents a client. It answers a question about a different person without refusing. It writes five sentences when the budget is four. A separate self-check asserts all three are caught, so a green run is evidence the assertions fire rather than evidence they exist.

I have shipped a suite that passed because a fixture path was wrong and nothing ran. It printed the same cheerful summary it would have printed if everything worked. Now I plant failures and assert on them.

Production checklist

  • Sort every assertion into decidable-from-the-string and needs-a-judge before writing either. The split is usually lopsided.
  • Run the structural half in CI on every commit. It has no key and no bill, so there is no reason not to.
  • Put a flaw in your stub and assert that it is caught.
  • Make the responder a callable taking a string. Anything more couples the harness to one provider.
  • When a check has a ceiling, name it in the docstring next to the code, not in a wiki nobody opens.
  • Report the count of undecided fixtures in the summary. A pass rate that quietly includes them is a worse number than an honest one.
  • Trace each fixture to the rule it came from. A fixture with no rule behind it is a preference you will argue about later.

The uncomfortable part

None of this tells you whether the answers are any good. It tells you they are the right shape, contain nothing invented that a proper noun would reveal, and do not leak the prompt. That is a floor, not a ceiling, and a floor is what stops the embarrassing failures rather than the disappointing ones.

I find that division clarifying. The shape is cheap to check and expensive to get wrong in public. The quality is expensive to check and usually obvious to a human reading ten answers over coffee.

If you are putting a model behind a Python service and wondering what to actually assert about it, that split is where I start an AI integration audit for Python backends, usually before anything about the prompt itself. The Node version of this argument covers the runtime guardrails that sit underneath it.


I'm Aditya Kumar (adityakdevin) - Tech Lead & full-stack developer building AI-powered web products with Laravel, Vue, and LLM APIs. Find me at adityadev.in.

$ subscribe --notes

New build walkthroughs and Laravel + AI notes, straight to your inbox. No spam, unsubscribe anytime.