Build Your Own Token-as-a-Service: A Self-Hosted OpenAI-Compatible AI Gateway on Kubernetes¶
Public LLM APIs bill you per token and leak your prompts off-prem. The fix isn't "run Ollama on a box" — it's a centralized, OpenAI-compatible inference platform your whole org consumes like a utility: one API key, token-based quotas, models that stay in your datacenter. Call it token-as-a-service. This post is the architecture — model tiering, GPU math, MIG partitioning, vLLM tuning, and KV-cache-aware routing — generic enough to run on any Kubernetes cluster with NVIDIA GPUs.
The reference target: ~1,750 concurrent users during business hours, split across interactive chat, RAG, long-document synthesis, and agentic coding — served from a fixed pool of GPUs without overflowing into cloud spend. Everything below is sized against that.
The shape of the platform¶
Three layers, each independently scalable:
| Layer | Runs on | Job |
|---|---|---|
| Gateway | CPU-only nodes | One OpenAI-compatible REST endpoint. Auth, routing, rate limiting, audit logging. |
| Inference pools | Bare-metal GPU nodes | vLLM model servers grouped by workload (planner, builder, reviewer, support). |
| State / data | VM or managed | PostgreSQL for app + vector metadata, NFS/S3 for model artifacts. |
Keep the gateway off the GPUs. The gateway is CPU- and I/O-bound — auth, JSON, rate-limit counters. Pin it to standard CPU nodes so every gram of GPU memory goes to active inference. It also makes the gateway trivially HA: CPU pods migrate during maintenance, GPU pods don't have to.
client / IDE / agent
│ OpenAI schema + API key
▼
┌──────────────────────┐ CPU-only node pool
│ Envoy Gateway (x2) │ auth · rate limit · audit
│ + inference routing │ weighted routes per model
└─────────┬────────────┘
│ weighted-cluster route → Endpoint Picker (EPP)
┌──────┴───────┐
▼ ▼
prod cluster staging cluster bare-metal GPU node pools
(vLLM pools) (vLLM pools) planner · builder · reviewer · support
Model tiering: stop using one big model for everything¶
A single monolithic model is the wrong default. Different model families have different strengths, and an agentic coding flow has at least three distinct jobs — plan, build, review. Routing each through a model suited to it produces higher-quality output and catches mistakes a single model misses.
Every model below is open-weight and on Hugging Face — swap in whatever your evals favor, but these are concrete, runnable starting points:
| Role | Example open model | Why this shape | Workload |
|---|---|---|---|
| Planner | openai/gpt-oss-120b1 — MoE, 117B total / 5.1B active, configurable reasoning effort | Deep multi-step reasoning + tool calling matter more than raw concurrency | Decompose features, survey the codebase, agentic flows |
| Builder | google/gemma-4-26B-A4B-it2 — MoE, 26B total / 4B active | Low active-parameter decode = cheap, high-concurrency throughput | Write/edit code, run builds, high-volume chat, RAG |
| Reviewer | google/gemma-4-31B-it3 — dense, 31B, 256K context | Dense compute = predictable throughput on big prefill-heavy inputs | Review diffs, long-document synthesis (100K+ context) |
Strong open alternatives if you want a second opinion in your evals:
Qwen/Qwen3-30B-A3B4 (MoE builder/planner),Qwen/Qwen3-Coder-30B-A3B-Instruct5 (coding-tuned builder), andgoogle/gemma-3-27b-it6 (dense reviewer). Validate the actual checkpoint against your own benchmarks before committing GPUs.
Plus the support tier — small open models that do the unglamorous work, each well under 8B parameters:
| Support class | Example open model | Job | Footprint |
|---|---|---|---|
| Embedding | ibm-granite/granite-embedding-107m-multilingual7 | Documents + queries → dense vectors for RAG retrieval | ~107M params, ~0.5 GB FP16 |
| Reranker | BAAI/bge-reranker-v2-m38 | Reorder top-k candidates for precision before they hit the LLM | ~0.6B params, ~1 GB FP16 |
| Guardrail / safety | meta-llama/Llama-Guard-3-8B9 | Input/output content safety, prompt-injection + PII checks every request | ~8B params, ~8 GB FP8 |
These are tiny. Handing each a full 96 GB GPU wastes 80–90% of the card. That's exactly what MIG fixes — more below.
GPU capacity is KV-cache math, not parameter count¶
Model weights are the small part of GPU memory at scale. The real consumer is the KV cache — and it grows with concurrent sessions and context length, not model size.
Each concurrent request reserves memory roughly proportional to its context (input + output). On a 96 GB card running FP8 KV cache, rough per-session costs:
| Workload | Context (in + out) | KV per session |
|---|---|---|
| Chat / coding assistant | ~6K + 2K = 8K | ~0.6 GB |
| RAG Q&A / structured extraction | ~15K + 2K = 17K | ~1.5 GB |
| Summarization / deep research / agentic | ~100K + 6K = 106K | ~8 GB |
That converts directly to a replica count. Take the available KV budget per replica, divide by per-session cost, divide your user target by that:
planner 1,500 chat users × 0.6 GB ÷ ~200 users/replica → ≥ 8 replicas
builder 200 RAG users × 1.5 GB ÷ ~110 users/replica → ≥ 2 replicas
reviewer 50 deep users × 8 GB = ~400 GB aggregate KV → ≥ 2 replicas
Pick GPUs with enough HBM to keep peak KV cache fully resident. Long-context workloads (256K-capable models, 100K reviewer inputs) generate KV loads that overflow to host memory on small cards — and the moment KV spills, inter-token latency gets unstable. A 96 GB card leaves 64–88 GB free for KV after weights and gives you headroom.
Tensor parallelism shards weights across GPUs and frees more room for KV:
| Model | Weights (FP8/MXFP4) | Tensor parallelism | Free KV per GPU |
|---|---|---|---|
Planner — gpt-oss-120b (MoE) | ~3 GB | TP=2 (~32 GB sharded) | ~64 GB |
Builder — gemma-4-26B-A4B-it (MoE) | ~26 GB | TP=2 (~13 GB sharded) | ~83 GB |
Reviewer — gemma-4-31B-it (dense) | ~31 GB | TP=4 (~8 GB sharded) | ~88 GB |
Support — granite-embedding / bge-reranker-v2-m3 / Llama-Guard-3-8B | ~7–8 GB | TP=1 on a MIG slice | ~16 GB/slice |
If your GPUs lack NVLink, all tensor-parallel traffic crosses PCIe Gen5 inside the node. It works, but keep TP groups within a single physical node — don't shard a model across the network.
MIG: turn one stranded GPU into four productive ones¶
Small support models on full GPUs is a waste. Multi-Instance GPU (MIG) carves a physical card into hardware-isolated slices, so embeddings, reranking, and guardrails each get a right-sized partition instead of a whole card.
This section is the why for a TaaS platform. For installing the GPU Operator from scratch see GPUs on Kubernetes, and for the full MIG + time-slicing
values.yamlapplied through GitOps see Stacking MIG + time-slicing.
The strategy that makes this work is mixed mode — the only MIG strategy that allows different layouts on a single node, so large-model passthrough GPUs and sliced small-model GPUs coexist.
Example layout per GPU node (8 GPUs each):
- First 2 GPUs → MIG-enabled, four
1g.24gbslices each = 8 slices for support models. - Remaining 6 GPUs → full passthrough for the large model pools.
Drive it with node labels via the NVIDIA GPU Operator:
# Slice the MIG-enabled nodes
kubectl label node <node> nvidia.com/mig.config=mig-mixed-config --overwrite
# Keep the rest as full GPUs
kubectl label node <node> nvidia.com/mig.config=all-disabled --overwrite
Three structural wins:
- Density. Each MIG GPU yields 4 isolated slices — a clean 4:1 gain on otherwise-stranded capacity.
- Isolation. Hardware partitioning means a request burst on one endpoint can't monopolize compute or memory of a neighbor on the same card.
- Failure domains. Spreading MIG-enabled GPUs across multiple nodes distributes support-model capacity, so a single GPU or node loss doesn't take out a whole tier. That preserves N+1 for failover and canary releases.
The
1g.24gbprofile cleanly matches the embedding/rerank/guardrail footprint. Bigger profiles (2g.48gb,4g.96gb) exist if your support tier grows — match the slice to the model, don't over-allocate.
The hard limit: one model can't span MIG slices¶
This is the constraint that decides the whole layout, so state it plainly: a single inference process can only use one MIG slice. It's by design, not a bug or a missing config.
MIG instances are hardware-isolated — dedicated SMs, memory, and cache paths. NVIDIA deliberately blocks the cross-instance communication that multi-GPU inference needs:
- No NVLink, no peer-to-peer, no CUDA IPC between slices. Tensor parallelism relies on NCCL collectives that require P2P access between devices. MIG forbids it.
- So a MIG slice is always
TP=1. You can attach multiple MIG devices to one pod, but the model server still can't fuse them into one tensor-parallel group — they sit there as separate, unusable-together devices.
One MIG slice = one model replica, period. If a model doesn't fit in your card's biggest slice, you're off MIG and onto full GPUs.
Deciding MIG vs. full GPU¶
That limitation turns into a simple decision tree:
Does the model fit in a single MIG slice (≤ largest profile, e.g. 4g.96gb)?
├─ Yes → does it need TP > 1 for throughput/latency?
│ ├─ No → MIG slice, TP=1 ← support tier lives here
│ └─ Yes → full GPUs, TP=2/4 ← MIG can't shard, so don't
└─ No → full GPUs, TP=2/4 ← too big for any slice
| Model profile | Fits one slice? | Needs TP>1? | Decision |
|---|---|---|---|
| Embedding / rerank / guardrail (≤8B) | Yes | No | MIG 1g.24gb, TP=1 |
| Mid model that fits a big slice but wants throughput | Yes | Yes | Full GPU — MIG can't give it TP |
| Planner / builder / reviewer (large or TP-bound) | No | Yes | Full GPU, TP=2/4 |
The trade-off in one line: MIG buys you density and isolation for small models, and costs you tensor parallelism. The moment a model needs to shard across GPUs, MIG is the wrong tool — give it whole cards.
Time-slicing: pack several models onto one slice¶
MIG gives you N isolated slices. But a 1g.24gb slice running one idle reranker still wastes most of its duty cycle. Time-slicing oversubscribes a slice so multiple model pods share it — the GPU context-switches between them in round-robin.
It stacks on MIG. You partition with MIG, then advertise each slice R times through the NVIDIA device plugin's timeSlicing config:
# device-plugin config — oversubscribe each MIG slice 3×
sharing:
timeSlicing:
resources:
- name: nvidia.com/mig-1g.24gb
replicas: 3
# Point the MIG-enabled nodes at the time-sliced profile
kubectl label node <node> nvidia.com/device-plugin.config=mig-timeslice --overwrite
Now kubectl describe node shows 3 schedulable mig-1g.24gb units per physical slice — so the 8 slices on a node become 24 schedulable units. The density multiplies: MIG slices × time-slice replicas = total units.
Full working values for stacking MIG + time-slicing through the GPU Operator are in Stacking MIG and Time-Slicing on One GPU Operator values.yaml.
The catch: time-slicing isolates nothing¶
MIG and time-slicing are opposites on the one axis that matters — isolation:
| MIG | Time-slicing | |
|---|---|---|
| Memory | Hard-partitioned per slice | Shared — pods see the whole slice's memory |
| Compute | Dedicated SMs | Time-multiplexed — pods take turns |
| Fault isolation | Yes — a crash stays in its slice | No — one pod can OOM or stall its neighbors |
| QoS guarantee | Yes | None — cooperative round-robin |
| Best for | Production, latency-sensitive | Idle, bursty, dev/test |
The LLM-specific landmine: time-slicing does not divide memory, so every vLLM replica on the slice sees the full 24 GB and, left at the default --gpu-memory-utilization 0.90, each tries to grab almost all of it. Three replicas → instant OOM. You must hand-cap each replica so the sum fits:
That tiny per-replica budget is why time-slicing only makes sense for the small, idle support tier — a reranker that fires for 20 ms per query, not a planner streaming tokens for 30 seconds.
Deciding: MIG, time-slicing, or both¶
Need hardware isolation / predictable latency?
├─ Yes → MIG only (no time-slicing). Production support models.
└─ No → are the models small AND mostly idle/bursty?
├─ Yes → MIG + time-slicing. Pack dev/test or low-traffic models.
└─ No → full GPU. Throughput-bound or latency-sensitive work.
| Workload | Isolation need | Utilization | Choice |
|---|---|---|---|
| Prod embeddings / guardrails | High (shared tenants) | Steady | MIG only |
| Dev / QA / staging support models | Low | Spiky, mostly idle | MIG + time-slicing |
| Many tiny experiments on a budget | Low | Bursty | MIG + time-slicing |
| Planner / builder / reviewer | High | Sustained | Full GPU, TP=2/4 |
The rule that ties it together: MIG splits the silicon, time-slicing oversubscribes the splits — and the safe place to oversubscribe is exactly where isolation matters least. Production tenants get whole slices; dev and idle models share them.
vLLM engine tuning that actually moves the needle¶
Catalog defaults get you running; they don't get you optimal. The settings below are where throughput and latency are won. Per inference pool:
| Setting | Value & rationale |
|---|---|
--max-model-len | Set to the real aggregate context after multi-turn, per role — e.g. 64K planner, 32K builder, 262K reviewer. Don't pay KV tax for context you never use. |
--max-num-seqs | Concurrency per iteration. Tune per role: ~128 planner, ~320 builder, ~64 reviewer. |
--max-num-batched-tokens | Chunk prefill on small-model/big-GPU pools to prioritize decode latency — e.g. 24K builder, 32K reviewer. |
--gpu-memory-utilization | 0.90 (down from default 0.92) leaves slack for tensor-parallel sharding on large models. |
--kv-cache-dtype fp8 | Halves KV footprint per session vs auto. The single biggest concurrency lever. |
--kv-offloading-backend native + --kv-offloading-size 200 | Offload cold KV blocks to host DRAM (200 GiB). Raises users/replica and prefix-cache hit rate, absorbs bursts. |
--disable-hybrid-kv-cache-manager | Uniform KV allocation across attention layers — set as a bare flag. |
--block-size 16 | KV block granularity. Default is fine for most. |
--enable-prefix-caching | On. Reuses shared prompt prefixes across requests. |
--enable-auto-tool-choice + --tool-call-parser | On, with the parser matched to the model family. |
--reasoning-parser | Matched to the model for models that emit reasoning traces. |
If you turn on KV offloading, raise the engine's CPU and RAM limits to match — e.g. 24 vCPU and enough host RAM to hold the offload buffer (a 200 GiB buffer wants ~384 GiB on the node). Offloading to DRAM that isn't there does nothing.
Treat host-DRAM KV offloading as upside, not baseline. Size your capacity for worst-case all-resident KV, then let offloading buy you extra concurrency and burst tolerance on top.
KV-cache-aware routing: don't recompute the conversation¶
Round-robin load balancing quietly destroys multi-turn performance. Every time a follow-up message lands on a different GPU, that GPU recomputes the entire conversation history from scratch — Time-to-First-Token climbs with every turn.
The fix is the Kubernetes Gateway API Inference Extension — an InferencePool plus an Endpoint Picker (EPP) that schedules requests based on backend state, not blind rotation. Envoy Gateway applies a weighted-cluster route per model, then fans out to the EPP, which picks the optimal model-server pod.
The EPP optimizes two things at once:
| Mechanism | Objective | What it evaluates |
|---|---|---|
| Prefix-aware scheduling | Cut multi-turn latency | Maps prompt prefixes to the pod that already holds the matching KV cache |
| Load-aware scheduling | Prevent hotspots | Scores pods on queue depth, active requests, and KV utilization |
The architectural trick is decoupling ingress routing from backend scheduling: the gateway weights traffic across environments; the EPP picks the replica inside the target pool. Requires a minimum of 2 replicas per pool — for HA and so prefix-aware routing has somewhere to route.
Asymmetric prod/staging weights¶
Run staging as live failover capacity, but weight traffic by physical capacity, not 50/50:
| Pool | Prod / Stage | Why |
|---|---|---|
Planner (gpt-oss-120b), Builder (gemma-4-26B-A4B-it) | 75% / 25% | Standard split; staging absorbs real failover |
Reviewer (gemma-4-31B-it, dense, big) | 90% / 10% | Staging can't safely absorb 25% of dense-model load without breaching SLOs |
Support (granite-embedding / bge-reranker-v2-m3 / Llama-Guard-3-8B) | 50% / 50% | MIG slices are evenly distributed across both |
Token-based rate limiting (RPM lies)¶
Rate-limit on tokens per minute, not requests — one request might burn 10 tokens, another 10,000. Requests-per-minute tells you nothing about GPU pressure.
Combine two scopes, evaluated independently; a request is rejected if it trips either:
- Global TPM — pool-wide ceiling shared across all keys (use a shared store like Redis across gateway replicas). The ultimate hardware safeguard: even a high-override key gets throttled once aggregate traffic hits the pool ceiling.
- Per-key TPM — individual buckets per API key.
Example ceilings by pool — note the Planner carries the lowest limits (accuracy over concurrency) and the MoE Builder the highest (cheap per-token decode):
| Pool | Global TPM | free | standard | enterprise | premium |
|---|---|---|---|---|---|
Planner (gpt-oss-120b) | 2,950,000 | 20K | 160K | 800K | 1,625K |
Builder (gemma-4-26B-A4B-it) | 6,650,000 | 55K | 530K | 2,550K | 5,000K |
Reviewer (gemma-4-31B-it) | 6,325,000 | 55K | 530K | 2,630K | 5,275K |
No native tier concept? Encode it in the key name. A <tier>-<consumer>-<env> convention — enterprise-orderflow-prod, standard-marketing-bot-prod — turns a wall of opaque keys into a greppable, self-documenting policy surface. The prefix is the contract.
Governing agent tools with a single MCP endpoint¶
Agentic workflows want to call external systems — databases, metrics, ticketing. Don't let autonomous agents connect to internal systems directly. Put the gateway in the path as an interception proxy using the Model Context Protocol (MCP).
| Component | Location | Function |
|---|---|---|
| Remote MCP servers | Per cluster | Connect locally to specific tools (metrics, logs, topology) |
| Unified connector endpoint | Gateway cluster | Aggregates every remote server into one client-facing interface |
One endpoint gives you a single pane for monitoring, auditing, and authorization. The gateway intercepts every tool call from IDEs or agent orchestrators, verifies the key's permissions, and proxies execution down to the right cluster — so multi-turn reasoning chains are governed before they touch corporate data.
Infrastructure notes that bite if you skip them¶
- Separate the databases from the inference platform. Run PostgreSQL on its own VMs/instances with independent BCDR. Don't co-locate app state with GPU workloads.
- Mixed VM + bare-metal Kubernetes. Virtualized control plane and CPU workers, bare-metal GPU node pools joined as a separate pool. GPU nodes carry a taint so stray workloads can't land on them — add explicit tolerations for the inference pods only.
- Observability the platform won't give you for free. Add a vLLM service monitor and dashboards for: KV-cache metrics and query stats, plus Envoy overview / upstream / downstream. TTFT, queue wait, and token-generation rate are your real SLO signals — not CPU%.
Summary¶
The reusable pattern, end to end:
- Gateway on CPU nodes, inference on GPU nodes. Never burn GPU memory on auth and JSON.
- Tier your models by job — planner / builder / reviewer + small support models — instead of one monolith.
- Size GPUs by KV-cache math: per-session GB × user target ÷ replica capacity = replica count. Keep peak KV resident in HBM.
- MIG mixed-mode to slice small-model GPUs 4:1 while large models keep full cards. One slice = one model (
TP=1) — span more than that and you're on full GPUs. - Time-slice only the idle support tier to oversubscribe slices (
slices × replicasunits) — and cap each vLLM's--gpu-memory-utilizationso they fit shared memory. Never time-slice latency-sensitive prod. - Tune vLLM:
fp8KV cache, right-sized--max-model-len, prefix caching on, offload cold KV to DRAM as upside. - Route with the Gateway API Inference Extension + EPP — prefix-aware and load-aware, not round-robin. Min 2 replicas/pool.
- Rate-limit on tokens (TPM), global + per-key, with tier baked into the key name.
- Front all agent tools with one governed MCP endpoint.
The one-line mental model: a self-hosted token-as-a-service platform is just the right model, on the right slice of GPU, reached by the route that already holds your KV cache — wrapped in token quotas so no single consumer can starve the rest.
-
OpenAI,
openai/gpt-oss-120b— open-weight MoE, 117B total / 5.1B active per token, MXFP4-quantized, Apache 2.0. ↩ -
Google,
google/gemma-4-26B-A4B-it— Gemma 4 MoE, 26B total / 4B active, multimodal, multilingual. ↩ -
Google,
google/gemma-4-31B-it— Gemma 4 dense, 31B, multimodal, long context. ↩ -
Alibaba,
Qwen/Qwen3-30B-A3B— MoE, 30B total / 3B active, 262K native context. ↩ -
Alibaba,
Qwen/Qwen3-Coder-30B-A3B-Instruct— coding-tuned MoE variant. ↩ -
Google,
google/gemma-3-27b-it— dense, 27B, 128K context, multimodal. ↩ -
IBM,
ibm-granite/granite-embedding-107m-multilingual— multilingual dense embedding model. ↩ -
BAAI,
BAAI/bge-reranker-v2-m3— multilingual cross-encoder reranker, up to 8192-token inputs. ↩ -
Meta,
meta-llama/Llama-Guard-3-8B— 8B safety classifier for input/output moderation. ↩
Discussion
Have thoughts on this post? Share them below — questions, corrections, or your own experience are all welcome.