n4nAI

How random sampling makes every LLM answer a bit different

Understand why LLMs produce different outputs for the same prompt — sampling mechanics, temperature, top-p, and how to control randomness in production.

n4n Team5 min read1,036 words

Audio narration

Coming soon — every post will get a voice note here.

The reason why llm gives different answers for identical prompts comes down to one design choice: token generation is probabilistic, not deterministic. Every forward pass produces a probability distribution over the vocabulary, and the sampling algorithm draws from that distribution. Change the random seed, change the hardware, or change the sampling parameters, and you get a different completion. This isn’t a bug — it’s the feature that makes LLMs useful for creative tasks, but it creates real engineering challenges when you need reproducibility.

The sampling loop is where randomness lives

When a model generates text, it doesn’t pick the highest-probability token and stop. It samples. The standard loop looks like this:

def generate(model, input_ids, max_new_tokens, temperature=1.0, top_p=1.0, top_k=0):
    for _ in range(max_new_tokens):
        logits = model(input_ids)[:, -1, :]  # (batch, vocab)
        logits = logits / temperature
        
        # Top-k filtering
        if top_k > 0:
            indices_to_remove = logits < torch.topk(logits, top_k)[0][..., -1, None]
            logits[indices_to_remove] = -float('inf')
        
        # Top-p (nucleus) filtering
        if top_p < 1.0:
            sorted_logits, sorted_indices = torch.sort(logits, descending=True)
            cumulative_probs = torch.cumsum(F.softmax(sorted_logits, dim=-1), dim=-1)
            sorted_indices_to_remove = cumulative_probs > top_p
            sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
            sorted_indices_to_remove[..., 0] = 0
            indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove)
            logits[indices_to_remove] = -float('inf')
        
        probs = F.softmax(logits, dim=-1)
        next_token = torch.multinomial(probs, num_samples=1)
        input_ids = torch.cat([input_ids, next_token], dim=-1)
        
        if next_token.item() == eos_token_id:
            break
    return input_ids

The torch.multinomial call is the source of variance. Given the same probability distribution, two calls with different random seeds produce different tokens. This is why llm gives different answers even when you think you’ve fixed everything else.

Temperature reshapes the distribution before sampling

Temperature divides logits before the softmax. At temperature=0, the distribution collapses to a point mass on the argmax — deterministic greedy decoding. At temperature=1, you get the model’s raw calibrated probabilities. Above 1, the distribution flattens; below 1, it sharpens.

# Temperature effect on a 5-token vocabulary
logits = torch.tensor([2.0, 1.5, 1.0, 0.5, 0.0])

for t in [0.1, 0.5, 1.0, 1.5, 2.0]:
    probs = F.softmax(logits / t, dim=-1)
    print(f"temp={t}: {probs.tolist()}")

Output:

temp=0.1: [0.9999, 0.0001, 0.0000, 0.0000, 0.0000]
temp=0.5: [0.8438, 0.1271, 0.0191, 0.0029, 0.0004]
temp=1.0: [0.4871, 0.2369, 0.1152, 0.0560, 0.0272]
temp=1.5: [0.3543, 0.2452, 0.1694, 0.1170, 0.0808]
temp=2.0: [0.2930, 0.2240, 0.1710, 0.1305, 0.0995]

Low temperature reduces variance but increases repetition and hallucination risk. High temperature increases diversity but can produce incoherent output. Most production systems settle between 0.3 and 0.7 for structured tasks, 0.7 to 1.0 for creative writing.

Top-p and top-k truncate the tail differently

Top-k keeps only the k most likely tokens. Top-p (nucleus sampling) keeps the smallest set of tokens whose cumulative probability exceeds p. They compose: top-k applies first, then top-p on the remainder.

# Top-p example with sorted probabilities
probs = torch.tensor([0.4, 0.25, 0.15, 0.1, 0.05, 0.03, 0.02])
cumsum = torch.cumsum(probs, dim=0)
# cumsum = [0.4, 0.65, 0.8, 0.9, 0.95, 0.98, 1.0]

# top_p=0.9 keeps first 4 tokens (cumsum 0.9)
# top_p=0.95 keeps first 5 tokens (cumsum 0.95)

Top-p adapts to the distribution’s shape. On a sharp distribution (low entropy), it keeps few tokens. On a flat distribution (high entropy), it keeps many. Top-k is simpler but can keep obviously bad tokens when the distribution is sharp, or exclude good ones when it’s flat. Most APIs expose both; use top-p as your primary control and set top_k=0 to disable it.

Why deterministic decoding still isn’t deterministic

You set temperature=0, top_p=1, top_k=1. Greedy decoding. Same prompt, same model, same parameters. You should get the same output every time. In practice, you often don’t.

Floating-point non-determinism. GPU kernels (especially tensor cores) don’t guarantee bitwise identical results across runs. Different cuDNN algorithms, different GPU models, or even different driver versions can produce logits that differ in the 6th decimal place. When two logits are extremely close, that tiny difference flips the argmax.

Parallelism and batching. Most serving engines batch requests. The order of operations in a batched matmul depends on batch size and padding. A request running alone vs. batched with three others can produce different logits.

Model updates. Providers silently update model weights, tokenizers, or inference kernels. The model identifier stays the same; the behavior shifts.

Hardware entropy sources. Some sampling implementations read from /dev/urandom or hardware RNGs for the multinomial draw, even at temperature=0, due to framework bugs or defensive coding.

