, ,

What is model serving? From a model file to an API

What turns a folder of model weights into an API: what is in a model file, what a serving engine does (API, tokenizer, scheduler, GPU workers, streaming), the main engines, what happens when a server starts, and what else production serving needs.

Request flowing from an app through a model serving engine to GPUs

AI Infrastructure series · Part 16 of 24

A downloaded model is a folder of files. On its own it does nothing. To answer questions it has to be loaded onto GPUs, wrapped in software that accepts requests, queues them, batches them and streams the answer back, and kept running when traffic rises or a GPU fails. That software and everything around it is model serving. This part opens the model folder file by file, follows one request from the HTTP call to the last token, and looks at what happens between “start the server” and “ready for traffic”.

Try it: Model Serving Lab

Send a request through a serving engine and watch each stage, from HTTP to the usage count. Click through the files in a real model folder, time a cold start from different storage, and push traffic up until the queue and time to first token climb.

Open the demo →

Parts 9 to 14 explained what a model is and what happens inside it: tokens, layers, attention, prefill and decode, and the KV cache. All of that assumed something was already running the model. This part is about that something. A serving engine is to a model what a database server is to its data files: the files hold everything, but nothing answers until a process loads them, accepts connections and manages memory. The difference is that this process owns several GPUs and spends most of its time deciding which of hundreds of conversations gets to run in the next 10 milliseconds.

16.06 GBof weights in the Llama 3.1 8B Instruct folder, 99.9% of its size
~110 msto the first word for a 2,000-token prompt on one H100 (illustrative)
1 to 7 mintypical cold start for 8B to 405B from fast storage near the GPUs (illustrative)

What is inside a model folder?

Open models are published as a folder, usually on Hugging Face. Here is the real content of the Llama 3.1 8B Instruct repository. Almost all of it is weights; the small files tell the engine how to build the network around them and how to talk to it.

FileSizeWhat it holdsWho reads it
model-0000N-of-00004.safetensors (4 files)4.98, 5.00, 4.92, 1.17 GBThe weights: about 8.03 billion numbers at 2 bytes each (BF16), 16.06 GB in totalGPU workers, at start-up
model.safetensors.index.json24 KBWhich shard holds each named tensorLoader, before the weights
config.json896 bytesThe shape of the network: layers, heads, sizes, context length, data typeEngine, to build empty layers; you, to size GPUs
generation_config.json239 bytesDefault sampling: temperature 0.6, top_p 0.9, three end-of-sequence idsEngine, when a request sets no values
tokenizer.json17.2 MB128,256-token vocabulary and merge rulesAPI process (CPU)
tokenizer_config.json, special_tokens_map.json55 KB, 454 bytesWhich ids are special: begin-of-text, end-of-turn, paddingAPI process
chat_template.jinja4.6 KBHow a list of chat messages becomes one string with role headersAPI process, for chat requests

Why are the weights in safetensors files?

Older PyTorch checkpoints were Python pickle files, and loading a pickle can run arbitrary code hidden in the file. Downloading a model from the internet and loading it was, in effect, running a program from a stranger. Safetensors removes that: it is a plain data format, designed by Hugging Face, with three parts. The first 8 bytes are a number N. The next N bytes are a JSON header that lists every tensor by name with its data type, shape and byte offsets. The rest is raw tensor bytes, back to back.

One safetensors shard, byte by byte 8 bytesN JSON headerN bytes embed_tokensq_projo_proj…mlp weightslayer 1 …raw bytes, back to back { “model.layers.0.self_attn.q_proj.weight”: { “dtype”: “BF16”, “shape”: [4096, 4096], “data_offsets”: [x, x + 33554432] }, … one entry per tensor … “__metadata__”: {“format”: “pt”} } 4096 × 4096 values × 2 bytes = 33,554,432 bytes Llama 3.1 8B Instruct: 4 shards 00001 · 4.98 GB 00002 · 5.00 GB 00003 · 4.92 GB 00004 · 1.17 GB index.json maps every tensor name to its shard 16.06 GB = 8.03B parameters × 2 bytes Each GPU worker reads the header, then copies only the byte ranges it needs. Nothing in the file is executed.

A safetensors file is a length, a JSON table of contents and a block of bytes. Shard sizes are from the Llama 3.1 8B Instruct repository.

