CourseLarge Language Models: The Complete Course · Module 1: What a Large Language Model Is · part 7 of 103
Part 7 · Module 1: What a Large Language Model Is

Lesson 1: A model that only predicts the next token

24 min read·13 Oct 2026
Module 1 · Lesson 1 · About 50 minutes · Needs: basic Python (lists, dictionaries, functions) and a Google account for Colab

The situation

It is Monday 1 June 2026, your first day at Quillpath, which sells project-management software to small teams. Maya, the Support Lead, shows you her queue: 1,800 tickets a week, six agents, a median wait of 7.5 hours for a first reply. Most answers are already in the help centre. She wants an assistant that labels every ticket and drafts a reply for an agent to check. (Quillpath and its data are made up for this course.) First you need to know what a language model actually does.

MAYA'S QUEUE, WEEK 1 (SIMULATED) 1,800 tickets a week 6 support agents 7.5 h median first reply assistant: label and draft
Quillpath's desk gets 1,800 tickets a week for six agents, and the median ticket waits 7.5 hours (red: the problem); the assistant (amber: the model) is meant to label and draft.

A real case, first

In March 2025 Anthropic published "Tracing the thoughts of a large language model", summarising two interpretability papers on Claude 3.5 Haiku. It starts from a plain fact: "Claude writes text one word at a time." Does the model only predict the next word, or does it plan ahead?

Given "He saw a carrot and had to grab it," the model wrote "His hunger was like a starving rabbit." Inside it, "rabbit" was active before the second line began. Suppress that concept and the line ended with "habit"; inject "green" and it ended with "green". Their conclusion: "even though models are trained to output one word at a time, they may think on much longer horizons to do so."

Keep both halves in mind: the output really is produced one piece at a time, and what chooses each piece can be surprisingly rich.

"...had to grab it," already active: rabbit line ends: rabbit suppress rabbit line ends: habit inject green line ends: green ANTHROPIC'S INTERVENTIONS
Anthropic (2025) found Claude picked the rhyme before writing the line: change that inner plan and the line ends differently, though the text still comes out one word at a time.

What you will be able to do

  • Read a Quillpath ticket and its gold labels, and say why the dev and test sets are kept apart.
  • Call three model providers through one client, changed by one setting.
  • Send a first triage request and compare the answer with the gold label.
  • Explain the next-token objective and compute its loss by hand.
  • Build, train and open TinyLM, and generate a reply from it one token at a time.

1. Quillpath tickets and gold labels (Essential)

You cannot tell whether a model did well unless you know the right answer first. So before any model call, you need tickets with answers written down by people.

T-1001 · en · team "charged 288 USD twice on 1 June..." billing category high priority billing-refunds article the input a customer wrote gold labels
A ticket is the customer's words plus three gold labels written by people (green: the correct answer): T-1001 is billing, high priority, answered by billing-refunds.

T-1001 comes from Ana Duarte, owner of Ardent Design, who was charged 288 USD twice. Lena and Ravi, two of Maya's agents, labelled it from written definitions: billing, high, article billing-refunds. A label written by a person and treated as the correct answer is a gold label. This lesson follows T-1001 throughout.

python
CATEGORIES = ("billing", "cancellation", "account_access", "bug", "how_to", "feature_request")
PRIORITIES = ("low", "normal", "high", "urgent")

CATEGORY_DEFINITIONS = {
    "billing": "Charges, invoices, prices, plan changes, and refund requests that do not end the plan.",
    "cancellation": "The customer wants to end a paid plan, with or without a refund.",
    "account_access": "Signing in, passwords, two-factor, SSO, and deleting a personal account.",
    "bug": "Something in Quillpath does not work as documented, including slowness and outages.",
    "how_to": "How to do something Quillpath already supports, or where to find a document.",
    "feature_request": "Something Quillpath does not do today that the customer would like.",
}

PRIORITY_DEFINITIONS = {
    "urgent": "A whole team cannot work right now.",
    "high": "One person cannot work, money was taken wrongly, or data is being lost.",
    "normal": "Everything else that needs an answer within a working day.",
    "low": "Ideas and questions with no time pressure, stated or implied.",
}

assert set(CATEGORY_DEFINITIONS) == set(CATEGORIES) and set(PRIORITY_DEFINITIONS) == set(PRIORITIES)
print(len(CATEGORIES), "categories,", len(PRIORITIES), "priorities")

Code walkthrough

  • CATEGORIES and PRIORITIES are the fixed label sets, as tuples so they cannot change by accident.
  • The definition dictionaries hold one sentence per label; when two labels seem to fit, the definition decides.
  • The assert checks every label has a definition. It prints 6 categories, 4 priorities.
36 TICKETS, SPLIT ONCE ON 8 JUNE 24 dev: read and tune 12 test: locked used every day while building scored when finished
The 24 dev tickets (blue: your working set) are for building; the 12 test tickets (grey, dashed: set aside) are scored only when a version is finished, so the score stays honest.

