Module 5: Structured Logging for AI Systems
6. Debugging Non-Deterministic Systems
Description
When a deterministic system fails, you can reproduce it: same input → same output → same error. When an LLM system fails, this isn't automatically true: the same input can produce different outputs. This capsule tackles the central challenge of AI debugging: how to capture enough context to reproduce a problematic request, and how to combine seed, temperature=0, and reproducibility logging to transform a non-deterministic system into something deterministic enough to be debuggable.
The challenge of non-deterministic debugging
Deterministic system:
→ User: "Error with input X"
→ Dev: "I reproduce with input X"
→ Result: Same error, reproducible, debuggable
→ Time to reproduce: 30 seconds
Non-deterministic LLM system:
→ User: "The response was weird with input Y"
→ Dev: "I run with input Y"
→ Result: Completely different response, normal
→ "Which model was used exactly? What temperature? Was there any accumulated context?"
→ "I don't remember, that was yesterday..."
→ Time to reproduce: impossible without logs
Without reproducibility logging:
→ The bug exists, but you can't reproduce it
→ You can't debug it
→ You can't write a test to prevent regressions
→ You don't know when it's "fixed"
With reproducibility logging:
→ The bug occurred
→ You have the exact prompt, model, temperature, seed
→ You can reproduce the exact context (or a very similar one)
→ You can experiment with fixes
→ You can write a test that captures the case
What to save to be able to reproduce
# src/reproducibility.py
from dataclasses import dataclass, field
from typing import Optional, List
import hashlib
import json
@dataclass
class LLMCallContext:
"""
Complete context of an LLM call for reproducibility.
Captures everything needed to re-run the request under similar conditions.
"""
# Identification
request_id: str
# Model parameters
model: str
temperature: float
max_tokens: int
seed: Optional[int] = None
# The prompt (careful with PII)
messages: List[dict] = field(default_factory=list)
# Result
raw_response: Optional[str] = None
finish_reason: Optional[str] = None # "stop", "length", "content_filter"
# Metrics
input_tokens: int = 0
output_tokens: int = 0
duration_ms: float = 0.0
@property
def messages_hash(self) -> str:
"""Hash of the prompt for identification without exposing the content."""
content = json.dumps(self.messages, sort_keys=True)
return hashlib.sha256(content.encode()).hexdigest()[:16]
@property
def response_hash(self) -> Optional[str]:
"""Hash of the response to detect changes."""
if self.raw_response is None:
return None
return hashlib.sha256(self.raw_response.encode()).hexdigest()[:16]
def to_reproduction_script(self) -> str:
"""
Generates a Python script that can re-run the request.
Useful for offline debugging.
"""
return f"""
# Reproduction of request {self.request_id}
# Automatically generated from the reproducibility log
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="{self.model}",
messages={json.dumps(self.messages, indent=4, ensure_ascii=False)},
temperature={self.temperature},
max_tokens={self.max_tokens},
seed={self.seed},
)
print(response.choices[0].message.content)
"""
How and when to log for reproducibility
# src/llm_wrapper.py (with reproducibility logging)
import structlog
from src.reproducibility import LLMCallContext
log = structlog.get_logger()
def call_llm_with_reproducibility(
client,
model: str,
messages: list,
temperature: float = 0.0,
max_tokens: int = 500,
seed: Optional[int] = None,
request_id: str = None,
debug_mode: bool = False
) -> tuple:
"""
LLM call with reproducibility logging.
Always:
- Hash of the prompt (to identify without exposing)
- model, temperature, max_tokens, seed
- finish_reason (truncated? stop?)
Only on ERROR:
- Truncated prompt (first 500 chars)
Only in DEBUG mode:
- Full prompt (careful with PII)
"""
ctx = LLMCallContext(
request_id=request_id or "unknown",
model=model,
temperature=temperature,
max_tokens=max_tokens,
seed=seed,
messages=messages
)
start = time.time()
try:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
seed=seed
)
ctx.raw_response = response.choices[0].message.content
ctx.finish_reason = response.choices[0].finish_reason
ctx.input_tokens = response.usage.prompt_tokens
ctx.output_tokens = response.usage.completion_tokens
ctx.duration_ms = (time.time() - start) * 1000
# INFO: hash of the context for traceability without exposing content
log.info(
"llm_call_completed",
model=ctx.model,
temperature=ctx.temperature,
seed=ctx.seed,
messages_hash=ctx.messages_hash,
response_hash=ctx.response_hash,
finish_reason=ctx.finish_reason,
input_tokens=ctx.input_tokens,
output_tokens=ctx.output_tokens,
duration_ms=round(ctx.duration_ms, 1)
)
# WARNING: if the response was truncated by max_tokens
if ctx.finish_reason == "length":
log.warning(
"response_truncated",
reason="max_tokens reached",
max_tokens=ctx.max_tokens,
output_tokens=ctx.output_tokens,
messages_hash=ctx.messages_hash
)
# DEBUG: full prompt (only if enabled)
if debug_mode:
# Only the first 500 chars to reduce size even in debug
prompt_preview = ""
if messages:
last_msg = messages[-1].get("content", "")
prompt_preview = last_msg[:500]
if len(last_msg) > 500:
prompt_preview += f"...[{len(last_msg)} total chars]"
log.debug(
"llm_reproducibility_context",
model=ctx.model,
temperature=ctx.temperature,
seed=ctx.seed,
max_tokens=ctx.max_tokens,
prompt_preview=prompt_preview,
response_preview=(ctx.raw_response or "")[:300]
)
return response, ctx
except Exception as e:
ctx.duration_ms = (time.time() - start) * 1000
# ERROR: include more context because we need to debug
prompt_preview = ""
if messages:
last_msg = messages[-1].get("content", "")
prompt_preview = last_msg[:300] # More context on error
log.error(
"llm_call_failed",
error_type=type(e).__name__,
error_message=str(e)[:300],
model=ctx.model,
temperature=ctx.temperature,
seed=ctx.seed,
messages_hash=ctx.messages_hash,
prompt_preview=prompt_preview, # On error: more context
duration_ms=round(ctx.duration_ms, 1)
)
raise
seed: the parameter that (almost) makes the LLM deterministic
# What does seed do?
# When you specify seed, the model should produce the same response
# for the same input and the same parameters.
# OpenAI calls it "reproducibility" and the system_fingerprint property
# confirms when the model's underlying implementation didn't change.
import openai
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What is the capital of France?"}],
temperature=0.0, # temperature=0 reduces variance
seed=42 # seed makes the response reproducible
)
# system_fingerprint indicates the model version:
fingerprint = response.system_fingerprint
# "fp_13c70b9f70" — if this changes, the response may change even if seed is the same
# Log the fingerprint:
log.info("llm_call_completed",
...,
seed=42,
system_fingerprint=response.system_fingerprint
)
# If the fingerprint changes between two calls with the same seed,
# the response may have changed not because of your code but because of an
# update to the model on OpenAI's server.
# Important: seed does NOT guarantee 100% reproducibility
# → If system_fingerprint changes, the response may change
# → It's "best effort" according to OpenAI
# → For debugging it works very well, not for absolute guarantees
temperature: the variance control
# How temperature affects reproducibility:
# temperature=0.0: Completely greedy — always picks the most probable token
# → Very high reproducibility (almost always the same response)
# → Good for: classification, extraction, structured analysis
# → Bad for: creative generation, brainstorming
# temperature=0.3: Low but with some variance
# → Good for: summaries, responses with some flexibility
# → Moderate reproducibility
# temperature=0.7 (default for many models): High variance
# → For: creative generation
# → Low reproducibility — the same input can give very different responses
# temperature=1.0+: Very high variance, sometimes incoherent
# → Rarely useful in production
# RECOMMENDATION for analysis apps (like sentiment analysis):
# → temperature=0.0 for maximum consistency
# → Only use temperature > 0 if you genuinely need a variety of responses
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
temperature=0.0, # For analysis: always 0.0
seed=42, # For debugging: always specify
max_tokens=500
)
Complete debugging workflow with reproducibility logs
SCENARIO: A user reports that the sentiment analysis of their review
"The product arrived late but the support was excellent"
was classified as "negative" when it should be "mixed".
STEP 1: The user reports the error with the request_id
→ User: "request_id: a7b3c9d2"
STEP 2: Search the logs
$ jq 'select(.request_id == "a7b3c9d2")' logs.json | jq -s 'sort_by(.timestamp)'
Output:
{"event": "request_started", "request_id": "a7b3c9d2", "timestamp": "..."}
{"event": "llm_call_completed", "request_id": "a7b3c9d2",
"messages_hash": "abc123", "model": "gpt-4o-mini",
"temperature": 0.7, "seed": null, "finish_reason": "stop",
"input_tokens": 156, "output_tokens": 43, "timestamp": "..."}
{"event": "request_completed", "request_id": "a7b3c9d2",
"result": {"sentiment": "negative"}, "timestamp": "..."}
STEP 3: Identify the problem
→ temperature=0.7 (high variance) — that explains the inconsistent result
→ No seed — we can't reproduce it exactly
→ The model generated "negative" in this run, but may generate "mixed" in another
STEP 4: Reproduce (with low temperature to stabilize)
$ python scripts/reproduce_request.py \
--model "gpt-4o-mini" \
--temperature 0.7 \
--seed 42 \
--messages-hash "abc123"
→ With temperature=0.7, the response varies. Confirmed: the system is unstable
STEP 5: Diagnosis
→ For sentiment analysis, temperature=0.7 is too high
→ Fix: change to temperature=0.0
STEP 6: Validate the fix
→ With temperature=0.0, the same input always gives "mixed"
→ Fix validated
STEP 7: Write the regression test
def test_mixed_sentiment_stable():
"""Input with mixed sentiments should give 'mixed', not 'negative'."""
result = analyze_sentiment(
"The product arrived late but the support was excellent"
)
assert result.sentiment == "mixed"
Reproduction script
# scripts/reproduce_from_logs.py
"""
Script that reads a request_id from the logs and generates the code
to reproduce the LLM call.
Usage: python scripts/reproduce_from_logs.py <request_id> [log_file]
"""
import json
import sys
from pathlib import Path
def find_llm_call_log(request_id: str, log_file: str = "logs/app.json") -> dict:
"""Searches for the LLM call log for a request_id."""
with open(log_file) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if (entry.get("request_id") == request_id and
entry.get("event") == "llm_reproducibility_context"):
return entry
except json.JSONDecodeError:
pass
return None
def generate_reproduction_script(log_entry: dict) -> str:
"""Generates a Python script to reproduce the call."""
model = log_entry.get("model", "gpt-4o-mini")
temperature = log_entry.get("temperature", 0.0)
seed = log_entry.get("seed")
max_tokens = log_entry.get("max_tokens", 500)
prompt_preview = log_entry.get("prompt_preview", "[No prompt available]")
seed_line = f" seed={seed}," if seed is not None else " # seed not available"
return f'''#!/usr/bin/env python
"""
Reproduction of the request: {log_entry.get("request_id")}
Generated by: scripts/reproduce_from_logs.py
NOTE: The prompt is a preview — it may be truncated.
"""
from openai import OpenAI
client = OpenAI()
# NOTE: The full prompt may not be available if
# the log was generated with debug_mode=False.
# Use the real prompt for an exact reproduction.
prompt = """{prompt_preview}"""
response = client.chat.completions.create(
model="{model}",
messages=[{{"role": "user", "content": prompt}}],
temperature={temperature},
max_tokens={max_tokens},
{seed_line}
)
print("Response:", response.choices[0].message.content)
print("Finish reason:", response.choices[0].finish_reason)
'''
if __name__ == "__main__":
request_id = sys.argv[1] if len(sys.argv) > 1 else None
log_file = sys.argv[2] if len(sys.argv) > 2 else "logs/app.json"
if not request_id:
print("Usage: python reproduce_from_logs.py <request_id> [log_file]")
sys.exit(1)
entry = find_llm_call_log(request_id, log_file)
if not entry:
print(f"No reproducibility log found for request_id: {request_id}")
print("Note: Reproducibility logs are only generated in debug_mode=True")
sys.exit(1)
script = generate_reproduction_script(entry)
output_file = f"reproduce_{request_id}.py"
with open(output_file, "w") as f:
f.write(script)
print(f"Reproduction script generated: {output_file}")
print(f"Run: python {output_file}")
Exercises
Exercise 1: Identify why you can't reproduce
Given this log, explain why it's hard to reproduce the request exactly:
{"event": "llm_call_completed", "model": "gpt-4o", "temperature": 0.8,
"seed": null, "finish_reason": "stop", "request_id": "z9x8w7v6"}
See solution
Three problems:
-
temperature=0.8 is high — the model has a lot of variance in its responses. The same input can give very different outputs across different calls.
-
seed=null — without a seed, there's no way to ask the model to try to reproduce the same result. The randomness is completely free.
-
No prompt in the log — without the exact prompt, you can't even try to reproduce. You only have the hash.
To improve: use temperature=0.0 for deterministic analysis, always specify seed, and in debug mode log the prompt.
Exercise 2: Design the reproducibility logging policy
For an app with real users, define:
- What to log always?
- What to log only on ERROR?
- What only in DEBUG mode?
See guide
Always (INFO):
- model, temperature, seed (if used)
- messages_hash, response_hash
- finish_reason, input_tokens, output_tokens
Only on ERROR (you need more context to debug):
- First 300 chars of the prompt (may have PII → use with care or redact)
- system_fingerprint (to correlate with model changes)
- Full messages if the app has legal access and there's consent
Only in DEBUG mode (explicitly enabled):
- Full prompt (with PII redaction)
- Full response
- The entire messages[] object
Exercise 3: finish_reason
Why is it important to log finish_reason?
See guide
finish_reason indicates why the model stopped generating:
"stop": finished normally — the expected case"length":max_tokenswas reached — the response was truncated, it may be incomplete. This can cause invalid JSON if the output is structured JSON."content_filter": the content was filtered by OpenAI's moderation"tool_calls": the model wants to call a tool
If your app has JSON parsing errors and the finish_reason is "length", the cause is clear: increase max_tokens or truncate the input.
Summary
- Reproducibility isn't determinism: we don't ask the LLM to be 100% predictable, but to capture enough context to get as close as possible
temperature=0.0for analysis: maximum consistency, the same results in most casesseedtogether withtemperature=0.0: the most effective combination for reproducibilitysystem_fingerprint: lets you detect when the model changed on the server, not in your code- Always log: model, temperature, seed, messages_hash, finish_reason
- On ERROR: more context (prompt preview) to be able to diagnose
- The workflow: request_id → logs → params → reproduction script → diagnosis
Additional resources
- OpenAI — Reproducible outputs — Documentation for seed in OpenAI
- OpenAI API Reference — seed — The seed parameter
- Temperature in LLMs — How temperature works
- Debugging ML systems — Debugging techniques for ML systems