The model you call today is not the model you called last week. Providers update weights, tweak sampling logic, and adjust default system prompts without ever sending you a changelog. Your integration tests stay green because they assert on happy paths, so the edge cases drift silently into production. I have seen this happen twice this month alone, and both times the first signal came from an angry user, not a failing test.
This article shows how to build a cheap regression tripwire that runs on free model access and a free server. The detector compares fresh LLM responses against a frozen baseline using a simple n-gram overlap metric. You will end up with a scheduled job that flags behavioral drift before your users feel it.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Why Your Current Tests Miss Behavioral Drift
Exact string matching breaks on harmless rewording, while substring checks miss semantic changes. You need a middle ground: a similarity score that is cheap to compute and stable across trivial variation. Character n-gram overlap, normalized by the longer text, gives you exactly that. It measures how much of the output's βshapeβ has changed without needing expensive embeddings.
Ask yourself this: when was the last time you verified that a model still returns JSON for malformed input? Probably before the last upstream update. That is the gap this detector fills.
The Artifact: A Self-Contained Drift Checker
The following Python script reads a CSV with id and prompt columns, calls an OpenAI-compatible API, and compares the response to a stored baseline. It supports any endpoint, including MonkeyCode's free model endpoint.
import csv
import hashlib
import json
import os
import sys
import urllib.request
from collections import Counter
API_URL = os.getenv("LLM_API_URL", "https://api.monkeycode.example/v1/chat/completions")
API_KEY = os.getenv("LLM_API_KEY")
MODEL = os.getenv("LLM_MODEL", "default-model")
THRESHOLD = float(os.getenv("DRIFT_THRESHOLD", "0.75"))
BASELINE_FILE = "baseline.json"
def ngrams(text: str, n: int = 3) -> Counter:
text = text.lower()
return Counter(text[i:i+n] for i in range(len(text) - n + 1))
def overlap(a: str, b: str) -> float:
ca, cb = ngrams(a), ngrams(b)
if not ca or not cb:
return 0.0
common = sum((ca & cb).values())
total = max(sum(ca.values()), sum(cb.values()))
return common / total
def call_llm(prompt: str) -> str:
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.2,
"max_tokens": 500,
}
req = urllib.request.Request(
API_URL,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {API_KEY}"},
)
with urllib.request.urlopen(req) as resp:
return json.loads(resp.read().decode())["choices"][0]["message"]["content"]
def short_hash(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()[:12]
def load_baseline() -> dict:
if os.path.exists(BASELINE_FILE):
with open(BASELINE_FILE) as f:
return json.load(f)
return {}
def save_baseline(data: dict) -> None:
with open(BASELINE_FILE, "w") as f:
json.dump(data, f, indent=2)
def main(csv_path: str) -> None:
with open(csv_path) as f:
cases = list(csv.DictReader(f))
baseline = load_baseline()
report = []
for case in cases:
cid = case["id"]
prompt = case["prompt"]
output = call_llm(prompt)
digest = short_hash(output)
if cid not in baseline:
baseline[cid] = {"output": output, "hash": digest}
report.append({"id": cid, "status": "baseline", "similarity": 1.0})
continue
sim = overlap(baseline[cid]["output"], output)
status = "drift" if sim < THRESHOLD else "ok"
report.append({"id": cid, "status": status, "similarity": round(sim, 3)})
save_baseline(baseline)
with open("drift_report.json", "w") as f:
json.dump(report, f, indent=2)
for row in report:
print(row)
if any(r["status"] == "drift" for r in report):
sys.exit(1)
if __name__ == "__main__":
main(sys.argv[1])
Run it once to establish a baseline. Subsequent runs will compare every fresh output against that snapshot.
LLM_API_URL=https://api.monkeycode.example/v1/chat/completions \
LLM_API_KEY=your_token \
LLM_MODEL=your_model \
python drift_check.py golden_prompts.csv
The first run populates baseline.json and marks every case as baseline. Your next run will produce ok or drift verdicts. Exit code 1 on drift is enough to fail a CI job or trigger a notification.
Step-by-Step: From Golden Prompts to Alert
Step 1: Curate a Small Golden Set
Ten prompts that cover your real edge cases beat a hundred generic ones. Include malformed JSON, empty strings, very long context, and one prompt that demands exact output formatting. These are the first places drift shows up.
Step 2: Freeze the Baseline
Run the script after you have manually verified each output is correct. Commit baseline.json to version control. This gives you a recoverable point when an upstream change breaks something.
Step 3: Schedule the Check on a Free Server
A cron job that runs every six hours is plenty for most teams. The free server from MonkeyCode handles this workload easily, since each run makes only a handful of API calls.
0 */6 * * * cd /home/user/drift && /usr/bin/python3 drift_check.py golden_prompts.csv >> drift.log 2>&1
Step 4: Interpret the Report
One drifted case is suspicious. Two or more drifted cases across different prompts mean you have a genuine upstream regression. Pull the baseline and new outputs side by side. If the format changed, update your parser. If semantics changed, decide whether the new behavior is actually better.
Decision Table: Similarity Score vs. Action
| Similarity | Verdict | Recommended Action |
|---|---|---|
| β₯ 0.90 | ok | No action; keep monitoring |
| 0.75β0.89 | warn | Inspect the diff manually before the next deployment |
| < 0.75 | drift | Block the pipeline; notify the team; investigate root cause |
These thresholds are starting points. Tune them with your own prompts and historical outputs.
What This Detector Will Not Catch
N-gram overlap is blind to paraphrases that preserve meaning but change wording completely. It also cannot detect factual errors inside a confidently written answer. If the model says β2+2=5β with perfect grammar and identical structure, the similarity score stays high. This is a tripwire, not a judge.
You still need a small set of exact-match constraints for structured outputs like JSON schemas. And if you run with high temperature, expect false alarms. Keep the temperature low during these checks.
Who Should Not Use This Approach
Teams that rewrite prompts several times a day will spend more time updating baselines than catching drift. If your LLM call is a one-off script that never changes, the tripwire buys you little. And if you have no basic tests at all, build those first; drift detection is an extra layer, not a replacement.
Running the Whole Loop on MonkeyCode's Free Tier
MonkeyCode is an open-source project that offers free model access and a free server option for small experiments. The drift checker above fits comfortably within both. You get enough tokens for a daily cron job and a server that costs nothing to keep running. Exact quotas and model names change over time, so check the project's repository before relying on a specific number.
I kept the script dependency-free so you can drop it onto any free server without installing extra packages. The only external requirement is an OpenAI-compatible endpoint, which MonkeyCode provides.
Final Thought
Silent regressions are worse than loud failures because nobody knows who to blame. A 30-minute setup today can save you a late-night debugging session next month. Freeze your baseline, schedule the cron job, and treat every drift alert as a question to answer, not a notification to mute.
If you have been meaning to test MonkeyCode's free models and free server, this detector is a practical way to do both at once. Run it for a week; see what surprises show up. The tripwire will do its job, and you will finally have a number to quote when someone asks, βIs the model behaving the same as last week?β
Top comments (0)