DEV Community

Sam Li
Sam Li

Posted on

48-Hour Field Notes: The Prompt That Broke Without a Changelog

A prompt that returned clean JSON on Friday came back on Monday as a friendly sentence wrapped around the JSON. No changelog. No version bump in my config. The endpoint string was byte-for-byte identical, and that is exactly the problem: an alias is not a version.

So I spent 48 hours on one narrow question — can a solo developer notice a model swap before a user does, using nothing but a cron job and a git repo? Short answer: yes, if you stop diffing text and start tracking a small, stable set of prompt digests.

Hour 0-4: why diffing the raw output fails

My first instinct was the obvious one. Store yesterday's answers, compare today's, print the difference. That lasted about ninety minutes. An answer that says "30 seconds" one day and "30.0 seconds" the next is not a regression, and a diff tool will scream at both that and a genuine format break with identical volume.

The fix is normalization before hashing: collapse digits to a placeholder, squeeze horizontal whitespace, collapse blank-line runs, lowercase. You lose the ability to see what changed in the digest, but you gain a signal that only fires when the shape of the answer moves. Keep the raw outputs too, on disk, for the day the digest actually flips.

#!/usr/bin/env python3
"""canary.py - detect silent model drift on an OpenAI-compatible endpoint."""
import hashlib, json, os, re, urllib.request

BASE = os.environ["LLM_BASE_URL"].rstrip("/")
KEY = os.environ["LLM_API_KEY"]
MODEL = os.environ["LLM_MODEL"]
BASELINE = "baseline.json"
RUNS = 2  # two reads per prompt: separates flake from a real swap

NUM = re.compile(r"\d+(?:\.\d+)?")
WS = re.compile(r"[ \t]+")

def call(prompt: str) -> str:
    body = json.dumps({
        "model": MODEL,
        "messages": [{"role": "user", "content": prompt}],
        "temperature": 0,
    }).encode()
    req = urllib.request.Request(
        f"{BASE}/chat/completions",
        data=body,
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {KEY}"},
    )
    with urllib.request.urlopen(req, timeout=90) as resp:
        return json.loads(resp.read())["choices"][0]["message"]["content"]

def norm(text: str) -> str:
    text = NUM.sub("<n>", text)
    text = WS.sub(" ", text)
    text = re.sub(r"\n{2,}", "\n", text)
    return text.strip().lower()

def digest(text: str) -> str:
    return hashlib.sha256(norm(text).encode()).hexdigest()[:16]

def main() -> int:
    prompts = json.load(open("prompts.json"))
    baseline = json.load(open(BASELINE)) if os.path.exists(BASELINE) else {}
    report, drift = {}, []

    for pid, prompt in prompts.items():
        seen = [digest(call(prompt)) for _ in range(RUNS)]
        stable = seen[0] == seen[1]
        known = baseline.get(pid)
        if known is None:
            verdict = "new"
        elif not stable:
            verdict = "flaky"
        elif seen[0] == known:
            verdict = "stable"
        else:
            verdict = "drift"
        report[pid] = {"digests": seen, "baseline": known, "verdict": verdict}
        if verdict == "drift":
            drift.append(pid)

    os.makedirs("runs", exist_ok=True)
    run_id = os.environ.get("RUN_ID", "local")
    json.dump(report, open(f"runs/{run_id}.json", "w"), indent=2)
    print(json.dumps({k: v["verdict"] for k, v in report.items()}, indent=2))
    return 1 if drift else 0

if __name__ == "__main__":
    raise SystemExit(main())
Enter fullscreen mode Exit fullscreen mode

Two details earn their keep. temperature: 0 reduces variance but does not remove it, which is why every prompt is read twice. And the exit code is the whole monitoring story: 1 means something moved, 0 means nothing did, and cron does not care about your feelings.

Hour 4-12: choosing prompts that fail loudly

A canary suite is only as good as its worst prompt. I picked three classes, and three was enough. One prompt demands a strict JSON object with fixed keys, because schema drift is the failure that actually breaks a parser in production. One asks for a one-sentence summary of a changelog entry, because refusal wording and verbosity drift show up there first. One asks for arithmetic with the steps shown, because a swap toward a smaller or differently tuned model usually changes the shape of the reasoning before it changes the answer.

{
  "format_json": "Return only a JSON object with keys id, severity, owner for this log line: ERROR db timeout after 30s",
  "summary_floor": "Summarize in exactly one sentence: deprecate /v1/charge, add /v2/charge with same fields.",
  "arith_shape": "A queue is 12000 deep and draining at 40/s. Time to empty? Show the arithmetic."
}
Enter fullscreen mode Exit fullscreen mode

Run it once to seed the baseline, commit baseline.json next to the script, and never edit it by hand without a reason in the commit message. That file is the contract.

Hour 12-36: the run that has to survive a recycled box

Here is where an ephemeral free server changes the design. You cannot trust local disk to hold your history, so the repo has to be the memory. The cron line writes the run file, commits it, and pushes before the box can be reclaimed.

# every six hours, from the free server
17 */6 * * * cd ~/canary && \
  RUN_ID=$(date -u +%Y-%m-%dT%H) python3 canary.py >> runs.log 2>&1; \
  git add runs baseline.json && \
  git commit -qm "canary $RUN_ID" && git push -q origin main
Enter fullscreen mode Exit fullscreen mode

What you get back is a shape, not a screenshot. The output below is an illustrative template run — the verdict strings and exit code are real behavior of the script, but these particular digests are placeholders, so do not read them as measurements of any provider.

{
  "format_json": "drift",
  "summary_floor": "stable",
  "arith_shape": "flaky"
}
Enter fullscreen mode Exit fullscreen mode

That combination is the useful part. One prompt moved and held its new shape across both reads, which means the endpoint's behavior changed. A second flipped between reads, which means sampling noise and nothing else. If every prompt drifts at once, you are not chasing a model change, you are chasing a re-pointed alias, and the right move is to re-baseline and eyeball three raw samples instead of filing a bug.

Symptom Likely cause First move
format_json drifts, prose stable output template or schema change pin the contract, add one parse-and-retry
every prompt drifts together alias re-pointed to a new model re-baseline, diff three raw samples by hand
one prompt flips between reads sampling, not a swap raise RUNS, do not page anyone
everything reports new baseline never seeded or lost commit baseline.json, re-run

Hour 36-48: where the free tier actually fits

I ran the suite against free model access and let cron live on a free server option, which is the only reason a 48-hour experiment like this is affordable at all. The economics are simple: a canary suite is small, frequent, and boring, and boring traffic is exactly what you should not be paying metered rates for. I used MonkeyCode for both halves — the free model access behind the harness, and the free server holding the cron entry. I did not pin any specific model name in this article on purpose, because the harness only needs a base URL, a key, and a model string from the environment. The operator advertises a free allowance on the order of 10 million tokens plus the free server option; treat that as a claim to verify on the project page at the moment you sign up, since free-tier terms move.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The honest limitation: this workflow measures change, not correctness. A model can drift into a better answer and trip your alarm, and a model can degrade in a way your three prompts never touch. It is a smoke detector, not a unit test suite. Keep the prompt count small enough that a human reads every verdict.

Who should skip it: anyone whose output is contractually bound to a named model version with a vendor SLA, where the right instrument is the vendor's changelog and not a cron job. And anyone who has not yet written down what the output is supposed to look like — a canary with no expectation just records weather.

What I would repeat in the next 48 hours is the boring part. Normalize before hashing. Read twice before alarming. Commit the results off the box. If you run this against a free endpoint, the interesting question is not whether it drifts, but which of the three prompt classes flips first for you.

Top comments (0)