Depending entirely on cloud APIs (OpenAI, Anthropic) carries real risks for enterprise applications: leaking sensitive internal data, costs that climb with every input token, and an outage whenever the internet connection drops.

The answer is local AI β€” running open-weight large language models directly on your own machine through Ollama. This is also the first lesson in the series where the program talks to a real LLM server rather than the mocks of Lessons 11 and 12. We will stand that server up, measure how much memory the model occupies and how many tokens per second it generates, then write a streaming chat client that works fully offline.

βœ… What you need before starting
Software: install Ollama from ollama.com/download (macOS, Windows and Linux builds exist). Then pull at least one model β€” ollama pull qwen2.5:7b for example. A note on size: a 7B model quantised to 4 bits is roughly 4-5 GB, so the first download takes a few minutes and needs that much free disk. This is the only lesson in the series that requires a large download.

Python libraries: none β€” the project uses only urllib and json from the standard library, deliberately avoiding requests and the ollama package, so that you see the HTTP protocol itself instead of a library hiding it.

Knowledge you need: Lesson 11 for the role-tagged messages array β€” Ollama uses exactly that format, so everything you learned there transfers unchanged.

13.1 Why run an LLM locally?

The explosion of high-quality open-weight models (Meta's Llama, Google's Gemma, Alibaba's Qwen, France's Mistral) changed the game completely. A mid-range personal computer can now host a genuinely useful AI assistant that answers to nobody.

The advantages of going local:

  • Absolute data privacy: every conversation, internal document and piece of proprietary source code stays in local memory. Not one byte reaches an outside server. For data like the customer email of Lesson 12, this is not a convenience but a legal requirement.
  • No per-token bill: no monthly API invoice to worry about. You can run as much inference as you like at zero marginal cost.
  • No network dependency: works in places with no internet, and on isolated internal networks.
⚠️ Pitfall: "free" does not mean "unlimited"
The marginal cost is zero, but the throughput certainly is not unlimited. Measured on the machine this lesson was written on (a MacBook with Apple Silicon, a 7B model quantised to 4 bits): 36 to 48 tokens per second depending on answer length. Rounding to 40, generating a million tokens needs about 7 hours of continuous running, during which the GPU is occupied, the machine runs hot and the battery drains fast. By comparison, a cloud API serves hundreds of requests in parallel. Going local trades money for time and hardware; it is not a free lunch.
⚠️ Pitfall: expecting cloud-model reasoning from a local model
A common mistake is expecting a small local model of 7 to 8 billion parameters, running on a laptop, to reason as well as cloud models hundreds of times larger. Small local models are strong at information extraction, summarising and classification β€” exactly the tasks from Lesson 12 β€” but show clear weaknesses on multi-step logic or programming problems.

13.2 Meet Ollama & managing models

Setting up a local AI model used to be genuinely painful: install CUDA graphics drivers, install PyTorch, download tens of gigabytes of weight files and compile C++ code.

Ollama packages the best inference libraries (with llama.cpp at its core) into a single lightweight background service, and detects your hardware automatically to enable acceleration:

  • On macOS: it uses Metal, Apple's low-level graphics API, to drive the integrated GPU of Apple Silicon chips (M1 and later).
  • On Windows/Linux: it uses CUDA for NVIDIA cards, or ROCm for AMD.
  • With no suitable GPU: it still runs on the CPU, just much more slowly β€” section 13.3 shows how much more slowly, and why.

The essential Ollama CLI commands:

Terminal
# Download a model and start chatting with it immediately
ollama run qwen2.5:7b

# Only download it, without starting a chat
ollama pull gemma2

# List the models stored on this machine
ollama list

# Show which models are loaded in memory right now, and on what hardware
ollama ps

# Unload a model from memory (the file stays on disk)
ollama stop qwen2.5:7b

# Delete a model to reclaim disk space
ollama rm qwen2.5:7b

The two commands most often overlooked are ollama ps and ollama stop, and they are exactly the tools for understanding the next section: where the model lives, how much it takes, and which processor is doing the work.

