~4–8 hrs
Requires: start here
Project 01 of 9

AI Chatbot

Build intuition for how LLMs actually work under the hood

📖 Read the lesson ✍ Fill in understanding 🛠 Build the project 🚫 Break it 📊 Evaluate it

Learning Objectives

By the end of this project, you will be able to:

  • Call a chat completion API and explain every field in the request and response (model, messages, system, max_tokens, temperature, stop_reason, usage).
  • Implement multi-turn conversation by correctly maintaining and resending the messages array, given that the API is stateless.
  • Explain why context windows exist and predict what happens when a conversation exceeds one.
  • Stream tokens to the UI using server-sent events instead of waiting for the full response.
  • Calculate the exact cost of any API call from usage and per-MTok pricing, and project costs at scale.
  • Design a system prompt that reliably changes model behavior, and distinguish it from a user message.
  • Switch LLM providers without changing application logic, using LiteLLM's unified completion() interface.
  • Identify when a chatbot is the right architecture — and when a single-shot call or a different pattern is better.

1. Motivation

Why This Exists

A raw language model is a function: text in, next-token-probabilities out. It has no concept of "a conversation," no memory, and no notion of who is speaking. The messages array standardized dialogue into a structured, role-tagged format that every major provider now speaks — so you stop hand-formatting prompt strings and re-parsing output every turn.

The Core Problem

LLMs are stateless. Every API call starts fresh. Ask "What's the capital of France?" → "Paris," then "What's its population?" and the model has no idea what "its" means — the second call knows nothing about the first.

The Solution (and Its Consequences)

The fix is counterintuitive: you give the model memory by resending the entire conversation every single time. The model re-reads the whole history on every turn. This one fact explains why long chats get slow, why they get expensive, and why context windows matter.

Real-World Stakes

Every RAG system, coding copilot, and agent is a chatbot underneath. Cursor, Claude.ai, ChatGPT, support bots — all manage a messages array, a system prompt, and a context budget. A team that doesn't track tokens ships a product that quietly costs 10× what it should.

Startup Lens — Would Users Pay For This?

On its own, no — a bare chatbot competes with free ChatGPT. The value is what you wrap around the loop: a system prompt encoding domain expertise, private data the public model can't see, context management, and cost control. The chatbot is the infrastructure; the product is the customization.

2. Mental Model

Explain Like I'm 12

Imagine a brilliant friend with total amnesia. Every time you talk, they forget everything the instant the conversation ends. To have a real conversation, you hand them a notebook with the entire chat so far written down. They read the whole notebook, say one new thing, and forget again. To continue, you write their new sentence into the notebook and hand the whole thing back. The notebook is the messages array. The friend is the model.

Explain Like I'm a Software Engineer

The chat API is a pure function over conversation state: f(messages, system, params) → next_message. No session, no cookie, no server-side state. You maintain the state client-side as an append-only list of {role, content} dicts. Each request sends the full list; the response is one assistant message you append before the next user turn.

This is the same shape as a reducer: state = reduce(state, action) where actions alternate user/assistant. The model is trained on strictly alternating roles. The system prompt is not a message in the array — it's a separate top-level field that conditions every turn.

Real-World Analogy

A Courtroom Transcript

The stenographer records every exchange in order. Before ruling on a new objection, the judge has the entire transcript available — nothing assumed from memory, it's all in the record. Add a line, and the next ruling is made against the whole updated record. The transcript grows, and reading it takes longer each time — exactly like input_tokens growing every turn.

How It Works (Diagram)

Turn 1 request:                      Turn 2 request (resends everything):
  system: "You are helpful."           system: "You are helpful."
  messages:                            messages:
    [user] "Capital of France?"          [user] "Capital of France?"
                                         [assistant] "Paris."     ← appended from turn 1
  →  [assistant] "Paris."                [user] "Its population?"  ← new
                                       →  [assistant] "About 2.1M."

Each turn re-sends all prior turns. input_tokens grows monotonically.

3. Technical Explanation

Formal Definition

A chat completion is a request to POST /v1/messages containing a model, a max_tokens cap, and a messages array of {role: "user"|"assistant", content} objects — optionally a top-level system string and sampling params (temperature, top_p, stop_sequences). The response is a Message with content, a stop_reason, and a usage object reporting input_tokens and output_tokens. [anthropic-messages-api]

How It Works, Step by Step

  1. You assemble messages (full history) + system + params.
  2. The provider tokenizes the entire input. The token count = input_tokens.
  3. The model generates output tokens one at a time until a natural stop, max_tokens, or a stop_sequence — recorded in stop_reason.
  4. Non-streaming: the whole Message at once. Streaming: tokens arrive as SSE events (message_startcontent_block_delta* → message_stop).
  5. You read usage to compute cost, append the assistant message to history, and wait for the next user turn.

The Math — Why Long Chats Get Expensive

cost = input_tokens/1e6 * input_price_per_MTok
     + output_tokens/1e6 * output_price_per_MTok