On Monday 8 June Maya's team took 36 tickets from the queue and split them once. You read and tune on the 24 dev set tickets. The 12 test tickets stay aside until a version is finished, so the score on them is honest; they arrive in Module 4.

python
# (id, language, plan, subject, body, category, priority, gold article or None)
DEV = [
    ("T-1001", "en", "team", "Charged twice this month", "Hi, my card was charged 288 USD twice on 1 June for the Team plan (invoice INV-2026-004512). Please refund the duplicate. Ana Duarte, owner, Ardent Design", "billing", "high", "billing-refunds"),
    ("T-1002", "en", "business", "Switch to annual billing?", "We are on Business monthly with 8 seats. If we move to annual, what do we pay and when does it start?", "billing", "normal", "billing-plans"),
    ("T-1003", "en", "team", "VAT number on invoices", "Our accountant needs our VAT number on every invoice. Where do I add it, and can you reissue May's invoice?", "billing", "normal", "billing-invoices"),
    ("T-1004", "en", "team", "Refund for annual plan", "I bought the annual Team plan 20 days ago and we have decided not to use Quillpath. Can I get my money back? Yuki Sato, Studio Nami", "cancellation", "normal", "billing-refunds"),
    ("T-1005", "en", "business", "Cancel our subscription", "Please cancel our Business plan at the end of this month. We are moving to another tool.", "cancellation", "normal", "billing-refunds"),
    ("T-1006", "en", "free", "Forgot my password", "The reset email never arrived and I checked spam. My login is kofi.osei@example.com, phone +44 7700 900321.", "account_access", "high", "account-login"),
    ("T-1007", "en", "business", "SSO login loop", "Since this morning everyone in our team is sent back to the login page after signing in through our identity provider. Nobody can get in.", "account_access", "urgent", "account-sso"),
    ("T-1008", "en", "team", "Locked out", "I typed my password wrong a few times and now it says my account is locked. I have a client demo in an hour.", "account_access", "high", "account-login"),
    ("T-1009", "en", "team", "Add a teammate", "How do I invite a new person to our workspace, and will it change our bill?", "how_to", "low", "billing-plans"),
    ("T-1010", "en", "team", "Automations not firing", "Our rule 'move card to Done when checklist complete' stopped running yesterday. We use a lot of automations. Nothing changed on our side.", "bug", "normal", "boards-automations"),
    ("T-1011", "en", "business", "Export all our boards", "We need a full export of every board with comments for an audit. What formats do you support?", "how_to", "normal", "exports-data"),
    ("T-1012", "en", "business", "Slack posts duplicated", "Every card update now posts twice in our Slack channel.", "bug", "normal", "integrations-slack"),
    ("T-1013", "en", "team", "App crashes on start", "Since Friday's update the Android app closes as soon as I open it. Android 16. I already reinstalled and cleared the cache.", "bug", "normal", None),
    ("T-1014", "en", "free", "Dark mode please", "Would love a dark mode in the web app. My eyes would thank you.", "feature_request", "low", "feature-requests"),
    ("T-1015", "en", "business", "Is Quillpath down?", "Boards are loading very slowly for the whole team and most pages time out.", "bug", "urgent", "status-incidents"),
    ("T-1016", "en", "enterprise", "Data processing agreement", "Our legal team needs your DPA and the list of subprocessors before we renew.", "how_to", "normal", "data-privacy"),
    ("T-1017", "de", "team", "Doppelte Abbuchung", "Hallo, am 1. Juni wurden uns 60 USD zweimal abgebucht (Rechnung INV-2026-004530). Bitte erstatten Sie die doppelte Zahlung. Jonas Feldmann", "billing", "high", "billing-refunds"),
    ("T-1018", "es", "business", "No puedo entrar", "Desde ayer no puedo iniciar sesión. Dice que mi contraseña es incorrecta, pero la acabo de cambiar.", "account_access", "high", "account-login"),
    ("T-1019", "en", "team", "Delete my account", "Please delete my personal account and my data. I am leaving the company.", "account_access", "normal", "data-privacy"),
    ("T-1020", "en", "team", "Gantt view?", "Is there a timeline or Gantt view? I could not find one.", "feature_request", "low", "feature-requests"),
    ("T-1021", "en", "business", "Recurring cards", "Can I make a card that comes back every Monday?", "how_to", "low", "boards-automations"),
    ("T-1022", "en", "team", "Charged after cancelling", "I cancelled on 2 June but was charged again on 5 June. Why?", "billing", "high", None),
    ("T-1023", "en", "enterprise", "Contract question", "Our procurement team wants to change the liability clause in our Enterprise agreement. Who should they talk to?", "how_to", "normal", None),
    ("T-1024", "ja", "team", "請求書のダウンロード", "請求書をPDFでダウンロードするにはどうすればいいですか?", "how_to", "low", "billing-invoices"),
]
print(len(DEV), "dev tickets")