The layout matters for infrastructure for two more reasons. Because the header says exactly where each tensor sits, the engine can memory-map the file and copy tensors to the GPU without first building a second copy in system RAM. And with tensor parallelism (part 5), each GPU worker needs only its slice of each matrix, so it reads only those byte ranges. Shards of about 5 GB let a download run several files in parallel and resume after a failure.

Which config.json fields matter for infrastructure?

config.json is under 1 KB, but it is the file to read before you buy or assign GPUs. The engine uses it to build empty layers before filling them with weights; you use it to work out memory.

FieldLlama 3.1 8BWhat it decides
num_hidden_layers32How many layers the weights and the KV cache are split across
hidden_size, intermediate_size4096, 14336Matrix sizes, so weight memory and compute per token
num_attention_heads, num_key_value_heads32, 8KV cache size: only the 8 KV heads are stored, 4 times less than 32
head_dim128With layers and KV heads, gives 128 KB of KV per token in 16-bit
max_position_embeddings131,072Longest context the model supports; engines size for it unless you cap it
vocab_size128,256Size of the embedding and output layers
torch_dtypebfloat16Bytes per weight as published (2); quantized repos change this (part 18)

The KV figure is worth doing once by hand: 32 layers × 8 KV heads × 128 values × 2 (keys and values) × 2 bytes = 131,072 bytes, 128 KB for every token of every live conversation. Part 14 built the whole memory budget on that number.

The last two small files decide behaviour more than people expect. The chat template turns [{"role":"user","content":"What is a GPU?"}] into a string wrapped in special tokens such as <|start_header_id|>user<|end_header_id|> and <|eot_id|>. A model fine-tuned on that exact layout gives worse answers if the layout is wrong, and nothing errors: the answers just get quietly worse. The template also adds tokens the user never typed, which is why the usage count for a five-word question comes back as a few dozen prompt tokens. generation_config.json supplies default temperature and top_p, which apply whenever a client does not send its own.

What happens to one request, step by step?

Here is one chat request to Llama 3.1 8B on a single H100, with a 2,000-token prompt (a question plus some pasted context) and a 300-token answer. The walk-through lights each stage in order. The top row runs on CPU in the API process; the bottom row starts on the GPU and comes back through the CPU to the client.

Going in: text becomes token ids (CPU, API process and scheduler) Client appPOST /v1/chat/… HTTP + authparse, check key Chat templatemessages to text Tokenize2,000 token ids Queuewait for KV space Schedulejoin batch, blocks t = 0~1 ms~1 ms~1 ms0 ms to secondsnext engine step Coming out: tokens become text (GPU, then CPU) Prefillall 2,000 at once Decode loop1 token per step Detokenizeids back to text SSE stream“data:” events Usagecount tokens Clienttext appears ~100 ms8 ms × 300< 1 ms per tokenas each token landsfinal eventfirst word ~110 ms Same request on a time line (illustrative): 2.5 s in total decode: 300 tokens × 8 ms = 2.4 s, streamed as they come API + template + tokenizer ~3 ms · prefill ~100 ms · first token at ~110 ms (dashed line) Everything before the dashed line is time to first token. Everything after it is time per output token × answer length.

Life of one request. Stage timings are illustrative for Llama 3.1 8B in BF16 on one H100 with no other traffic; the queue stage grows when the server is busy.

1

HTTP and auth. The API process parses the JSON body, checks the API key if one is set (--api-key in vLLM), checks the model name and validates fields such as max_tokens. A request asking for more tokens than the context allows is rejected here, before it costs any GPU time.

2

Chat template and tokenize. The messages are laid out with the template, then the tokenizer turns the string into 2,000 integer ids. Both run on CPU. For a 2,000-token prompt this is about a millisecond; for a 100,000-token document it becomes tens of milliseconds, which is why busy servers run several API processes.

3

Queue. The request joins the waiting list. The scheduler admits it only when there is room for its KV cache. On a quiet server this is zero; on a full one it is the biggest part of the wait, and it is invisible in GPU metrics.

4

Schedule. Once per engine step (every few milliseconds) the scheduler picks which requests run next, reserves KV cache blocks for them and sends the batch to the GPU workers. Part 17 shows how vLLM does this with continuous batching.

5

