llama.cpp runs large language models directly on your machine, with no Python runtime, no server process you did not start, and no account. On Apple Silicon it uses Metal for GPU work and the unified memory architecture means a model does not have to be copied to a separate card before it can run.
This article installs llama.cpp with Homebrew, pulls a model from Hugging Face, runs it two ways, and shows exactly where the weights land on disk. It closes with a small Python project that reads a GGUF file’s header with nothing but the standard library, so the model file stops being an opaque blob you downloaded and becomes something you can inspect and assert against.
Two things worth knowing before you start, because most of the llama.cpp tutorials currently online predate them:
- The binaries were consolidated. Older material calls
llama-cliandllama-server. Current builds dispatch through a singlellamacommand with subcommands:llama cli,llama serve,llama bench. - GPU offload is automatic. The advice to “pass
-ngl 99to use the GPU” is stale.-nglnow defaults toauto, and the flag’s job today is mostly to let you turn offload off for comparison.
Prerequisites
- macOS on Apple Silicon. Written and validated on macOS 26.5.2, arm64. Intel Macs work but have no Metal offload worth measuring, so the timings in Step 6 will not reproduce.
- Homebrew 6.0.15 or newer —
brew --version. - uv 0.11.26 or newer for the Python project in
Steps 7–11 —
uv --version. Install withbrew install uv. - About 2 GB of free disk. The model used here is 795 MiB at Q8_0, and Hugging Face’s cache keeps one copy.
- llama.cpp b10330 or newer. The project tags a release most days; pin the build number you actually ran, because flag names move.
No Hugging Face account or token is needed. Every model in this article is publicly downloadable.
Step 1: Install llama.cpp
brew install llama.cpp
Verify the install:
llama version
b10330-687e77892
Detailed breakdown
- That one line is the whole version output: the build number and the commit
it was built from. Older material shows a two-line
version: … / built with …banner, which was the standalonellama-cliformat; the dispatcher prints this instead. - The formula pulls four dependencies:
ggml,openssl@3,ca-certificates, andlibomp.ggmlis the tensor library underneath llama.cpp, split into its own formula so other tools can share it.openssl@3andca-certificatesare there because llama.cpp downloads models over HTTPS itself in Step 3; without TLS the-hfflag would not work.libompprovides OpenMP for CPU threading. - The build number (
10330) is the upstream release tag, not a semantic version. There is no1.xto reason about, so record the number. When a command in this article stops matching what you see, comparing build numbers is the fastest way to find out why. - Homebrew ships a bottle, so this is a download rather than a compile. If you
want a source build with different flags,
brew install --HEAD llama.cppcompiles from the tip of master instead.
Step 2: One binary, several subcommands
Run llama with no arguments:
llama
Usage: llama <command> [options]
Available commands:
serve HTTP API server
cli Command-line interactive interface
download Download a model
version Show version
licenses Show third-party licenses
help Show available commands
Run 'llama help all' to show additional commands.
Run 'llama <command> --help' for command-specific usage.
Several more subcommands exist but are hidden from the default listing:
llama help all
That adds update, completion, bench, batched-bench, fit-params,
quantize, and perplexity.
Detailed breakdown
- The subcommands you will use most.
cliis the interactive chat loop.servestarts an OpenAI-compatible HTTP server.completionis the one to reach for in scripts and tests — but only with-no-cnv, which is what makes it read a prompt, write an answer, and exit. Step 3 covers why. - Aliases are real but no longer advertised. The listing prints one name per
command, yet
llama server,llama client,llama get, andllama creditsall still resolve toserve,cli,download, andlicenses. If you are adapting a script written against older material,serverstill works. - Hidden does not mean unsupported.
bench,quantize, andperplexityare fully supported; they are hidden to keep the top-level help short for people who only want to chat with a model. Each takes--helpof its own, andllama bench --helpis a different flag set fromllama cli --help. - On older tutorials. If you find a command like
llama-cli -m model.gguf, translate it tollama cli -m model.gguf. The flags after the subcommand have mostly survived the consolidation; the binary name is the part that changed. Some builds also install standalonellama-<tool>executables alongside the dispatcher, but the subcommand form is the one that is always present.
Step 3: Run a model from Hugging Face
The -hf flag takes a Hugging Face repository and handles the download itself:
llama cli -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 -rea off
The first run downloads the weights and then drops into an interactive prompt. Type a question, press Return, and press Ctrl-C when you want out.
-rea off is not optional here, and leaving it out is the single most likely
way for this article to waste your afternoon. Qwen3.5-0.8B is a reasoning
model: it opens a thinking block before answering, and at 0.8B parameters it is
not strong enough to close one. Ask it mary had a without -rea off and it
argues with itself forever —
* Wait, let me look closer at the prompt: "mary had a". It's missing a word.
* Wait, is it asking for the completion? Or is it a prompt to generate content?
* No, that's not it.
* Wait, there's a specific line: "Mary had a little lamb".
— and never returns your prompt, because nothing stops it. This build ships
--repeat-penalty 1.00, which means repetition penalties are disabled, so the
loop has no brake. -rea off skips the thinking block entirely and the same
prompt answers immediately and terminates.
If you want to keep thinking enabled, --repeat-penalty 1.1 --dry-multiplier 0.8
breaks the verbatim loop but the model still circles for hundreds of tokens, and
-n 500 only caps the damage. The real answer at this size is to turn reasoning
off; a 4B or 8B model handles a thinking loop far better if you want one.
For anything scripted, use completion with -no-cnv, which prints one answer
and exits:
llama completion -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 \
-p "In one sentence, what is a GGUF file?" \
-n 64 -no-cnv
Without -no-cnv that command answers and then sits at a > prompt waiting for
your next turn, which is a hang in any script or Makefile target.
Getting just the answer
That command works, but “works” and “usable in a pipeline” are different things.
It writes 38 lines of log to stderr, echoes your prompt back before answering,
and — because this is a reasoning model — often opens a <think> block first.
How you avoid the thinking depends on what you are asking for, and the difference is bigger than it looks.
Continuing text is reliable. completion is a text-continuation tool, and a
prompt that reads like the start of a sentence gets treated as one. Across five
runs of this, a <think> block appeared zero times:
llama completion -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 \
-p "A GGUF file is" \
-n 48 -no-cnv --no-display-prompt -lv 0 \
| sed -e 's/ *\[end of text\]//' -e '/^[[:space:]]*$/d'
a binary format used for the conversion of large text files into a smaller size.
Asking a question is not reliable, on this model. Phrase the prompt as a
question and the model reaches for its chat behavior even with -no-cnv — a
<think> block showed up in three of five runs. Going the other way and asking
properly through the chat template with reasoning disabled is the honest version:
llama completion -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 \
-p "In one sentence, what is a GGUF file?" \
-n 64 -st -rea off --no-display-prompt -lv 0 \
| awk '/<think>/ { t = 1; next } /<\/think>/ { t = 0; next } !t { gsub(/ *\[end of text\]/, ""); if ($0 ~ /[^[:space:]]/) { print; got = 1 } } END { if (!got) { print "no answer: the model never finished thinking. Retry, or use a larger model." > "/dev/stderr"; exit 1 } }'
A GGUF file is a compressed format used to store large models for fast inference.
…when it answers at all. In eight runs of that command, five printed no answer,
because the model spent all 64 tokens thinking and never emitted a closing
</think>. That is the same defect Step 3 warns about, and -rea off reduces it
without eliminating it. Raising -n does not help — at -n 200 and -n 400 the
model simply thinks for longer.
Detailed breakdown
-lv 0sets the log verbosity threshold to zero, which silences all 38 stderr lines — backend init, theunused tensorwarnings, load timings, and thecommon_perf_printblock — without touching the generated text.- Do not reach for
--log-disablehere. It looks like the obvious flag and it suppresses the model’s output along with the logs, so you get an empty result and a zero exit code.-lv 0is the one that keeps the answer. --no-display-promptstops the prompt being echoed before the completion. The default is to print it, which is helpful interactively and noise everywhere else.-st -rea offreplaces-no-cnvfor the question form.-rea offonly works through the chat template, and-no-cnvdiscards the template — so the two do not combine.-st(--single-turn) keeps the template, runs exactly one turn, and exits.- The filter reports failure instead of hiding it, and that is the whole point.
The obvious version of this is
sed '/<think>/,/<\/think>/d', and it is a trap:sedranges run to end of file when the closing pattern never arrives, so on every run where the model does not finish thinking it deletes the entire output and prints nothing. A command that silently produces empty output on more than half its runs is worse than one that is merely noisy. Theawkversion drops the block the same way, strips the trailing[end of text], removes the blank lines left behind, and — if nothing survived — writes a diagnosis to stderr and exits 1. - No flag will remove the tags for you. Even with
-rea offthe model emits the<think>pair, and--reasoning-formatonly changes how thoughts are reported through the server API, not whether they appear in CLI output.--reasoning-budget 0sounds like the answer and still left one run in five unclosed. - If you need dependable one-shot answers, this is the wrong model. 0.8B is too small to run a reasoning loop, which is the same conclusion Step 3 reaches from the other direction. Use the continuation form here, or move to a 4B or 8B model where the question form actually returns.
Step 4: Find where the model landed
llama.cpp does not invent its own model directory. It writes into the standard Hugging Face hub cache:
ls ~/.cache/huggingface/hub/
CACHEDIR.TAG
models--ggml-org--Qwen3.5-0.8B-GGUF
Look inside one:
find ~/.cache/huggingface/hub/models--ggml-org--Qwen3.5-0.8B-GGUF -maxdepth 2
.../models--ggml-org--Qwen3.5-0.8B-GGUF
.../models--ggml-org--Qwen3.5-0.8B-GGUF/blobs
.../models--ggml-org--Qwen3.5-0.8B-GGUF/snapshots
.../models--ggml-org--Qwen3.5-0.8B-GGUF/refs
And check what it cost you:
du -sh ~/.cache/huggingface/hub/*
Detailed breakdown
- The layout is Hugging Face’s, not llama.cpp’s. A repository
user/namebecomes the directorymodels--user--name. Actual file contents live inblobs/keyed by object id;snapshots/<commit>/holds human-named symlinks pointing at those blobs. That indirection is why two revisions of a repo can share unchanged files instead of storing them twice. - The practical consequence is that the cache is shared. If you already use
Python’s
huggingface_hub, llama.cpp reads and writes the same directory. A model pulled by one is visible to the other, and clearing the cache affects both. - Six environment variables can move it, checked in this order:
LLAMA_CACHE,HF_HUB_CACHE,HUGGINGFACE_HUB_CACHE, thenHF_HOME(withhubappended), thenXDG_CACHE_HOME(withhuggingface/hubappended), and finally$HOME/.cache/huggingface/hub.LLAMA_CACHEwins over all of them, which is the one to set if you want llama.cpp’s models on an external disk without moving anyone else’s. - To delete a model, delete its
models--…directory. There is nollama uninstall. Removing the directory is the supported way to reclaim the space, and the next-hffor that repo re-downloads it.
Step 5: Download without running
llama download fetches weights and stops, which is useful when you want the
network work to happen before a demo rather than during it:
llama download -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0
The -hf flag is required here. llama download <repo> with the repository as a
bare positional argument fails with error: invalid argument.
Afterwards, llama completion -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 … starts
immediately, because the file is already in the cache.
You can also skip -hf entirely and point at a file you have on disk:
llama completion -m /path/to/model.gguf -p "Hello" -n 32 -no-cnv
Detailed breakdown
-hfand-mare alternatives: the first resolves a repository through the cache, the second takes a filesystem path.-hfis really “download if needed, then set-mto the cached path.”- Separating the download matters for anything timed. A benchmark that includes a 900 MB download in its first iteration produces a meaningless first number.
-mis also how you run a model that was never on Hugging Face, including one you quantized yourself.
Step 6: Control GPU offload
By default llama.cpp offloads as much of the model to the GPU as fits. The -ngl
flag overrides that:
# CPU only — no Metal offload
time llama completion -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 \
-p "Count from one to ten." -n 64 -ngl 0 -no-cnv
# Every layer on the GPU
time llama completion -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 \
-p "Count from one to ten." -n 64 -ngl all -no-cnv
Detailed breakdown
-nglaccepts an exact layer count,auto, orall, and its long forms are--gpu-layersand--n-gpu-layers. The default isauto.autois why the old advice is obsolete. Tutorials that tell you to pass-ngl 99were written when the default was zero and you had to opt in. Passing a large number today is harmless but redundant.- The interesting direction is now downward.
-ngl 0forces CPU-only execution, which is the honest baseline to compare against and a useful diagnostic: if a model produces correct output at-ngl 0and garbage atauto, you have a backend problem, not a model problem. - Expect the gap to be smaller than you think on a model this size. A 0.8B
model at Q8_0 is small enough that CPU inference on Apple Silicon is already
quick, and a short 64-token generation is dominated by startup. The offload
difference typically widens with model size and prompt length. Measure on your
own hardware rather than trusting a number from an article;
llama benchexists for exactly this and is the subject of its own piece. - Unified memory changes the trade-off. On a discrete GPU, offload is bounded
by VRAM and exceeding it is catastrophic. On Apple Silicon the GPU addresses the
same memory as the CPU, so
autohas far more room and the failure mode is gradual rather than a hard wall.
Step 7: Scaffold the inspection project
The rest of the article builds a small tool that reads a GGUF file’s header. The point is not that you need it to run a model — you do not — but that it turns the model file into something you can assert against, which is what makes the setup testable.
Create the .gitignore before anything else, so no generated file is ever
untracked-by-accident:
Create the files
mkdir -p ~/projects/gguf-inspector
cd ~/projects/gguf-inspector
touch .gitignore
Add the code: .gitignore
# Python
__pycache__/
*.py[cod]
.venv/
*.egg-info/
# uv
.uv/
# pytest
.pytest_cache/
.coverage
# Models — never commit weights
*.gguf
models/
# Editor / OS
.DS_Store
.idea/
.vscode/
Detailed breakdown
*.ggufandmodels/are the entries that matter here. A quantized model is hundreds of megabytes to tens of gigabytes. Committing one, even once, is difficult to undo because git keeps it in history. The pattern goes in before the firstgit add, not after..venv/is excluded becauseuvcreates the environment locally and it is rebuilt frompyproject.tomlplus the lockfile. The lockfile is what belongs in version control..pytest_cache/and__pycache__/are regenerated on every run and carry no information worth sharing.
Step 8: Initialize with uv
Create the file
cd ~/projects/gguf-inspector
uv init --package --name gguf-inspector
uv add --dev pytest
Add the code: pyproject.toml (generated, shown for reference)
[project]
name = "gguf-inspector"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["uv_build>=0.11.26,<0.12.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"pytest>=9.1.1",
]
Detailed breakdown
--packageis what makespython -m gguf_inspectorwork. A plainuv initcreates an application: no[build-system], nothing installed into the environment, andsrc/never on the import path — so the tests and the CLI both die withModuleNotFoundError: No module named 'gguf_inspector'. With--package, uv writes the build backend shown above, createssrc/gguf_inspector/, anduv syncinstalls the project so the module resolves.- Two things to delete from what uv generates. It adds a
[project.scripts]entry pointing atgguf_inspector:main, which will not exist once Step 9 replaces__init__.py— remove that block. It also fills inauthorsfrom your git config; that is your name and email, so decide deliberately whether it belongs in a file you publish. dependenciesis empty and stays empty. The parser usesstruct,pathlib,dataclasses, andargparse, all standard library. There is aggufpackage on PyPI that does this and more; the point of writing it by hand is that the format is small enough to understand in one sitting, and a dependency-free reader is easy to drop into a test suite or a CI check.uv add --dev pytestputs pytest in the dev dependency group, so it is available foruv run pytestbut not part of what the project would ship.uv init --packagealso writes aREADME.mdand a stubsrc/gguf_inspector/__init__.pycontaining amain()function. Step 9 empties that file. Unlike a plainuv init, there is no top-levelmain.pyto remove.
Step 9: Write the GGUF header parser
Create the file
# src/gguf_inspector/ already exists from `uv init --package`; empty the stub
# __init__.py it wrote, since the entry point lives in __main__.py instead.
: > src/gguf_inspector/__init__.py
touch src/gguf_inspector/reader.py
Add the code: src/gguf_inspector/reader.py
"""Read GGUF file headers and metadata using only the standard library.
The format is documented at https://github.com/ggml-org/ggml/blob/master/docs/gguf.md
Every integer is little-endian by default.
"""
from __future__ import annotations
import struct
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, BinaryIO
GGUF_MAGIC = b"GGUF"
# gguf_metadata_value_type, straight from the specification.
UINT8, INT8 = 0, 1
UINT16, INT16 = 2, 3
UINT32, INT32 = 4, 5
FLOAT32, BOOL = 6, 7
STRING, ARRAY = 8, 9
UINT64, INT64 = 10, 11
FLOAT64 = 12
# value type -> (struct format, byte width)
_SCALARS: dict[int, tuple[str, int]] = {
UINT8: ("<B", 1),
INT8: ("<b", 1),
UINT16: ("<H", 2),
INT16: ("<h", 2),
UINT32: ("<I", 4),
INT32: ("<i", 4),
FLOAT32: ("<f", 4),
BOOL: ("<?", 1),
UINT64: ("<Q", 8),
INT64: ("<q", 8),
FLOAT64: ("<d", 8),
}
class GGUFError(ValueError):
"""Raised when a file is not valid GGUF or ends unexpectedly."""
@dataclass(frozen=True)
class GGUFFile:
"""The header and metadata of a GGUF file. Tensor data is not read."""
path: Path
version: int
tensor_count: int
metadata: dict[str, Any] = field(default_factory=dict)
@property
def architecture(self) -> str | None:
return self.metadata.get("general.architecture")
@property
def name(self) -> str | None:
return self.metadata.get("general.name")
@property
def quantization(self) -> int | None:
return self.metadata.get("general.file_type")
@property
def context_length(self) -> int | None:
arch = self.architecture
if arch is None:
return None
return self.metadata.get(f"{arch}.context_length")
def _read_exact(fh: BinaryIO, count: int) -> bytes:
data = fh.read(count)
if len(data) != count:
raise GGUFError(f"file ended early: wanted {count} bytes, got {len(data)}")
return data
def _read_u32(fh: BinaryIO) -> int:
return struct.unpack("<I", _read_exact(fh, 4))[0]
def _read_u64(fh: BinaryIO) -> int:
return struct.unpack("<Q", _read_exact(fh, 8))[0]
def _read_string(fh: BinaryIO) -> str:
length = _read_u64(fh)
return _read_exact(fh, length).decode("utf-8", errors="replace")
def _read_value(fh: BinaryIO, value_type: int, max_array: int) -> Any:
if value_type == STRING:
return _read_string(fh)
if value_type == ARRAY:
element_type = _read_u32(fh)
length = _read_u64(fh)
kept: list[Any] = []
for index in range(length):
value = _read_value(fh, element_type, max_array)
if index < max_array:
kept.append(value)
if length > max_array:
kept.append(f"... {length - max_array} more")
return kept
if value_type in _SCALARS:
fmt, width = _SCALARS[value_type]
return struct.unpack(fmt, _read_exact(fh, width))[0]
raise GGUFError(f"unknown metadata value type: {value_type}")
def read_gguf(path: str | Path, *, max_array: int = 8) -> GGUFFile:
"""Parse the header and metadata block of a GGUF file.
Only the header is read; the tensor data that follows is left untouched,
so this is fast even on a multi-gigabyte model.
"""
path = Path(path)
with path.open("rb") as fh:
magic = _read_exact(fh, 4)
if magic != GGUF_MAGIC:
raise GGUFError(f"not a GGUF file: magic was {magic!r}, expected {GGUF_MAGIC!r}")
version = _read_u32(fh)
tensor_count = _read_u64(fh)
kv_count = _read_u64(fh)
metadata: dict[str, Any] = {}
for _ in range(kv_count):
key = _read_string(fh)
value_type = _read_u32(fh)
metadata[key] = _read_value(fh, value_type, max_array)
return GGUFFile(
path=path,
version=version,
tensor_count=tensor_count,
metadata=metadata,
)
Detailed breakdown
- The header is fixed and tiny: the four magic bytes
GGUF, auint32version (currently 3), then twouint64counts for tensors and metadata entries. Everything after that is variable-length, which is why the parser is a sequential read rather than a set of offsets. - Checking the magic first is the whole validity test. A truncated download or
an HTML error page saved with a
.ggufextension both fail here, immediately, with a message naming what was actually found. That is the single most useful thing this tool does. - Strings are
uint64length plus raw UTF-8, with no terminator. The 8-byte length prefix is generous for a format whose longest strings are chat templates, but it means you never have to scan for a null byte. errors="replace"on decode is deliberate. Tokenizer vocabularies contain byte sequences that are not valid UTF-8 on their own, because a token can be a fragment of a multi-byte character. Strict decoding raises on real, well-formed model files.- Arrays must be consumed even when you do not want them.
max_arraylimits what is kept, not what is read: the loop still parses every element, because entries are variable-length and there is no size field to skip past. A vocabulary array holds a hundred thousand-plus strings, and storing them all would dominate memory for no benefit — but skipping the reads would desynchronize the stream and corrupt every key that follows. _read_exactexists so truncation is an error, not silence. A barefh.read(n)returns short at end of file, andstruct.unpackwould then raise something opaque about buffer length. Failing here names the problem.- The properties encode the naming convention. Generic keys are prefixed
general.; architecture-specific ones are prefixed with the architecture name itself, so context length lives underqwen35.context_lengthfor the model in this article andllama.context_lengthfor another.context_lengthreadsgeneral.architecturefirst and builds the key from it, which is why it can returnNonetwice over. - Tensor data is never touched. The parser stops at the end of the metadata block, so inspecting a 40 GB model costs the same as inspecting a 1 MB one.
Step 10: Add the command-line entry point
Create the file
touch src/gguf_inspector/__main__.py
Add the code: src/gguf_inspector/__main__.py
"""Print a summary of a GGUF file: `uv run python -m gguf_inspector <path>`."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from .reader import GGUFError, read_gguf
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
prog="gguf-inspector",
description="Read the header and metadata of a GGUF model file.",
)
parser.add_argument("path", type=Path, help="path to a .gguf file")
parser.add_argument(
"--json", action="store_true", help="print all metadata as JSON"
)
parser.add_argument(
"--max-array",
type=int,
default=8,
help="how many elements of each array to keep (default: 8)",
)
args = parser.parse_args(argv)
try:
model = read_gguf(args.path, max_array=args.max_array)
except GGUFError as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
except OSError as exc:
print(f"error: cannot read {args.path}: {exc}", file=sys.stderr)
return 1
if args.json:
print(json.dumps(model.metadata, indent=2, default=str))
return 0
size_mb = args.path.stat().st_size / (1024 * 1024)
print(f"file {model.path.name}")
print(f"size {size_mb:.1f} MiB")
print(f"gguf version {model.version}")
print(f"tensors {model.tensor_count}")
print(f"metadata keys {len(model.metadata)}")
print(f"architecture {model.architecture or 'unknown'}")
print(f"name {model.name or 'unknown'}")
print(f"context {model.context_length or 'unknown'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Detailed breakdown
__main__.pymakes the package runnable aspython -m gguf_inspector, with no console-script entry point and no install step. Underuv runthat resolves against the project environment automatically.- Both failure paths return 1 and write to stderr.
GGUFErrorcovers “this is not a GGUF file”,OSErrorcovers “there is no file there at all”. Separating them means the message tells you which mistake you made, and a non-zero exit makes the tool usable in a shell conditional or a Makefile. --jsondumps metadata rather than the summary, withdefault=strso any value that is not JSON-serializable degrades to its string form instead of raising. That keeps the flag useful on unusual models rather than fragile.--max-arrayis exposed because the default hides things. At 8, a vocabulary shows its first eight tokens. Raising it is how you inspect a chat template or a full token list when you actually need one.- The file-size line comes from
stat(), not from the parser, because the parser deliberately never reads to the end of the file.
Step 11: Add the tests
Create the file
mkdir -p tests
touch tests/test_reader.py
Add the code: tests/test_reader.py
"""Tests for the GGUF reader.
The fixtures build GGUF files byte by byte, so the suite runs without
downloading a model. One test reads a real model if it is present and skips
otherwise.
"""
from __future__ import annotations
import os
import struct
from pathlib import Path
import pytest
from gguf_inspector.reader import (
ARRAY,
GGUF_MAGIC,
STRING,
UINT32,
GGUFError,
read_gguf,
)
def _string(text: str) -> bytes:
"""Encode a gguf_string_t: uint64 length, then UTF-8 bytes."""
raw = text.encode("utf-8")
return struct.pack("<Q", len(raw)) + raw
def _kv_string(key: str, value: str) -> bytes:
return _string(key) + struct.pack("<I", STRING) + _string(value)
def _kv_u32(key: str, value: int) -> bytes:
return _string(key) + struct.pack("<I", UINT32) + struct.pack("<I", value)
def _kv_string_array(key: str, values: list[str]) -> bytes:
body = _string(key) + struct.pack("<I", ARRAY)
body += struct.pack("<I", STRING) + struct.pack("<Q", len(values))
for value in values:
body += _string(value)
return body
def _build_gguf(path: Path, *, kv_blocks: list[bytes], tensor_count: int = 0) -> Path:
"""Write a minimal but specification-valid GGUF header to `path`."""
header = GGUF_MAGIC + struct.pack("<I", 3)
header += struct.pack("<Q", tensor_count)
header += struct.pack("<Q", len(kv_blocks))
path.write_bytes(header + b"".join(kv_blocks))
return path
@pytest.fixture
def sample_model(tmp_path: Path) -> Path:
return _build_gguf(
tmp_path / "sample.gguf",
kv_blocks=[
_kv_string("general.architecture", "llama"),
_kv_string("general.name", "Sample Model"),
_kv_u32("llama.context_length", 4096),
],
tensor_count=12,
)
def test_reads_header_fields(sample_model: Path) -> None:
model = read_gguf(sample_model)
assert model.version == 3
assert model.tensor_count == 12
assert len(model.metadata) == 3
def test_exposes_convenience_properties(sample_model: Path) -> None:
model = read_gguf(sample_model)
assert model.architecture == "llama"
assert model.name == "Sample Model"
assert model.context_length == 4096
def test_context_length_key_follows_architecture(tmp_path: Path) -> None:
"""The context key is prefixed with the architecture, not a fixed string."""
path = _build_gguf(
tmp_path / "qwen.gguf",
kv_blocks=[
_kv_string("general.architecture", "qwen3"),
_kv_u32("qwen3.context_length", 32768),
_kv_u32("llama.context_length", 2048),
],
)
assert read_gguf(path).context_length == 32768
def test_missing_metadata_returns_none(tmp_path: Path) -> None:
path = _build_gguf(tmp_path / "bare.gguf", kv_blocks=[])
model = read_gguf(path)
assert model.architecture is None
assert model.context_length is None
def test_rejects_a_file_that_is_not_gguf(tmp_path: Path) -> None:
path = tmp_path / "not-a-model.gguf"
path.write_bytes(b"<html>404 Not Found</html>")
with pytest.raises(GGUFError, match="not a GGUF file"):
read_gguf(path)
def test_rejects_a_truncated_file(tmp_path: Path) -> None:
"""A download cut short must fail loudly rather than parse as empty."""
path = tmp_path / "truncated.gguf"
path.write_bytes(GGUF_MAGIC + struct.pack("<I", 3) + b"\x00\x00")
with pytest.raises(GGUFError, match="file ended early"):
read_gguf(path)
def test_array_is_truncated_but_fully_consumed(tmp_path: Path) -> None:
"""Truncation must not desynchronize the keys that follow the array."""
path = _build_gguf(
tmp_path / "vocab.gguf",
kv_blocks=[
_kv_string_array("tokenizer.ggml.tokens", [f"tok{n}" for n in range(50)]),
_kv_string("general.architecture", "llama"),
],
)
model = read_gguf(path, max_array=3)
tokens = model.metadata["tokenizer.ggml.tokens"]
assert tokens[:3] == ["tok0", "tok1", "tok2"]
assert tokens[-1] == "... 47 more"
# The key after the array is the real proof: the stream stayed aligned.
assert model.architecture == "llama"
def test_max_array_can_keep_everything(tmp_path: Path) -> None:
path = _build_gguf(
tmp_path / "small-vocab.gguf",
kv_blocks=[_kv_string_array("tokenizer.ggml.tokens", ["a", "b"])],
)
model = read_gguf(path, max_array=100)
assert model.metadata["tokenizer.ggml.tokens"] == ["a", "b"]
@pytest.mark.skipif(
not os.environ.get("GGUF_MODEL_PATH"),
reason="set GGUF_MODEL_PATH to a downloaded .gguf to run this test",
)
def test_reads_a_real_model() -> None:
model = read_gguf(os.environ["GGUF_MODEL_PATH"])
assert model.version >= 2
assert model.tensor_count > 0
assert model.architecture
Detailed breakdown
- The suite builds its own GGUF files.
_build_ggufwrites a specification-valid header fromstruct.packcalls, so the tests are fast, deterministic, and runnable on a machine that has never downloaded a model. This matters more than it sounds: a test suite that depends on a 900 MB artifact is a test suite people skip. - Writing the fixtures is a second implementation of the format. The parser reads what the fixtures write; if either misunderstands the layout, the tests disagree. Hand-rolling both directions is the cheapest available check on a binary format.
test_array_is_truncated_but_fully_consumedis the important test. It puts a 50-element array first and a scalar key second, then asserts on the second key. If the parser ever “optimized” truncation by skipping reads, the array assertion would still pass and the architecture assertion would fail. That is the bug this design invites, so it gets a dedicated test.test_context_length_key_follows_architecturepins the prefix rule by writing both aqwen3.and allama.context length into one file and requiring the reader to pick the one matchinggeneral.architecture. A parser that hardcodedllama.would pass every other test in the file.test_rejects_a_file_that_is_not_ggufuses an HTML error page rather than random bytes, because that is the realistic failure: a redirect or a rate-limit response saved under a.ggufname.- The real-model test is opt-in via
GGUF_MODEL_PATH. It skips by default so the suite stays hermetic, but it is there so you can point the parser at an actual download and confirm the fixtures were not lying.
Step 12: Add the Makefile
Create the file
touch Makefile
Add the code: Makefile
.DEFAULT_GOAL := help
MODEL_REPO ?= ggml-org/Qwen3.5-0.8B-GGUF:Q8_0
PROMPT ?= In one sentence, what is a GGUF file?
TEXT_PROMPT ?= A GGUF file is
TOKENS ?= 64
CACHE ?= $(HOME)/.cache/huggingface/hub
.PHONY: help install test check model run ask text cpu inspect cache clean
help: ## Show this help screen
@echo "GGUF inspector — llama.cpp getting started"
@echo ""
@echo "Targets:"
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) \
| awk 'BEGIN {FS = ":.*?## "}; {printf " %-10s %s\n", $$1, $$2}'
@echo ""
@echo "Variables: MODEL_REPO=$(MODEL_REPO) TOKENS=$(TOKENS)"
install: ## Sync dependencies with uv
uv sync
test: ## Run the test suite
uv run pytest -q
check: ## Verify llama.cpp is installed and print its build
@command -v llama >/dev/null || { echo "llama not found: brew install llama.cpp"; exit 1; }
@llama version
model: ## Download MODEL_REPO without running it
llama download -hf $(MODEL_REPO)
run: ## Generate TOKENS tokens with default GPU offload
llama completion -hf $(MODEL_REPO) -p "$(PROMPT)" -n $(TOKENS) -no-cnv
ask: ## Print only the answer -- reports if the model never stops thinking
@llama completion -hf $(MODEL_REPO) -p "$(PROMPT)" -n $(TOKENS) \
-st -rea off --no-display-prompt -lv 0 \
| awk '/<think>/ { t = 1; next } /<\/think>/ { t = 0; next } !t { gsub(/ *\[end of text\]/, ""); if ($$0 ~ /[^[:space:]]/) { print; got = 1 } } END { if (!got) { print "no answer: the model never finished thinking. Retry, or use a larger model." > "/dev/stderr"; exit 1 } }'
text: ## Continue TEXT_PROMPT as plain text -- no chat template, no thinking
@llama completion -hf $(MODEL_REPO) -p "$(TEXT_PROMPT)" -n $(TOKENS) \
-no-cnv --no-display-prompt -lv 0 \
| sed -e 's/ *\[end of text\]//' -e '/^[[:space:]]*$$/d'
cpu: ## Same generation with offload disabled (-ngl 0)
llama completion -hf $(MODEL_REPO) -p "$(PROMPT)" -n $(TOKENS) -ngl 0 -no-cnv
inspect: ## Summarize every cached GGUF file
@find $(CACHE) -name '*.gguf' -print0 \
| xargs -0 -I{} uv run python -m gguf_inspector {}
cache: ## Show what the model cache is costing you
@du -sh $(CACHE)/* 2>/dev/null || echo "cache is empty: $(CACHE)"
clean: ## Remove Python build and test artifacts (keeps models)
rm -rf .pytest_cache .venv **/__pycache__ src/**/__pycache__
Detailed breakdown
.DEFAULT_GOAL := helpmeans a baremakeprints the help screen, which the house style requires. Thegrep/awkpair reads the##comments off the target lines, so the help text cannot drift from the targets: adding a target with a##comment adds a help line automatically.checkfails fast with an actionable message.command -v llamacosts nothing and turns “some later target exploded confusingly” into “llama not found: brew install llama.cpp”.runandcpudiffer only in-ngl 0, which makes the comparison from Step 6 a two-command exercise rather than a copy-paste of a long command line. Both carry-no-cnv; without it these targets answer and then hang waiting for a second turn. They can still emit a<think>block —-no-cnvdrops the chat template but not the model’s habit of producing thought tags — and what keeps that bounded here is-n $(TOKENS), not the absence of thinking. Use the-st -rea offform from Step 3 when you want the answer alone.- The
##help comments name variables without$(...). Make does not expand inside those comments, so$(MODEL_REPO)would print literally on the help screen. The expanded values are shown by theVariables:line instead. inspectuses-print0/xargs -0so paths with spaces survive. The Hugging Face cache nests models under directories derived from repo names, and while those are usually tame, a snapshot path is not something to assume about.- Three generation targets, because they fail differently.
runshows what llama.cpp actually prints, logs and all, which is what you want when something is wrong.askasksPROMPTas a question and prints only the answer — and exits 1 with a diagnosis on the runs where the model never stops thinking, which is most of them on this model.textcontinuesTEXT_PROMPTas plain text, which is the form that does not trigger thinking at all. - Two Makefile-specific details in those recipes. Each is prefixed with
@so Make does not echo the long pipeline before running it, and every$inside theawkandsedprograms is written$$— otherwise Make expands$0and$/as variables and hands the filter a broken script. cleandeliberately leaves models alone. Deleting hundreds of megabytes of weights is not the sort of thing acleantarget should do silently; thecachetarget shows you the cost and you remove directories yourself.- The variables are overridable at the command line:
make run TOKENS=200ormake run MODEL_REPO=ggml-org/tiny-llamasworks without editing the file.
Step 13: Run everything
make # help screen, because .DEFAULT_GOAL is help
make check # llama.cpp present, with its build number
make install # sync the Python environment
make test # the parser suite, no model needed
make model # download the weights
make inspect # parse every cached GGUF header
make run # generate text, logs and all
make ask # ask PROMPT, answer only (may report no answer)
make text # continue TEXT_PROMPT, reliably clean
Expected shape of make inspect against the downloaded model:
file Qwen3.5-0.8B-Q8_0.gguf
size 795.0 MiB
gguf version 3
tensors 335
metadata keys 41
architecture qwen35
name Qwen3.5-0.8B
context 262144
To run the opt-in real-model test:
GGUF_MODEL_PATH=$(find ~/.cache/huggingface/hub -name '*.gguf' | head -1) \
uv run pytest -q
Detailed breakdown
- Running
makefirst is not a formality: it is the check that the help rule works, which is easy to break by editing theawkformat string. make testbeforemake modelis intentional ordering. The suite passing without any model on disk is the proof that the fixtures are self-contained.- The exact
size,tensors, andmetadata keysvalues depend on which quantization you pulled; thearchitectureandcontextlines come from the model’s own metadata and are the ones to sanity-check against the repository’s model card.
Troubleshooting
llama: command not found after brew install. Homebrew’s bin directory is
not on your PATH. brew --prefix prints the install root; make sure
$(brew --prefix)/bin is on PATH in your shell profile.
unknown argument: -hf. You are running a build old enough to predate the
flag, or a subcommand that does not accept it. Check llama version and
llama <subcommand> --help.
The download fails with a TLS or certificate error. llama.cpp does the HTTPS
fetch itself through openssl@3. brew reinstall openssl@3 is the first thing to
try; a corporate TLS-inspecting proxy is the second thing to suspect.
A :quant tag reports no GGUF files found in repository. The message is
misleading: the repository has GGUF files, just not one matching the tag you
asked for. Open the repo’s file listing on Hugging Face and use a tag that
matches an actual filename — quantization names are not standardized across
repositories.
make inspect prints “not a GGUF file”. A previous download was interrupted
and left a partial or error-page file in the cache. Delete the corresponding
models--… directory and re-download. This is exactly the case the magic-byte
check exists to catch.
Generation is much slower than expected. Confirm you are not stuck at
-ngl 0 from an earlier experiment, and check whether the model actually fits in
memory — once macOS starts swapping, throughput collapses in a way that looks like
a llama.cpp problem but is not.
Tests fail with ModuleNotFoundError: No module named 'gguf_inspector'. The
project was created with a plain uv init rather than uv init --package, so it
is an application with no [build-system] and nothing installs src/ onto the
import path. Adding __init__.py and re-running uv sync does not fix it. Add
the build backend from Step 8 to pyproject.toml and run uv sync again, or
start over with uv init --package.
The model never stops talking, or fills the screen with repeated half-thoughts.
You are in a reasoning loop; see Step 3. Add -rea off. This build disables
repetition penalties by default (--repeat-penalty 1.00), so nothing interrupts
the loop on its own.
Recap
- llama.cpp installs from Homebrew as a single
llamacommand with subcommands.cliis interactive,completionis the scriptable one-shot,serveis the HTTP server, andllama help allreveals the rest. -hf <user>/<model>[:quant]downloads from Hugging Face on demand, andllama downloaddoes it without running anything.- Models live in the shared Hugging Face hub cache at
~/.cache/huggingface/hub/models--<user>--<repo>/.LLAMA_CACHEoverrides the location and takes priority over theHF_*variables. - GPU offload defaults to
auto;-nglis now mainly useful for turning offload off to get a CPU baseline. - A GGUF file’s header is four magic bytes, a version, two counts, and a run of typed key/value pairs — enough structure to validate a download and read a model’s architecture and context length in about 120 lines of standard-library Python.
Next improvements
- Serve the same model over an OpenAI-compatible HTTP endpoint with
llama serve, which lets existing OpenAI-SDK code run locally with a base-URL change. - Constrain output to a grammar so the model cannot emit malformed JSON, rather than validating and retrying after the fact.
- Quantize a model yourself and measure what each level costs in size, speed, and output quality.
- Benchmark prompt processing separately from token generation with
llama bench, across quantizations and-nglvalues.