Code walkthrough

  • Each ticket is a tuple: id, language, plan, subject, body, then the three gold labels. A None article means a person must answer.
  • T-1017, T-1018 and T-1024 are not in English. It prints 24 dev tickets.

2. One client for three providers (Essential)

Providers change model names, prices and limits every few months. If the provider is written all through your code, each change is a rewrite; if it is one setting, it is a one-line edit.

your code llm Groq Colab Gemini Colab Ollama your laptop PROVIDER picks one
Your code talks only to llm (blue); one setting decides which provider's model (amber) answers, so switching provider is a one-line change.

The course uses LangChain, a Python library that gives every provider the same interface. You create one object, llm, and talk only to it. Behind it sits Groq or Google Gemini in Colab, or Ollama on your own computer. Keys live in Colab's Secrets panel (set-up steps: course-guide/02).

python
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollama
python
import os
from langchain.chat_models import init_chat_model

PROVIDER = "groq"   # "groq" or "gemini" in Colab; "ollama" on your own computer
MODELS = {"groq": "groq:openai/gpt-oss-20b", "gemini": "google_genai:gemini-3.5-flash-lite", "ollama": "ollama:llama3.2:3b"}
KEY = {"groq": "GROQ_API_KEY", "gemini": "GOOGLE_API_KEY", "ollama": None}[PROVIDER]
if KEY and KEY not in os.environ:
    from google.colab import userdata   # keys live in Colab's Secrets panel
    os.environ[KEY] = userdata.get(KEY)
llm = init_chat_model(MODELS[PROVIDER], temperature=0, max_retries=6)

Code walkthrough

  • PROVIDER is the one word you change; MODELS holds the model names (checked October 2026).
  • The if block copies the provider's key from Colab Secrets into an environment variable.
  • init_chat_model builds the connector; temperature=0 asks for repeatable answers.
python
def ask(model, messages):
    """Returns (reply, None), or (None, reason) after two refusals."""
    for attempt in range(2):                     # one retry: the next reply is usually fine
        try:
            return model.invoke(messages), None
        except Exception as err:
            last = err
    return None, f"provider_error: {type(last).__name__}"

Code walkthrough: ask (course-guide/02) retries a reply the provider rejects once, then returns no reply and the reason provider_error.

groq : openai/gpt-oss-20b provider picks the connector model name as the provider writes it
One string names both: the provider before the colon chooses the connector, the model name after it is passed to that provider unchanged.

In groq:openai/gpt-oss-20b, the part before the colon is the provider, which picks the connector; after it is the model name as that provider writes it. Swap the string and nothing else changes.

python
reply, err = ask(llm, "Reply with the single word: ready")
print("connector:", type(llm).__name__)
print("reply:    ", reply.text.strip() if reply else f"no answer ({err})")
print("tokens:   ", reply.usage_metadata if reply else None)

Code walkthrough

  • ask sends one message through llm.invoke and waits for the whole reply; .text is the reply as a string.
  • type(llm).__name__ names the connector built. On Gemini: ChatGoogleGenerativeAI, reply ready.
  • usage_metadata counts tokens sent and received; Module 2 turns them into money.

3. First triage call to a model (Essential)

This is Maya's first job: a category and a priority for every ticket. Watching a real model do it, on a ticket whose answer you know, is the quickest way to learn what you are working with.

system label definitions human T-1001 subject + body model reply "billing high"
A chat request is a list of messages: standing instructions in the system message, the ticket in the human message, and the model's reply comes back as one more message.

A chat request is a list of messages. The system message holds standing instructions, here the label definitions. The human message holds the ticket. The model answers with one more message. Nothing else passes between you.

python
tid, lang, plan, subject, body, gold_cat, gold_pri, gold_article = DEV[0]   # T-1001

system = ("You triage support tickets for Quillpath, a project-management app.\nCategories:\n"
          + "\n".join(f"- {c}: {d}" for c, d in CATEGORY_DEFINITIONS.items())
          + "\nPriorities:\n"
          + "\n".join(f"- {p}: {d}" for p, d in PRIORITY_DEFINITIONS.items())
          + "\nReply with two words only: the category, then the priority.")

reply, err = ask(llm, [("system", system), ("human", f"Subject: {subject}\n\n{body}")])
if reply is None:
    print(tid, "no answer:", err)
else:
    words = reply.text.strip().lower().split()
    print(tid, "model:", words, "| gold:", [gold_cat, gold_pri])
    print("category correct:", gold_cat in words, "| priority correct:", gold_pri in words)

Code walkthrough

  • DEV[0] is T-1001; unpacking names its eight fields.
  • system is built from the definitions, so prompt and labels cannot drift apart.
  • The ("system", ...) and ("human", ...) pairs are the message list; the last lines compare with the gold labels, or say no answer.
model gold category billing billing match priority high high match
On T-1001, Gemini's answer (amber) matched both gold labels (green); one ticket is a start, not a score.