Prefill. All 2,000 prompt tokens pass through all 32 layers in one go. That is compute-heavy and fast: at an illustrative 20,000 tokens a second, about 100 ms. It writes 2,000 × 128 KB = 256 MB of KV cache and produces the first output token.

6

Decode loop. Each step reads all 16 GB of weights from HBM to produce one more token per request in the batch, about 8 ms here (part 4 explains why memory bandwidth sets this). The loop stops at an end-of-sequence token or at max_tokens.

7

Detokenize and stream. Each new id is turned back into text and sent as a server-sent event straight away. Some tokens are only part of a character, so the detokenizer holds those bytes until it can send a whole character.

8

Usage accounting. When the answer ends, the server knows exactly how many tokens went in and out and sends them in a last event. Gateways read this for billing, quotas and cost per team.

Add it up: about 3 ms on CPU, 100 ms of prefill and one 8 ms step give a first token at roughly 110 ms. The remaining 299 tokens take 299 × 8 ms ≈ 2.4 s, so the whole answer lands after about 2.5 s. The user starts reading at 110 ms, which is why streaming is the default for chat. Two numbers describe the experience, and they have different causes: time to first token (TTFT) is set by queueing and prefill, time per output token (TPOT) by decode and how many other requests share each step.

prefilldecode 2.4 s
CPU stages ~3 msprefill ~100 msdecode ~2,400 ms (illustrative)

What does an OpenAI-compatible API look like?

Almost every engine offers the same HTTP API that OpenAI made popular, so an application written for a hosted model can point at your own server by changing the base URL and key. With vLLM the whole stack starts with one command and listens on port 8000:

vllm serve meta-llama/Llama-3.1-8B-Instruct --max-model-len 32768 --api-key $KEY

curl http://localhost:8000/v1/chat/completions \
  -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"model": "meta-llama/Llama-3.1-8B-Instruct",
       "messages": [{"role": "user", "content": "What is a GPU?"}],
       "max_tokens": 300, "stream": true,
       "stream_options": {"include_usage": true}}'

With "stream": true the answer comes back as server-sent events: one line per chunk, each starting with data:, each carrying a small piece of text in delta.content. With include_usage the last chunk has no text but carries the token counts, and the stream ends with [DONE]:

data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}
data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"A"}}]}
data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" GPU"}}]}
... one event per token ...
data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"choices":[],"usage":{"prompt_tokens":42,"completion_tokens":187,"total_tokens":229}}
data: [DONE]

The finish_reason tells you why the answer ended: stop for a natural end, length when it hit max_tokens. A stream of length answers usually means the limit is set too low. The 42 prompt tokens (illustrative) for a four-word question come from the chat template.

Three more endpoints matter to the platform rather than the application:

GET /v1/models    # which models (and LoRA adapters) this server answers to
{"object":"list","data":[{"id":"meta-llama/Llama-3.1-8B-Instruct","object":"model","max_model_len":32768}]}

GET /health       # 200 when the engine is up; an error if the engine has died

GET /metrics      # Prometheus text format
vllm:num_requests_running{model_name="..."} 37
vllm:num_requests_waiting{model_name="..."} 4
vllm:kv_cache_usage_perc{model_name="..."} 0.81

Gateways and routers use /v1/models to discover what each server can answer, Kubernetes uses /health for probes, and Prometheus scrapes /metrics. “OpenAI-compatible” means the request and response shapes match; it does not mean every field is supported, so test the options your applications actually send.

What runs inside the engine?

A serving engine is not one program loop. Recent vLLM versions split it into separate processes so that CPU work never stalls the GPUs. With --tensor-parallel-size 4 on one node there are six: one API server process, one engine core process and four GPU worker processes, one per GPU.

Llama 3.1 70B with tensor parallel 4: six processes API server process (CPU) HTTP, auth, validationchat templatetokenizer and detokenizerSSE streaming to clients/health, /metrics, /v1/models Engine core process (CPU) scheduler, runs every stepKV cache block managerprefix cache indexsends each batch to workerscollects sampled tokens tokens in tokens out ZMQ Why split it?Tokenizing a longprompt or streamingto 500 clients neverdelays the next GPUstep. Busy servers addmore API processes. same batch to every worker, every step Worker 0 · GPU 01/4 of every layer~35 GB of weightsKV for 2 of 8 KV heads80 KB per token Worker 1 · GPU 11/4 of every layer~35 GB of weightsKV for 2 of 8 KV heads80 KB per token Worker 2 · GPU 21/4 of every layer~35 GB of weightsKV for 2 of 8 KV heads80 KB per token Worker 3 · GPU 31/4 of every layer~35 GB of weightsKV for 2 of 8 KV heads80 KB per token NVLink: two all-reduces per layer (after attention, after the MLP), so 160 per decode step for 80 layers

