cd /news/large-language-models/cheap-model-first-strong-model-on-fa… · home topics large-language-models article
[ARTICLE · art-94766] src=dev.to ↗ pub= topic=large-language-models verified=true sentiment=· neutral

Cheap Model First, Strong Model on Failure: Building an Auditable Two-Tier LLM Pipeline

A developer has built an auditable two-tier LLM pipeline that routes work to a cheap model by default and escalates to a stronger model only when an external deterministic check fails. The implementation, written in Python using only the standard library and requests, logs every routing decision to a JSONL audit file. The design explicitly avoids using an LLM as a judge, relying instead on test suites, JSON schema validators, or regex patterns for validation.

read4 min views1 publishedAug 13, 2026

There's a pattern I see every release cycle: a new budget-friendly model ships, the discourse explodes with hot takes, and within 48 hours half my feed has declared it a drop-in replacement for everything. The claim might even be true. But here's what nobody posting those takes can tell you: whether it's true for your specific workload. And most of the time, that's the only question that matters.

A healthier mental model: stop treating model selection as a one-time shopping decision and start treating it as a runtime policy. Route work to the inexpensive option by default, check the output with something that isn't a model, and only pay for the heavyweight option when the check fails. Below is a working implementation of that policy, plus the measurement discipline that turns it from a hunch into an auditable system.

My configuration is deliberately boring:

On the cost side, one note: I iterate on this pipeline using MonkeyCode, which at the time of writing provides free model access along with a free server option, so experimentation doesn't rack up a bill. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The pipeline itself is provider-agnostic — anything speaking the OpenAI-compatible chat API drops in, so treat endpoints as configuration, not commitment.

The single most important design decision: never let a model decide whether its own output (or a peer's) was good enough. LLMs report confidence regardless of correctness. Instead, escalate only when an external, deterministic check fails — a test suite, a parser, a schema validator, a diff against expected output.

Here's a compact implementation. Standard library plus requests

, fully rerunnable, and every routing decision gets written to a JSONL audit log:

import hashlib, json, subprocess, time
from dataclasses import dataclass, asdict

import requests

@dataclass
class RouteRecord:
    job_id: str
    lane: str            # "A" or "B"
    fell_back: bool
    check_ok: bool
    seconds: float
    prompt_fingerprint: str

def chat(endpoint: str, model: str, prompt: str) -> str:
    resp = requests.post(
        f"{endpoint}/v1/chat/completions",
        json={
            "model": model,
            "messages": [{"role": "user", "content": prompt}],
            "temperature": 0.2,
        },
        timeout=120,
    )
    resp.raise_for_status()
    return resp.json()["choices"][0]["message"]["content"]

def objective_check(job: dict, candidate: str) -> bool:
    """Deterministic validation only. No LLM-as-judge allowed here."""
    mode = job["check"]
    if mode == "pytest":
        path = job["write_to"]
        with open(path, "w") as fh:
            fh.write(strip_fences(candidate))
        run = subprocess.run(job["command"], shell=True,
                             capture_output=True, timeout=90)
        return run.returncode == 0
    if mode == "valid_json":
        try:
            parsed = json.loads(strip_fences(candidate))
        except json.JSONDecodeError:
            return False
        required = job.get("required_keys", [])
        return all(k in parsed for k in required)
    if mode == "regex":
        import re
        return re.fullmatch(job["pattern"], candidate.strip()) is not None
    raise ValueError(f"unsupported check: {mode}")

def strip_fences(text: str) -> str:
    """Pull code out of a markdown fence if present; else return as-is."""
    if "```

" not in text:
        return text
    block = text.split("

```")[1]
    lines = block.splitlines()
    if lines and lines[0].strip().isalpha():  # language tag line
        lines = lines[1:]
    return "\n".join(lines)

def route(job: dict, lanes: list[dict], log_path: str = "routes.jsonl") -> RouteRecord:
    fp = hashlib.sha256(job["prompt"].encode()).hexdigest()[:12]
    fell_back = False
    for idx, lane in enumerate(lanes):
        start = time.time()
        candidate = chat(lane["endpoint"], lane["model"], job["prompt"])
        elapsed = round(time.time() - start, 2)
        passed = objective_check(job, candidate)
        if passed or idx == len(lanes) - 1:
            record = RouteRecord(
                job_id=job["id"],
                lane=lane["label"],
                fell_back=fell_back,
                check_ok=passed,
                seconds=elapsed,
                prompt_fingerprint=fp,
            )
            with open(log_path, "a") as fh:
                fh.write(json.dumps(asdict(record)) + "\n")
            return record
        fell_back = True

Wiring it up:

lanes = [
    {"label": "A", "endpoint": "https://lane-a-endpoint", "model": "current-budget-model"},
    {"label": "B", "endpoint": "https://lane-b-endpoint", "model": "premium-model"},
]

job = {
    "id": "csv-to-json-migration-014",
    "prompt": (
        "Convert the transformation in migrate.py so it emits newline-delimited JSON. "
        "Return only the complete updated file inside a code fence."
    ),
    "check": "pytest",
    "write_to": "migrate.py",
    "command": "python -m pytest tests/test_migrate.py -q",
}

print(route(job, lanes))

The router code is maybe a weekend of effort. The compounding value lives in routes.jsonl

. After a few weeks of real traffic you can compute things that are otherwise pure speculation:

fell_back

. If data-formatting jobs pass on Lane A 92% of the time, Lane A is a rational default there. If multi-file refactors fail 60% of the time, Lane A is a false economy for that family — you're paying for a doomed first attempt plus added latency on most calls.Operating rules I'd insist on:

If most of your workload is unverifiable generation, or everything you run is on a hard latency budget, honestly — skip the router. Picking the strong model outright is the simpler and more correct engineering decision in that world.

Pull your last ~50 real prompts, sort them into "has a deterministic check" versus "doesn't," and push the checkable subset through the two-lane setup for a week. If you want the measurement phase to cost nothing, MonkeyCode's free model access and free server option work fine as Lane A and host while you gather data — and since the log format is provider-neutral, whatever you learn transfers when you point the lanes elsewhere.

The question "is the cheap model good enough?" has an answer, and it's already sitting in your prompt history. Measure it; don't outsource the decision to launch-week sentiment.

── more in #large-language-models 4 stories · sorted by recency
── more on @monkeycode 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/cheap-model-first-st…] indexed:0 read:4min 2026-08-13 ·