Lesson 1: A model that only predicts the next token
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.
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.
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 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.
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
CATEGORIESandPRIORITIESare 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
assertchecks every label has a definition. It prints6 categories, 4 priorities.
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.
# (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
Nonearticle 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.
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).
%pip install -q -U langchain langgraph langchain-groq langchain-google-genai langchain-ollamaimport 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
PROVIDERis the one word you change;MODELSholds the model names (checked October 2026).- The
ifblock copies the provider's key from Colab Secrets into an environment variable. init_chat_modelbuilds the connector;temperature=0asks for repeatable answers.
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.
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.
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
asksends one message throughllm.invokeand waits for the whole reply;.textis the reply as a string.type(llm).__name__names the connector built. On Gemini:ChatGoogleGenerativeAI, replyready.usage_metadatacounts 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.
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.
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.systemis 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 sayno answer.
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.
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
ARTICLESmaps each article id to its title and text. It prints12 articles, 610 words; longest: billing-refunds 76.
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
QApairs eight questions with one fixed answer each, taken from the help centre.build_corpusadds each article once, then 3,000 conversations with a random name, greeting and question; theseedmakes every run identical.- It prints
408,165 characters, 69,049 wordsand one conversation about an annual refund.
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.
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_wordssplits on spaces, a rough stand-in for tokens. TheCountercounts 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".
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.
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.
%pip install -q tokenizers==0.23.3First, 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.
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; theByteLevelparts make it work on raw bytes, so any language can be encoded.vocab_size=2048allows 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 corpusand the sample as 10 tokens.
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.
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
qkvmakes a query, key and value per token;scaled_dot_product_attentionmixes them.is_causal=Truehides later tokens, so each position sees only the text before it: a next-token predictor.mlpis a small network per position; thex + ...lines keep the original signal.
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=128is the most tokens TinyLM reads at once. Tyinghead.weightto the embedding table saves 262,144 numbers.- The output scores are logits; softmax turns them into probabilities. It prints
1,071,872 parameters.
Those 1,071,872 numbers are the parameters: everything TinyLM will know lives in their values. Right now they are random; training sets them.
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;
yisxmoved one token on. F.cross_entropyis 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.
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.
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.
@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.softmaxturns scores into probabilities;probs.maxpicks the top token.ids.appendfeeds it back in. That line turns a predictor into a writer.
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.
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_handthen writes the greedy reply, as before.
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.
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.
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.streamyields chunks;chunk.textis the new text in each, and the list keeps the non-empty ones. A stream rejected twice printsno 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.
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.
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
llmfor three providers: the start ofsupportdesk/llm.py. - A first triage call, checked against gold labels.
- TinyLM, trained in Colab: the start of
supportdesk/tinylm.py.
Check yourself
- Why are the 12 test tickets kept aside while you build?
- A model gives the true next token probability 0.5. What is the loss?
- Why did TinyLM still write a reply to T-1001 when it had no answer?
- What did Anthropic find when it suppressed "rabbit" inside its model?
- Once you tune on them, their score no longer tells you how the assistant does on new tickets.
- Minus the natural log of 0.5, about 0.69.
- Greedy decoding always picks a token, however flat the probabilities. Nothing checks for an answer.
- 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.
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
triagesends one ticket with the section 3 prompt and keeps the reply's first word; a rejected reply becomesno answer, left out of the score.- The
assertis the check: every reply must be one of the six categories. If it fails, read the reply and tighten the last line ofsystem. - On Gemini this scored 75%: it called T-1003
how_toand T-1004billing. Read T-1004 and its definition: would you argue with Lena's label?
Key terms
| Term | Meaning |
|---|---|
| Gold label | The correct answer for a ticket, written by a person from definitions |
| Token | A piece of text (word, part of a word, punctuation) a model reads and writes |
| Next-token prediction objective | Training goal: give high probability to the token that really comes next |
| Loss | Minus the log of the probability given to the true next token |
| Tokeniser | Turns text into numbered tokens and back |
| Parameters | The numbers inside a model that training adjusts |
| Greedy decoding | Always picking the most likely next token |
| Streaming | Receiving 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