Generation & Decoding
How the next-token probabilities become actual text: temperature and top-p sampling, greedy vs sampled decoding, why "temperature 0" isn't truly deterministic, stop conditions, max_tokens, and how all of this maps to cost and latency.
- Explain how the model's next-token probabilities become chosen tokens (decoding).
- Use temperature and top-p deliberately, and know what each actually changes.
- Explain why "temperature 0" is repeatable but not guaranteed deterministic or correct.
- Control generation with stop conditions and max_tokens, and reason about the cost/latency impact.
Model families differ in which knobs they expose — some newer models drop temperature/top_p and
steer with effort/prompting instead. This topic teaches the concepts (they generalize); check
your provider's current API for exact parameters before relying on any one knob.
From probabilities to a token: decoding
Recall the loop: at each step the model outputs a probability for every vocabulary token. Turning that distribution into one chosen token is decoding (a.k.a. sampling). The strategy you pick controls how "safe" vs. "varied" the output is.
Greedy decoding — always take the single highest-probability token. Repeatable and focused, but can be bland or get stuck in loops.
Sampling — draw a token according to its probability (a token with p=0.3 is picked ~30% of the time). Introduces variety — and the possibility of a less obvious, more creative continuation.
Two knobs shape the distribution before sampling:
Temperature
Temperature flattens or sharpens the probability distribution before sampling.
- Low (→0): sharpen toward the top token → near-greedy, focused, repeatable. Good for extraction, classification, code, anything where you want the "obvious" answer.
- High (→1 and above): flatten the distribution → lower-probability tokens get a real chance → more varied, creative, and more prone to going off the rails.
Probabilities for the next token, before vs. after temperature:
" Paris" " a" " located" …
raw 0.80 0.10 0.05
temp = 0.2 → 0.97 0.02 0.01 (sharpened — almost always " Paris")
temp = 1.2 → 0.55 0.20 0.12 (flattened — " a" and " located" now plausible)
Top-p (nucleus sampling)
Top-p keeps only the smallest set of tokens whose probabilities sum to p, and samples from that
"nucleus" (renormalized). top_p = 0.9 means "consider only the most likely tokens that together
cover 90% of the probability mass, ignore the long tail." It caps how weird the choice can get
without hard-coding a count.
Temperature and top-p are often used together, but change one at a time when tuning — moving both makes the effect hard to reason about.
↳ Exercise 1 has you run the same creative prompt at temperature 0.1, 0.7, and 1.2 and compare — the fastest way to feel what temperature does.
"Temperature 0" ≠ deterministic (or correct)
A common trap. Setting temperature to 0 makes decoding greedy, which is much more repeatable — but not a guarantee of identical output across calls, because:
- Floating-point non-determinism, hardware/batching differences, and provider-side changes can break exact ties differently.
- Model updates change the weights entirely.
And crucially, repeatable ≠ correct. Greedy decoding just takes the most probable token; if the model's most-probable answer is wrong, temperature 0 returns that wrong answer every time. Use low temperature for consistency, not as a correctness or determinism guarantee.
Don't design a system that depends on byte-identical model output across calls. If you need determinism (caching, testing), cache the result yourself keyed on the input — don't rely on the model to reproduce it.
Stopping: stop tokens, stop sequences, and max_tokens
Generation runs the loop until something tells it to stop:
- End-of-turn token — the model predicts a special stop token on its own (normal completion).
- Stop sequences — you supply strings that force a halt when generated (e.g. stop at
"\n\n"or a closing tag). Useful for structured output where you know the boundary. max_tokens— a hard cap on output tokens. If the model hits it mid-sentence, output is truncated and the response reports amax_tokensstop reason — that's your signal to raise the cap or stream.
from anthropic import Anthropic
client = Anthropic()
resp = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024, # HARD cap on OUTPUT tokens; too low → truncated answer
messages=[{"role": "user", "content": "List three RAG evaluation metrics."}],
)
resp.stop_reason # "end_turn" (finished) or "max_tokens" (was truncated) — always check this
↳ Exercise 2 has you deliberately set max_tokens too low, detect the max_tokens stop reason,
and handle it — the guard every production call needs.
max_tokens, cost, and latency
Two knobs, two cost/latency effects:
max_tokensis a ceiling, not a target — the model stops when done; you're billed for actual output, not the cap. But set it high enough that legitimate answers aren't cut off. For long outputs, stream the response (token-by-token) so a slow generation doesn't hit an HTTP timeout, and so users see progress.- Every output token is one forward pass — so latency scales with output length far more than input length. Shorter, more focused answers are faster and cheaper. (Input tokens are cheaper per token than output tokens on most providers, and can be discounted further by prompt caching.)
# Streaming: yields text as it's generated — avoids timeouts on long outputs and shows progress.
with client.messages.stream(
model="claude-opus-4-8", max_tokens=8000,
messages=[{"role": "user", "content": "Explain RAG in detail."}],
) as stream:
for text in stream.text_stream: # iterate the text deltas as they arrive
print(text, end="", flush=True) # flush so tokens appear immediately (like process.stdout.write)
final = stream.get_final_message() # the complete assembled message, plus usage stats
final.usage.output_tokens # what you were actually billed for on output
↳ Exercise 3 has you generate a long response two ways — non-streaming vs. streaming — and observe the latency-to-first-token difference and why streaming matters for UX.
Choosing settings — a practical default
| Task | Temperature | Notes |
|---|---|---|
| Extraction, classification, structured output | low (0–0.3) | Want the obvious, consistent answer |
| Coding, factual Q&A, RAG | low (0–0.4) | Consistency + fewer creative detours |
| Brainstorming, copywriting, variety | higher (0.7–1.0) | Want range across runs |
| "Determinism" for tests | cache the result yourself | Don't rely on temperature 0 |
Newer model families may replace these knobs with an effort/verbosity control and heavier reliance on prompting — the goal (focused vs. varied, short vs. thorough) is the same; the lever changes.
Recap
- Decoding turns next-token probabilities into a chosen token: greedy (top token) or sampling (by probability).
- Temperature sharpens (low) or flattens (high) the distribution; top-p samples from the
smallest set covering probability mass
p. Tune one at a time. - Temperature 0 is repeatable, not truly deterministic, and never a correctness guarantee — cache results yourself if you need determinism.
- Control length with stop sequences and
max_tokens; always checkstop_reason, and stream long outputs. Output tokens dominate cost and latency.
Generation & Decoding — Exercises
Use any provider SDK; examples use Anthropic. If your model family doesn't accept temperature,
do Exercise 1 with a provider/model that does (or use an open model) — the concept is what matters.
Exercise 1 — Feel the temperature
Run the same creative prompt three times at temperature 0.1, 0.7, and 1.2 (a few generations each). Note how variety and coherence change.
Reference solution
from anthropic import Anthropic
client = Anthropic()
prompt = "Write a one-sentence tagline for a coffee shop on Mars."
for t in [0.1, 0.7, 1.2]:
print(f"\n=== temperature {t} ===")
for _ in range(3):
resp = client.messages.create(
model="claude-sonnet-5", max_tokens=60, temperature=t,
messages=[{"role": "user", "content": prompt}],
)
print("-", resp.content[0].text.strip())
Expected: at 0.1 the three outputs are nearly identical and "safe"; at 0.7 they vary meaningfully while staying coherent; at 1.2 they diverge a lot and may get strange. That spread is temperature flattening vs. sharpening the next-token distribution.
Exercise 2 — Detect and handle truncation
Ask for a long answer with max_tokens set deliberately too low. Detect the max_tokens stop
reason and handle it (e.g. re-request with a higher cap, or continue).
Reference solution
from anthropic import Anthropic
client = Anthropic()
def answer(question: str, max_tokens: int):
resp = client.messages.create(
model="claude-opus-4-8", max_tokens=max_tokens,
messages=[{"role": "user", "content": question}],
)
return resp
resp = answer("Explain how RAG works, step by step, in detail.", max_tokens=40)
print("stop_reason:", resp.stop_reason) # -> "max_tokens" : the answer was cut off
if resp.stop_reason == "max_tokens":
print("Truncated — retrying with more room.")
resp = answer("Explain how RAG works, step by step, in detail.", max_tokens=1500)
print(resp.content[0].text)
The reflex: never trust an answer without checking stop_reason. A max_tokens stop means the
output is incomplete — raise the cap, stream, or ask the model to continue.
Exercise 3 — Streaming vs. non-streaming latency
Generate a long response twice — once non-streaming, once streaming — and compare time-to-first-token and total experience.
Reference solution
import time
from anthropic import Anthropic
client = Anthropic()
q = "Write a detailed 300-word explanation of vector databases."
# Non-streaming: you wait for the whole thing before seeing anything.
t = time.perf_counter()
resp = client.messages.create(model="claude-opus-4-8", max_tokens=800,
messages=[{"role": "user", "content": q}])
print(f"non-streaming: first output at {time.perf_counter()-t:.1f}s (the whole response)")
# Streaming: first tokens arrive almost immediately.
t = time.perf_counter()
first = None
with client.messages.stream(model="claude-opus-4-8", max_tokens=800,
messages=[{"role": "user", "content": q}]) as stream:
for _ in stream.text_stream:
if first is None:
first = time.perf_counter() - t
break
print(f"streaming: first token at {first:.1f}s")
The total generation time is similar, but streaming's time to first token is a fraction of the non-streaming wait — which is why chat UIs stream. It also avoids HTTP timeouts on very long outputs.