Process layout of a tensor-parallel engine. Each GPU holds a quarter of every weight matrix and a quarter of the KV heads; the weight and KV figures follow from Llama 3.1 70B’s published shape (140 GB in BF16, 320 KB of KV per token).

The API server process does everything that touches text. The engine core process holds the scheduler and the KV block table; it decides, every step, which requests run and where their KV blocks live. The GPU workers hold the weights and the KV cache itself and do the maths. They talk over ZMQ sockets, so a slow client on a mobile network delays only its own stream, never the next GPU step.

Tensor parallelism splits each weight matrix across the GPUs. Llama 3.1 70B has 64 query heads and 8 KV heads, so with four GPUs each holds 16 query heads and 2 KV heads, about 35 GB of weights and 80 KB of KV per token. After each attention block and each MLP block the four partial results must be added together across GPUs (an all-reduce). With 80 layers that is 160 all-reduces for every single token generated, which is why tensor parallelism stays inside one NVLink-connected server and is not run across a normal network (part 5).

What happens when a server starts?

Starting a model server takes minutes where a web server takes seconds, and it happens every time you scale up, roll out a new version or replace a failed GPU. The timeline below is for Llama 3.1 70B on four H100s, weights on network storage at 1 GB/s.

Cold start, 70B on 4 × H100, weights from network storage (illustrative) Pull imageRead weightsCopy to GPUsProfile, KV cacheGPU graphsReadiness probeTraffic 0 to 30 s (skipped if cached) 140 GB at 1 GB/s = 140 s ~6 s, 35 GB per GPU over PCIe ~10 s: test pass, measure free memory ~30 s: compile, capture ××××××××××× ✓✓✓✓ no traffic: the load balancer keeps this pod out serving requests ready at ~216 s 060 s120 s180 s240 s300 s

Start-up stages with illustrative times. Reading the weights dominates; probes fail until the engine opens its port.

Each stage does real work:

  • Get the weights. From Hugging Face, an object store, a network file system or a local disk. This is the stage that changes most with storage.
  • Load into GPU memory. Each worker reads its slices from the safetensors files and copies them over PCIe into HBM.
  • Profile and reserve the KV cache. The engine runs a test pass with the largest batch it allows, measures how much memory is left, and reserves most of it for the KV cache. vLLM targets 90% of GPU memory by default (--gpu-memory-utilization 0.9).
  • Prepare GPU programs. Recent engines compile parts of the model and record CUDA graphs for common batch sizes, so each decode step launches as one unit instead of hundreds of separate kernel calls.
  • Open the port. Only now does the server answer HTTP and report healthy.

The start-up log shows these stages. The lines look like this (wording varies by version; numbers here are illustrative for 8B on one H100):

INFO  Loading weights took 6.12 seconds
INFO  Model loading took 14.99 GiB memory
INFO  GPU KV cache size: 411,000 tokens
INFO  Maximum concurrency for 131,072 tokens per request: 3.14x
INFO  Graph capturing finished in 18 secs
INFO  Starting vLLM API server on http://0.0.0.0:8000
INFO  Application startup complete.

How much KV cache does a server get? A worked example

Take Llama 3.1 8B in BF16 on one 80 GB H100, default settings:

  1. Memory the engine may use: 80 GB × 0.9 = 72 GB.
  2. Minus the weights: 72 − 16.06 = about 56 GB.
  3. Minus working memory for activations and graphs, about 2 GB (illustrative): about 54 GB left for the KV cache.
  4. Divide by 128 KB per token: 54 × 109 ÷ 131,072 ≈ 411,000 tokens.
  5. In vLLM’s 16-token blocks that is about 25,700 blocks.
  6. At the full 131,072-token context, 411,000 ÷ 131,072 ≈ 3.1 conversations fit at once. At 2,500 tokens per chat, about 164 do.

That last line is why --max-model-len is one of the most important settings: if your users never send more than 32,000 tokens, capping the context keeps any single request from claiming a huge share of the cache, and the “maximum concurrency” line in the log goes up four times.

