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.
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.
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:
# 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.
Q4_K_M label you see in ollama list is the name of one specific 4-bit
quantisation scheme.
- 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.
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:
$ 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.
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.
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:
{
"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.
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.
"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.
"""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:
=== 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`
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
-
Install Ollama, then pull a model:
ollama pull qwen2.5:7b(about 4.7 GB). Confirm withollama list. -
Run
python3 local_chat.py. The script looks for a model from itsPREFERREDlist; if none of them match, it takes the first model you have. -
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 twoexceptblocks soURLErrorcomes 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 runollama psand look at the PROCESSOR column β if it is no longer100% GPU, you have just watched the boundary from section 13.3 in action.
Lesson summary & what comes next
- 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 psto 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):
π Further reading
- Ollama Official Website β downloads and the official API documentation
- llama.cpp GitHub β the core C++ inference library that makes local models fast on consumer CPUs/GPUs (Georgi Gerganov)
- QLoRA: Efficient Finetuning of Quantized LLMs β the paper establishing how well 4-bit quantised models hold up (Dettmers et al., 2023)
Comments