On Gemini the reply was billing high, matching both gold labels (October 2026). One ticket proves little; the exercise scores eight. The model was told nothing about Quillpath's prices or refund policy: it labelled the ticket from the words alone.

4. The next-token prediction objective (Essential)

Every behaviour you will see from a language model, good or bad, comes from one training goal. Knowing it tells you when a model will be confident and when it will make things up.

The goal is the next-token prediction objective: given the text so far, give a probability to every possible next piece of text. That piece is a token: a word, part of a word or a punctuation mark. You can see the idea with plain counting on the course's training text, the help centre plus 3,000 short support conversations built from it.

python
ARTICLES = {
    "account-login": ("Sign-in and passwords", "Reset your password from the sign-in page with Forgot password. The reset link is emailed and expires after 30 minutes. After 5 failed sign-in attempts the account is locked for 15 minutes and then unlocks by itself. If you lost your two-factor device, a workspace owner can reset two-factor for you."),
    "account-sso": ("Single sign-on", "SSO with SAML or OIDC is available on Business and Enterprise plans. A workspace owner sets it up in Settings > Security. If people are sent back to the sign-in page after signing in, check that your identity provider's certificate has not expired and that the callback URL matches the one in Settings."),
    "billing-invoices": ("Invoices and tax details", "Invoices are emailed to the billing contact after every charge and can be downloaded as PDF from Settings > Billing > Invoices. Add a VAT or tax number in Settings > Billing > Tax details; it appears on future invoices. Support can reissue invoices from the last 12 months."),
    "billing-plans": ("Plans and seats", "Free: up to 3 users and 5 boards. Team: 12 USD per member per month, or 10 USD billed annually. Business: 24 USD per member per month, or 20 USD billed annually, and adds SSO and audit logs. Enterprise: custom pricing, contact sales. A member added mid-period is charged pro rata on the next invoice. Switching from monthly to annual starts a new annual period that day."),
    "billing-refunds": ("Refunds and cancellations", "Cancel at any time from Settings > Billing > Cancel plan. Monthly plans stay active until the end of the period and are not refunded pro rata. Annual plans cancelled within 14 days of purchase or renewal get a full refund; after 14 days, no refund. Duplicate charges are always refunded in full. Only workspace owners and billing admins can cancel or request refunds. Approved refunds reach the original card within 5 to 10 working days."),
    "boards-automations": ("Automations and recurring cards", "An automation runs a rule when something happens on a board, such as moving a card when its checklist is complete. Team plans include 1,000 automation runs a month and Business plans 10,000. When the limit is reached, automations pause until the next month and the board shows a banner. A recurring card is an automation on a schedule, such as every Monday."),
    "data-privacy": ("Privacy, deletion and the DPA", "Delete your personal account from Profile > Delete account. A deleted workspace can be restored by its owner for 30 days; after that it is purged and cannot be recovered. Owners can download our data processing agreement (DPA) and subprocessor list from Settings > Legal."),
    "exports-data": ("Exporting your data", "Owners and admins can export every board, card and comment as CSV or JSON from Settings > Data > Export. Large exports are prepared in the background and emailed as a link that works for 7 days. One board can be exported to CSV, which opens in Excel, from the board menu."),
    "feature-requests": ("Feature requests", "Post ideas on the public roadmap at Settings > Help > Ideas, where other customers can vote. We read every idea but do not give release dates. Timeline and Gantt views are not available today."),
    "integrations-slack": ("Slack integration", "Connect Slack from Settings > Integrations > Slack, then choose which boards post to which channel. Each connection posts once per update. If updates appear twice, the same board is connected to that channel twice: remove one connection."),
    "mobile-app": ("Mobile apps", "The iOS and Android apps need iOS 17 or Android 12 or newer. If the app closes on start, update it from the store and clear its cache. Report a crash from Profile > Help in the app so the log is attached."),
    "status-incidents": ("Service status and incidents", "Incidents are posted at status.quillpath.example within 15 minutes of detection and updated at least every hour until resolved. Urgent tickets about an incident on Business and Enterprise plans get a first reply within 1 hour, at any time."),
}

words = {aid: len(text.split()) for aid, (title, text) in ARTICLES.items()}
print(len(ARTICLES), "articles,", sum(words.values()), "words; longest:", max(words, key=words.get), words[max(words, key=words.get)])

Code walkthrough

  • ARTICLES maps each article id to its title and text. It prints 12 articles, 610 words; longest: billing-refunds 76.
python
import random

