You ship an LLM feature. It works in staging. In production, the same prompt returns different answers — sometimes subtly, sometimes catastrophically. This is llm determinism in production: the gap between “it works once” and “it works every time.” You cannot eliminate variance entirely, but you can bound it. Here is the ordered path from chaos to control.
Lock the sampling parameters
Start with the obvious: temperature, top_p, and top_k. Set temperature to 0. Set top_p to 1 (or omit it). Set top_k to 0 (or omit it). This disables nucleus sampling and forces greedy decoding — the model picks the highest-probability token at each step.
{
"model": "gpt-4o-mini",
"messages": [...],
"temperature": 0,
"top_p": 1,
"top_k": 0
}
Pitfall: Some providers ignore top_k or treat 0 as “unlimited.” Read the provider docs. Anthropic, for example, only respects temperature and top_p. OpenAI respects all three. If you send top_k to a provider that doesn’t support it, you may get an error or silent ignore.
Tradeoff: Greedy decoding can produce repetitive or “stuck” outputs on some models, especially smaller ones. If you see loops, raise temperature to 0.1–0.2 — just enough to break ties without reintroducing meaningful variance.
Use a seed, but know its limits
Most major providers now accept a seed parameter. Use it. It locks the random number generator for sampling.
{
"model": "gpt-4o-mini",
"messages": [...],
"temperature": 0,
"seed": 42
}
What seed actually controls: The sampling RNG. That’s it. It does not control:
- Model weights (obviously)
- Provider-side batching or parallelism decisions
- Hardware non-determinism (GPU non-deterministic ops, race conditions in kernels)
- Provider infrastructure changes (model version bumps, quantization changes)
Pitfall: Seed reproducibility is best-effort. OpenAI documents that “determinism is not guaranteed.” Anthropic says the same. In practice, same seed + same prompt + same model version + same parameters = same output ~99% of the time. But a provider rolling out a new model build breaks the chain.
Action: Log the seed you used with every request. When outputs drift, you can reproduce the exact call.
Pin the model version explicitly
Never use aliases like gpt-4o or claude-3-5-sonnet-latest in production. They move. Pin to dated snapshots:
{
"model": "gpt-4o-2024-08-06",
"messages": [...]
}
{
"model": "claude-3-5-sonnet-20241022",
"messages": [...]
}
Pitfall: Providers deprecate dated models. You will get 404s eventually. Build a model registry in your code that maps logical names to pinned versions, with a deprecation watcher that alerts you when a pinned model approaches end-of-life.
# models.py
MODEL_REGISTRY = {
"primary": "gpt-4o-2024-08-06",
"fallback": "gpt-4o-mini-2024-07-18",
"reasoning": "o1-preview-2024-09-12",
}
DEPRECATION_DATES = {
"gpt-4o-2024-08-06": "2025-08-06", # hypothetical
}
Canonicalize your prompts
Two prompts that differ only in whitespace, trailing newlines, or system message ordering can produce different outputs. Treat prompts as immutable artifacts.
Do:
- Store prompts as versioned files (JSON, YAML, or .prompt files)
- Strip trailing whitespace from every message content
- Normalize line endings to
\n - Hash the rendered prompt and log the hash with every request
import hashlib
def canonicalize_messages(messages: list[dict]) -> list[dict]:
"""Return a copy with normalized whitespace."""
return [
{
"role": m["role"],
"content": m["content"].rstrip().replace("\r\n", "\n").replace("\r", "\n")
}
for m in messages
]
def prompt_hash(messages: list[dict]) -> str:
canonical = canonicalize_messages(messages)
serialized = json.dumps(canonical, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(serialized.encode()).hexdigest()[:16]
Pitfall: Templating engines (Jinja, f-strings) can introduce invisible differences — extra spaces after conditionals, different newline behavior. Render templates to a string, then canonicalize, then parse back to messages if your SDK requires structured input.
Control the context window
Dynamic context (RAG results, conversation history, tool outputs) is the biggest source of unintended variance. You cannot make RAG deterministic — the retrieval step is inherently non-deterministic. But you can bound the damage.
Strategies:
- Fix the retrieval seed if your vector store supports it (many do not).
- Cap context length explicitly. Don’t rely on the model’s max; set a hard token budget for retrieved docs.
- Sort retrieved chunks deterministically — by score descending, then by doc_id ascending.
- Log the exact context sent to the model (chunk IDs, scores, text hashes).
def build_context(query: str, chunks: list[Chunk], max_tokens: int = 3000) -> str:
# Sort deterministically
chunks = sorted(chunks, key=lambda c: (-c.score, c.doc_id))
# Pack until budget
context_parts = []
token_count = 0
for chunk in chunks:
chunk_tokens = count_tokens(chunk.text)
if token_count + chunk_tokens > max_tokens:
break
context_parts.append(f"[DOC:{chunk.doc_id}] {chunk.text}")
token_count += chunk_tokens
return "\n\n".join(context_parts)
Handle structured output with schemas, not prayers
If you need JSON, use the provider’s structured output feature (OpenAI’s response_format: { "type": "json_schema", ... }, Anthropic’s tool use with a JSON schema tool). Do not rely on prompt engineering alone.
{
"model": "gpt-4o-2024-08-06",
"messages": [...],
"temperature": 0,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "extraction",
"schema": {
"type": "object",
"properties": {
"entities": {
"type": "array",
"items": { "type": "string" }
}
},
"required": ["entities"],
"additionalProperties": false
},
"strict": true
}
}
}
Why strict matters: With strict: true, OpenAI constrains the tokenizer to only emit valid JSON per the schema. Without it, the model can still hallucinate keys or trailing commas.
Pitfall: Structured output adds latency (typically 100-300ms). Some complex schemas (deep nesting, many enums) hit provider limits. Test your schema against the provider’s validator before deploying.
Build a deterministic fallback chain
Providers go down. Models get deprecated. You need fallbacks that preserve determinism as much as possible.
Rule: Each fallback tier must have its own pinned model, its own seed, its own prompt version. Do not share seeds across models — a seed that works for GPT-4o produces garbage on Claude.
FALLBACK_CHAIN = [
{
"model": "gpt-4o-2024-08-06",
"seed": 42,
"prompt_version": "v3.1",
"params": {"temperature": 0, "top_p": 1},
},
{
"model": "claude-3-5-sonnet-20241022",
"seed": 12345,
"prompt_version": "v3.1-claude",
"params': {"temperature": 0, "top_p": 1},
},
{
"model": "gpt-4o-mini-2024-07-18",
"seed": 999,
"prompt_version": "v3.1-mini",
"params": {"temperature": 0, "top_p": 1},
},
]
async def generate_with_fallback(messages: list[dict]) -> GenerationResult:
for tier in FALLBACK_CHAIN:
try:
prompt = load_prompt(tier["prompt_version"])
rendered = render(prompt, messages)
response = await client.chat.completions.create(
model=tier["model"],
messages=rendered,
seed=tier["seed"],
**tier["params"],
)
return GenerationResult(
content=response.choices[0].message.content,
model=tier["model"],
seed=tier["seed"],
prompt_version=tier["prompt_version"],
fallback_tier=FALLBACK_CHAIN.index(tier),
)
except ProviderError as e:
log.warning(f"Tier {tier['model']} failed: {e}")
continue
raise AllProvidersFailed()
Tradeoff: Fallback outputs will differ. Log which tier succeeded. Your eval pipeline must test every tier.
Instrument for observability
You cannot debug determinism without data. Log every request with:
{
"request_id": "req_abc123",
"timestamp": "2024-01-15T10:30:45.123Z",
"model": "gpt-4o-2024-08-06",
"seed": 42,
"temperature": 0,
"top_p": 1,
"prompt_hash": "a1b2c3d4",
"prompt_version": "v3.1",
"context_hash": "e5f6g7h8",
"response_hash": "i9j0k1l2",
"latency_ms": 1240,
"finish_reason": "stop",
"usage": { "prompt_tokens": 1200, "completion_tokens": 340 }
}
Response hash is critical. Hash the raw completion text (after canonicalizing whitespace). When you see a new response hash for the same prompt hash + seed + model, you have detected drift.
Run regression tests on every deploy
Determinism is a property you test, not a property you assume. Build a golden set: 50-200 representative inputs with expected outputs (or at least expected response hashes).
# test_determinism.py
GOLDEN_CASES = [
{"input": "Extract entities: Apple announced iPhone 15", "expected_hash": "a1b2c3d4"},
{"input": "Summarize: The quarterly report shows...", "expected_hash": "e5f6g7h8"},
# ...
]
async def test_determinism():
for case in GOLDEN_CASES:
result = await generate_with_fallback([{"role": "user", "content": case["input"]}])
actual_hash = hashlib.sha256(result.content.encode()).hexdigest()[:8]
assert actual_hash == case["expected_hash"], (
f"Drift detected for '{case['input'][:50]}...': "
f"expected {case['expected_hash']}, got {actual_hash}. "
f"Model: {result.model}, seed: {result.seed}, tier: {result.fallback_tier}"
)
Run this in CI. Run it nightly against production (with a read-only API key). When it fails, you know exactly what changed.
Accept what you cannot control
Even with all of the above, you will see variance from:
- Provider infrastructure changes: Kernel updates, driver updates, hardware replacements
- Quantization changes: Providers silently swap FP16 → INT8 → FP8 serving stacks
- Batching non-determinism: Continuous batching reorders tokens across requests
- MoE routing: Mixture-of-experts models route tokens differently under load
Mitigation:
- Monitor response hash drift rate. Alert if >0.1% of requests with identical prompt_hash + seed + model produce a new response_hash.
- Keep a “shadow” model (previous generation) running for comparison.
- Design your product to tolerate minor output differences — semantic equivalence matters more than byte-for-byte identity.
Summary checklist
| Layer | Action | Verification |
|---|---|---|
| Sampling | temperature=0, top_p=1, top_k=0 | Unit test params sent |
| Seed | Fixed seed per model tier | Log seed with every request |
| Model | Pinned dated versions only | CI checks no aliases in code |
| Prompt | Versioned files, canonicalized | Prompt hash logged |
| Context | Deterministic sort, token cap | Context hash logged |
| Output | Structured output with strict schema | Schema validation in CI |
| Fallback | Per-tier config (model, seed, prompt) | Fallback tier logged |
| Observability | Request/response hashes, latency | Drift alert on hash mismatch |
| Regression | Golden set with expected hashes | Nightly + CI runs |
Determinism in production is not a single switch. It is a discipline: pin everything you can, log everything you pinned, test the hashes, and alert when they drift. The engineers who sleep well are the ones who built the instrumentation to know when the floor moved.