If you need true reproducibility, you need: fixed model weights (self-hosted or version-pinned), fixed hardware, fixed batch size (batch=1), and a deterministic sampling implementation. Most teams don’t need this — they need stability, not bitwise reproducibility.

The randomness tradeoff table

Goal Temperature Top-p Top-k Seed control
Code generation, extraction 0.0–0.2 0.95 0 Optional
Structured data (JSON, SQL) 0.0–0.1 0.9–1.0 0 Recommended
Creative writing 0.7–1.0 0.9–0.95 40–100 Not needed
Brainstorming, diverse ideas 1.0–1.2 0.95 50–100 Not needed
Classification, routing 0.0 1.0 1 Required

Notice that classification and routing tasks should use greedy decoding (temperature=0, top_k=1). The variance you see in those tasks is almost always harmful. If a classifier flips labels on the same input, you have a calibration problem, not a sampling problem.

Controlling variance in production systems

Pin the seed when you can

OpenAI-compatible APIs accept a seed parameter. When provided, the sampler uses a deterministic PRNG initialized with that seed. This gives you run-to-run consistency for that specific model version on that specific provider.

{
  "model": "gpt-4o-mini",
  "messages": [{"role": "user", "content": "Extract JSON from: ..."}],
  "temperature": 0.1,
  "top_p": 0.95,
  "seed": 42
}

The seed only controls the multinomial draw. It doesn’t fix floating-point variance in the forward pass. But for most practical purposes, it reduces variance enough for testing and evaluation pipelines.

Use logprobs to detect instability

Request logprobs=true and examine the top-token probability. If the top token has 0.95+ probability, the output is stable. If it’s 0.55 with the second token at 0.40, small perturbations will flip the result.

# Detecting unstable generations
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": prompt}],
    temperature=0.3,
    logprobs=True,
    top_logprobs=5
)

for choice in response.choices:
    for token_logprob in choice.logprobs.content:
        top_prob = math.exp(token_logprob.logprob)
        second_prob = math.exp(token_logprob.top_logprobs[1].logprob) if len(token_logprob.top_logprobs) > 1 else 0
        margin = top_prob - second_prob
        if margin < 0.15:
            print(f"UNSTABLE: '{token_logprob.token}' margin={margin:.3f}")

This pattern lets you flag generations that need human review or a lower temperature.

Fallback to greedy for critical paths

For any step where correctness > creativity — function calling arguments, SQL generation, JSON extraction, classification — force greedy decoding:

CRITICAL_PARAMS = {
    "temperature": 0.0,
    "top_p": 1.0,
    "top_k": 1,
    "max_tokens": 512
}

CREATIVE_PARAMS = {
    "temperature": 0.8,
    "top_p": 0.95,
    "top_k": 50,
    "max_tokens": 1024
}

def generate(prompt, mode="critical"):
    params = CRITICAL_PARAMS if mode == "critical" else CREATIVE_PARAMS
    return client.chat.completions.create(model=MODEL, messages=prompt, **params)

Don’t rely on “low temperature” as a proxy for deterministic. Temperature 0.1 still samples. Temperature 0.0 with top_k=1 is the only way to get greedy behavior.

When variance is a feature, not a bug

Some workflows need diversity. Reranking, best-of-n sampling, and self-consistency all exploit variance deliberately.

Best-of-n: Generate n completions at temperature 0.7–1.0, score them with a reward model or heuristic, pick the best.

def best_of_n(prompt, n=8, temperature=0.8):
    completions = []
    for i in range(n):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "user", "content": prompt}],
            temperature=temperature,
            seed=i  # Different seed per generation
        )
        completions.append(resp.choices[0].message.content)
    
    scores = [score_completion(c) for c in completions]
    return completions[scores.index(max(scores))]

Self-consistency (Chain-of-Thought): Generate multiple reasoning traces at temperature 0.5–0.7, take the majority answer. This works because reasoning errors are uncorrelated across samples, while correct reasoning converges.

def self_consistency(prompt, n=5, temperature=0.6):
    answers = []
    for i in range(n):
        resp = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "user", "content": prompt + "\nThink step by step."}],
            temperature=temperature,
            seed=i
        )
        answer = extract_final_answer(resp.choices[0].message.content)
        answers.append(answer)
    
    return Counter(answers).most_common(1)[0][0]

These patterns turn the “problem” of variance into a reliability mechanism. But they cost n× latency and tokens. Use them selectively.

The decisive takeaway

Why llm gives different answers is not a mystery — it’s by design. The sampling layer converts a probability distribution into a token, and that conversion is stochastic. You control the shape of the distribution with temperature, top-p, and top-k. You control the draw with the seed. You cannot control floating-point variance across hardware or model updates.

For production systems: use greedy decoding (temperature=0, top_k=1) for any task where correctness is binary. Use low temperature (0.1–0.3) with a fixed seed for tasks where you want mostly-deterministic but slightly varied output. Use high temperature (0.7+) only when diversity is explicitly valuable, and pair it with a selection mechanism (reranker, majority vote, human review).

Don’t chase bitwise reproducibility across providers or model versions. It doesn’t exist. Build your evaluation and guardrails around behavioral consistency — does the output pass your tests, match your schema, satisfy your constraints — not token-level identity.

Tagssampling-parametersrandomnessllm-basics

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All sampling parameters: top-p, top-k & penalties posts →