A Brain She Owns
A brain she owns, not one she rents
She can speak and she can hear. What she says is still your typing, so today she gets a mind. The obvious move is to wire her to a hosted chat API, and everything about this project argues against it: an API key, a network round-trip inside every sentence, a per-token bill, and a hard dependency on someone else's uptime. For a machine that lives in your room, "the internet is down" must never mean "she is dead." Her brain belongs on the same disk she does.
Ollama makes that practical. It runs quantized language models locally behind a small HTTP API: pull a model once, and it answers forever, with no key and no meter. A three-billion-parameter model quantized to four bits fits in a couple of gigabytes of memory and replies in about a second on an ordinary machine. What quantization trades away, and how these models work inside, is AI Zero's deep water; the fact that matters tonight is blunter. A small model that answers now beats a giant one that cannot run on your hardware at all.
The other thing you learn tonight is the one that surprises everyone: the model has no memory. Ask "what's my name?", tell it, ask again, and it has no idea, because each call starts from nothing. A language model is a pure function of the message list you hand it, this call, only. Conversation is something you maintain, and this chapter is where you learn to maintain it.
curl -fsSL https://ollama.com/install.sh | sh
ollama pull llama3.2:3b
uv add ollama
$ ollama run llama3.2:3b "Say exactly: facility online"
Facility online.
The pull downloads a few gigabytes once and stores them locally. The one-line test proves the whole stack (server, model, sampling) before any Python enters the picture. Even told to answer exactly, the model capitalized and punctuated on its own: your first sighting of the fact that model output is generated, not retrieved, and will vary run to run.
The list is the memory
# labs/glados_llm.py
SYSTEM_PROMPT = """You are GLaDOS, the AI from Portal.
You are sardonic, passive-aggressive, and darkly witty.
Keep responses brief and in character."""
def build_messages(history: list[dict]) -> list[dict]:
return [{"role": "system", "content": SYSTEM_PROMPT}] + history
history = [{"role": "user", "content": "Are you there?"}]
for msg in build_messages(history):
print(f"[{msg['role']}] {msg['content'].splitlines()[0]}")
$ uv run python labs/glados_llm.py
[system] You are GLaDOS, the AI from Portal.
[user] Are you there?
Every message is a dict with a role and a content. The
roles are a convention the whole industry shares: system for
instructions and persona, user for what the human said,
assistant for what the model said. The system prompt is prepended
fresh on every build rather than stored in the history, because it is not part of
the conversation; it is standing instructions, constant while the history grows.
And note what this stage does not contain: a model. You can inspect the exact list
the model will receive before one is involved, which is precisely the kind of seam
chapter 2 promised returns would buy you.
import ollama
MODEL = "llama3.2:3b"
def chat(user_input: str, history: list[dict]) -> tuple[str, list[dict]]:
history.append({"role": "user", "content": user_input})
response = ollama.chat(
model=MODEL,
messages=build_messages(history),
)
reply = response["message"]["content"]
history.append({"role": "assistant", "content": reply})
return reply, history
The double append is the heart of the chapter. The user's line goes in
before the call so the model sees it; the reply goes in after so
the next call remembers it. Skip the second append and she forgets every answer she
gives, replying to each line as if the conversation just started. And
chat() takes the history as an argument and returns it updated, so the
state is visible at every call site and testable with a fixed list. A hidden global
would work today and haunt volume 4.
def main() -> None:
print(f"GLaDOS online. Model: {MODEL}")
print("Type 'quit' to exit.\n")
history: list[dict] = []
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("quit", "exit"):
print("GLaDOS: Goodbye. Don't come back.")
break
reply, history = chat(user_input, history)
print(f"GLaDOS: {reply}\n")
if __name__ == "__main__":
main()
$ uv run python labs/glados_llm.py
GLaDOS online. Model: llama3.2:3b
Type 'quit' to exit.
You: My name is Chell.
GLaDOS: Of course it is. The one test subject who simply refuses to stay filed away.
You: What's my name?
GLaDOS: Chell. I just said... never mind. Memory intact. Unlike some of us.
You: quit
GLaDOS: Goodbye. Don't come back.
Her wording will differ on your machine; what must not differ is the second
exchange. She knows the name because the first exchange, both halves of it, was in
the list she received. The history = [] sits outside the loop, created
once and threaded through every call; move it inside and you reset her memory on
every line, rebuilding the amnesia you just cured.
Why this works: stateless model, stateful list
An LLM keeps no hidden state between calls. Ollama loads the model, feeds it exactly
the messages you sent, samples a reply token by token, and forgets everything. Send
[system, user] and you get a one-shot answer with no past, because there
is no past. Send [system, user, assistant, user] and the model can refer
to the earlier turns, because they are physically present in its input. That is the
entire mechanism of "memory" in every chat system you have ever used: a list somebody
keeps handing back in.
Owning that fact pays twice. It demystifies the products (the hosted assistants are maintaining the same kind of list on their servers), and it hands you the lever: what she remembers is decided by what you put in the list. Volume 2 pulls hard on that lever, giving her memory that survives the power button by choosing what to write down and what to load back. Tonight's version lives until the process exits, and that is enough to feel like someone is in there.
It is tempting to make the history optional so quick calls need no setup, defaulting it to an empty list right in the signature. Reduced to the append logic, with a fake reply standing in for the model:
def add_turn(user_input, reply, history=[]): # mutable default!
history.append({"role": "user", "content": user_input})
history.append({"role": "assistant", "content": reply})
return history
a = add_turn("Are you there?", "Unfortunately.")
print("Conversation A length:", len(a))
b = add_turn("Hello?", "We've met. Twice now.") # a brand-new conversation
print("Conversation B length:", len(b))
$ uv run python labs/fail_default.py
Conversation A length: 2
Conversation B length: 4
Conversation B was born with conversation A inside it. Python evaluates a default
argument once, at function definition, so every call that omits
history shares one list, accumulating forever. In a chat program the
symptom is exactly the spooky one in the title: a "fresh" conversation where she
references things nobody said. The fix is the standard idiom, default to
None and create inside:
def add_turn(user_input, reply, history=None): if history is None: history = [].
Or do what our chat() does and make the caller own the list outright;
state this important deserves to be visible.
Checkpoint, and a mind of her own
- I can name the three message roles and say who writes each one.
- I can explain, mechanically, why she remembered the name: what was in the list, and who put it there.
- I can point to both appends in
chat()and describe the distinct amnesia each one prevents. - I know why the system prompt is prepended per call instead of stored in history.
- I can spot a mutable default argument and state the once-at-definition rule that makes it a trap.
Exercise 1 — watch the amnesia. Comment out the second append (the assistant one) and re-run the name test. What changes, and why does the first exchange still look normal?
The first exchange is fine because the model never needs its own past to answer a fresh line. The second falls apart: asked "what's my name?", the model's list contains your question and your earlier line but none of its own replies, so it answers out of thin air and the thread of the conversation dissolves. One missing append, and she becomes a stranger every other turn.
Exercise 2 — count the cost of memory. Print
len(build_messages(history)) each turn and chat for a while. What
grows, and what does that imply for an assistant that runs for a month?
The list grows by two every exchange, without bound, and every message in it is re-sent and re-processed on every call. A month of history would blow past any model's context window and slow every reply before it did. Boundless memory is a bug wearing a feature's clothes; volume 2 builds the real fix, deciding what is worth keeping.
Exercise 3 — swap the brain. Pull a second model
(ollama pull qwen2.5:3b or any small one), switch
MODEL, and rerun your test lines. What changed about her, and what
did not have to change?
Tone, verbosity and speed shift with the model; the code does not move at all, because the messages convention is shared across models. That one-line swappability is the payoff of building on the standard interface, and it is about to matter: volume 8 swaps her onto a bigger brain the same way, on different hardware.
She thinks, she remembers within a session, and the system prompt already leans her toward sarcasm. But three hardcoded lines in a string is a costume, not a character. The next chapter builds her a real one: personality as data, with example lines from the game itself teaching the model how GLaDOS actually talks.