13.3 Memory and speed: the numbers that decide everything

Every local-LLM guide says "you need enough VRAM", but few say how much enough is, or why that number is larger than the file on disk. Let us start from the theory and then check it against a real measurement.

πŸ’‘ What is weight quantisation?
By default a model's neural weights are stored as 16-bit or 32-bit floating point numbers (FP16/FP32), which demands enormous disk space and VRAM. Quantisation compresses the model by squeezing those floats into 4-bit or 8-bit integers (INT4/INT8). This shrinks the model by a factor of 4 to 8 while losing only a small amount of quality, which is what makes consumer hardware viable. The Q4_K_M label you see in ollama list is the name of one specific 4-bit quantisation scheme.
πŸ“ The size formula, and its error against reality
The formula: size in bytes $\approx$ parameter count $\times$ bits per parameter $\div 8$. For the 7.6-billion-parameter model used in the project:
  • FP32 (32-bit): $\approx 30.40$ GB.
  • FP16 (16-bit): $\approx 15.20$ GB.
  • INT8 (8-bit): $\approx 7.60$ GB.
  • INT4 (4-bit): $\approx 3.80$ GB.
But the real file Ollama reports is 4.68 GB, which is 23% larger than the theoretical figure. The gap comes from Q4_K_M not quantising everything uniformly: the sensitive layers (particularly the embedding layer and parts of the attention layers) are kept at higher precision than 4 bits to avoid degrading quality. The "M" in the name stands for "Medium" β€” the compromise point between size and quality.

And here is the part that usually gets missed. The file size is not the amount of memory the model occupies while running. Running ollama ps right after asking the model a question, on the same machine:

Terminal
$ ollama ps
NAME                ID              SIZE      PROCESSOR    CONTEXT    UNTIL
qwen2.5-coder:7b    dae161e27b0e    6.6 GB    100% GPU     32768      4 minutes from now

The file on disk is 4.68 GB, but running it takes 6.6 GB. Almost 2 GB of that gap is the KV cache: the buffer holding the Key and Value vectors of every token in the context window β€” the same K and V you met in Lesson 10. The longer the window, the larger the buffer; here it is configured for 32768 tokens, and that is why the rule of thumb always demands more free memory than the file size.

⚑ The line that decides your speed: the PROCESSOR column
That 100% GPU reading is the most important number in this lesson. If graphics memory cannot hold the model plus its KV cache, Ollama is forced to run part of it on the CPU, and ollama ps will show something like 60% GPU / 40% CPU. When that happens speed does not drop by 40% β€” it drops by an order of magnitude, because every generated token waits on the CPU portion, whose memory bandwidth is many times lower.

Rule of thumb for free memory:
  • A 7B/8B model at 4-bit (a 4-5 GB file): allow at least 8 GB.
  • A 70B model at 4-bit (roughly a 40 GB file): allow at least 48 GB.
If the machine feels unusually slow, run ollama ps before blaming the model β€” the PROCESSOR column answers the question immediately.

13.4 Calling the Ollama API from your application

On startup, Ollama exposes a local REST API at http://localhost:11434. This is what makes it useful to a developer: everything you wrote in Lessons 11 and 12 carries over almost unchanged β€” only the URL differs, and the auth header disappears.

The main chat endpoint is /api/chat, and the request body will look very familiar:

request body
{
  "model": "qwen2.5:7b",
  "messages": [{ "role": "user", "content": "Why is the sky blue?" }],
  "stream": true
}

That is the same three-role messages array from Lesson 11. The difference is "stream": true: rather than waiting for the model to finish the whole answer and returning one JSON blob, Ollama sends back a sequence of independent JSON lines, each carrying a freshly generated fragment. At roughly 40 tokens per second, a 200-token answer takes nearly 5 seconds β€” streaming turns those 5 silent seconds into text appearing immediately.

