AI Chatbot
Build intuition for how LLMs actually work under the hood
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
messagesarray, 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
usageand 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.
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.
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
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
- You assemble
messages(full history) +system+ params. - The provider tokenizes the entire input. The token count =
input_tokens. - The model generates output tokens one at a time until a natural stop,
max_tokens, or astop_sequence— recorded instop_reason. - Non-streaming: the whole
Messageat once. Streaming: tokens arrive as SSE events (message_start→content_block_delta* →message_stop). - You read
usageto compute cost, append theassistantmessage 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)/2 — quadratic-ish. This is why long conversations get disproportionately expensive and why context management exists.
Key Concepts
| Concept | Definition | Why It Matters |
|---|---|---|
| Messages array | The ordered list of {role, content} turns sent on every request. | It is the conversation — there is no server-side memory. |
| Stateless | The model retains nothing between requests; each call starts fresh. | Forces you to resend history, which drives cost and context limits. |
| System prompt | Top-level persistent instructions that shape behavior across all turns. | The cheapest, highest-leverage way to control the model. |
| Context window | Max tokens the model can "see" (input + output) in one call. | When history exceeds it, the call fails or must be trimmed. |
| Token | The 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. |
| Temperature | A 0.0–1.0 dial for randomness. | Controls reproducibility vs. variety. |
| Provider abstraction | A library (LiteLLM) giving all providers one interface. | Avoids vendor lock-in; swap models by changing a string. |
"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
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
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)
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
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
- Explain "the API is stateless" in your own words (ELI12, then ELI-Engineer). What concretely gives a chatbot memory?
- Draw your mental model of how
messagesflows from turn to turn. Where does the assistant's reply go? - Predict: if you forget to append the assistant's response to history, what exactly breaks — and on which turn?
- 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). - The system prompt vs. a first user message: why prefer the system prompt for persistent instructions?
- Where have you seen the "resend full state each time" pattern before, inside or outside AI?
- 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:
| Files | Role | You... |
|---|---|---|
config.py, .env.example, tests/ | provided | copy .env.example → .env; make the tests pass |
cost_tracker.py, context.py | partial | implement the TODO(learner) bodies |
chatbot.py | learner | build the conversation loop — the heart of the project |
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.
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.
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.
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.
One-shot call
Send a single message, print the reply and raw usage. Validate: you can name every field in the response.
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.
System prompt
Inject a configurable system prompt; observe behavior change. Validate: same question, two system prompts → visibly different style.
Streaming
Switch to stream=True; print tokens as they arrive. Validate: text appears incrementally, not all at once.
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.
| Criterion | Does your implementation... | Pass? |
|---|---|---|
| History correctness | append BOTH user and assistant messages every turn? | ☐ |
| Statelessness understood | resend the full history (not just the latest message)? | ☐ |
| System prompt | apply persistent instructions separately from user turns? | ☐ |
| Streaming | render tokens incrementally via the stream? | ☐ |
| Cost accuracy | compute cost from real usage, matching a manual check? | ☐ |
| Context awareness | detect/handle approaching the context window? | ☐ |
| Provider abstraction | swap models without rewriting the loop? | ☐ |
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_tokensgrows each turn. - Streaming "works" but you never capture final token counts.
9. Common Mistakes
| Mistake | Why It Happens | Consequence | Fix |
|---|---|---|---|
| Sending only the latest message | Assuming the server remembers | Bot has no memory; pronouns/context break | Resend the full messages array every call |
| Forgetting to append the assistant reply | Only appending user turns | Model loses its own prior answers | Append BOTH roles each turn |
| Putting instructions in a user message | Not knowing about system | Instructions get diluted as chat grows | Use the system field/message |
Ignoring stop_reason | Only reading content | Silent truncation when max_tokens hit | Check stop_reason == "max_tokens" |
| Guessing cost | usage not inspected | Off-by-orders-of-magnitude budgeting | Compute from usage + per-MTok price |
| Assuming words ≈ tokens | Intuitive but wrong | Bad window/cost estimates | Use ~4 chars/token; verify with usage |
| Letting history grow unbounded | No trimming | Eventually exceeds context window → error | Cap/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"replacesend_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
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
- Anthropic Messages API — request/response contract, stateless multi-turn,
stop_reason,usage. - Anthropic Streaming — the SSE event protocol.
- Anthropic Pricing & Token Cost — per-MTok pricing, cost formula, token estimation.
- LiteLLM completion() — the provider-agnostic unified interface.
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
- Hugging Face LLM Course — Introduction — NLP vs. LLM framing.
- mlabonne/llm-course — "Running LLMs" as the foundational first step.