All postsAI

Ship prompts like code: versioned files, eval gates and alias rollback

Keep prompts in git with their model and schema, gate changes on sampled evals in CI, and roll back by moving an alias instead of redeploying.

Johnny Unar11 min read

01

a prompt change is a behaviour change

Once an LLM feature is in production, its prompt is business logic. Changing a sentence in it, swapping the model, raising the reasoning effort or loosening the output schema all change what customers get, often in ways that only show up on inputs nobody tried before merging. Yet prompts often live as strings scattered through handlers or as rows edited in an admin panel, with no review, no history and no quick way back.

The fix borrows three habits from ordinary software delivery. Prompts live in versioned files in git, together with everything that affects their behaviour, and a linter checks them. Every change runs an eval suite in CI that fails on explicit thresholds. And production resolves prompts through an alias in a small registry, so rolling back is a data change that takes effect in seconds. This post builds each piece in Python; the same shape works in any stack.

02

one file holds the whole contract

A prompt file should contain everything that changes the output: the model, its settings, the input variables, the output schema and the text itself. Then a pull request that changes behaviour shows up as a diff a reviewer can read.

yaml
1id: lead_qualifier
2version: 4
3provider: openai
4model: gpt-6-luna
5reasoning_effort: low
6max_output_tokens: 400
7owner: jana
8inputs: [company_name, website, notes]
9output_schema:
10 type: object
11 additionalProperties: false
12 required: [score, industry, employee_band, reason]
13 properties:
14 score: {type: string, enum: [low, medium, high]}
15 industry: {type: [string, "null"]}
16 employee_band: {type: string, enum: ["1-10", "11-50", "51-250", "251+", unknown]}
17 reason: {type: string, maxLength: 300}
18system: |
19 You qualify inbound B2B leads for a sales team that sells to companies
20 with 50 or more employees. If a field can't be determined from the input,
21 use null or "unknown" and say why in the reason.
22user_template: |
23 Company: {company_name}
24 Website: {website}
25 Notes: {notes}

There's no temperature in that file on purpose. OpenAI's GPT-6 models reject it at their default reasoning settings (the API returns "Unsupported parameter: 'temperature' is not supported with this model"), and Anthropic removed temperature, top_p and top_k in API version 2026-10-21. On current models the knobs that matter are the model ID, the reasoning effort and the output limit, so those are what the file pins.

The output schema is enforced by the provider rather than hoped for. OpenAI's Responses API takes it as text.format with type: "json_schema" and strict: true, and Anthropic's Messages API has the equivalent under output_config.format. That removes a whole class of tests about markdown fences and prose around the JSON, although your code should still validate the result, because a refusal or a truncated response won't match the schema. A call that turns the file into a request looks like this:

python
1from openai import OpenAI
2
3client = OpenAI()
4
5
6def call_model(prompt, inputs):
7 resp = client.responses.create(
8 model=prompt["model"],
9 reasoning={"effort": prompt["reasoning_effort"]},
10 max_output_tokens=prompt["max_output_tokens"],
11 instructions=prompt["system"],
12 input=prompt["user_template"].format(**inputs),
13 text={"format": {
14 "type": "json_schema",
15 "name": prompt["id"],
16 "schema": prompt["output_schema"],
17 "strict": True,
18 }},
19 )
20 return resp.output_text

A linter catches the mistakes that don't need a model call to find. This one checks for required keys, a moving alias in the model field, a schema that allows extra fields, template variables that don't match the declared inputs and a file name that doesn't match the version:

python
1import string
2import sys
3from pathlib import Path
4
5import yaml
6
7REQUIRED = {"id", "version", "provider", "model", "owner", "inputs", "output_schema", "system", "user_template"}
8FLOATING = {"gpt-6-luna-latest", "chat-latest", "gemini-flash-latest"}
9
10
11def lint(path):
12 p = yaml.safe_load(path.read_text())
13 errors = [f"missing key: {k}" for k in sorted(REQUIRED - p.keys())]
14 if p.get("model") in FLOATING:
15 errors.append(f"model {p['model']} is a moving alias")
16 if p.get("output_schema", {}).get("additionalProperties") is not False:
17 errors.append("output_schema must set additionalProperties: false")
18 used = {f for _, f, _, _ in string.Formatter().parse(p.get("user_template", "")) if f}
19 if used != set(p.get("inputs", [])):
20 errors.append(f"template uses {sorted(used)}, inputs declare {sorted(p.get('inputs', []))}")
21 if Path(path).stem != str(p.get("version")):
22 errors.append("file name must match version")
23 return errors
24
25
26failed = False
27for path in sorted(Path("prompts").glob("*/*.yaml")):
28 for err in lint(path):
29 print(f"{path}: {err}")
30 failed = True
31sys.exit(1 if failed else 0)