🧠 The first three stream lines and the last one, captured live from Ollama
The project prints the first three lines of the stream (with the model and created_at fields, which repeat on every line, removed for readability):
{"message": {"role": "assistant", "content": "The"}, "done": false}
{"message": {"role": "assistant", "content": " sky"}, "done": false}
{"message": {"role": "assistant", "content": " appears"}, "done": false}
Concatenated in order: "The" β†’ "The sky" β†’ "The sky appears". The for raw in response loop reads each line, pulls out content and writes it straight to the console with sys.stdout.write β€” never accumulating a string in memory, so the text appears at exactly the pace the model produces it.

The final line (done: true) has empty content but is far from useless β€” it carries all the instrumentation:
{"total_duration": 2067325583, "load_duration": 1502167333,
 "prompt_eval_count": 43, "prompt_eval_duration": 203854000,
 "eval_count": 13, "eval_duration": 358924000}
The units are nanoseconds. From these you get the real speed: $13 \div (358924000 \div 10^9) \approx 36.2$ tokens per second. This is how you measure your own machine instead of reading somebody else's benchmark table.

13.5 Lesson 13 project: an offline chat client that measures itself

The project is a Python script that talks directly to Ollama on your machine. It does four things: ask the server which models exist, choose one that is actually installed, stream the answer to the screen, and print the speed measured from the last line of the stream.

πŸ’‘ Why the script discovers the model instead of hardcoding a name
The most common way a script like this fails is a hardcoded "model": "llama3". If you never pulled that exact name, the server returns HTTP 404 with model 'llama3' not found β€” Ollama is running perfectly, the model is simply missing. But in Python HTTPError is a subclass of URLError, so an except urllib.error.URLError block placed first swallows the 404 and prints "cannot reach Ollama". The reader goes off to restart Ollama, which of course fixes nothing.

So this script calls /api/tags first to get the real list of models, and catches HTTPError before URLError so the two situations produce two different messages.
local_chat.py
"""Lesson 13 project: talk to a local Ollama server, and measure it.

Run:  python3 local_chat.py
Needs: Ollama running, plus at least one model pulled (`ollama pull qwen2.5:7b`).

Standard library only - no `requests`, no `ollama` package. The point is to see
the HTTP stream itself rather than have a library hide it.
"""

import json
import sys
import urllib.error
import urllib.request

OLLAMA = "http://localhost:11434"
PREFERRED = ["qwen2.5-coder:7b", "qwen2.5:7b", "llama3.2", "llama3.1", "gemma2"]


def list_models():
    """Ask the server which models are pulled. Returns [] if it is not running."""
    try:
        with urllib.request.urlopen(f"{OLLAMA}/api/tags", timeout=5) as response:
            payload = json.loads(response.read())
    except urllib.error.URLError as exc:
        print(f"Cannot reach Ollama at {OLLAMA} - {exc.reason}")
        print("Start the Ollama app (or run `ollama serve`) and try again.")
        return []
    return payload.get("models", [])


def pick_model(models):
    """Choose a model that actually exists here, instead of hardcoding a name.

    Hardcoding "llama3" is the most common way this script fails for a reader:
    the server is running fine, the model simply was never pulled.
    """
    names = [m["name"] for m in models]
    for wanted in PREFERRED:
        for name in names:
            if name == wanted or name.startswith(wanted + ":"):
                return name
    return names[0] if names else None


def report_models(models):
    """Print what is installed, with the size and quantisation of each."""
    print("=== Models available on this machine ===")
    for model in models:
        details = model.get("details", {})
        print(f"  {model['name']:<30} {model['size'] / 1e9:5.2f} GB"
              f"  params={details.get('parameter_size', '?'):>7}"
              f"  quant={details.get('quantization_level', '?')}")
    print()


