LLM Fundamentals: Tokenisation, Pre-training & Scaling Laws
Everything downstream of the tokenizer depends on it. Understand BPE, the pre-training objectives, and the scaling laws that decide how big a model should be.
Covers: BPE tokenisation, embeddings, pre-training objectives, BERT vs GPT, scaling laws, emergent abilities
Applied LLM interviews start here. Before anyone asks about RAG or agents, they check that you understand what the model actually is: a next-token predictor over a learned vocabulary, trained under a compute budget governed by well-understood scaling relationships.
30-second answer
Word-level vocabularies explode in size and cannot handle out-of-vocabulary words; character-level sequences are far too long for quadratic attention. BPE starts from bytes and iteratively merges the most frequent adjacent pair, producing subwords that cover any input with a fixed vocabulary. The practical consequences are real: non-English text costs 2–3× more tokens, numbers tokenise inconsistently which hurts arithmetic, and trailing whitespace changes completions.
| Granularity | Vocab size | Sequence length | OOV handling |
|---|---|---|---|
| Character/byte | ~256 | Very long | Perfect |
| Word | Millions, and unbounded | Short | Fails on any unseen word |
| Subword (BPE) | 32k–256k | Moderate | Perfect (falls back to bytes) |
from collections import Counter
def train_bpe(corpus: list[str], n_merges: int):
# Start from characters, with a marker for word boundaries
words = {tuple(w) + ("</w>",): c for w, c in Counter(corpus).items()}
merges = []
for _ in range(n_merges):
pairs = Counter()
for symbols, freq in words.items():
for i in range(len(symbols) - 1):
pairs[(symbols[i], symbols[i + 1])] += freq
if not pairs:
break
best = max(pairs, key=pairs.get) # greedy: most frequent adjacent pair
merges.append(best)
new_words = {}
for symbols, freq in words.items():
out, i = [], 0
while i < len(symbols):
if i < len(symbols) - 1 and (symbols[i], symbols[i+1]) == best:
out.append(symbols[i] + symbols[i+1]); i += 2
else:
out.append(symbols[i]); i += 1
new_words[tuple(out)] = freq
words = new_words
return merges
corpus = ["low"]*5 + ["lower"]*2 + ["newest"]*6 + ["widest"]*3
print(train_bpe(corpus, 8))
# [('e','s'), ('es','t'), ('est','</w>'), ('l','o'), ('lo','w'),
# ('n','e'), ('ne','w'), ('new','est</w>')]
# It discovered the "est" suffix and the "low" stem from raw frequency alone.The practical problems that show up in production
Language cost asymmetry
English averages ~1.3 tokens per word. Hindi, Thai, Burmese and many others can average 3–5× more because their scripts are underrepresented in the merge training corpus. The same request literally costs several times more in some languages — a genuine fairness and unit-economics issue, not a curiosity.Number tokenisation breaks arithmetic
Depending on the tokenizer,1234might be one token, or12+34, or1+234. The model must learn arithmetic over an inconsistent representation. Newer tokenizers deliberately split digits individually, which measurably improves arithmetic.Trailing whitespace changes outputs
"The answer is"and"The answer is "tokenise differently, because most tokens embed a leading space. Ending a prompt with a space frequently degrades completions. This is a real, reproducible API gotcha.Structured formats are token-expensive
JSON's braces, quotes and repeated keys cost tokens on every record. For long lists, a compact format can cut token usage 30–50% for identical information — a direct cost saving at scale.Character-level tasks are hard
'How many r's in strawberry?' is difficult because the model sees a few subword tokens, not letters. It is a tokenisation artefact, not a reasoning failure — a useful thing to be able to explain to a stakeholder.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
texts = {
"English": "The quick brown fox jumps over the lazy dog.",
"Spanish": "El rápido zorro marrón salta sobre el perro perezoso.",
"Hindi": "तेज़ भूरी लोमड़ी आलसी कुत्ते के ऊपर से कूदती है।",
"Code": "def fib(n): return n if n < 2 else fib(n-1) + fib(n-2)",
}
for name, t in texts.items():
n = len(enc.encode(t))
print(f"{name:9s} {len(t):3d} chars -> {n:3d} tokens ({len(t)/n:.1f} chars/token)")
# English 45 chars -> 10 tokens (4.5 chars/token)
# Spanish 52 chars -> 17 tokens (3.1 chars/token)
# Hindi 47 chars -> 49 tokens (1.0 chars/token) <- ~5x the cost
# Code 57 chars -> 22 tokens (2.6 chars/token)
print(enc.encode("The answer is")) # [791, 4320, 374]
print(enc.encode("The answer is ")) # [791, 4320, 374, 220] <- extra dangling tokenSay these points
- BPE greedily merges the most frequent adjacent pair, starting from bytes.
- Fixed vocabulary with zero OOV, and moderate sequence length — the best of both extremes.
- Non-English text can cost 3–5× more tokens for the same content.
- Digit tokenisation inconsistency degrades arithmetic; digit-splitting helps.
- Trailing whitespace changes tokenisation and can degrade completions.
Avoid these mistakes
- Estimating cost in words rather than tokens.
- Assuming a character-counting failure indicates a reasoning failure.
- Ignoring that changing tokenizer invalidates every prior token-based benchmark.
Expect these follow-ups
- How would you extend a tokenizer's vocabulary for a specialised domain?
- Why do some models use SentencePiece/unigram instead of BPE?
- How does vocabulary size trade off against sequence length and embedding size?
30-second answer
MLM masks ~15% of tokens and predicts them using bidirectional context — excellent representations, but it cannot generate and only trains on the masked fraction. CLM predicts the next token with causal masking — it trains on every position, generates naturally, and scales better. Use an encoder for classification, retrieval and NER where you need the best embeddings cheaply; use a decoder for generation and for anything you want to steer with instructions.
| Encoder (BERT) | Decoder (GPT) | Encoder-decoder (T5) | |
|---|---|---|---|
| Objective | Masked LM | Next-token prediction | Span corruption |
| Attention | Bidirectional | Causal | Bi- in encoder, causal in decoder |
| Training signal | ~15% of positions | 100% of positions | Corrupted spans |
| Generation | No | Yes | Yes |
| Best at | Classification, NER, retrieval | Generation, in-context learning | Translation, summarisation |
| Typical size today | 100M–1B | 1B–1T+ | 200M–20B |
import torch
sentence = ["The", "cat", "sat", "on", "the", "mat"]
# --- MLM: bidirectional context, sparse supervision ---
masked = ["The", "[MASK]", "sat", "on", "[MASK]", "mat"]
print("MLM sees full context both directions; predicts only 2 of 6 positions")
print(f" input : {masked}")
print(f" targets: position 1 -> 'cat', position 4 -> 'the'")
# --- CLM: causal context, dense supervision ---
print("\nCLM predicts EVERY position from its left context:")
for i in range(1, len(sentence)):
print(f" {' '.join(sentence[:i]):24s} -> {sentence[i]}")
# Efficiency: for a 1024-token sequence,
# MLM produces ~154 training signals
# CLM produces 1023
# ~6.6x more gradient per forward pass at identical compute.When an encoder is still the right call
- Embeddings for retrieval. A 110M-parameter bidirectional encoder produces excellent sentence embeddings at a fraction of a decoder's cost. Nearly every production RAG system uses an encoder for the embedding model.
- High-volume classification. Classifying 50M documents a day: a fine-tuned DeBERTa costs cents; calling an LLM costs thousands of dollars and adds latency.
- Token-level tasks. NER and extractive QA benefit genuinely from seeing tokens on both sides.
- Reranking. A cross-encoder that reads query and document jointly is the standard second stage in retrieval, and bidirectional attention is exactly what makes it strong.
- Strict latency budgets. Sub-10ms inference is achievable with a small encoder and effectively impossible with a generative model.
For completeness: T5-style encoder-decoders remain strong for seq2seq tasks with a fixed input-output structure (translation, summarisation) because the encoder can attend bidirectionally over the source while the decoder generates. They lost ground mostly because a single decoder-only model with a good prompt is simpler to operate, not because the architecture is worse.
Say these points
- MLM: bidirectional, ~15% supervision, no generation, great embeddings.
- CLM: causal, 100% supervision, generative, in-context learning emerges at scale.
- Decoders won on training efficiency and interface uniformity, not raw quality per token.
- Encoders remain optimal for embeddings, reranking, high-volume classification and low latency.
- Distilling LLM labels into a small encoder is the standard cost-optimisation pattern.
Avoid these mistakes
- Claiming BERT 'cannot understand context on the right' — it can; it just cannot generate.
- Using a 70B decoder for a binary classification task running at 10k QPS.
- Forgetting that BERT's [CLS] embedding is poor for similarity without contrastive fine-tuning.
Expect these follow-ups
- Why does a raw BERT [CLS] embedding perform badly for semantic similarity?
- What is span corruption and why did T5 use it?
- How would you distil GPT-4 labels into a 100M-parameter classifier?
30-second answer
Loss follows a power law in parameters, data and compute. Kaplan et al. concluded you should scale model size faster than data; Chinchilla corrected this with a fixed learning-rate-schedule methodology and found parameters and tokens should scale roughly equally — about 20 tokens per parameter. Given compute C ≈ 6ND, the optimal split is N ∝ C^0.5 and D ∝ C^0.5. In practice teams now deliberately overtrain small models because inference cost dominates over the model's lifetime.
Chinchilla's parametric fit. E is the irreducible entropy of natural text.
The structure matters: two separate terms means you can be bottlenecked by either parameters or data. Kaplan's original analysis used a fixed LR schedule across all runs, which systematically disadvantaged the longer (more data) runs. Chinchilla re-ran with per-run cosine schedules and the conclusion flipped.
def chinchilla_optimal(compute_flops):
"""C = 6ND with N/D balanced -> N = sqrt(C/120), D = 20N."""
n = (compute_flops / 120) ** 0.5
return n, 20 * n
for c in (1e21, 1e22, 1e23, 1e24):
n, d = chinchilla_optimal(c)
print(f"C={c:.0e} optimal N={n/1e9:6.1f}B D={d/1e12:6.2f}T tokens")
# C=1e+21 optimal N= 2.9B D= 0.06T tokens
# C=1e+22 optimal N= 9.1B D= 0.18T tokens
# C=1e+23 optimal N= 28.9B D= 0.58T tokens
# C=1e+24 optimal N= 91.3B D= 1.83T tokens
# But TRAINING optimal != DEPLOYMENT optimal.
def lifetime_cost(n_params, train_tokens, inference_tokens, flops_per_dollar=1e18):
train = 6 * n_params * train_tokens
infer = 2 * n_params * inference_tokens
return (train + infer) / flops_per_dollar, train / (train + infer)
INFERENCE = 1e15 # 1 quadrillion tokens served over the model's life
for n, d in [(70e9, 1.4e12), (8e9, 15e12)]: # Chinchilla-optimal vs overtrained
cost, train_share = lifetime_cost(n, d, INFERENCE)
print(f"N={n/1e9:5.0f}B D={d/1e12:5.1f}T -> lifetime {cost:,.0f} units "
f"({train_share:.1%} training)")
# N= 70B D= 1.4T -> lifetime 140,588 units (0.4% training)
# N= 8B D= 15.0T -> lifetime 16,720 units (4.3% training)
# The 8B model overtrained 8x past Chinchilla costs 8x LESS over its lifetime.| Model | Params | Tokens | Tokens/param | Rationale |
|---|---|---|---|---|
| GPT-3 (2020) | 175B | 300B | 1.7 | Pre-Chinchilla — badly undertrained |
| Chinchilla (2022) | 70B | 1.4T | 20 | Compute-optimal by construction |
| Llama-2-7B | 7B | 2T | 286 | Overtrained for inference efficiency |
| Llama-3-8B | 8B | 15T | 1875 | Aggressively overtrained; near saturation |
The caveats to raise
- Scaling laws are fits, not physics. They hold impressively over many orders of magnitude but extrapolation far beyond the fitted range is an assumption, not a guarantee.
- Data quality shifts the curve. Deduplication, filtering and curriculum can be worth an effective 2× in data. The laws assume a fixed data distribution.
- Data is finite. High-quality public text is estimated in the tens of trillions of tokens. Beyond that you need synthetic data, multiple epochs (which degrade in value) or new modalities.
- MoE changes the accounting. A mixture-of-experts model has many more parameters than active parameters per token, so N in the FLOP formula is the active count, not the total.
- 'Emergent abilities' are partly a metric artefact. Sharp jumps often disappear under continuous metrics — the underlying capability improves smoothly, but a thresholded metric like exact-match only registers it late.
Say these points
- L(N,D) = E + A/N^α + B/D^β; you can be parameter-bound or data-bound.
- Chinchilla: scale N and D roughly equally, ≈20 tokens per parameter, C ≈ 6ND.
- Chinchilla optimises training compute only — inference cost usually dominates lifetime cost.
- Modern practice deliberately overtrains small models (Llama-3-8B at ~1900 tokens/param).
- MoE FLOP accounting uses active parameters, not total parameters.
Avoid these mistakes
- Quoting Kaplan's conclusion (scale parameters faster) as current.
- Applying Chinchilla to a model that will serve trillions of inference tokens.
- Claiming emergent abilities are a well-established discontinuity.
Expect these follow-ups
- How do scaling laws change for mixture-of-experts models?
- What happens when you run out of high-quality training data?
- How would you decide between a 7B and a 70B model for a specific product?
30-second answer
Options in increasing sophistication: sliding window over recent turns, recursive summarisation of older turns, retrieval over the conversation history so only relevant past turns are injected, and a structured memory store holding extracted facts. In production I would combine them — always keep the system prompt and the last N turns verbatim, retrieve relevant older turns, and maintain a running summary plus a structured fact store.
| Strategy | Preserves | Loses | Cost |
|---|---|---|---|
| Truncate oldest | Recency | Everything early, including the system prompt if careless | Free |
| Sliding window + pinned system prompt | Recency + instructions | Mid-conversation detail | Free |
| Recursive summarisation | Gist of the whole conversation | Specific details, exact quotes | One extra LLM call per compaction |
| Retrieval over history | Relevant specifics from any point | Global narrative flow | Embedding + vector search |
| Structured memory (facts/entities) | Durable user facts across sessions | Conversational nuance | Extraction call + storage |
import tiktoken
class ConversationContext:
"""Pinned system prompt + verbatim recent turns + summary + retrieved history."""
def __init__(self, model="gpt-4", max_tokens=100_000, reserve_output=4_000):
self.enc = tiktoken.encoding_for_model(model)
self.budget = max_tokens - reserve_output
self.system, self.turns, self.summary = "", [], ""
def _count(self, text: str) -> int:
return len(self.enc.encode(text))
def build(self, query: str, retriever=None) -> list[dict]:
msgs = [{"role": "system", "content": self.system}]
used = self._count(self.system) + self._count(query)
if self.summary:
block = f"[Earlier conversation summary]\n{self.summary}"
msgs.append({"role": "system", "content": block})
used += self._count(block)
# Semantically relevant older turns -- NOT just the most recent ones
if retriever:
for hit in retriever.search(query, k=3):
if used + self._count(hit) > self.budget * 0.35:
break
msgs.append({"role": "system",
"content": f"[Relevant earlier exchange]\n{hit}"})
used += self._count(hit)
# Fill the remaining budget with the most recent turns, newest first
recent = []
for turn in reversed(self.turns):
t = self._count(turn["content"])
if used + t > self.budget:
break
recent.append(turn); used += t
msgs.extend(reversed(recent))
msgs.append({"role": "user", "content": query})
return msgs
def compact(self, llm):
"""Summarise the oldest half once we are near the limit."""
total = sum(self._count(t["content"]) for t in self.turns)
if total < self.budget * 0.6:
return
half = len(self.turns) // 2
old, self.turns = self.turns[:half], self.turns[half:]
transcript = "\n".join(f"{t['role']}: {t['content']}" for t in old)
self.summary = llm(
"Update the running summary. Preserve decisions, user preferences, "
"commitments, names, numbers and open questions. Drop pleasantries.\n\n"
f"Existing summary:\n{self.summary}\n\nNew transcript:\n{transcript}")The cost argument for not just using a bigger window
Prefill cost is linear in context length and attention is quadratic in it. A 100k-token prompt is roughly 100× the prefill cost and noticeably slower time-to-first-token compared with a 1k prompt built by good retrieval — and it is frequently less accurate because of the middle-of-context degradation. 'Just use the long-context model' is usually the expensive and worse option.
- Always pin the system prompt. The most common production bug is a truncation routine that drops the instructions and the assistant silently changes behaviour.
- Use prompt caching. Anthropic and OpenAI both cache stable prefixes, so putting the invariant system prompt and long documents first can cut cost by up to 90% on repeated calls.
- Store structured memory separately. User preferences and durable facts belong in a database, not re-derived from a transcript every session.
- Track token usage as a product metric. Tokens per conversation is a cost line item; alert on it the way you alert on latency.
Say these points
- Combine pinned system prompt + recent verbatim turns + summary + retrieved history.
- Never truncate the system prompt — pin it explicitly.
- Lost-in-the-middle means advertised window ≠ usable window; place key content at the edges.
- Long prompts cost linearly in prefill and are often less accurate than good retrieval.
- Order the prompt so the stable prefix is cacheable.
Avoid these mistakes
- Naive truncation that silently drops instructions.
- Summarising every turn and losing exact figures the user will later reference.
- Assuming a 200k context model removes the need for retrieval.
Expect these follow-ups
- How does prompt caching work and how do you structure prompts to exploit it?
- How would you evaluate whether your summarisation is losing important information?
- Design a cross-session memory system for a personal assistant.
30-second answer
An embedding model maps text to a fixed-size vector optimised so semantically similar texts are close, typically trained with a contrastive objective on positive/negative pairs. Choose based on retrieval benchmark performance in your domain, dimension (which drives index cost), max sequence length, multilingual coverage, and whether you can self-host. Always evaluate on your own queries — MTEB rankings frequently do not transfer.
The mechanical difference: an LLM produces a distribution over next tokens; an embedding model produces one vector for the whole input. A generative model's hidden states are not good similarity embeddings out of the box — they are optimised for prediction, not for a metric space. That is why dedicated embedding models exist and why mean-pooling GPT hidden states underperforms.
InfoNCE contrastive loss. τ is the temperature; in-batch negatives make this cheap to compute.
| Selection criterion | Why it matters | How to check |
|---|---|---|
| Domain retrieval quality | MTEB is general-purpose and often does not transfer | Build a 100–300 query gold set from real traffic |
| Dimension | Drives index memory and search latency | 1536-d × 10M docs ≈ 61 GB in fp32 |
| Max sequence length | Truncation silently drops content | Check your p95 chunk length |
| Multilingual | Cross-lingual retrieval needs a shared space | Test query-in-A / doc-in-B retrieval |
| Self-hostable | Cost, latency and data residency | Licence + GPU memory footprint |
| Matryoshka support | Truncate dimensions without re-embedding | Enables a cheap coarse pass then a fine rerank |
import numpy as np
from sentence_transformers import SentenceTransformer
# gold: list of (query, set_of_relevant_doc_ids) built from real user queries
def evaluate(model_name, docs, doc_ids, gold, k=10):
model = SentenceTransformer(model_name)
D = model.encode(docs, normalize_embeddings=True, batch_size=64)
recalls, mrrs = [], []
for query, relevant in gold:
q = model.encode([query], normalize_embeddings=True)[0]
top = np.argsort(-(D @ q))[:k]
hits = [doc_ids[i] in relevant for i in top]
recalls.append(sum(hits) / max(1, len(relevant)))
mrrs.append(next((1/(r+1) for r, h in enumerate(hits) if h), 0.0))
return {"recall@k": float(np.mean(recalls)),
"mrr@k": float(np.mean(mrrs)),
"dim": int(D.shape[1])}
for name in ["BAAI/bge-base-en-v1.5",
"intfloat/e5-base-v2",
"sentence-transformers/all-MiniLM-L6-v2"]:
print(name, evaluate(name, docs, doc_ids, gold))
# BAAI/bge-base-en-v1.5 {'recall@k': 0.812, 'mrr@k': 0.694, 'dim': 768}
# intfloat/e5-base-v2 {'recall@k': 0.798, 'mrr@k': 0.681, 'dim': 768}
# all-MiniLM-L6-v2 {'recall@k': 0.703, 'mrr@k': 0.561, 'dim': 384}
#
# MiniLM is 2x smaller and 3x faster. If 0.70 recall clears your bar,
# the "worse" model may be the right production choice.Finally, mention Matryoshka representation learning: models trained so that the first 256 of 1536 dimensions are themselves a usable embedding. This lets you run a cheap 256-d first pass over the whole corpus and rerank the top candidates with full dimensions — meaningful index-cost savings with negligible quality loss.
Say these points
- Embedding models are trained contrastively for a metric space; LLM hidden states are not.
- Hard negatives drive quality far more than more data or a bigger model.
- Evaluate on a gold set from your own traffic — MTEB rank often does not transfer.
- Dimension drives index cost; Matryoshka embeddings let you truncate safely.
- Use the model's required query/passage prefixes, and plan for re-indexing on upgrade.
Avoid these mistakes
- Mean-pooling a generative model's hidden states and expecting good similarity.
- Omitting the 'query:' / 'passage:' prefixes for asymmetric models.
- Choosing a model on leaderboard rank without a domain evaluation.
Expect these follow-ups
- How would you fine-tune an embedding model on your domain with limited labels?
- Compare bi-encoders and cross-encoders for retrieval.
- How do you handle documents longer than the embedding model's context?
Showing 5 of 5 questions for llm-fundamentals-interview-questions.
Check your understanding
5 questions · no sign-up, nothing stored
Hands-on challenge
Build it — this is what you talk about in a deep-dive round.
Build a token-aware LLM cost and quality analyser
Write a tool that quantifies the tokenisation and context decisions in a real prompt pipeline.
Requirements
- Token counts and cost per request broken down by system prompt, retrieved context, history and user input.
- Comparison of the same information rendered as JSON, YAML and prose; report the token delta.
- Multilingual comparison across at least four languages with the cost multiplier for each.
- A context manager implementing pinned system prompt + recent turns + summary + retrieval, with tests for the truncation edge cases.
Stretch goals
- Run a needle-in-a-haystack evaluation at 5 depths × 4 context lengths and plot the accuracy surface.
- Measure the cost saving from reordering the prompt to maximise prefix-cache hits.