On a deliberately broken file it prints every problem and exits non-zero, which is all CI needs:

text
1prompts/bad/1.yaml: model chat-latest is a moving alias
2prompts/bad/1.yaml: output_schema must set additionalProperties: false
3prompts/bad/1.yaml: template uses ['a', 'b'], inputs declare ['a']
4prompts/bad/1.yaml: file name must match version

03

temperature 0 was never a determinism switch

Old prompt-testing advice says to set temperature to 0 and compare outputs with stored snapshots. Even on models that still accept the parameter, that doesn't make outputs repeatable. Horace He's write-up at Thinking Machines Lab from September 2025 sampled 1,000 completions at temperature 0 from Qwen3-235B and got 80 distinct ones, which diverged at the 103rd token. The cause is that GPU kernels give slightly different numerical results depending on batch size, and batch size depends on how busy the server is, which no API user controls. With batch-invariant kernels all 1,000 matched, but hosted APIs don't promise that.

So exact-string snapshots of paid model calls make a flaky suite that people learn to rerun until it's green. What holds up is asserting on the structured fields that matter, running each case several times and gating on the pass rate, and keeping the raw outputs of failures so a human can read them.

04

evals in ci with explicit thresholds

An eval case is an input plus the values each field is allowed to take. Real production inputs make the best cases, with personal data removed, and every bug a user reports should become one.

json
1{"id": "saas-us-400", "input": {"company_name": "Northwind Analytics", "website": "northwind.example", "notes": "VP Ops, 400 staff, wants a demo next week"}, "expect": {"score": ["high"], "employee_band": ["251+"]}}
2{"id": "solo-founder", "input": {"company_name": "Kit & Co", "website": "", "notes": "just me for now"}, "expect": {"score": ["low"], "employee_band": ["1-10"]}}
3{"id": "empty-notes", "input": {"company_name": "Acme Ltd", "website": "acme.example", "notes": ""}, "expect": {"employee_band": ["unknown"]}}

The thresholds go in a file next to the prompts, so a change to the bar is reviewed like any other change:

yaml
1lead_qualifier:
2 samples_per_case: 5
3 min_valid_rate: 0.99
4 min_pass_rate: 0.90
5 max_pass_rate_drop: 0.02

The runner calls the model for every case several times, validates against the schema, checks the expected fields and writes a JSON artifact for every failure. The gate compares the result with the absolute thresholds and with the pass rate of the version currently in production:

python
1import json
2from pathlib import Path
3
4import jsonschema
5import yaml
6
7
8def check(prompt, case, raw):
9 try:
10 out = json.loads(raw)
11 jsonschema.validate(out, prompt["output_schema"])
12 except (json.JSONDecodeError, jsonschema.ValidationError) as err:
13 return False, False, f"invalid output: {err}"
14 for field, allowed in case["expect"].items():
15 if out[field] not in allowed:
16 return True, False, f"{field}={out[field]!r}, expected one of {allowed}"
17 return True, True, ""
18
19
20def run_eval(prompt, cases, call_model, samples, artifacts=Path("artifacts")):
21 valid = passed = total = 0
22 for case in cases:
23 for i in range(samples):
24 raw = call_model(prompt, case["input"])
25 ok_schema, ok_fields, why = check(prompt, case, raw)
26 total += 1
27 valid += ok_schema
28 passed += ok_fields
29 if not ok_fields:
30 artifacts.mkdir(exist_ok=True)
31 (artifacts / f"{prompt['id']}-{case['id']}-{i}.json").write_text(json.dumps({
32 "prompt": f"{prompt['id']}@{prompt['version']}",
33 "model": prompt["model"],
34 "case": case["id"],
35 "input": case["input"],
36 "raw_output": raw,
37 "failure": why,
38 }, indent=2))
39 return {"valid_rate": valid / total, "pass_rate": passed / total, "samples": total}
40
41
42def gate(result, baseline, limits):
43 problems = []
44 if result["valid_rate"] < limits["min_valid_rate"]:
45 problems.append(f"valid_rate {result['valid_rate']:.3f} < {limits['min_valid_rate']}")
46 if result["pass_rate"] < limits["min_pass_rate"]:
47 problems.append(f"pass_rate {result['pass_rate']:.3f} < {limits['min_pass_rate']}")
48 if baseline and baseline["pass_rate"] - result["pass_rate"] > limits["max_pass_rate_drop"]:
49 problems.append(f"pass_rate fell from {baseline['pass_rate']:.3f} to {result['pass_rate']:.3f}")
50 return problems