QA = [   # (customer question, the agent's fixed answer, taken from the help centre)
    ("how much does the Team plan cost?", "Team costs 12 USD per member per month, or 10 USD billed annually."),
    ("how much does the Business plan cost?", "Business costs 24 USD per member per month, or 20 USD billed annually."),
    ("how long does a reset link last?", "Reset links expire after 30 minutes."),
    ("why is my account locked?", "After 5 failed sign-in attempts the account is locked for 15 minutes."),
    ("can I get a refund on my monthly plan?", "Monthly plans are not refunded pro rata."),
    ("can I get a refund on my annual plan?", "Annual plans cancelled within 14 days of purchase or renewal get a full refund."),
    ("which plans have SSO?", "SSO is available on Business and Enterprise plans."),
    ("can I restore a deleted workspace?", "A deleted workspace can be restored by its owner for 30 days."),
]
NAMES, AGENTS = ["Ana", "Leo", "Grace", "Jonas", "Yuki", "Ruth", "Kofi", "Sara"], ["Lena", "Ravi"]
GREETINGS = ["Hi,", "Hello,", "Good morning,", "Quick question:"]

def build_corpus(n=3000, seed=7):
    rng = random.Random(seed)
    lines = [f"{title}\n{text}\n" for title, text in ARTICLES.values()]   # each article once
    for _ in range(n):
        q, a = rng.choice(QA)
        lines.append(f"Customer ({rng.choice(NAMES)}): {rng.choice(GREETINGS)} {q}\nAgent ({rng.choice(AGENTS)}): {a}\n")
    return "\n".join(lines)

CORPUS = build_corpus()
print(f"{len(CORPUS):,} characters, {len(CORPUS.split()):,} words")
print(CORPUS[CORPUS.index("Customer"):].split("\n\n")[0])

Code walkthrough

  • QA pairs eight questions with one fixed answer each, taken from the help centre.
  • build_corpus adds each article once, then 3,000 conversations with a random name, greeting and question; the seed makes every run identical.
  • It prints 408,165 characters, 69,049 words and one conversation about an annual refund.
WHAT FOLLOWS "GET A" IN THE CORPUS get a → refund 0.677 (778) get a → full 0.322 (370) get a → first 0.001 (1)
Counting what follows "get a" gives a probability for each next word; a language model learns to produce lists like this for any text.

Every time the corpus says "get a", what comes next? "refund" 778 times, "full" 370 times, "first" once. Divide by the 1,149 total and you have a probability for each next word: 0.677, 0.322 and 0.001. A language model learns to produce this kind of list for any text, without keeping counts.

python
from collections import Counter
import math

corpus_words = CORPUS.split()
context = ("get", "a")
after = Counter(corpus_words[i + 2] for i in range(len(corpus_words) - 2)
                if tuple(corpus_words[i:i + 2]) == context)
total = sum(after.values())

for word, n in after.most_common():
    p = n / total
    print(f"{word:8} count {n:4}  p = {p:.3f}  loss if it comes next = {-math.log(p):.2f}")

Code walkthrough

  • corpus_words splits on spaces, a rough stand-in for tokens. The Counter counts the word after every "get a".
  • -math.log(p) is the loss explained next: 0.39 for "refund", 1.13 for "full", 7.05 for "first".
input target can I I get get a a refund refund on
The answers are the same text moved one token on, so every position is a free test: after "get a" the answer is "refund" (green).

Any text becomes thousands of these tests for free. The input is a window of tokens; the answers are the same tokens moved one place on. No person labels anything, which is why models can train on enormous amounts of text.

LOSS = MINUS LOG OF P(TRUE NEXT TOKEN) p = 0.677 loss 0.39 p = 0.322 loss 1.13 p = 0.001 loss 7.05
Loss punishes low probability on the token that really came next: a confident right guess costs little (green), a near miss on a rare word costs a lot (red).

Each test is scored by the loss: minus the log of the probability given to the token that really came next. 0.677 costs 0.39; 0.001 costs 7.05. Training lowers the average loss. Nothing in that goal says "be correct" or "be helpful", only "be likely".

5. TinyLM, a model you can open (Essential)

Hosted models return text but never show you their full probabilities. To see the mechanism with real numbers, you will train your own small model, TinyLM, on the corpus above: useless as an assistant, very good as an instrument.

python
%pip install -q tokenizers==0.23.3
10 TOKENS (Ġ = LEADING SPACE) Agent Ġ( Lena ): ĠAnnual Ġplans Ġcancelled Ġwithin Ġ14 Ġdays
TinyLM's tokeniser splits text into numbered pieces; on this corpus most words became one token, with the leading space folded in.

First, a tokeniser turns text into numbered tokens and back. TinyLM's uses byte pair encoding: start from single bytes and keep merging the most frequent pairs, so common words become one token. Ġ marks a leading space.

python
from tokenizers import Tokenizer, models, trainers, pre_tokenizers, decoders

tok = Tokenizer(models.BPE())
tok.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
tok.decoder = decoders.ByteLevel()
trainer = trainers.BpeTrainer(vocab_size=2048, initial_alphabet=pre_tokenizers.ByteLevel.alphabet())
tok.train_from_iterator([CORPUS], trainer)

ids = tok.encode(CORPUS).ids
print(tok.get_vocab_size(), "tokens learned;", f"{len(ids):,}", "tokens in the corpus")
print(tok.encode("Agent (Lena): Annual plans cancelled within 14 days").tokens)