How long does a cold start take?

Where the weights come from changes the start time more than anything else. The table uses sustained read speeds of 3 GB/s for local NVMe, 1 GB/s for network storage and 100 MB/s for an internet download, about 25 GB/s per GPU over PCIe and 40 seconds of profiling and graph capture, with the container image already cached. All figures are illustrative.

Model, GPUsWeightsLocal NVMeNetwork storageInternet downloadSpare, already loaded
8B BF16, 1 GPU16 GB~46 s~57 s~3.3 min0 s
70B BF16, 4 GPUs140 GB~1.5 min~3.0 min~24 min0 s
405B FP8, 8 GPUs405 GB~3.0 min~7.5 min~68 min0 s
Local NVMe70B, 3 GB/s
88 s
Network storage70B, 1 GB/s
181 s
Internet download70B, 100 MB/s
1,441 s
Spare serveralready loaded
0 s

Production platforms keep a copy of every model close to the GPUs (a node-local cache or fast shared storage, see the storage part of the GPU series), pre-pull the engine image onto GPU nodes, and keep one or two spare servers loaded when spikes matter more than idle cost. If traffic doubles and the new server needs three minutes, every request in those three minutes lands on the servers you already have.

Why must the readiness check wait?

Kubernetes asks three different questions of a container, and a model server needs all three answered differently from a web app:

ProbeQuestionOn failureSetting for a model server
StartupHas it finished starting?Keeps waiting, up to the limit, then restartsAllow 1.5 to 2 times your measured cold start, for example 10 s × 60 tries
ReadinessCan it take traffic now?Removed from the Service endpoints; no restart/health every 5 s; fails while loading and if the engine dies
LivenessIs it stuck beyond repair?Container is killed and restarted/health, generous timeout; only active after the startup probe passes

Readiness must wait for the whole sequence: weights loaded, KV cache reserved, graphs captured, port open. A server that says “ready” after the process starts but before the model is loaded receives requests it cannot answer, and users see errors or timeouts during every scale-up. vLLM avoids this by not opening its HTTP port until the engine is ready, so the probe gets “connection refused” until then.

The more common failure goes the other way. A liveness probe copied from a web service (check every 10 seconds, fail after 3) kills the container 30 seconds into a three-minute load. It restarts, loads for 30 seconds, is killed again, and ends in CrashLoopBackOff without ever serving a request. A startup probe fixes this, because liveness checks do not begin until it passes:

startupProbe:
  httpGet: {path: /health, port: 8000}
  periodSeconds: 10
  failureThreshold: 60      # up to 10 minutes to load
readinessProbe:
  httpGet: {path: /health, port: 8000}
  periodSeconds: 5
livenessProbe:
  httpGet: {path: /health, port: 8000}
  periodSeconds: 15
  timeoutSeconds: 5
  failureThreshold: 6       # 90 s of failures before a restart

What sits around the engine?

The engine turns one model on one set of GPUs into an API. A production service needs layers around it: something to authenticate and meter users, something to pick which copy of which model handles each request, something to add and remove copies, somewhere to keep the weights, and a way to see what is happening.

Serving stack around the engine Observability metrics(Prometheus)TTFT, TPOT,queue, KV uselogs, tracestoken usageper teamdashboards,alerts Applicationschat UI, RAG service, agents, IDE plugins GatewayTLS, API keys, rate limits, token quotas, request logs Routermodel name to pool, KV- and prefix-aware routing, retries Engine replicas vLLM pod70B, TP 4 vLLM pod70B, TP 4 starting…not ready yet GPU nodes8 × H100 per server, NVLink, local NVMe cache Kubernetesscheduling, GPU device plugin, probes (KServe, llm-d on top) Autoscaler reads queue lengthand KV use, addsor removes podsnot GPU utilisation Model store registry andversionsobject storageweights to new podsnode NVMe cacherollout: newversion to some pods /metrics from pods

