openai SDK pointing at a local Ollama server (no Anthropic API key required). View Claude version → · OS Track Index →
M01: The LLM Mental Model
Understand what a Large Language Model really is, how the model processes your text, and why the right mental model changes everything you'll build in this course.
Learning Objectives
- Explain what a Large Language Model is and how it generates text one token at a time
- Describe the difference between how the model reads input (all at once) and writes output (one token at a time)
- Use temperature, top-p, and top-k controls and predict how they change output
- Make your first the model API call using Python and Node.js
- Identify the four major LLM specializations — generative, embedding, reranker, multimodal — and when each is used in an agent pipeline
- Adopt the "thinker, not calculator" mental model for working with LLMs
What Is a Large Language Model?
BEFORE: Before LLMs, if you wanted a computer to answer a question, someone had to explicitly program every possible answer — think of old-school chatbots with giant lists of if/then rules, or search engines that could only find pages containing your exact keywords.
PAIN: That approach broke down constantly. Ask the chatbot something the programmer didn't anticipate, and you'd get "I don't understand." Ask a search engine a nuanced question, and you'd sift through ten blue links hoping one had your answer.
MAPPING: An LLM like the model is the world's most well-read autocomplete. Your phone's autocomplete has read your messages; the model has read billions of documents — books, code, conversations, scientific papers — and uses all of that to predict what comes next. Instead of following hand-written rules, it learned patterns from that mountain of text, so it can handle questions and tasks nobody explicitly programmed it for. It's autocomplete that went to every university, read every manual, and practiced every writing style.
What this actually looks like: When you type "The capital of France is", the model doesn't look up "France" in a table. Instead, it computes a probability for every possible next token. Here's a simplified version of what that prediction looks like internally:
Input: "The capital of France is"
Next token predictions:
" Paris" → 0.92 (92% probability)
" the" → 0.03 (3%)
" located" → 0.02 (2%)
" a" → 0.01 (1%)
" Lyon" → 0.005 (0.5%)
... thousands more tokens with tiny probabilities
First, "neural network" means a mathematical system that learns by example rather than by following hand-written rules. You show it billions of text samples, and it gradually adjusts billions of internal numbers (called parameters) until it gets good at one specific job.
That one job is: given a sequence of tokensThe smallest units of text that an LLM works with. A token can be a word, part of a word, or a punctuation mark. We'll explore tokens deeply in Module 2. (small chunks of text — roughly words or word-pieces), predict the most likely next token. That's it. Every impressive thing an LLM does — writing code, answering questions, translating languages — is a side effect of getting extremely good at next-token prediction.
Now for the "Large" part. The model-class models have hundreds of billions of parameters and were trained on terabytes of text. It doesn't "understand" language the way humans do — it finds statistical patterns at a scale that produces remarkably useful results. The reason this matters for you as a builder is that the model's power and its failure modes both come from this prediction mechanism.
The Model Zoo: Not All AI Models Do the Same Job
BEFORE: When most people say "AI" or "LLM," they picture one thing: a chatbot that takes questions and gives answers. It feels natural to assume there’s a single model powering all of AI.
PAIN: That assumption causes a recurring architectural mistake. Developers try to use a generative model like the model to do semantic search by comparing long text responses — when a purpose-built embedding model does it orders of magnitude faster and cheaper. They skip reranking and wonder why their RAG pipeline retrieves the wrong documents. The wrong model type for the job produces wrong results at higher cost, and the fix isn’t a better prompt — it’s reaching for a different kind of model entirely.
MAPPING: Think of AI models like kitchen knives. A chef’s knife is the versatile workhorse you reach for most — that’s a generative/chat model like the model. A bread knife has serrations for one specific job — that’s an embedding model, converting text to compact numeric vectors for search. A paring knife does precise, fast scoring work — that’s a reranker, ordering retrieved results by relevance before handing them off. And some knives handle fish, vegetables, and meat simultaneously — that’s a multimodal model, accepting text, images, and documents at once. Agent pipelines routinely chain 2–3 of these model types in a single request flow.
| Type | Input → Output | When to Use | Common Models | In This Course |
|---|---|---|---|---|
| Generative | Text → Text | Conversation, reasoning, writing, code generation, tool use — the general-purpose workhorse | The model Sonnet, GPT-4o, Gemini Flash | All of M01–M27 |
| Embedding | Text → Vector (1024–3072 floats) |
Semantic search, RAG indexing, similarity matching, clustering | Voyage-3, text-embedding-3-large, Cohere Embed | M09, M10, M11 |
| Reranker | (Query + [Doc⊂1…Doc⊂n]) → Scores | Second-stage RAG filtering: score retrieved chunks by relevance before sending to LLM | Cohere Rerank, Voyage Rerank, BGE-Reranker | M10 |
| Multimodal | Text + Images / PDFs / Audio → Text | Document parsing, vision Q&A, chart reading, computer use — when input isn’t just text | The model 3+ (all tiers), GPT-4o, Gemini 1.5 | M09, M24 |
Embedding modelA neural network that converts text into a dense numeric vector — a list of floating-point numbers. Similar texts land near each other in vector space. Embedding models never generate text; they only encode meaning into a compact, searchable format. — A specialized neural network that converts a piece of text into a dense numeric vector (e.g., 1024 numbers). Unlike generative models, it never produces text output. You call it once per document when building an index, and once per query at search time. The resulting vectors can be compared instantly across millions of documents using cosine similarity or dot-product search — something that would take hundreds of thousands of LLM calls if you used a generative model instead. We’ll build with these in M09.
Reranker modelA cross-encoder model that takes a query and a candidate document together and scores how relevant the document is. Because it sees both at once, it is significantly more accurate than vector similarity — but it only scales to dozens of candidates, not millions, so it runs after vector search narrows the field. — A cross-encoder that scores (query, document) pairs jointly rather than comparing pre-computed vectors. Because it sees both at once it is dramatically more accurate than vector similarity — but it only scales to tens of candidates, not millions. That’s why reranking runs after the fast vector search narrows the field: vector search finds the top-50, reranker promotes the truly relevant ones to the top-5. We’ll add reranking in M10.
Multimodal modelAn LLM trained to accept inputs beyond text — images, PDFs, audio, video frames — alongside text. Open models like LLaVA and Llama 3.2 Vision accept text and images. The output is still text; "multimodal" describes the input space, not the output space. — An LLM whose input layer accepts more than text. Open multimodal models such as LLaVA, Llama 3.2 Vision, and Qwen2-VL accept text plus images natively (Mistral 7B does not — it’s text-only). The output is still text — “multimodal” describes the input modalities, not the output. This lets you send screenshots, charts, and scanned forms to a vision model and feed its text answer into your pipeline, which matters for document-heavy agent use cases.
A production RAG pipeline that skips reranking typically retrieves the right document in its top-50 results but places it at rank 23 — so the generative model never sees it. Adding a reranker typically promotes it to rank 2. That quality jump has nothing to do with prompting the model differently. Similarly: doing semantic search by calling the model and comparing long text responses costs 50–100× more than calling an embedding model and comparing vectors — and it’s also less accurate. Choosing the right model type for each job isn’t just cleaner architecture; it’s often the difference between a $10/day agent and a $500/day one.
llava) so you can experience multimodal input directly — no extra API key required (just ollama pull llava).
Embedding and reranker calls require a third-party key (Voyage or Cohere) and are covered in depth starting in M09. For now, the taxonomy above is all the mental model you need.
Mistral 7B — the model powering the rest of this course — is a generative, text-only model. The other types are purpose-built models you run alongside it: the RAG track (M09–M11) adds embedding and reranker models, and images go to a vision model like llava. For now, let’s zoom in on exactly how the model processes text.
How the model Processes Text
BEFORE: Before transformer-based models, older AI systems (like recurrent neural networks) had to read text word-by-word in order, like a person reading a sentence while covering up the words ahead of them. This made them slow and forgetful — by the time they reached the end of a long paragraph, they'd already started "forgetting" the beginning.
PAIN: That sequential reading created a bottleneck: longer inputs meant worse comprehension, because the model couldn't hold everything in mind at once.
MAPPING: the model works like a speed reader who absorbs an entire page in one glance — every word attended to simultaneously, related to every other word. Then it writes its response one word at a time, each word influenced by everything it read plus everything it has written so far. The reading is instant and parallel; the writing is careful and sequential.
What this actually looks like: Here's a real API response showing the timing asymmetry. Notice how the input (your prompt) is processed almost instantly, but the output tokens trickle out one by one:
Request: 850 tokens of input
Response: 120 tokens of output
Timeline:
0ms → Request sent
180ms → First output token arrives (all 850 input tokens processed)
180ms → "The"
210ms → " best"
240ms → " approach"
... (each token ~30ms apart)
3780ms → Final token generated
Input processing: 180ms (850 tokens, all at once)
Output generation: 3600ms (120 tokens, one at a time)
This is why sending a 1,000-token prompt to the model is nearly as fast as sending a 100-token prompt: the input processing step happens in parallel.
Output generation, however, works completely differently. It's autoregressiveA process where each output depends on all previous outputs. the model generates token 5 by looking at tokens 1-4 plus the entire input. This is why generation is slower than reading., meaning "each step feeds into the next." Each new token is predicted based on all input tokens plus all previously generated output tokens. This is why output is the slow part — each token must wait for the previous one to be generated first.
(all at once)
(one by one)
How Inference Actually Works
BEFORE: You probably picture an LLM as a function: question goes in, full answer comes out. That mental model is wrong in a specific way that matters once you start optimizing latency and cost.
PAIN: Without the right picture, you can’t reason about why the first token takes 800 ms but the next 200 tokens stream out at 60 ms each. You can’t explain why a 50K-token prompt is expensive even before the model writes a single word back. You can’t budget for production.
MAPPING: Inference is autoregressive — the model generates output one token at a time, and each new token is conditioned on every token before it (yours and its own). Picture someone typing a long reply on a phone: they read what they’ve typed so far, pick the most likely next letter, append it, re-read, pick the next letter, append, and so on. That’s inference. There’s no “full answer” sitting in the model waiting to be unwrapped — the answer is constructed token-by-token, in real time, in the same call.
InferenceThe process of running a trained LLM to produce output. As opposed to training (which adjusts the model’s weights), inference uses the frozen weights to predict tokens. The cost you pay per API call is inference cost. is what happens when the model is running, not learning. Every call to the API kicks off two distinct phases:
1. Prefill (a.k.a. the “forward pass” over your prompt). Your prompt — system + messages + tool definitions — is tokenized (M02), converted to embeddingsHigh-dimensional vectors (4096 numbers, in many models) that represent a token in a meaning-space. Tokens that are semantically related land near each other in that space. We’ll dig into embeddings in M09., and pushed through every transformer layer in parallel. The model computes attention across all input tokens at once, which is fast per-token but heavy: cost is roughly O(N²) in prompt length. The output of prefill is one set of logits — a probability distribution over the entire vocabulary — for the next token to generate.
2. Decode (a.k.a. token-by-token generation). The model samples one token from the prefill logits, appends it to the running sequence, and runs just that new token through the transformer (reusing cached attention values for everything before it — the KV cacheKey/Value cache. During decode, the attention computations for already-processed tokens are kept in memory so each new token only needs to compute its own attention against the cache, not re-run the whole prompt. This is what makes streaming cheap per-token after the first one.). That produces the next set of logits. Sample, append, repeat — until the model emits a stop token or hits max_tokens. Decode is sequential by construction: token N can’t start until token N−1 exists.
Two phases, two costs. Prefill latency is paid once per request and dominates “time to first token.” Decode latency is paid per output token and dominates “tokens per second.” This split is why a 50K-token prompt with a 100-token answer feels slow to start but finishes quickly — and why a 200-token prompt with a 4000-token answer feels snappy at first but takes forever.
- Tokenize prompt (N tokens)
- Embed all N tokens
- Run through every transformer layer in parallel
- Build the KV cache for every token
- Output: logits for token N+1
Cost: ~O(N²) compute, but parallelizable. Latency: drives time-to-first-token.
- Sample one token from current logits
- Append it to the running sequence
- Run just the new token through layers, reading KV cache
- Get logits for the next token
- Stop if token == <end> or budget hit; else go to step 1
Cost: ~O(1) per token (memory-bound). Latency: drives tokens-per-second.
Sampling — How a List of Scores Becomes One Word
Start with the question every beginner actually has: why does the model give a different answer when I ask the exact same thing twice? The answer lives in the last step of decode, and it’s simpler than it sounds. Let’s shrink the problem down to something you can hold in your head.
Imagine you ask the model for the next word in a sentence, and instead of 32,000 choices it only has five. The model does not pick a word. It hands you a scorecard — one raw, uncalibrated number per word:
| Candidate next word | Raw score (“logit”) |
|---|---|
| cat | 4.0 |
| dog | 3.0 |
| bird | 2.0 |
| rock | 1.0 |
| the | 0.5 |
Those raw scores are called logitsThe raw, unnormalized scores a neural network outputs — one per token in the vocabulary. They can be any real number (negative or positive) and do not add up to 1. Softmax converts them into probabilities.. The real model produces one for every word it knows — tens of thousands of them (~32K for a model like Mistral) — but five is enough to see the whole mechanism. Logits aren’t probabilities yet: they can be negative, and they don’t add up to 100%. Three steps turn this scorecard into one chosen word.
Step 1 — Softmax: turn scores into percentages. SoftmaxA function that squashes a list of real-valued scores into positive numbers that sum to 1, while preserving their order. Bigger score in → bigger probability out. It exponentiates each score then divides by the total. is just a formula that squashes any list of scores into percentages that add up to 100%, while keeping their order (biggest stays biggest). Run our scorecard through it and we get a real probability distribution — a likelihood for each word:
Step 2 — Temperature: the “boldness dial.” Before softmax runs, we divide every score by a number T called temperatureA scalar that divides the logits before softmax. Low temperature sharpens the distribution toward the top token (more predictable); high temperature flattens it (more variety). Named by analogy to thermodynamics, where higher temperature means more random motion.. Think of it as a dial for how bold the model is allowed to be:
- T = 0 → always take the single highest score. “cat,” every single time. Predictable and repeatable, but it can feel robotic.
- T = 1 → use the model’s honest percentages above (cat 63%, dog 23%…).
- T = 2 → flatten the gaps so the underdogs get a real shot. The same scorecard now reads cat 42%, dog 25%, bird 15%, rock 9%, the 7%. More variety, more surprise — and more risk of “rock.”
Step 3 — Top-k / Top-p: the bouncer at the door. Even after softmax, we usually don’t want the model to ever blurt out a clearly-wrong word like “rock.” So we trim the tail before drawing:
- Top-kKeep only the k highest-probability tokens, discard the rest, then renormalize. A hard cap on how many candidates survive. — keep only the k highest words. With
k = 2, only cat and dog survive; everything else is turned away. - Top-pAlso called nucleus sampling. Sort tokens by probability and keep the smallest set whose cumulative probability reaches p, then renormalize. The number of survivors flexes with how confident the model is. (nucleus) — keep just enough words to cover p% of the probability. With
p = 0.90, we add words until we’ve covered 90%: cat (63%) + dog (86%) + bird (95% ≥ 90%) — so cat, dog, bird stay and rock, the are cut.
Then: roll the dice. From whichever words survived the bouncer, the model draws one at random — weighted by those percentages, so “cat” comes up far more often than “bird,” but “bird” can still win sometimes. That chosen word becomes the next token, gets appended to the sequence, and the whole decode loop repeats for the word after it.
That final dice roll is the entire reason the same prompt can give you different answers. The model isn’t being random for fun — it’s drawing from a weighted distribution it computed. And it’s exactly why setting temperature=0 makes output repeatable: with the dial at zero there’s no distribution to sample from, just “take the top word,” so the dice never get rolled. Everything you just watched happen to five words happens to ~32,000 words on every single token the model generates.
Latency Anatomy — Where Your Seconds Go
Real numbers, on the model Sonnet 4.6 (typical 2026 production load, single-region):
| Phase | What dominates | Order of magnitude |
|---|---|---|
| Network | TLS, request routing, region distance | 30–150 ms |
| Prefill (TTFT) | Prompt length; quadratic-ish in N | ~50 ms / 1K tokens (uncached); <5 ms / 1K cached |
| Decode | Output length; linear in tokens generated | ~50–100 tokens/s (Sonnet); higher with speculative decoding |
| Server queue | Concurrent traffic, rate-limit tier | 0–500 ms p99 |
- Time-to-first-token (TTFT) is set almost entirely by prompt length and whether the prefix is cached. Prompt caching (M22) turns “5 seconds of prefill” into “200 ms of prefill” on repeat-heavy prompts.
- Tokens-per-second (TPS) is set by the decode step. Output length is the cost driver here — doubling
max_tokensdoubles decode time, regardless of how long the prompt was. - Streaming (next subsection) doesn’t change total wall-clock latency; it just delivers tokens as they’re produced so users see something happening at TTFT instead of waiting for the full decode.
- Extended thinking (M22) and reasoning models add a hidden third phase — thinking tokens are decoded before any visible response. We’ll connect the dots in M22.
Streaming vs Batch — Same Inference, Different Delivery
One request, two ways to receive the answer:
- Non-streaming — you wait for the full decode, then receive the entire response in one chunk. Simple; the agent code in M05–M07 uses this shape.
- Streaming — the server sends each decoded token (or small group) as Server-Sent Events. Same total time, but the user sees the first tokens immediately. Essential for chat UIs and any agent loop where you want to surface progress before the response is complete.
from openai import OpenAI
import time
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
# Streaming: each token (or small chunk) arrives as it’s decoded.
t0 = time.perf_counter()
first_token_t = None
total_tokens = 0
stream = client.chat.completions.create(
model="mistral",
messages=[{"role": "user", "content": "Explain inference in one paragraph."}],
stream=True,
)
for chunk in stream:
delta = chunk.choices[0].delta.content
if not delta: # role-only / empty deltas arrive first
continue
if first_token_t is None:
first_token_t = time.perf_counter() - t0
print(f"[TTFT: {first_token_t*1000:.0f} ms]")
print(delta, end="", flush=True)
total_tokens += 1 # text chunks, not perfect token counts — close enough for ops
elapsed = time.perf_counter() - t0
print(f"\n[total: {elapsed:.2f}s, decode rate ~ {total_tokens/max(elapsed-first_token_t, 0.001):.0f} chunks/s]")
import OpenAI from "openai";
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const t0 = performance.now();
let firstTokenMs: number | null = null;
let chunks = 0;
const stream = await client.chat.completions.create({
model: "mistral",
messages: [{ role: "user", content: "Explain inference in one paragraph." }],
stream: true,
});
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta?.content;
if (!delta) continue; // role-only / empty deltas arrive first
if (firstTokenMs === null) {
firstTokenMs = performance.now() - t0;
console.log(`[TTFT: ${firstTokenMs.toFixed(0)} ms]`);
}
process.stdout.write(delta);
chunks++;
}
const elapsed = performance.now() - t0;
console.log(`\n[total: ${(elapsed / 1000).toFixed(2)}s, decode rate ~ ${(chunks / Math.max((elapsed - (firstTokenMs ?? 0)) / 1000, 0.001)).toFixed(0)} chunks/s]`);
You watched the two-phase model in real time. The TTFT print fires exactly when prefill finishes (and the first decoded token lands); the “decode rate” reports how fast subsequent tokens stream in. Run this against a short prompt and a 10K-token prompt back-to-back — you’ll see TTFT scale roughly with prompt length while decode rate stays roughly constant. That’s the prefill/decode split made visible.
“Inference and training are the same thing.” — They’re not. Training updates billions of weights using gradient descent across millions of examples; inference reads those frozen weights to predict the next token. You only ever do inference when calling the API; training happened at Anthropic before the model shipped.
“Streaming is faster than non-streaming.” — Same total time. Streaming just shows tokens as they decode rather than buffering them. Use streaming for UX (perceived latency); use non-streaming when you need the full response before doing anything (parsing JSON, tool dispatch).
“Big prompts are slow because the model has to read them all.” — Half-right. Prefill processes the prompt in parallel, but the work scales roughly with N² due to attention. Long prompts hurt TTFT, not decode rate. Prompt caching (M22) collapses that cost on repeat-heavy prompts.
“temperature=0 means deterministic.” — Mostly true, with caveats. At 0 the sampling step becomes argmax (no randomness in the model). But tie-breaking, server-side batching, and floating-point non-determinism on GPUs can still produce different outputs across runs. For strict reproducibility, also pin the model snapshot and seed if the API supports it.
Temperature, Top-p & Top-k
BEFORE: Without sampling controls, a language model would always pick the single highest-probability next word — like a restaurant that only ever serves the most popular dish, regardless of what you're in the mood for. Every sentence would sound the same, mechanical and repetitive.
PAIN: That's terrible for creative tasks (bland writing), but also bad for technical tasks where multiple phrasings are equally correct — the model would get stuck in ruts, always producing identical outputs.
MAPPING: Temperature is a creativity dial. At 0, the model always picks the safest, most predictable next word — like a cautious writer sticking to cliches. At 1.0, the model is willing to take risks and surprise you, choosing less-probable but more interesting words. Top-p and top-k are like narrowing the menu of options the model considers before making a choice — top-p says "only consider words that make up the top 90% of the probability," and top-k says "only consider the top 50 most likely words."
What this actually looks like: Here's the same set of next-token probabilities at three different temperature settings. Watch how the distribution shifts:
Prompt: "The best way to learn programming is"
Temperature 0.0 (greedy — always pick the top word):
" to" → 99.8% ← always chosen
" by" → 0.1%
" through" → 0.1%
Temperature 0.5 (moderate — top words dominate but others have a chance):
" to" → 58%
" by" → 22%
" through" → 12%
" with" → 5%
" from" → 3%
Temperature 1.0 (creative — spread across many options):
" to" → 30%
" by" → 22%
" through" → 15%
" with" → 10%
" from" → 6%
" when" → 4%
... more words now have a real chance
Step 1 — Temperature: The model produces logitsThe raw, unnormalized scores the model assigns to every possible next token before converting them into probabilities. Higher logits = higher probability. — raw prediction scores, think of them as "confidence points" for every possible next word. TemperatureA number (0 to 1) that scales the model's confidence scores before picking the next token. Lower = more deterministic, higher = more random/creative. divides all logits by the temperature value before they're converted to probabilities via softmaxA mathematical function that converts a list of numbers into probabilities (all positive, summing to 1). The bigger the input number, the bigger its share of the probability. (a function that turns numbers into percentages that add up to 100%). A low temperature like 0.1 makes the top word's probability dominate (say, 95%). A high temperature like 1.0 keeps the distribution spread out (maybe 30%, 20%, 15%...).
Step 2 — Top-p: Top-pAlso called nucleus sampling. Instead of considering all possible next tokens, the model only considers the smallest set whose combined probability exceeds p (e.g., 0.9 = top 90% of probability mass). (also called nucleus sampling) then trims the menu. It sorts words by probability and keeps only the smallest set whose probabilities add up to p. For example, 0.9 means "keep the top 90% of probability mass, discard the rest."
Step 3 — Top-k: Top-kLimits the model to only consider the k most likely next tokens. For example, top-k=50 means the model only picks from its top 50 predictions, ignoring all others. is a simpler filter — it limits consideration to the k most likely tokens regardless of their probabilities. For example, top-k=50 means only the top 50 words can be chosen.
In practice, temperature is the one you'll adjust most often. Top-p and top-k are fine-tuning knobs for when you need precise control.
Prompt: "The best way to learn programming is"
The "Calculator vs. Thinker" Mental Model
BEFORE: Before LLMs, most software was deterministic — a calculator gives you the same answer every time: 2 + 2 = 4, always. Developers built their entire workflow around this certainty: write code, run tests, expect exact outputs.
PAIN: When teams first adopt LLMs, they instinctively treat the model like a calculator. They write a prompt, get a great answer, ship it — then are shocked when the same prompt gives a subtly different (or wrong) answer the next day. Bug reports pile up, tests fail intermittently, and trust erodes.
MAPPING: The fix is a mental model shift: the model is a thinker, not a calculator. A thinker gives you their best answer, which can vary, can be wrong, and can surprise you with insight. Treat it like a very knowledgeable colleague who sometimes needs to be double-checked, not a database that returns exact facts. Once you internalize this, you'll naturally build verification steps, add guardrails, and design your agent for graceful handling of imperfect outputs.
What this actually looks like: Here's a real-world example of why the "thinker" model matters. Same prompt, two runs, both at temperature 0.0:
Prompt: "What is the population of Tokyo?"
Run 1: "Tokyo has a population of approximately 13.96 million
people in the city proper as of 2023."
Run 2: "The population of Tokyo is about 14 million in the
city proper, or roughly 37 million in the greater
metropolitan area."
Both are reasonable. Neither is "wrong." But a calculator
would give you the exact same answer every time.
An agent that routes decisions based on this output
needs to handle BOTH variations gracefully.
This mental model is the single most important idea in this course. In plain English: a calculator always gives you 2 + 2 = 4. A thinker gives you their best reasoning, which is usually excellent but occasionally off. When you build an agent, you're building around a thinker — so you design for "usually right" rather than "always right."
How does this work internally? When the model generates a response, it's making thousands of probabilistic choices (one per token). Each choice has some chance of going a different direction. Even at temperature 0.0, server-side implementation details like floating-point rounding and batch scheduling can cause tiny variations. The result is that outputs are highly consistent but not identical — and when the model is uncertain (borderline cases, ambiguous questions, math), those small variations can compound into meaningfully different answers.
How is this different from traditional software? In conventional programming, if a function returns the wrong result, it's a bug — you fix the code and it works. With LLMs, variation isn't a bug; it's a fundamental property of how the system works. This means your job as an agent builder shifts from "make it correct" to "make it reliably useful despite occasional imperfection." That's a completely different engineering discipline, and it's what the rest of this course teaches.
Here's how this mental model affects every decision you'll make:
- Prompts (M03): You're giving instructions to a thinker, not writing code for a machine
- Tool Use (M05): You give the thinker tools to compensate for what prediction can't do (real-time data, calculations, database lookups)
- Guardrails (M16–M17): You build checks because thinkers can make mistakes
- Evaluation (M18): You measure quality probabilistically, not as pass/fail
"LLMs are basically a smarter search engine / database, right?" — No. A database stores facts and retrieves them exactly. The model doesn't store or retrieve anything — it generates new text by predicting tokens. When it gives you a correct fact, it's because its training patterns lead to that prediction, not because it "looked it up." This is why it can produce plausible-sounding facts that are completely wrong — there's no lookup step to fail; it just predicts what sounds right.
"If I use temperature 0, the output is deterministic." — Almost, but not quite. Temperature 0 makes the model pick the highest-probability token each time, which is highly consistent. But in practice, minor server-side differences (floating-point math, batching) can occasionally produce slightly different outputs. Design your agents for "extremely consistent," not "bit-for-bit identical every time."
"the model understands what I'm saying." — It's more accurate to say the model is extremely good at pattern matching over language. It processes the statistical relationships between tokens in ways that produce remarkably useful results, but it has no internal model of truth, no beliefs, and no comprehension in the human sense. This matters because it means the model can confidently produce incorrect information — it doesn't "know" it's wrong.
"More parameters = more accurate." — Bigger models are generally more capable, but "capable" and "accurate" are different things. A larger model can handle more complex reasoning and nuanced prompts, but it can still hallucinate, and it may do so more convincingly. Size doesn't eliminate the need for verification and guardrails.
"If the model gets something wrong, I should just ask again." — Retrying the same prompt is a lottery, not a strategy. If the model's training patterns lead it toward a wrong answer, it will likely give the same wrong answer again. The fix is to change the approach: rephrase the prompt, provide examples, add context, or use a tool to fetch the correct information. You'll learn all these techniques in Modules 3 through 5.
Code Walkthrough: Your First the model API Call
Let's make your first call to the model API. This track uses Ollama running locally — no API key needed. Ollama exposes an OpenAI-compatible endpointA URL where your code sends requests. Ollama's Chat Completions endpoint is http://localhost:11434/v1 — the same interface as the OpenAI API, just running on your machine. at http://localhost:11434/v1.
Setup: Start Ollama & Pull a Model
# Start Ollama (runs as a background daemon)
ollama serve
# Pull Mistral (first time only — ~4GB download)
ollama pull mistral
# Verify it's available
ollama list # should show "mistral" in the list
Making the Call
Let's start with the simplest possible API call. The code below creates an OpenAI client pointing at Ollama, sends a single message, and prints the response. This three-step pattern — create client, call chat.completions.create, read the content — is the foundation for every API call you'll make in this course. Once you internalize this structure, adding tools, streaming, and multi-turn conversations in later modules will feel like natural extensions.
Here's the one thing that trips up almost everyone on their first try: the response is message.choices[0].message.content, not message.text. Why the extra [0]? Because content is a list of content blocks — the model can return text, images, and tool calls in the same response. Even for a simple text reply, you need [0] to grab the first block. Forget this and you'll get a confusing list object instead of a string.
# pip install openai>=0.30.0
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
try:
message = client.chat.completions.create(
model="mistral",
messages=[
{"role": "system", "content": "You are a helpful assistant who explains things clearly."},
{"role": "user", "content": "What is a large language model? Explain in 2 sentences."}
]
)
print(message.choices[0].message.content)
print(f"\nTokens used: {message.usage.prompt_tokens} in, {message.usage.completion_tokens} out")
except Exception as e:
print(f"API error: {e}")
// npm install openai
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
try {
const message = await client.chat.completions.create({
model: 'mistral',
messages: [
{ role: 'system', content: 'You are a helpful assistant who explains things clearly.' },
{ role: 'user', content: 'What is a large language model? Explain in 2 sentences.' }
]
});
console.log(message.choices[0].message.content);
console.log(`\nTokens used: ${message.usage.prompt_tokens} in, ${message.usage.completion_tokens} out`);
} catch (error) {
if (error?.status) {
console.error(`API error: ${error.status} - ${error.message}`);
} else {
throw error;
}
}
curl http://localhost:11434/v1/chat/completions \
-H "content-type: application/json" \
-d '{
"model": "mistral",
"messages": [
{"role": "system", "content": "You are a helpful assistant who explains things clearly."},
{"role": "user", "content": "What is a large language model? Explain in 2 sentences."}
]
}'
usage object tells you exactly how many tokens were consumed — this will matter for cost tracking (Module 2) and context window management (Module 4).
Experimenting with Temperature
The interesting part of this next example is that it sends the exact same prompt three times, each with a different temperature value (0.0, 0.5, and 1.0). The results will be noticeably different — that's the whole point. Seeing identical input produce varied output is the fastest way to feel that LLMs are stochastic, not deterministic. This is the kind of experiment worth running yourself, because reading about probability distributions is one thing — watching the model give you three different answers to the same question makes it click.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
prompt = "Write a one-sentence description of the moon."
for temp in [0.0, 0.5, 1.0]:
try:
message = client.chat.completions.create(
model="mistral",
temperature=temp,
messages=[{"role": "user", "content": prompt}]
)
print(f"Temperature {temp}: {message.choices[0].message.content}")
except Exception as e:
print(f"Error at temperature {temp}: {e}")
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const prompt = 'Write a one-sentence description of the moon.';
for (const temp of [0.0, 0.5, 1.0]) {
try {
const message = await client.chat.completions.create({
model: 'mistral',
temperature: temp,
messages: [{ role: 'user', content: prompt }]
});
console.log(`Temperature ${temp}: ${message.choices[0].message.content}`);
} catch (error) {
if (error?.status) {
console.error(`Error at temp ${temp}: ${error.message}`);
} else {
throw error;
}
}
}
Hands-On Exercise: Hello the model
What You'll Build
A series of small scripts that call the model's API, experiment with temperature, and culminate in a working CLI chatbot. By the end you'll have made your first API call, observed how temperature changes output, and built a multi-turn conversation loop.
Time estimate: 20–30 minutes • Prerequisites: Python 3.9+ or Node.js 18+ • Ollama installed and running (ollama serve)
Environment Setup
Copy and paste this entire block into your terminal to create a project folder and install the SDK:
mkdir hello-model && cd hello-model
python -m venv venv
# macOS/Linux:
source venv/bin/activate
# Windows:
# venv\Scripts\activate
pip install openai
# No API key needed — Ollama runs locally!
# Make sure Ollama is running: ollama serve
# Make sure mistral is downloaded: ollama pull mistral
mkdir hello-model && cd hello-model
npm init -y
npm install openai
# No API key needed — Ollama runs locally!
# Make sure Ollama is running: ollama serve
# Make sure mistral is downloaded: ollama pull mistral
Step 1: Make Your First API Call
This step verifies that Ollama is running and you can communicate with the model. It's the "hello world" of agent development — if this works, everything else in the course will build on it.
Create a new file called hello.py (or hello.mjs for Node.js):
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
try:
message = client.chat.completions.create(
model="mistral",
messages=[
{"role": "system", "content": "You are a helpful assistant who explains things clearly."},
{"role": "user", "content": "What is a large language model? Explain in 2 sentences."}
]
)
print(message.choices[0].message.content)
print(f"\nTokens used: {message.usage.prompt_tokens} in, {message.usage.completion_tokens} out")
except Exception as e:
print(f"API error: {e}")
print("Is Ollama running? Try: ollama serve")
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
try {
const message = await client.chat.completions.create({
model: 'mistral',
messages: [
{ role: 'system', content: 'You are a helpful assistant who explains things clearly.' },
{ role: 'user', content: 'What is a large language model? Explain in 2 sentences.' }
]
});
console.log(message.choices[0].message.content);
console.log(`\nTokens used: ${message.usage.prompt_tokens} in, ${message.usage.completion_tokens} out`);
} catch (error) {
console.error(`API error: ${error.message}`);
console.error('Is Ollama running? Try: ollama serve');
}
Run it: python hello.py (or node hello.mjs)
Troubleshooting
Connection refused/ timeout — Ollama is not running. Start it withollama servein a separate terminal. Verify withcurl http://localhost:11434/v1/models.ModuleNotFoundError: No module named 'openai'— You're not in the virtual environment. Runsource venv/bin/activatefirst, thenpip install openai.- Model not found error — Run
ollama listto see available models. If mistral is missing, runollama pull mistral.
Step 2: Experiment with System Prompts
System prompts shape the model's personality and behavior. This step shows you how much control a single string gives you over the output. You'll use the same user message but swap the system prompt to see wildly different responses.
Create a new file called system_prompts.py (or system_prompts.mjs):
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
prompts = [
"You are a pirate. Respond in pirate speak.",
"You are a formal academic. Use precise, scholarly language.",
"Respond only in haiku format (5-7-5 syllables).",
]
for system_prompt in prompts:
try:
message = client.chat.completions.create(
model="mistral",
messages=[{"role": "user", "content": "What is the moon?"}]
)
print(f"System: {system_prompt}")
print(f"Response: {message.choices[0].message.content}\n")
except Exception as e:
print(f"Error: {e}")
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const prompts = [
'You are a pirate. Respond in pirate speak.',
'You are a formal academic. Use precise, scholarly language.',
'Respond only in haiku format (5-7-5 syllables).',
];
for (const systemPrompt of prompts) {
try {
const message = await client.chat.completions.create({
model: 'mistral',
messages: [{ role: 'user', content: 'What is the moon?' }]
});
console.log(`System: ${systemPrompt}`);
console.log(`Response: ${message.choices[0].message.content}\n`);
} catch (error) {
console.error(`Error: ${error.message}`);
}
}
Run it: python system_prompts.py (or node system_prompts.mjs)
Step 3: Temperature Experiment
This step makes the "thinker, not calculator" concept visceral. You'll run the exact same prompt multiple times at different temperatures and compare how consistent the outputs are. This is the experiment that makes non-determinism click.
Create a new file called temperature.py (or temperature.mjs):
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
prompt = "Write a one-sentence description of the moon."
for temp in [0.0, 1.0]:
print(f"\n--- Temperature {temp} ---")
for i in range(3):
try:
message = client.chat.completions.create(
model="mistral",
temperature=temp,
messages=[{"role": "user", "content": prompt}]
)
print(f" Run {i+1}: {message.choices[0].message.content}")
except Exception as e:
print(f" Error: {e}")
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const prompt = 'Write a one-sentence description of the moon.';
for (const temp of [0.0, 1.0]) {
console.log(`\n--- Temperature ${temp} ---`);
for (let i = 0; i < 3; i++) {
try {
const message = await client.chat.completions.create({
model: 'mistral',
temperature: temp,
messages: [{ role: 'user', content: prompt }]
});
console.log(` Run ${i + 1}: ${message.choices[0].message.content}`);
} catch (error) {
console.error(` Error: ${error.message}`);
}
}
}
Run it: python temperature.py (or node temperature.mjs)
Step 4: Observe Token Usage
Token counts drive API costs and context limits — two concepts you'll use throughout the entire course. This step trains you to notice how different prompts and settings affect token consumption, so it becomes instinctive. This uses the message.usage object from Step 1.
Create a new file called token_usage.py (or token_usage.mjs):
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
tests = [
("Short prompt", "Hi!", 50),
("Medium prompt", "Explain what a large language model is in detail.", 200),
("Long prompt with constraint", "Write a 3-paragraph essay about the history of computing.", 1024),
]
for label, prompt, max_tok in tests:
try:
message = client.chat.completions.create(
model="mistral",
messages=[{"role": "user", "content": prompt}]
)
u = message.usage
print(f"{label}:")
print(f" Input tokens: {u.prompt_tokens}")
print(f" Output tokens: {u.completion_tokens}")
print(f" Total tokens: {u.prompt_tokens + u.completion_tokens}\n")
except Exception as e:
print(f"Error: {e}")
import OpenAI from 'openai';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const tests = [
['Short prompt', 'Hi!', 50],
['Medium prompt', 'Explain what a large language model is in detail.', 200],
['Long prompt with constraint', 'Write a 3-paragraph essay about the history of computing.', 1024],
];
for (const [label, prompt, maxTok] of tests) {
try {
const message = await client.chat.completions.create({
model: 'mistral',
max_tokens: maxTok,
messages: [{ role: 'user', content: prompt }]
});
const u = message.usage;
console.log(`${label}:`);
console.log(` Input tokens: ${u.prompt_tokens}`);
console.log(` Output tokens: ${u.completion_tokens}`);
console.log(` Total tokens: ${u.prompt_tokens + u.completion_tokens}\n`);
} catch (error) {
console.error(`Error: ${error.message}`);
}
}
Run it: python token_usage.py (or node token_usage.mjs)
max_tokens parameter is a ceiling, not a target — the model often uses fewer. You'll explore tokens deeply in M02.
Step 5 (Stretch Goal): Build a CLI Chat
conversation list — you accumulate messages so the model can see the full history, just like how each output token depends on all previous tokens.
Now let's build something you can actually interact with: a terminal chatbot that remembers your conversation. This matters because multi-turn conversation is the foundation of every agent — agents don't just answer one question, they maintain context across a sequence of actions. The pattern you'll see here (accumulate messages in a list, send the full list on each call, append the response) is the exact same loop that powers production agent frameworks.
Here's the dilemma to watch for: what happens when an API call fails mid-conversation? Notice the conversation.pop() in the error handler. If you skip that, you'd have a user message sitting in the list with no assistant response after it. The next API call would fail because the model's API requires strict alternating user/assistant messages. That one line of cleanup prevents a cascade of confusing errors.
from openai import OpenAI
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
conversation = []
print("Chat with the model! (type 'quit' to exit)\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("quit", "exit"):
break
if not user_input:
continue
conversation.append({"role": "user", "content": user_input})
try:
response = client.chat.completions.create(
model="mistral",
messages=[{"role": "system", "content": "You are a friendly, helpful assistant."}] + conversation
)
assistant_msg = response.choices[0].message.content
conversation.append({"role": "assistant", "content": assistant_msg})
print(f"\nClaude: {assistant_msg}\n")
except Exception as e:
print(f"\nError: {e}\n")
# Remove the failed user message so conversation stays consistent
conversation.pop()
import OpenAI from 'openai';
import * as readline from 'readline';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const conversation = [];
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
console.log("Chat with the model! (type 'quit' to exit)\n");
function ask() {
rl.question('You: ', async (userInput) => {
userInput = userInput.trim();
if (['quit', 'exit'].includes(userInput.toLowerCase())) {
rl.close();
return;
}
if (!userInput) { ask(); return; }
conversation.push({ role: 'user', content: userInput });
try {
const response = await client.chat.completions.create({
model: 'mistral',
messages: [{ role: 'system', content: 'You are a friendly, helpful assistant.' }, ...conversation],
});
const assistantMsg = response.choices[0].message.content;
conversation.push({ role: 'assistant', content: assistantMsg });
console.log(`\nClaude: ${assistantMsg}\n`);
} catch (error) {
console.error(`\nError: ${error.message}\n`);
conversation.pop();
}
ask();
});
}
ask();
conversation array, sent to the model along with the full history, and the response is appended back. the model sees every previous exchange on each call — that's how it "remembers" the conversation. This pattern (accumulate messages → send all → append response) is the exact loop that powers agent frameworks you'll see in Modules 7–9.
quit to exit.
Troubleshooting
TypeError: Cannot read properties of undefined(Node.js) — Make sure your file has the.mjsextension or yourpackage.jsoncontains"type": "module"for ES module imports.- The model doesn't remember previous messages — Check that you're appending both the user message and the assistant response to the
conversationlist. Both must be present. - Rate limit errors after many messages — Add a short delay between calls or reduce your conversation length. Long conversations also consume more tokens per call.
Bonus Step 6: Experience the Multimodal Mode
The Model Zoo section introduced four model types. You’ve been using the generative mode the whole lab. This bonus step lets you experience the multimodal mode: pass an image URL alongside a text question and the model describes what it sees. No additional API key required.
The script (model_zoo_lab.py / model_zoo_lab.mjs) is in the starter folder alongside the other labs. Fill in the two TODO functions, then run it:
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
MODEL = "mistral" # swap to "llama3" or "phi3" for other models
# --- GENERATIVE (text → text) ---
def generative_call(question: str) -> str:
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": question}],
)
return response.choices[0].message.content
# --- MULTIMODAL (image + text → text) — needs a vision model: `ollama pull llava` ---
def multimodal_call(image_url: str, question: str) -> str:
response = client.chat.completions.create(
model="llava", # Mistral is text-only; images need a multimodal model
messages=[{
"role": "user",
"content": [
{"type": "text", "text": question},
# OpenAI-compatible image block (Ollama accepts a URL or a base64 data: URI)
{"type": "image_url", "image_url": {"url": image_url}},
],
}],
)
return response.choices[0].message.content
IMAGE_URL = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Bikesgray.jpg/320px-Bikesgray.jpg"
print(generative_call("What is the Eiffel Tower? One sentence."))
print(multimodal_call(IMAGE_URL, "Describe this image in one sentence."))
import OpenAI from 'openai';
import 'dotenv/config';
const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
const MODEL = 'mistral';
async function generativeCall(question) {
const r = await client.chat.completions.create({
model: MODEL, messages: [{ role: 'user', content: question }],
});
return r.choices[0].message.content;
}
// needs a vision model: `ollama pull llava`
async function multimodalCall(imageUrl, question) {
const r = await client.chat.completions.create({
model: 'llava', // Mistral is text-only; images need a multimodal model
messages: [{
role: 'user',
content: [
{ type: 'text', text: question },
// OpenAI-compatible image block (Ollama accepts a URL or a base64 data: URI)
{ type: 'image_url', image_url: { url: imageUrl } },
],
}],
});
return r.choices[0].message.content;
}
const IMAGE_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Bikesgray.jpg/320px-Bikesgray.jpg';
console.log(await generativeCall('What is the Eiffel Tower? One sentence.'));
console.log(await multimodalCall(IMAGE_URL, 'Describe this image in one sentence.'));
llava produce a one-sentence description of the bicycle image. Two things change between the calls: the content array (generative passes a plain string; multimodal passes a text block and an image_url block), and the model — Mistral is text-only, so vision needs a multimodal model like llava.
Why not call an embedding or reranker here? Those models need a separate provider key (Voyage, Cohere, or OpenAI) and only make sense once you have a document corpus to search. You’ll build the full pipeline in M09 (embedding + vector index) and M10 (reranking). For now, the four-type taxonomy is your design compass.
Verify Everything Works
Run all scripts in sequence to confirm your setup is complete:
# Python
python hello.py && python system_prompts.py && python temperature.py && python token_usage.py
# Bonus: multimodal (requires internet access for the image URL)
python model_zoo_lab.py
# Node.js
node hello.mjs && node system_prompts.mjs && node temperature.mjs && node token_usage.mjs
node model_zoo_lab.mjs
Want to Run This Without an API Key?
You're already here — this track uses Ollama + Mistral-7B for all code examples. All the concepts apply to any LLM. The comparison below shows what the same call looks like in the main course (Claude/Anthropic SDK) vs. this track (OpenAI SDK + Ollama), so you can follow along with either version.
Same Call, Two Providers — Python
# Main course: Claude via Anthropic SDK from anthropic import Anthropic client = Anthropic() response = client.messages.create( model="claude-sonnet-4-6", max_tokens=512, messages=[{"role": "user", "content": "Hello!"}] ) text = response.content[0].text # This track: Mistral-7B via Ollama (ollama pull mistral) from openai import OpenAI client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama") response = client.chat.completions.create( model="mistral", messages=[{"role": "user", "content": "Hello!"}] ) text = response.choices[0].message.content
Three differences: client initializer, model name, and response path (content[0].text vs choices[0].message.content). Every other concept — system prompts, temperature, multi-turn history, tool use — maps directly between the two.
Module M28 covers the full swap: Ollama setup, hardware requirements, LiteLLM for provider-agnostic agents, and an honest comparison of what Claude-specific features have no open-source equivalent. You can take M28 anytime — it’s a standalone appendix module.
Knowledge Check
Test your understanding of the concepts from this module. Select the best answer for each question.
Q1: What does a Large Language Model fundamentally do?
Q2: What does the temperature parameter control?
Q3: Why is the model described as a "thinker" rather than a "calculator"?
Q4: How does the model process input differently from how it generates output?
Q5: Fill in the blank to complete a valid the model API call:
message = client.chat.completions.create(
model="mistral",
messages=[{"role": "user", "content": "Hello!"}]
)
print(message.______[0].text)
response
text
content
choices
content array. Each element is a content block with a text property. So it's message.choices[0].message.content.Q6: What is the recommended temperature setting for an agent that calls tools and makes decisions?
Q7: Your agent needs to search a library of 1 million documents to find relevant context. Which model type handles the search step?
Module Summary
Key Takeaways
- LLMs are next-token predictors — they generate text by predicting the most likely continuation of a sequence.
- Input = parallel, Output = sequential — the model reads everything at once but writes one token at a time.
- Temperature controls randomness — low for agents (reliability), high for creative tasks (variety).
- Thinker, not calculator — outputs are probabilistic and need verification. This mental model guides every agent design decision.
- The API is simple — create a client, send messages, get a response with content blocks and usage stats.
- Four model types, four jobs — generative models reason and write; embedding models encode text as searchable vectors; rerankers score relevance; multimodal models accept images and documents. Agent pipelines chain all four.
Next Module Preview: M02 — Tokens
Now that you know the model predicts tokens, the natural question is: what exactly is a token? In Module 2, you'll build an interactive tokenizer, understand how tokens affect cost and context limits, and create a token budget calculator — a tool you'll use throughout the entire course.