From API to GPU, Week 2: What Actually Happens Behind the API
Phase 1 of 8: Comfortable running local models. Week 2 of 32.
In Week 1 I went through the machine and proved the GPU works. This week I run an actual language model on it and, for the first time, look at what sits behind the "AI API" I have called for years. The short version: the API is not magic. It is an HTTP layer over a local process that loads a file of numbers into memory and does the matrix math from Week 1. By the end of this post that sentence will be clear, backed by real commands and real output you can reproduce.
I am using Ollama, a runtime that makes running a local model about as easy as running a container. Other names you will see in this space are llama.cpp for lightweight local inference, vLLM for high-throughput serving, and NVIDIA TensorRT-LLM for optimized NVIDIA inference. Hugging Face Transformers is also common, but it is a broader Python framework for running and training models, not a ready-made local model service. These tools overlap, but they are not exact replacements.
I chose Ollama because it gives me a CLI and local HTTP API with very little setup. 1 Everything below runs on the DGX Spark and is reached over SSH as spark, the same setup as Week 1.
Concept 1: a model is not a runtime
The first idea to keep straight, because it holds for the rest of the series, is the split between the model and the runtime. The model is trained data: weights plus the metadata needed to use them. On its own it does nothing. It is data on disk. The runtime is the program that loads those weights into memory and runs the math to turn your prompt into text.
Ollama is the runtime here. If you know Docker, there is a useful analogy, but it is not exact. A model package is like an image made of versioned, content-addressed layers. Ollama is like the engine that pulls those layers and starts the workload. Unlike a container image, the main model layer is trained numeric data, not an app and its operating-system files.
Ollama calls each stored package file a blob. Here, a blob is just a file kept under a name derived from its content digest. The inspection below follows the package index to the model blob and checks that it is the expected file.
Rather than run a wrapper script, I inspected the model with a few direct commands, one at a time. Each command answers a single question, so you can paste it, read the output, then move to the next.
First, list what Ollama has downloaded locally:
ssh spark 'ollama list'
NAME ID SIZE MODIFIED
nomic-embed-text:latest 0a109f422b47 274 MB 2 days ago
llama3.2:3b a80c4f17acd5 2.0 GB 3 days ago
phi4:latest ac896e5b8b34 9.1 GB 7 days ago
qwen3.6:35b-a3b-bf16 94061ddd23a7 71 GB 8 days ago
Next, ask Ollama for the focused facts about Phi-4. The Ollama server listens on localhost:11434 on the Spark, so run this in a shell on the Spark (ssh spark):
curl -s http://localhost:11434/api/show -d '{"model": "phi4"}' | jq '{
format: .details.format,
architecture: .details.family,
parameters: .details.parameter_size,
context_length: (.model_info | to_entries | map(select(.key | endswith(".context_length"))) | first.value),
embedding_length: (.model_info | to_entries | map(select(.key | endswith(".embedding_length"))) | first.value),
quantization: .details.quantization_level,
capabilities,
runtime_parameters: (.parameters | split("\n") | map(select(length > 0) | gsub(" +"; " ")))
}'
{
"format": "gguf",
"architecture": "phi3",
"parameters": "14.7B",
"context_length": 16384,
"embedding_length": 5120,
"quantization": "Q4_K_M",
"capabilities": ["completion"],
"runtime_parameters": [
"stop \"<|im_start|>\"",
"stop \"<|im_end|>\"",
"stop \"<|im_sep|>\""
]
}
That call gives readable model facts, including "format": "gguf". For a normal check, this is how I know Ollama identifies the model as GGUF. I do not need to locate the raw file and inspect its bytes just to answer that question.
To see the exact files that make up the package, read Ollama's manifest. It is a small JSON file on the Spark, so jq can read it directly. I select just the package version and the file list:
jq '{schemaVersion, layers: [.layers[] | {mediaType, digest, size}]}' \
/usr/share/ollama/.ollama/models/manifests/registry.ollama.ai/library/phi4/latest
{
"schemaVersion": 2,
"layers": [
{
"mediaType": "application/vnd.ollama.image.model",
"digest": "sha256:fd7b6731c33c57f61767612f56517460ec2d1e2e5a3f0163e0eb3d8d8cb5df20",
"size": 9053114464
},
{
"mediaType": "application/vnd.ollama.image.template",
"digest": "sha256:32695b892af87ef8fca6e13a1a31c67c1441d7398be037e366e2fc763857c06a",
"size": 275
},
{
"mediaType": "application/vnd.ollama.image.license",
"digest": "sha256:fa8235e5b48faca34e3ca98cf4f694ef08bd216d28b58071a1f85b1d50cb814d",
"size": 1084
},
{
"mediaType": "application/vnd.ollama.image.params",
"digest": "sha256:45a1c652dddc9efdcefa977ab81cfbe26b6e52bc8e78f2f4c698538783e0ac80",
"size": 82
}
]
}
The registry path ends in library/phi4/latest, so this manifest is the index for the model name phi4 and tag latest. It does not hold the 9.1 GB of weights. It lists the files that make up the Ollama package, much like a lock file maps package names to exact artifacts.
Each entry under layers is one file in the package (one blob). It has three useful fields:
mediaTypesays what the fileβs role is. This package has a model, a prompt template, a license, and default parameters.digestis the fileβs content identity. Thesha256prefix names the hash algorithm, and the characters after the colon are the hash of the fileβs bytes.sizesays exactly how many bytes that file should contain.
The model layerβs digest is also how Ollama names the file on disk. It stores each blob under a blobs directory using the digest as the filename, with the colon changed to a hyphen, so sha256:fd7b...df20 becomes sha256-fd7b...df20. That file is 9,053,114,464 bytes, the size shown above, and its SHA-256 matches the digest. The linked investigation independently checks the file header with xxd: bytes 47 47 55 46 spell GGUF in ASCII. That deeper check is useful when reusing the raw file in another runtime, but .details.format is the simple Ollama API answer. 2
So ollama show and the manifest answer different questions. The metadata call gives readable model facts such as architecture and context length. The manifest tells Ollama which exact files form the runnable package and where to find them by content ID.
You might now wonder whether another runtime can use the same GGUF file. That is useful, but it is not part of the main Ollama lesson. I answer it near the end in Can llama.cpp reuse this Ollama model?.
I am using phi4, Microsoft's 14.7B-parameter model. At 16 bits per weight, 14.7 billion weights alone would need about 29.4 GB. The local package is only 9.1 GB, which leads to the first surprise. A quick tour of what each line means, since these terms come up constantly:
| Field | Value | Plain meaning |
|---|---|---|
| format | gguf | model file format reported by Ollama |
| architecture | phi3 | the neural-network design (phi4 reuses the phi3 family) |
| parameters | 14.7B | how many trained weights the model has |
| context length | 16384 | the most tokens (prompt + reply) it can consider at once |
| embedding length | 5120 | width of each token vector; covered later |
| quantization | Q4_K_M | the weights are stored at about 4 bits each, not 16 |
That last row answers the 9.1 GB surprise. This build is quantized: its weights use a mixed low-bit Q4_K_M representation instead of 16-bit values. Four bits per weight would be about 7.35 GB before metadata and quantization overhead, so a 9.1 GB model layer is reasonable. Quantization is a whole phase later in this series (weeks 14 to 16). For now the only thing to take away is that Ollama runs lower-precision weights, and that is why this 14.7B model takes much less space than its 16-bit source model.
The three stop entries (<|im_start|>, <|im_end|>, <|im_sep|>) are special tokens: markers the model was trained to use as turn boundaries in a chat. The runtime watches for them to know when the model has finished its reply. They show up again in the next section, so keep them in mind.
Concept 2: the API is just HTTP over a local process
Ollama exposes a local HTTP API on port 11434, 3 the same shape of thing as any cloud AI API. The difference is that the "server" is a process on my own machine. I called the generate endpoint with curl and formatted the reply with jq. Run it directly on the Spark:
curl -s http://localhost:11434/api/generate -d '{
"model": "phi4",
"prompt": "Explain Kubernetes scheduling in three sentences.",
"stream": false
}' | jq
Here is the complete response, nothing removed. It is long, but seeing all of it is the point, because two parts of it explain how the model actually works:
{
"model": "phi4",
"created_at": "2026-07-14T03:11:50.862545331Z",
"response": "Kubernetes scheduling is the process of assigning workloads, such as pods, to appropriate nodes within a cluster based on resource availability and constraints. The scheduler evaluates factors like CPU, memory requirements, affinity/anti-affinity rules, taints/tolerations, and node selectors to determine the best fit for each pod. This ensures optimal utilization of resources, workload distribution, and adherence to specified policies, thereby maintaining efficient cluster operation and performance.",
"done": true,
"done_reason": "stop",
"context": [
100264, 882, 100266, 198, 849, 21435, 67474, 38952, 304, 2380,
23719, 13, 100265, 198, 100264, 78191, 100266, 198, 42, 30927,
38952, 374, 279, 1920, 315, 61853, 990, 33785, 11, 1778, 439,
55687, 11, 8475, 7954, 2949, 264, 10879, 3196, 389, 5211, 18539,
323, 17413, 13, 578, 29909, 67349, 9547, 1093, 14266, 11, 5044,
8670, 11, 51552, 14, 15719, 71260, 13797, 5718, 11, 259, 1673,
82, 5640, 22847, 811, 11, 323, 2494, 57137, 311, 8417, 279,
1888, 5052, 369, 1855, 7661, 13, 1115, 26420, 23669, 50549,
315, 5070, 11, 54696, 8141, 11, 323, 71628, 311, 5300, 10396,
11, 28592, 20958, 11297, 10879, 5784, 323, 5178, 13
],
"total_duration": 3974942694,
"load_duration": 100037676,
"prompt_eval_count": 18,
"prompt_eval_duration": 46132000,
"eval_count": 89,
"eval_duration": 3817895000
}
Reading it top to bottom:
- The
responsefield is the answer, a real reply from a model running on my hardware with no network call leaving the box. - The
contextfield is the part worth staring at. You might be wondering what those numbers are. They are tokens. A model does not read text; it reads token IDs, which are integers. Before anything runs, a tokenizer splits the text into tokens and maps each one to an integer. And this array is not just the prompt, it is the whole conversation as tokens: the chat template, my question, and the model's full answer.
Decoding the first several so it is not a mystery:
100264is the special marker<|im_start|>(start of a turn), and882is the word "user". So the conversation begins "start of turn, user".849,21435,67474,38952are "Ex", "plain", " Kubernetes", " scheduling". Notice "Explain" is split into two tokens. Tokens are often sub-word pieces, not whole words.304,2380,23719,13are " in", " three", " sentences", ".", finishing my prompt.100265is<|im_end|>(end of turn), then10026478191100266is the start of the assistant's turn ("start, assistant, separator").42,30927onward ("K", "ubernetes", ...) is the model's answer, token by token, which is exactly the text in theresponsefield above.
The special IDs 100264, 100265, 100266 are the chat markers (<|im_start|>, <|im_end|>, <|im_sep|>), the turn boundaries from the model's embedded tokenizer and chat template.
Two questions probably popped into your head reading that, the same ones that popped into mine: wait, how do I even know 849 is "Ex" or that 100264 is <|im_start|>? And how does Ollama know which tokenizer to use? Both have clear answers, but they would break the flow here, so I answer them at the end. If you want them now, jump to the token questions, answered.
This is why token counts matter everywhere. On a cloud API you pay per token. In a model, tokens are what fill the context window, and the whole conversation is carried forward as this growing list of integers. When people say "tokens per second," this array is the unit being counted.
Concept 3: inference has two measured phases
The bottom of that same output has the timing, and it turns vague words like "latency" into measured numbers. All the durations are in nanoseconds, so here they are converted:
| Field | Raw value | Converted value |
|---|---|---|
| load_duration | 100,037,676 ns | 0.10 s |
| prompt_eval_count | 18 | 18 input tokens |
| prompt_eval_duration | 46,132,000 ns | 46 ms |
| eval_count | 89 | 89 output tokens |
| eval_duration | 3,817,895,000 ns | 3.82 s |
| total_duration | 3,974,942,694 ns | 3.97 s |
prompt_eval_durationis the time spent reading the tokenized input. That is the prefill phase.eval_durationis the time spent generating output tokens. That is the decode phase.load_durationis time Ollama spent loading or preparing the model for this request.
One headline number can be calculated directly: Tokens per second (generation speed): 89 tokens divided by 3.82 s is about 23.3 tokens/sec.
I cannot honestly calculate time to first token (TTFT) from this non-streaming response. Adding load and prefill gives 146 ms of server work before decode, but that is not the same as observing when the first token reaches the client. To measure TTFT, the client must request a streamed response and timestamp the first non-empty token. I do that in the benchmark later in this post.
The timing split also shows that generating text has two phases that behave differently:
- Prefill (
prompt_eval): the model reads all 18 prompt tokens at once. This is fast (46 ms) because the tokens are processed together, which is the parallel, compute-heavy work the GPU from Week 1 is good at. - **Decode (eva
Comments
No comments yet. Start the discussion.