def stream_chat(prompt, model, show_raw_lines=0):
    """Send one chat request and print tokens as they arrive.

    Returns the final `done: true` object, which carries the timing counters.
    """
    body = json.dumps({
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "stream": True,
    }).encode("utf-8")
    request = urllib.request.Request(
        f"{OLLAMA}/api/chat", data=body,
        headers={"Content-Type": "application/json"},
    )

    print(f"[{model}] {prompt}")

    final = {}
    kept = []
    try:
        with urllib.request.urlopen(request) as response:
            print("  ", end="")
            sys.stdout.flush()
            for raw in response:
                line = raw.decode("utf-8").strip()
                if not line:
                    continue  # a blank line is not JSON; json.loads() would raise
                chunk = json.loads(line)
                if len(kept) < show_raw_lines:
                    # Same line, minus two fields that repeat on every chunk,
                    # so the part that changes stays readable at this width.
                    kept.append({k: v for k, v in chunk.items()
                                 if k not in ("model", "created_at")})
                sys.stdout.write(chunk.get("message", {}).get("content", ""))
                sys.stdout.flush()
                if chunk.get("done"):
                    final = chunk
    except urllib.error.HTTPError as exc:
        # NOT the same failure as the server being down, and the message must
        # say so. HTTPError is a subclass of URLError, so the order matters:
        # catching URLError first would swallow this and blame the connection.
        detail = json.loads(exc.read() or b"{}").get("error", "no detail given")
        print(f"\n  the server answered HTTP {exc.code}: {detail}")
        print(f"  Ollama is running. Pull the model first: `ollama pull {model}`")
        return {}
    except urllib.error.URLError as exc:
        print(f"\n  cannot reach Ollama at {OLLAMA} - {exc.reason}")
        print("  Start the Ollama app (or run `ollama serve`) and try again.")
        return {}
    print()
    for number, chunk in enumerate(kept, 1):
        print(f"  chunk {number}: {json.dumps(chunk, ensure_ascii=False)}")
    if kept and final:
        counters = {k: v for k, v in final.items()
                    if k.endswith(("_count", "_duration"))}
        print(f"  last chunk carries the counters: "
              f"{json.dumps(counters, ensure_ascii=False)}")
    return final


def report_speed(final):
    """Turn the counters in the last chunk into numbers you can compare."""
    if not final:
        return
    tokens = final.get("eval_count", 0)
    eval_ns = final.get("eval_duration", 0)
    load_ns = final.get("load_duration", 0)
    prompt_tokens = final.get("prompt_eval_count", 0)
    if not eval_ns:
        return
    print(f"  generated {tokens} tokens in {eval_ns / 1e9:.2f}s"
          f"  ->  {tokens / (eval_ns / 1e9):.1f} tokens/s")
    print(f"  prompt was {prompt_tokens} tokens;"
          f" loading the model took {load_ns / 1e9:.2f}s")
    print()


def main():
    models = list_models()
    if not models:
        print("No models found. Pull one first, for example:")
        print("  ollama pull qwen2.5:7b")
        return
    report_models(models)

    model = pick_model(models)

    # First call: show the raw stream lines, so the wire format is visible.
    print("=== What the stream actually looks like ===")
    final = stream_chat("Why is the sky blue? Answer in under 12 words.",
                        model, show_raw_lines=3)
    report_speed(final)

    # Second call: the same model is already loaded, so load_duration collapses.
    print("=== Same model, second call ===")
    final = stream_chat("Name three primary colours, comma separated.", model)
    report_speed(final)

    # A model that is certainly not installed, to see the right error message.
    print("=== Asking for a model that was never pulled ===")
    stream_chat("hello", "definitely-not-a-real-model")


if __name__ == "__main__":
    main()

Running it looks like this β€” a real result on the authoring machine, not a simulation:

Terminal
=== Models available on this machine ===
  bge-m3:latest                   1.16 GB  params=566.70M  quant=F16
  qwen2.5-coder:7b                4.68 GB  params=   7.6B  quant=Q4_K_M
  qwen2.5:14b-instruct-q4_K_M     8.99 GB  params=  14.8B  quant=Q4_K_M
  translategemma:latest           3.30 GB  params=   4.3B  quant=Q4_K_M

