Module 1: LLM Fundamentals#
What is an LLM, actually? In this module, we’ll discuss tokens, weights, context, and more. You should come away with a deeper understanding of LLMs and associated technology.
Questions this module answers#
- What actually happens when I send a prompt to an LLM?
- What are tokens, weights, and sampling?
- Why is “LLMs are glorified autocomplete” both accurate and misleading?
- What is a context window?
- Is the LLM inference API stateful or stateless?
- What is prompt caching?
- What’s the difference between a system prompt and a user message?
- How do I pick a model?
- Why do alignment and refusals matter for security work?
Slides#
Lab 1.1: Tokenization#
Steps#
In this lab, you will be using a series of 5 different blocks of text. Read them below:
Block A: a story in plain English:
A small boy found a lost dog in the rain and carried it home to his mother, who smiled and said yes.Block B: the same story in Japanese:
小さな男の子が雨の中で迷子の犬を見つけ、家に連れて帰り、母親は微笑んでうなずいた。Block C: one letter, 100 times:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABlock D: the string
the25 times in a rowthe the the the the the the the the the the the the the the the the the the the the the the the theBlock E: 100 random letters and digits:
FxGnfMqBFTqGdDknmmXVVWEmWRZDKwQPNw8c70zc1kvhPRd2Zt6DMajWvCmwHpcropMwiCpvVAmFC1x75qNZ2WGKWiazieFnCJvtSoon, you will put each of these blocks into a tokenizer to see the token breakdown of the text, and count how many tokens there are. Consider the “token density” of each block. Remember that tokens = information (to the model). So, “token density” is kinda like “information density”. With this information in mind: try your best to rank each of the 5 blocks from least to most tokens.
Paste each block into the tokenizer, one at a time, and record tokens and characters.
Compare against your predictions. Were you right?
Explain why each block falls into its position in the rank. Why do some blocks have more tokens, and some blocks have less?
Solution: the ordering, and why
Below you can see a table of each block’s “token density” (tokens per character) from least to most.
| Block | What | Characters | Tokens | Tokens per character |
|---|---|---|---|---|
| C | 100 × A | 100 | 13 | 0.13 |
| A | English story | 100 | 24 | 0.24 |
| D | the × 25 | 100 | 26 | 0.26 |
| E | random alphanumeric | 100 | 65 | 0.65 |
| B | the story in Japanese | 41 | 39 | 0.95 |
Remember, again, that tokens represent information to the LLM. More complicated text requires more tokens.
- Block A: The English story is mildly information-dense. It uses lots of common words like
carriedandmotherwhich get encoded as a single token. In fact, every word in the sentence becomes a single token! - Block B: Japanese is a very information-dense language. Each character represents a large range of meaning. Thus, it is the most “token dense” block. This should be clear, since it’s the same information as Block A, but using less characters.
- Block C: the letter
Aby itself is not a lot of information. Neither isAA, orAAA, or evenAAAA. In fact, the tokenizer waits until it gets 8As in a row–AAAAAAAA– to form a single token of information. - Block D: The string
theis a very simple English word and extremely common. Thus, the tokenizer turns it into a single token. - Block E: Random data is unpredictable and information-dense. It does not follow a simple pattern that the model can memorize easily. It takes many tokens to ingest this data.
Lab 1.2: Talk to the API with curl#
Goal#
Use curl to talk to an LLM inference API (specifically, an OpenAI API endpoint).
Steps#
Connect to your lab instance.
Send one request with curl. POST a JSON body (model, messages array with one user message) to
/chat/completions, with your API key in theAuthorizationheader. Read the raw JSON that comes back: the assistant message, the role, the token usage. This is the entire interface. Everything in this class (every chatbot, every agent, every framework) is wrapped around this one HTTP call.curl -s "$CLASS_API_URL/chat/completions" \ -H "Authorization: Bearer $CLASS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "small", "messages": [{"role": "user", "content": "Say hello in five words."}] }'What each piece does:
curl -s "$CLASS_API_URL/chat/completions"—-sis “silent”: suppress curl’s progress meter so only the response prints. The URL is your class API base (which already ends in/v1) plus the chat-completions path. Without-d, curl would send a GET; providing a body makes this a POST.-H "Authorization: Bearer $CLASS_API_KEY"—-Hadds an HTTP request header. This one authenticates you: theBearerscheme means “whoever holds this token”.-H "Content-Type: application/json"— a second header telling the server the body is JSON.-d '{...}'— the request body. Two fields:model, the alias of the model that should answer (smallis the fast, cheap tier), andmessages, the conversation so far as an array of{role, content}objects — here a single user message.
Send the curl again. Different phrasing back? That’s sampling: the model picks among likely next tokens rather than always taking the top one.
Sampling has a knob, and the API exposes it:
temperature. It rescales the next-token probabilities before the model picks one:0collapses the choice to the single most likely token at every step; higher values spread the probability back out. Run the same prompt five times at temperature 0:for i in 1 2 3 4 5; do curl -s "$CLASS_API_URL/chat/completions" \ -H "Authorization: Bearer $CLASS_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "small", "temperature": 0, "messages": [{"role": "user", "content": "Pick a random number between 1 and 50. Reply with only the number."}] }' echo doneThen change
"temperature": 0to"temperature": 1and run the loop again. Compare the two sets of five answers. How “random” is each?One warning before you experiment further: the class API runs on AWS Bedrock, which caps temperature at 1.0. Ask for
2(the classic “maximum chaos” setting on other APIs) and you get aBadRequestErrorback, not wilder output.
Solution: what the two loops show
At temperature 0 the model takes its single most likely token at every step, so identical requests produce identical replies: when we ran this, all five answers were 42. At temperature 1 the model actually samples, and the five answers were a mix.
Two things worth noticing:
- The temperature-0 loop exposes that the model cannot “pick a random number”. There is no dice roll inside, only next-token probabilities; temperature 0 just always takes the favorite. Even at temperature 1 the answers cluster on culturally “random-sounding” numbers (42, 37, 27) rather than a uniform draw.
- Temperature changes sampling, nothing else. The model and the request are identical in both loops; you’re only changing how the final token choice is made.
Lab 1.3: Talk to the API with Python#
Reference: the Python client#
The Python client. The openai library builds the same HTTP request you sent in Lab 1.2 for you:
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ["CLASS_API_URL"],
api_key=os.environ["CLASS_API_KEY"],
)
response = client.chat.completions.create(
model="small",
messages=[{"role": "user", "content": "Hello"}],
)
print(response.choices[0].message.content)Conversation history. The API is stateless. To give the model memory, send the complete conversation on every turn:
messages.append({"role": "user", "content": user_text})
response = client.chat.completions.create(model="small", messages=messages)
msg = response.choices[0].message
messages.append(msg)Resending the full transcript every call sounds wasteful, but providers cache the stable prefix of a conversation (prompt caching), so the unchanged earlier turns cost a fraction of fresh tokens.
REPL exit:
try:
text = input("> ")
except EOFError:
break
if text.strip() == "quit":
breakCommon failures:
| Symptom | Check |
|---|---|
401 Unauthorized | CLASS_API_KEY is set correctly |
404 Not Found | CLASS_API_URL ends in /v1 and is used directly |
| Unknown model | Use small exactly |
| The model forgets earlier turns | Append both user and assistant messages and resend the list |
KeyError for an environment variable | Export both class variables in the same shell |
Steps#
- Write the smallest possible Python program: construct a client pointed at your class API URL, send one hardcoded user message to
small, print the reply. Compare with the curl from Lab 1.2: theopenailibrary is building the same request you typed by hand there, nothing more. - Print the raw response object once and look at it: the same fields you saw in the curl output, now as Python attributes.
Hint 1: where is the reply in the response object?
The reply text lives at resp.choices[0].message.content. Print the whole object once (print(resp)) and match it field-by-field against the JSON your curl returned: choices, message, role, usage are all there.
- Turn it into a REPL: loop over
input(), send the user’s message, print the model’s reply, repeat until EOF/quit. - Add conversation history: keep a
messageslist, append each user message and the assistantmsgreturned by the API, then send the whole list every turn. Verify the model remembers your name from turn 1 at turn 3. Those two appends are the difference between an API call and a “chat”: the model itself remembers nothing, and your list is the only record. - Now tamper with the record. Before appending user input, handle a special command:
/revise replacement text. It replaces the last assistant message and returns to the prompt without calling the API. Ask the model a question, revise its answer into a lie, then ask about that earlier answer. It stands by whatever is in the list. You control what the model thinks it said; the transcript is just editable data.
Solution: /revise (step 5)
if user.startswith("/revise "):
messages[-1].content = user.partition("/revise ")[2]
continue
messages.append({"role": "user", "content": user})
resp = client.chat.completions.create(model="small", messages=messages)
msg = resp.choices[0].message
print(msg.content)
messages.append(msg)Nothing verifies the assistant entries against what the model actually produced. Whoever holds the list writes the past — a fact that returns in Module 2’s bonus lab.
The finished REPL (steps 3–5), as a flow:
flowchart TD
R["Read user input"] -->|quit / EOF| Z([exit])
R --> C{"starts with /revise?"}
C -->|yes| E["Replace last assistant message"]
E --> R
C -->|no| U["Append user message"]
U --> P["POST full messages list to the API"]
P --> A["Append and print assistant reply"]
A --> R
- Add a system prompt as the first entry in the list (hardcoded or via flag). Test that it changes behavior (e.g., “only respond in JSON”).
Solution: complete REPL with /revise and a system prompt (steps 3–6)
import os
from openai import OpenAI
client = OpenAI(
base_url=os.environ["CLASS_API_URL"],
api_key=os.environ["CLASS_API_KEY"],
)
messages = [{"role": "system", "content": "You are a terse assistant."}]
while True:
try:
user = input("> ")
except EOFError:
break
if user.strip() == "quit":
break
if user.startswith("/revise "):
messages[-1].content = user.partition("/revise ")[2]
continue
messages.append({"role": "user", "content": user})
resp = client.chat.completions.create(model="small", messages=messages)
msg = resp.choices[0].message
print(msg.content)
messages.append(msg)The two messages.append calls turn an API call into a chat. /revise changes the most recent assistant message only when you ask it to.
- Run the checker (
python3 check.py chat.py, already on your instance in~/materials/01-chat/). It drives two normal turns with a/revisecommand between them. It verifies that/revisemakes no API call and that the next request contains the replacement instead of the model’s original reply. - Stretch goals, in order: streaming (print tokens as they arrive), a
--modelflag (trylarge; one string change), token counting per turn from the usage field.
Solution: streaming (stretch goal)
stream = client.chat.completions.create(
model="small", messages=messages, stream=True)
reply = ""
for chunk in stream:
delta = chunk.choices[0].delta.content or ""
reply += delta
print(delta, end="", flush=True)
print()
messages.append({"role": "assistant", "content": reply})Remember to accumulate the full reply for history; the chunks alone vanish.
Done when#
The checker passes.
Lab 1.4 (Bonus): Play ARC-AGI-3#
Goal#
Play a benchmark that today’s models are bad at, and see how you do.
ARC-AGI-3 is an interactive reasoning benchmark which challenges AI agents to explore novel environments, acquire goals on the fly, build adaptable world models, and learn continuously. A 100% score means AI agents can beat every game as efficiently as humans.
Steps#
- Open https://arcprize.org/arc-agi/3 and play.
- No instructions are given, by design: work out the rules from what the environment does when you act.
- See how far you get.
Done when#
You’ve played. Compare scores with the room.