The suite itself is a normal pytest file. In CI the call_model argument is the real client above. Here it's a stub that returns an incomplete answer with chatter in front of the JSON once in fifteen calls, which is enough to fail the 0.99 valid-output threshold and to check that the failure artifact contains what someone debugging it needs:

python
1import itertools
2import json
3from pathlib import Path
4
5import yaml
6
7from run_eval import gate, run_eval
8
9PROMPT = yaml.safe_load(Path("prompts/lead_qualifier/4.yaml").read_text())
10CASES = [json.loads(line) for line in Path("evals/lead_qualifier.jsonl").read_text().splitlines()]
11LIMITS = yaml.safe_load(Path("thresholds.yaml").read_text())["lead_qualifier"]
12
13ANSWERS = {
14 "Northwind Analytics": '{"score": "high", "industry": "software", "employee_band": "251+", "reason": "400 staff"}',
15 "Kit & Co": '{"score": "low", "industry": null, "employee_band": "1-10", "reason": "solo founder"}',
16 "Acme Ltd": '{"score": "medium", "industry": null, "employee_band": "unknown", "reason": "no data"}',
17}
18flaky = itertools.cycle([False] * 14 + [True])
19
20
21def fake_model(prompt, inputs):
22 if next(flaky):
23 return 'Sure! {"score": "high"}'
24 return ANSWERS[inputs["company_name"]]
25
26
27def test_gate_fails_on_flaky_candidate(tmp_path):
28 result = run_eval(PROMPT, CASES, fake_model, LIMITS["samples_per_case"], tmp_path)
29 assert result["samples"] == 15
30 problems = gate(result, baseline={"pass_rate": 1.0}, limits=LIMITS)
31 assert any("valid_rate" in p for p in problems)
32 artifact = json.loads(next(tmp_path.glob("*.json")).read_text())
33 assert artifact["prompt"] == "lead_qualifier@4"
34 assert artifact["failure"].startswith("invalid output")

Running the real suite costs money and takes minutes, so trigger it only on pull requests that touch the prompts, the eval cases or the model client, and upload the artifacts when it fails:

yaml
1name: prompt-evals
2on:
3 pull_request:
4 paths: ["prompts/**", "evals/**", "thresholds.yaml", "call_model.py"]
5jobs:
6 evals:
7 runs-on: ubuntu-latest
8 steps:
9 - uses: actions/checkout@v7
10 - uses: astral-sh/setup-uv@v10
11 - run: uv run lint_prompts.py
12 - run: uv run pytest -q
13 env:
14 OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
15 - uses: actions/upload-artifact@v7
16 if: failure()
17 with:
18 name: eval-failures
19 path: artifacts/

Pick the number of samples per case with the cost in front of you. Five samples of 200 cases is 1,000 calls per run, which is cheap on a small model and noticeable on a large one, and it's the reason the trigger paths above are narrow.

05

contracts for retrieval and attribution

In a retrieval-augmented feature, most regressions come from the retrieval side (a new chunker, a re-embedded corpus, changed index settings) while the generated answers still read fluently. Test that boundary directly, against a fixed corpus version, without calling the generating model at all. Three contracts catch most breakage: the expected document appears in the top k results, the offsets on each hit point at exactly the text the hit carries, and every citation in an answer quotes text from a chunk that was actually retrieved.

python
1import json
2from pathlib import Path
3
4import pytest
5
6GOLDEN = [json.loads(line) for line in Path("evals/retrieval.jsonl").read_text().splitlines()]
7
8
9@pytest.mark.parametrize("case", GOLDEN, ids=lambda c: c["expected_doc"])
10def test_expected_document_in_top_k(retriever, case):
11 hits = retriever.search(case["query"], top_k=case["top_k"])
12 ranked = [h.document_id for h in hits]
13 assert case["expected_doc"] in ranked, f"got {ranked}"
14
15
16@pytest.mark.parametrize("case", GOLDEN, ids=lambda c: c["expected_doc"])
17def test_offsets_point_at_source_text(retriever, corpus, case):
18 for h in retriever.search(case["query"], top_k=case["top_k"]):
19 assert corpus.document_text(h.document_id)[h.start:h.end] == h.text
20
21
22def test_citations_quote_retrieved_chunks(retriever, corpus, answer_with_citations):
23 question = GOLDEN[0]["query"]
24 hits = retriever.search(question, top_k=3)
25 _, citations = answer_with_citations(question, hits)
26 retrieved = {(h.document_id, h.chunk_id) for h in hits}
27 assert citations, "answer has no citations"
28 for c in citations:
29 assert (c.document_id, c.chunk_id) in retrieved, f"{c.chunk_id} was never retrieved"
30 assert c.quote in corpus.chunk(c.document_id, c.chunk_id).text, f"quote not in {c.chunk_id}"