=== What the stream actually looks like ===
[qwen2.5-coder:7b] Why is the sky blue? Answer in under 12 words.
  The sky appears blue because of Rayleigh scattering of sunlight.
  chunk 1: {"message": {"role": "assistant", "content": "The"}, "done": false}
  chunk 2: {"message": {"role": "assistant", "content": " sky"}, "done": false}
  chunk 3: {"message": {"role": "assistant", "content": " appears"}, "done": false}
  generated 13 tokens in 0.36s  ->  36.2 tokens/s
  prompt was 43 tokens; loading the model took 1.50s

=== Same model, second call ===
[qwen2.5-coder:7b] Name three primary colours, comma separated.
  Red, Blue, Green
  generated 6 tokens in 0.13s  ->  47.2 tokens/s
  prompt was 37 tokens; loading the model took 0.14s

=== Asking for a model that was never pulled ===
[definitely-not-a-real-model] hello
  the server answered HTTP 404: model 'definitely-not-a-real-model' not found
  Ollama is running. Pull the model first: `ollama pull definitely-not-a-real-model`
πŸ”’ Real numbers: why the first call is always slower
Compare the two loading the model took lines: 1.50 seconds the first time, 0.14 seconds the second β€” more than a tenfold difference. On the first call Ollama has to load 4.68 GB of weights from disk into graphics memory; on the second the model is already sitting there. This is why the first chat of the day feels like it hangs for a few seconds, and why Ollama keeps a model resident for five minutes after last use rather than freeing it immediately. To check it yourself: run ollama stop <model-name> and run the script again β€” the 1.50-second figure comes back.

Note that unlike previous lessons, this lesson's output is not reproducible. This is a real model doing real sampling, so the wording and the speed on your machine will differ β€” different hardware, different model. What should match is the shape: three chunk lines, the counters in the final line, and a first call slower than the second.

How to run this project on your machine

  1. Install Ollama, then pull a model: ollama pull qwen2.5:7b (about 4.7 GB). Confirm with ollama list.
  2. Run python3 local_chat.py. The script looks for a model from its PREFERRED list; if none of them match, it takes the first model you have.
  3. Then try breaking it three ways:
    • Quit the Ollama application entirely and run it again. You get a "cannot reach Ollama" message β€” quite different from the 404 message at the bottom of the output above. Two different failures must say two different things.
    • In stream_chat, swap the two except blocks so URLError comes first. The 404 is immediately misreported as a connection failure β€” precisely the trap described above, and now you see it rather than take it on trust.
    • If you have a larger model (14B or more), move its name to the top of PREFERRED, run again and compare tokens/s. Then run ollama ps and look at the PROCESSOR column β€” if it is no longer 100% GPU, you have just watched the boundary from section 13.3 in action.

Lesson summary & what comes next

πŸ”‘ What you achieved:
  • Achieved: running a large language model fully offline on your own machine with Ollama, and calling it from Python using only the standard library.
  • Achieved: reading and handling a line-by-line JSON stream, including the final line that carries the instrumentation.
  • Achieved: measuring your own machine's real speed in tokens per second, and explaining why the first call is slower.
  • Achieved: telling file size from resident memory, and using ollama ps to see whether the model is on the GPU or has spilled onto the CPU.

Bridge to the next lesson: the model now runs on your machine and sends data nowhere β€” exactly the condition for letting it read internal documents. But stuffing thousands of PDF pages into the context window is impossible (Lesson 11 showed what tokens cost). Lesson 14 solves that with the RAG architecture.

Download the practice code for this lesson

The Python file local_chat.py β€” model discovery, a streaming call to the local Ollama API, and the speed measurement (run python3 local_chat.py):

Download local_chat.py

πŸ“– Further reading

Related lessons in this series

Lesson 12: Structured Outputs & Function Calling Lesson 14: Basic RAG β€” question answering over your own documents Back to the Practical AI Engineer roadmap

Comments