Across an N-turn conversation where each turn adds ~t tokens, turn k resends ~k·t tokens, so total input ≈ t·N(N+1)/2quadratic-ish. This is why long conversations get disproportionately expensive and why context management exists.

Key Concepts

ConceptDefinitionWhy It Matters
Messages arrayThe ordered list of {role, content} turns sent on every request.It is the conversation — there is no server-side memory.
StatelessThe model retains nothing between requests; each call starts fresh.Forces you to resend history, which drives cost and context limits.
System promptTop-level persistent instructions that shape behavior across all turns.The cheapest, highest-leverage way to control the model.
Context windowMax tokens the model can "see" (input + output) in one call.When history exceeds it, the call fails or must be trimmed.
TokenThe unit of text a model processes; ~4 chars / ~0.75 words in English.Tokens — not words — are what you pay for and what fills the window.
TemperatureA 0.0–1.0 dial for randomness.Controls reproducibility vs. variety.
Provider abstractionA library (LiteLLM) giving all providers one interface.Avoids vendor lock-in; swap models by changing a string.
Common Misconception

"The API remembers my conversation." It does not — memory is client-side resending. And "tokens are words" is wrong too: ~4 characters per token. Both misconceptions lead to broken memory handling and badly wrong cost estimates.

4. Guided Examples

Example 1: Simplest Case — one stateless call

A single call carries no memory. The usage fields are your cost ground-truth.

import litellm

response = litellm.completion(
    model="claude-sonnet-4-6",
    messages=[{"role": "user", "content": "What is the capital of France?"}],
    max_tokens=100,
)
print(response.choices[0].message.content)        # "The capital of France is Paris."
print(response.usage.prompt_tokens,                # input_tokens
      response.usage.completion_tokens)            # output_tokens
What to Observe

Note how small prompt_tokens is here versus later turns — that growth is the whole cost story.

Example 2: Real-World Case — multi-turn loop with system prompt

The second question only works because the assistant's prior answer was appended to history.

import litellm

SYSTEM = "You are a concise assistant. Answer in one sentence."
history = [{"role": "system", "content": SYSTEM}]   # LiteLLM puts system in messages

def ask(user_text: str) -> str:
    history.append({"role": "user", "content": user_text})
    resp = litellm.completion(
        model="claude-sonnet-4-6", messages=history, max_tokens=200, temperature=0.7,
    )
    answer = resp.choices[0].message.content
    history.append({"role": "assistant", "content": answer})   # CRITICAL: persist the turn
    return answer

print(ask("What is the capital of France?"))   # "Paris."
print(ask("What is its population?"))          # resolves "its" -> Paris, because history was resent
What to Observe

Forget the assistant append and the model loses the thread. Watch prompt_tokens climb each turn.

Example 3: Edge Case — streaming + cost tracking

Text appears incrementally — that's the SSE content_block_delta stream.

import litellm

PRICES = {"claude-sonnet-4-6": (3.0, 15.0)}   # ($/MTok in, out) — src: anthropic-pricing

def stream_ask(history, model="claude-sonnet-4-6"):
    stream = litellm.completion(model=model, messages=history, max_tokens=500, stream=True)
    parts = []
    for chunk in stream:
        delta = chunk.choices[0].delta.content
        if delta:
            print(delta, end="", flush=True)     # token-by-token to the terminal
            parts.append(delta)
    print()
    return "".join(parts)
The Trap

Streamed chunks don't reliably carry final usage, so you must count tokens yourself (or do a non-streaming usage call) to bill accurately. This is the single hardest part of the project.

5. Reflection Before Building

Stop Here

Before writing any code, fill in UNDERSTANDING.md in your own words. No copy-pasting from the lesson. No AI assistance for writing — only for checking. If you can't explain it simply, go back and re-read.

Knowledge Check — Answer These in UNDERSTANDING.md

  1. Explain "the API is stateless" in your own words (ELI12, then ELI-Engineer). What concretely gives a chatbot memory?
  2. Draw your mental model of how messages flows from turn to turn. Where does the assistant's reply go?
  3. Predict: if you forget to append the assistant's response to history, what exactly breaks — and on which turn?
  4. Predict: what is the most likely failure mode once a conversation runs for 100 turns? Tie it to a specific field (input_tokens, context window, or cost).
  5. The system prompt vs. a first user message: why prefer the system prompt for persistent instructions?
  6. Where have you seen the "resend full state each time" pattern before, inside or outside AI?
  7. What is the one thing about streaming or token cost you still don't fully understand?

After filling in UNDERSTANDING.md, use the AI mentor pattern in docs/meta/learning-flow.md to get feedback. Record the feedback in UNDERSTANDING_FEEDBACK.md.

6. Project Assignment

See PROJECT.md for the overview and source/project.md for the full engineering spec.

Starter Code & Workflow

Setup is solved for you; the core logic is not. code/ ships labeled starter files:

FilesRoleYou...
config.py, .env.example, tests/providedcopy .env.example.env; make the tests pass
cost_tracker.py, context.pypartialimplement the TODO(learner) bodies
chatbot.pylearnerbuild the conversation loop — the heart of the project
One-Command Workflow