Serving stack around the engine. The request path runs down the middle; the side columns watch, scale and supply it.

  • Gateway. TLS, API keys, rate limits and token quotas per team. Limit tokens as well as requests: one request with a 100,000-token prompt costs as much prefill as fifty short ones.
  • Router. Maps the model name in the request to a pool of servers. A plain round-robin load balancer works, but a router that knows each server’s queue and KV use, or which server already holds a request’s prompt prefix in cache, cuts TTFT noticeably. That is what llm-d adds on Kubernetes (part 22).
  • Autoscaler. Adds replicas from queue length or KV cache use. GPU utilisation is a poor signal: a decode-heavy server shows near 100% while users wait, and an idle one with a full queue can look the same.
  • Model store. Versioned weights in object storage, with a cache on or near each GPU node, so a new version can go to some servers first and roll back.
  • Observability. The engine’s Prometheus metrics, request logs with token counts, and traces that show where time went: gateway, queue, prefill or decode.

Which serving engine should you use?

All of these do the same core job. They differ in speed on your hardware, which models and quantization formats they support, how much set-up they need and how they fit your platform.

EngineFromGood atWatch out for
vLLMOpen source, PyTorch Foundation project (began at UC Berkeley)Widest model support, PagedAttention and continuous batching, NVIDIA and other accelerators, OpenAI API built in; the default under llm-d and many platformsMany settings; peak speed on one GPU type can trail a compiled engine
SGLangOpen source (LMSYS)Reusing shared prompt prefixes (RadixAttention), structured output, large MoE modelsSmaller ecosystem; check your model is supported
TensorRT-LLMNVIDIA, open sourceHighest speed on NVIDIA GPUs, tuned FP8 and FP4 kernelsNVIDIA only; more build and tuning work per model and GPU
NVIDIA NIMNVIDIA, containersReady-made, tested containers with an optimised engine chosen for your GPU, OpenAI APIProduction use is licensed through NVIDIA AI Enterprise; less control inside
Triton Inference ServerNVIDIA, open sourceOne server for many model types (vision, embeddings, classic ML) through backends, including TensorRT-LLM and vLLMLLM features come from the backend; its native API is HTTP/gRPC in the KServe v2 format
llama.cpp, OllamaOpen sourceLaptops, desktops, small servers, CPU and Apple silicon; quantized GGUF files; very easy to startNot built for hundreds of concurrent users on data-centre GPUs

A sensible default for a data-centre platform is vLLM, with TensorRT-LLM or NIM considered when you run a few fixed models on NVIDIA hardware at large scale and the extra speed pays for the tuning effort. Because they all expose an OpenAI-style API, you can switch engines later without changing applications.

Which metrics matter, and what happens under load?

An engine exports dozens of metrics. These are the ones to collect from the first day, with the vLLM names:

Metric (vLLM name)What it tells youUse it for
vllm:time_to_first_token_secondsWait before the first word (queue + prefill)User experience SLO, usually p95
vllm:inter_token_latency_secondsGap between streamed tokensReading speed; rises as the batch grows
vllm:e2e_request_latency_secondsWhole request, first byte to lastTimeouts for non-streaming callers
vllm:num_requests_running, num_requests_waitingBatch size and queue lengthAutoscaling: waiting above zero for long means add capacity
vllm:kv_cache_usage_percShare of KV cache blocks in use (0 to 1)Early warning before the queue forms
vllm:num_preemptionsRequests paused because KV memory ran outSign the server is overcommitted
vllm:request_queue_time_secondsTime spent waiting before runningSeparating “busy” from “slow model”
vllm:prompt_tokens, generation_tokensTokens in and out (counters)Throughput, cost per team, capacity planning

The reason queue and KV metrics beat GPU utilisation shows up when you push traffic up. The bars below come from the simple load model in the demo, for Llama 3.1 8B on one H100 with chatbot-sized requests (500 prompt tokens, 250 answer tokens). Up to about 70% of capacity, more traffic barely changes TTFT; the batch just grows. Close to capacity, requests start to queue and TTFT climbs steeply.

1 request/s5% of capacity, batch 2
36 ms
10 requests/s48%, batch 33
41 ms
15 requests/s72%, batch 75
47 ms
18 requests/s86%, batch 129
69 ms
19 requests/s91%, batch 158
126 ms
20 requests/s96%, batch 200
482 ms
20.5 requests/s98%, batch 228
1.6 s

Time to first token by request rate, one server, illustrative. Time per output token rises at the same time, from about 8 ms to about 44 ms, because every decode step carries more requests.