Assert on rank rather than on absolute similarity scores, because any change to the embedding model shifts the scores even when the ranking is fine. When the attribution contract fails, the message should show the claim and the chunk side by side, as pytest does here for an answer that cites a refund policy saying the opposite of what it claims:

text
1> assert c.quote in corpus.chunk(c.document_id, c.chunk_id).text, f"quote not in {c.chunk_id}"
2E assert 'refunds are unavailable after renewal' in ' A customer may request a refund within 30 days of renewal.'

Tie the golden retrieval file to a corpus version that records the extractor, chunker, embedding model and index settings. When a test fails after a rebuild, you then know the rebuild caused it and can diff the chunks instead of arguing about whether the prompt or the provider changed.

06

aliases make rollback a data change

The last piece is how production finds the prompt. Store every released version in a table, and let the application ask for lead_qualifier@production, where production is an alias pointing at a version. Moving the alias is the deploy, moving it back is the rollback, and a log table records who did which.

sql
1create table prompt_versions (
2 prompt_id text not null,
3 version integer not null,
4 model text not null,
5 body jsonb not null,
6 git_sha text not null,
7 created_at timestamptz not null default now(),
8 primary key (prompt_id, version)
9);
10
11create table prompt_aliases (
12 prompt_id text not null,
13 alias text not null,
14 version integer not null,
15 updated_at timestamptz not null default now(),
16 updated_by text not null,
17 primary key (prompt_id, alias),
18 foreign key (prompt_id, version) references prompt_versions (prompt_id, version)
19);
20
21create table prompt_alias_log (
22 id bigint generated always as identity primary key,
23 prompt_id text not null,
24 alias text not null,
25 from_version integer,
26 to_version integer not null,
27 changed_at timestamptz not null default now(),
28 changed_by text not null
29);
python
1import time
2
3TTL_SECONDS = 30
4_cache = {}
5
6
7def resolve(conn, prompt_id, alias="production"):
8 key = (prompt_id, alias)
9 hit = _cache.get(key)
10 if hit and hit[0] > time.monotonic():
11 return hit[1]
12 row = conn.execute(
13 """
14 select v.version, v.model, v.body
15 from prompt_aliases a
16 join prompt_versions v using (prompt_id, version)
17 where a.prompt_id = %s and a.alias = %s
18 """,
19 (prompt_id, alias),
20 ).fetchone()
21 if row is None:
22 raise LookupError(f"{prompt_id}@{alias} is not set")
23 resolved = {"version": row[0], "model": row[1], **row[2]}
24 _cache[key] = (time.monotonic() + TTL_SECONDS, resolved)
25 return resolved
26
27
28def point_alias(conn, prompt_id, alias, version, who):
29 with conn.transaction():
30 old = conn.execute(
31 "select version from prompt_aliases where prompt_id = %s and alias = %s for update",
32 (prompt_id, alias),
33 ).fetchone()
34 conn.execute(
35 """
36 insert into prompt_aliases (prompt_id, alias, version, updated_by)
37 values (%s, %s, %s, %s)
38 on conflict (prompt_id, alias)
39 do update set version = excluded.version, updated_at = now(), updated_by = excluded.updated_by
40 """,
41 (prompt_id, alias, version, who),
42 )
43 conn.execute(
44 "insert into prompt_alias_log (prompt_id, alias, from_version, to_version, changed_by) values (%s, %s, %s, %s, %s)",
45 (prompt_id, alias, old[0] if old else None, version, who),
46 )

Pointing production at version 4, then back at version 3, resolves to the matching model and text each time, leaves an audit trail and refuses a version that was never released, because of the foreign key:

text
1{'version': 4, 'model': 'gpt-6-luna', 'system': 'v4 system'}
2{'version': 3, 'model': 'gpt-5.4-mini-2026-03-17', 'system': 'v3 system'}
3[(None, 4, 'ci'), (4, 3, 'jana')]
4rejected unknown version

With a 30-second cache per process, a rollback reaches every worker within half a minute without a build or a container restart. The CI pipeline inserts the version row when the eval gate passes, and a person or a script moves the alias. Log the resolved prompt version, the configured model and the model field the provider returns on every request, so that when someone reports a bad answer you can say exactly which prompt and model produced it. A second alias such as candidate, served to a small share of traffic, gives you a canary with the same mechanism, and a kill switch that points the feature at a rules-based fallback is worth having for flows where a wrong answer writes bad data downstream.

Thanks for reading.
Johnny Unar

Written by

Johnny Unar

Keep reading

Stop patching.Start building.

Tell us what's slowing you down. We'll bring coffee and opinions.