Run: python chatbot.py  ·  Test: python -m pytest. Make the provided tests pass first (no network needed), then build the loop. The finished core is intentionally not provided — search the starter files for TODO(learner).

Core Requirement

A CLI chatbot that: (a) holds a multi-turn conversation with correct history management, (b) applies a configurable system prompt, (c) streams responses token-by-token, and (d) reports per-turn and cumulative token cost. You must be able to explain every line — no copied magic.

Definition of Done

Multi-turn memory is correct (BOTH roles appended every turn); a system prompt visibly changes behavior; responses stream incrementally; and per-turn cost is derived from real usage and matches a hand calculation for one known turn.

Extended Requirements

Context-window guard (estimate & warn/trim before exceeding budget); provider switch (change one config value to run a different model); /cost and /reset commands; persist a transcript to disk.

Start building

Open code/README.md for setup, the milestone build order, and the file roles (which files are provided vs. learner-owned). Run python -m pytest to see the failing guiding tests, then implement the learner-owned functions in milestone order until they pass.

7. Project Milestones

Work through these in order. Each milestone should produce runnable code before moving on.

1

One-shot call

Send a single message, print the reply and raw usage. Validate: you can name every field in the response.

2

Multi-turn loop

Append user+assistant turns to a list; resend each turn. Validate: "What is its population?" correctly resolves a pronoun from the prior turn.

3

System prompt

Inject a configurable system prompt; observe behavior change. Validate: same question, two system prompts → visibly different style.

4

Streaming

Switch to stream=True; print tokens as they arrive. Validate: text appears incrementally, not all at once.

5

Cost tracking

Compute per-turn and cumulative cost from usage + prices. Validate: printed cost matches a hand calculation for a known turn.

8. Self-Evaluation

After building, honestly evaluate your implementation against these criteria. Record your answers in EVALUATION.md.

CriterionDoes your implementation...Pass?
History correctnessappend BOTH user and assistant messages every turn?
Statelessness understoodresend the full history (not just the latest message)?
System promptapply persistent instructions separately from user turns?
Streamingrender tokens incrementally via the stream?
Cost accuracycompute cost from real usage, matching a manual check?
Context awarenessdetect/handle approaching the context window?
Provider abstractionswap models without rewriting the loop?
Red Flags

Your implementation may have problems if:

  • You only send the latest user message (no history) — the bot will seem to have amnesia.
  • Your cost is hardcoded or guessed rather than derived from usage.
  • You can't explain why input_tokens grows each turn.
  • Streaming "works" but you never capture final token counts.

9. Common Mistakes

MistakeWhy It HappensConsequenceFix
Sending only the latest messageAssuming the server remembersBot has no memory; pronouns/context breakResend the full messages array every call
Forgetting to append the assistant replyOnly appending user turnsModel loses its own prior answersAppend BOTH roles each turn
Putting instructions in a user messageNot knowing about systemInstructions get diluted as chat growsUse the system field/message
Ignoring stop_reasonOnly reading contentSilent truncation when max_tokens hitCheck stop_reason == "max_tokens"
Guessing costusage not inspectedOff-by-orders-of-magnitude budgetingCompute from usage + per-MTok price
Assuming words ≈ tokensIntuitive but wrongBad window/cost estimatesUse ~4 chars/token; verify with usage
Letting history grow unboundedNo trimmingEventually exceeds context window → errorCap/trim/summarize history

10. Connections

Builds On

This is Project 1 — the foundation. Everything downstream builds on the messages/cost/context mental model established here.

Enables

  • P2 (Tokens & Embeddings): deepens the "what is a token" idea introduced here.
  • P4 (RAG): retrieved documents get injected into messages — the same array, now with stuffed context.
  • P5 (Memory): an explicit answer to "history grows unbounded" — summarize/store instead of resend.
  • P8 (Agent): the loop becomes a tool-use loop; stop_reason == "tool_use" replaces end_turn.

Production Patterns

Real products manage the array with a sliding window (drop oldest turns), summarization (compress old turns into a synthetic note), and prompt caching (cache the stable prefix so repeated history costs ~0.1× input). Model selection (Haiku→Sonnet→Opus) is a first-order cost lever.

StarcallOS Relevance

StarcallOS Connection

The conversation loop + system prompt + cost tracking is the substrate for any StarcallOS assistant surface. The "resend full state" cost dynamic is exactly why StarcallOS needs a memory layer: you cannot resend a user's entire history forever, so you need persistent, retrievable memory (P5) rather than an ever-growing array.

Sources

See source/resources.md for the complete annotated source list.

Tier 1 — Official Documentation

Tier 2 — Foundational Papers

  • None required for Project 1 — the Transformer paper is deferred to P2, where it becomes load-bearing.

Tier 3 — Engineering Blogs

  • None gathered for P1 — production patterns are covered by the Tier 1 pricing/Messages docs above.

Tier 4 — Educational Sources