GPU utilisation reads high across almost this whole range, so it cannot tell 15 requests a second (fine) from 20.5 (users waiting over a second). Queue length and KV cache use move exactly where the experience changes. Change the workload and the knee moves: with RAG-sized prompts of 6,000 tokens, the same server tops out near 3 requests a second, because each request holds twelve times more KV cache and prefill takes a much bigger share of the GPU. Part 20 turns this into a sizing method (how many GPUs).

What mistakes do teams make with model serving?

  • Liveness probe without a startup probe. The container is killed mid-load and never comes up. Measure the real cold start and give the startup probe 1.5 to 2 times that.
  • Downloading weights at every start. Slow, rate-limited, and it fails when the hub or the token for a gated model has a problem. Keep a copy near the GPUs.
  • Leaving the context at the model’s maximum. 131,072 tokens per request means a few requests can claim the whole KV cache. Set --max-model-len to what users really send.
  • Scaling on GPU utilisation. It sits near 100% in normal operation. Scale on waiting requests, KV cache use or TTFT.
  • A proxy that breaks streaming. Proxies that buffer responses deliver the whole answer at the end, so streaming looks broken; idle timeouts of 60 seconds (common defaults on load balancers and nginx) cut long answers. Turn off buffering for these routes and raise the timeouts.
  • Rate limits on requests only. A request count says nothing about cost. Limit tokens per minute as well.
  • Wrong chat template. A base model’s template, or none, with an instruct model gives quietly worse answers. Use the template from the repository and test a few known prompts after every upgrade.
  • One replica. A GPU fault or a rollout takes the service down for the length of a cold start. Run at least two.

Questions people ask about model serving

Is serving a model the same as running it in Python?

No. A Python script with a model library answers one prompt at a time. A serving engine batches many users onto the same GPU steps, manages KV cache memory across them and streams results, which is what turns tens of tokens a second into thousands.

Can one server serve several models?

Usually one engine process serves one base model, plus many LoRA adapters on top of it (part 19). Several base models means several engine processes, either on separate GPUs or sharing a GPU with a split memory budget.

Why is the first request after start-up sometimes slower?

Some work is lazy: caches warm up, batch sizes that were not captured as graphs run more slowly the first time, and prefix caches start empty. A few warm-up requests after readiness smooth this out.

Do I need Kubernetes to serve a model?

Not for one model on one server: vllm serve behind a reverse proxy works. Kubernetes becomes worth it when you have several models, several replicas, rollouts and autoscaling.

Model serving checklist
  • Read config.json before sizing: layers, KV heads, head size and context length set the memory budget.
  • Pick an engine that supports your model, your GPUs and your quantization format, and expose an OpenAI-compatible API.
  • Set --max-model-len to real usage and check the KV cache size and maximum concurrency in the start-up log.
  • Keep weights on fast storage near the GPUs, pre-pull the image, and measure the real cold start.
  • Use a startup probe sized to that cold start; mark ready only after weights, KV cache and graphs are done.
  • Turn off proxy buffering for streaming routes and raise idle timeouts.
  • Collect TTFT, inter-token latency, waiting requests and KV cache use from day one, and scale on them.

Sources: file list, sizes, config.json and generation_config.json from the Llama 3.1 8B Instruct repository on Hugging Face; safetensors format and rationale from the Hugging Face safetensors project; vLLM process architecture (API server, engine core, one worker per GPU, ZMQ), metric names, port, endpoints and default GPU memory share from the vLLM documentation; OpenAI chat completions streaming format from the OpenAI API reference. Llama 3.1 70B shape from Meta’s model card. All timings, start-up times, log numbers and load figures are illustrative and depend on hardware, storage, network, engine version and settings.

About The Author


Discover more from Journal of Intelligent Infrastructure

Subscribe to get the latest posts sent to your email.

Leave a Reply

Your email address will not be published. Required fields are marked *

Architect’s Toolkit

About the Author

Dr. Pranay Jha is a Cloud and AI Consultant with 18+ years of experience in hybrid cloud, virtualization, and enterprise infrastructure transformation. He specializes in VMware technologies, multi-cloud strategy, and Generative AI solutions. He holds a PhD in Computer Applications with research focused on Cloud and AI, has published multiple research papers, and has been a VMware vExpert since 2016 and a VMUG Community Leader.

Discover more from Journal of Intelligent Infrastructure

Subscribe now to keep reading and get access to the full archive.

Continue reading