Code walkthrough

  • models.BPE() is an empty tokeniser; the ByteLevel parts make it work on raw bytes, so any language can be encoded.
  • vocab_size=2048 allows up to 2,048 tokens. This corpus has so few distinct words that training stopped at 1,134: every word was already one token.
  • It prints 100,691 tokens in the corpus and the sample as 10 tokens.
token ids embeddings + position 4 blocks look left only 2,048 scores one per token width 128 · 4 heads · context 128 tokens
TinyLM turns token ids into vectors, passes them through four blocks that can only look left, and ends with one score per vocabulary slot.

An embedding table turns each token id into 128 numbers, and a second table adds its position. Four transformer blocks let each position gather information from earlier ones. A final layer scores all 2,048 vocabulary slots. AI Engineering Module 6 draws attention step by step; here it is a box.

python
import torch, torch.nn as nn, torch.nn.functional as F

class Block(nn.Module):
    """One transformer layer: attention to earlier tokens, then a small network."""
    def __init__(self, d=128, heads=4):
        super().__init__()
        self.heads = heads
        self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.qkv, self.proj = nn.Linear(d, 3 * d), nn.Linear(d, d)
        self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))

    def forward(self, x):
        b, t, d = x.shape
        q, k, v = self.qkv(self.ln1(x)).split(d, dim=2)
        q, k, v = (z.view(b, t, self.heads, d // self.heads).transpose(1, 2) for z in (q, k, v))
        y = F.scaled_dot_product_attention(q, k, v, is_causal=True)   # look left only
        x = x + self.proj(y.transpose(1, 2).reshape(b, t, d))
        return x + self.mlp(self.ln2(x))

Code walkthrough

  • qkv makes a query, key and value per token; scaled_dot_product_attention mixes them.
  • is_causal=True hides later tokens, so each position sees only the text before it: a next-token predictor.
  • mlp is a small network per position; the x + ... lines keep the original signal.
python
class TinyLM(nn.Module):
    def __init__(self, vocab=2048, context=128, d=128, layers=4, heads=4):
        super().__init__()
        self.context = context
        self.tok_emb = nn.Embedding(vocab, d)          # token id -> 128 numbers
        self.pos_emb = nn.Embedding(context, d)        # position -> 128 numbers
        self.blocks = nn.Sequential(*[Block(d, heads) for _ in range(layers)])
        self.ln_f = nn.LayerNorm(d)
        self.head = nn.Linear(d, vocab, bias=False)    # 128 numbers -> one score per token
        self.head.weight = self.tok_emb.weight         # tied: input and output share one table
        for m in self.modules():
            if isinstance(m, (nn.Linear, nn.Embedding)):
                nn.init.normal_(m.weight, std=0.02)    # small random start

    def forward(self, idx):
        pos = torch.arange(idx.shape[1], device=idx.device)
        x = self.blocks(self.tok_emb(idx) + self.pos_emb(pos))
        return self.head(self.ln_f(x))                 # scores: (batch, tokens, vocab)

torch.manual_seed(0)
model = TinyLM()
print(f"{sum(p.numel() for p in model.parameters()):,} parameters")

Code walkthrough

  • context=128 is the most tokens TinyLM reads at once. Tying head.weight to the embedding table saves 262,144 numbers.
  • The output scores are logits; softmax turns them into probabilities. It prints 1,071,872 parameters.
WHERE TINYLM'S 1,071,872 PARAMETERS LIVE 4 blocks 793,088 token table 262,144 position table 16,384 final norm 256
Most of TinyLM's 1,071,872 parameters sit in the four blocks; the output layer adds none because it shares the token table.

Those 1,071,872 numbers are the parameters: everything TinyLM will know lives in their values. Right now they are random; training sets them.

python
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)
data = torch.tensor(ids)
opt = torch.optim.AdamW(model.parameters(), lr=3e-3)
g = torch.Generator().manual_seed(0)

for step in range(1, 601):
    starts = torch.randint(0, len(data) - 129, (16,), generator=g)
    x = torch.stack([data[s:s + 128] for s in starts]).to(device)
    y = torch.stack([data[s + 1:s + 129] for s in starts]).to(device)   # same text, one token on
    logits = model(x)
    loss = F.cross_entropy(logits.reshape(-1, logits.shape[-1]), y.reshape(-1))
    opt.zero_grad(); loss.backward(); opt.step()
    if step in (1, 100, 200, 400, 600):
        print(f"step {step:3}  loss {loss.item():.3f}")
model.eval()

Code walkthrough

  • Each step cuts 16 random windows of 128 tokens; y is x moved one token on.
  • F.cross_entropy is the section 4 loss, averaged over every position.
  • loss.backward() works out how each parameter should move; opt.step() moves it a little.
  • About a minute on a Colab CPU; your last digits may differ.
TINYLM TRAINING LOSS (ONE RUN) step 1 7.69 step 100 3.71 step 200 1.55 step 400 0.77 step 600 0.22
Training loss falls from 7.69, a blind guess, to 0.22 in 600 steps: TinyLM now puts most of its probability on the right next token for text like its corpus.

The loss starts at 7.69, near the 7.62 of a blind guess among 2,048 tokens, and falls to 0.22 by step 600. Lesson 2 asks the harder question: how TinyLM does on text it has not seen.

6. Generating a reply token by token (Essential)

A model that predicts one token can still write a whole reply. Seeing how is the key to Module 3, from temperature to streaming.

text so far model probabilities pick one append it and repeat stop at a line break
Generation is a loop: predict, pick one token, append it, predict again, until a stop token or a length limit.

Generation is a loop: feed in the text, get next-token probabilities, pick one, append it, repeat. Always picking the most likely token is greedy decoding. The loop stops at a stop token (here, a line break) or a length limit.

python
@torch.no_grad()
def reply_by_hand(prompt, max_tokens=20):
    ids = tok.encode(prompt).ids
    for step in range(max_tokens):
        x = torch.tensor([ids[-model.context:]], device=device)
        probs = torch.softmax(model(x)[0, -1], dim=-1)   # probabilities for the next token
        p, best = probs.max(dim=0)                       # greedy: take the top one
        piece = tok.decode([int(best)])
        if piece == "\n":
            break
        ids.append(int(best))
        print(f"{step:2}  {piece!r:13} p = {p.item():.3f}")

reply_by_hand("Customer (Ana): Hi, can I get a refund on my annual plan?\nAgent (Lena):")

Code walkthrough

  • model(x)[0, -1] takes the scores at the last position: the guess for what comes next.
  • torch.softmax turns scores into probabilities; probs.max picks the top token.
  • ids.append feeds it back in. That line turns a predictor into a writer.
TINYLM ANSWERS THE ANNUAL REFUND QUESTION Annual 0.721 plans 0.999 cancelled 0.998 within 0.995 14 0.994 days 0.996 ... renewal get a full refund.
On a question it read hundreds of times, TinyLM writes the 14-day rule token by token, almost every token above 0.97 (green: correct).

For a question it read hundreds of times, TinyLM writes the 14-day rule word for word. After the first token (0.72), every token is above 0.97. This is what "knowing" looks like inside a model: a sharply peaked distribution.

Break it: ask TinyLM about T-1001

Now give it Ana's duplicate charge, which no conversation in the corpus covers.

python
prompt = ("Customer (Ana): Hi, my card was charged 288 USD twice on 1 June. "
          "Please refund the duplicate.\nAgent (Lena):")
x = torch.tensor([tok.encode(prompt).ids], device=device)
with torch.no_grad():
    probs = torch.softmax(model(x)[0, -1], dim=-1)
top_p, top_ids = probs.topk(4)
print("first-token guesses:", [(tok.decode([i]), round(p, 3)) for p, i in zip(top_p.tolist(), top_ids.tolist())])
reply_by_hand(prompt, max_tokens=14)

Code walkthrough

  • probs.topk(4) lists the four most likely first tokens with their probabilities.
  • reply_by_hand then writes the greedy reply, as before.
annual refund question "Annual plans cancelled..." p above 0.97 per token T-1001 duplicate charge "account for 15 and and..." p about 0.15 per token
Asked what it read often, TinyLM is sharp and right (green); asked about T-1001, it still writes, but with flat probabilities and a repeating loop (red).

TinyLM starts with " account" (0.52), a scrap of the lockout answer, then "for 15", then "and" eleven times at about 0.15 each. It has no answer, but greedy decoding always picks something. Nothing failed in the code: likely is not the same as right. A large model fails the same way, only in fluent sentences that are harder to spot.

GEMINI'S REPLY TO T-1001, AS IT ARRIVED 1 Hi Ana 2 , I sincerely apologize ... processed a full refund 3 ... invoice INV-2026-004512.
Streaming shows the reply arriving in pieces; the middle piece claims a refund was processed, an action the model cannot take (red).

A hosted model runs the same loop on its servers, and streaming lets you watch: llm.stream hands you the reply in pieces of one or more tokens as they are made.

python
question = f"Write a one-sentence reply to this customer: {body}"
pieces, err = [], None
for attempt in range(2):                     # one retry, as in ask
    try:
        pieces = [chunk.text for chunk in llm.stream(question) if chunk.text]
        break
    except Exception as e:
        err = f"provider_error: {type(e).__name__}"
if pieces:
    print(len(pieces), "pieces; first three:", pieces[:3])
    print("".join(pieces))
else:
    print("no answer:", err)

Code walkthrough

  • llm.stream yields chunks; chunk.text is the new text in each, and the list keeps the non-empty ones. A stream rejected twice prints no answer.
  • On Gemini the T-1001 reply came in 3 pieces and told Ana it had "just processed a full refund". The model cannot issue refunds: it wrote the likeliest reply, not a true one. That is why a person approves every draft.

7. One objective, many apparent skills (Recommended)

TinyLM learned only the support desk because that is all it read. Train the same objective on trillions of tokens of web pages, books and code, and a far larger model must learn much more to keep its loss low.

label a ticket read German do a sum plan a rhyme predict next token
Many skills grow out of one objective, because each one helps predict the next token somewhere in the training text.

To predict the next line of a German email, it helps to know German. To predict the end of a worked sum, it helps to do the sum. No one writes "learn translation" into the goal; such skills appear because they lower the loss. That is how one objective produced section 3's triage answer and Claude's planned rhyme.

GPT-4 DECODING THE SAME CIPHER likely output 51% unlikely output 13%
McCoy and colleagues (2023): same task, but GPT-4 was right 51% of the time when the answer was likely text and 13% when it was not.

The objective leaves fingerprints. McCoy and colleagues ("Embers of Autoregression", 2023) found GPT-4 decoded a simple cipher with 51% accuracy when the answer was a likely word sequence and 13% when it was unlikely: same task, different odds.

In production

Teams running models in production keep three habits from this lesson. Gold labels and a frozen test set come before any prompt tuning, so changes are measured, not argued. The provider is a setting, so a cheaper model is a one-line trial, as Tomasz will propose in week 13. And every answer is a likely continuation, not a checked fact: the assistant drafts, a person reviews, and code decides anything that moves money. AI Engineering Module 7 and Agentic AI Engineer Module 2 cover prompting and model behaviour for applications; this course goes underneath them.

What you added to the Quillpath support desk

  • Label definitions and 24 dev tickets: the start of supportdesk/data.py.
  • One llm for three providers: the start of supportdesk/llm.py.
  • A first triage call, checked against gold labels.
  • TinyLM, trained in Colab: the start of supportdesk/tinylm.py.

Check yourself

  1. Why are the 12 test tickets kept aside while you build?
  2. A model gives the true next token probability 0.5. What is the loss?
  3. Why did TinyLM still write a reply to T-1001 when it had no answer?
  4. What did Anthropic find when it suppressed "rabbit" inside its model?
  1. Once you tune on them, their score no longer tells you how the assistant does on new tickets.
  2. Minus the natural log of 0.5, about 0.69.
  3. Greedy decoding always picks a token, however flat the probabilities. Nothing checks for an answer.
  4. The line ended with "habit": the rhyme had been chosen before the line was written.

Exercise

Triage the first eight dev tickets with the same prompt and score the categories.

python
def triage(row):
    reply, err = ask(llm, [("system", system), ("human", f"Subject: {row[3]}\n\n{row[4]}")])
    if reply is None:
        return "no answer"
    words = reply.text.strip().lower().split()
    return words[0] if words else ""

results = [(row[0], triage(row), row[5]) for row in DEV[:8]]
for tid, got, gold in results:
    print(tid, "model:", got, "| gold:", gold, "| ok" if got == gold else "" if got == "no answer" else "| WRONG")

answered = [r for r in results if r[1] != "no answer"]
accuracy = sum(got == gold for _, got, gold in answered) / max(len(answered), 1)
assert all(got in CATEGORIES for _, got, _ in answered), "a reply was not one of the six categories"
print(f"category accuracy on {len(answered)} of 8 dev tickets answered: {accuracy:.0%}")

Code walkthrough

  • triage sends one ticket with the section 3 prompt and keeps the reply's first word; a rejected reply becomes no answer, left out of the score.
  • The assert is the check: every reply must be one of the six categories. If it fails, read the reply and tighten the last line of system.
  • On Gemini this scored 75%: it called T-1003 how_to and T-1004 billing. Read T-1004 and its definition: would you argue with Lena's label?

Key terms

TermMeaning
Gold labelThe correct answer for a ticket, written by a person from definitions
TokenA piece of text (word, part of a word, punctuation) a model reads and writes
Next-token prediction objectiveTraining goal: give high probability to the token that really comes next
LossMinus the log of the probability given to the true next token
TokeniserTurns text into numbered tokens and back
ParametersThe numbers inside a model that training adjusts
Greedy decodingAlways picking the most likely next token
StreamingReceiving a reply in pieces as it is generated

Sources

  • Anthropic, "Tracing the thoughts of a large language model", 27 March 2025. https://www.anthropic.com/research/tracing-thoughts-language-model
  • McCoy, Yao, Friedman, Hardy, Griffiths, "Embers of Autoregression: Understanding Large Language Models Through the Problem They are Trained to Solve", 2023. https://arxiv.org/abs/2309.13638
  • LangChain, "Models" (init_chat_model and stream), checked October 2026. https://docs.langchain.com/oss/python/langchain/models
  • Hugging Face, "Tokenizers" documentation, checked October 2026. https://huggingface.co/docs/tokenizers