LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 177 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/16
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnSystem Design CapstonesMulti-Tenant LLM Platform
🏗️HardSystem Design

Multi-Tenant LLM Platform

Design a shared LLM platform with tenant-scoped state, quota enforcement, adapter routing, KV accounting, and measured GPU utilization.

34 min read
Learning path
Step 150 of 177 in the full curriculum
Code Completion SystemLLM-Powered Search Engine

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

Code completion gave you a single high-frequency product surface: one developer, one editor context, one low-latency serving path. A multi-tenant large language model (LLM) platform generalizes that serving path into shared infrastructure where many tenants, adapters, quotas, and privacy boundaries coexist on the same GPU fleet.

A multi-tenant LLM platform shares expensive serving infrastructure while enforcing tenant-scoped state, scheduler policy, and measurable latency objectives. This design chapter covers routing, quotas, batching, data boundaries, and cost control.

A shared AI platform for developer workspaces serves one hundred teams. They use it to answer internal-doc questions, summarize incidents, and draft migration notes. Each workspace requires authorization boundaries around private docs, prompts, adapters, and usage records. Your job is to share GPU capacity while keeping every stateful path scoped to the authorized tenant.

This is the multi-tenant LLM serving problem. One concrete request moves through a shared platform below, layer by layer, so you can see how the platform enforces tenant scopes, schedules shared work, and measures latency.

Start with three ideas from earlier in the curriculum. First, an LLM generates text one token at a time. Second, to avoid recomputing the entire prompt on every single token, the model stores intermediate results in a structure called the KV cache. Third, batching lets the GPU apply resident model weights across multiple requests in shared serving steps, amortizing work across users. The design below builds on all three.

Multi-tenant serving path where tenant-tagged requests move through gateway, fair scheduler, shared GPU workers, and per-tenant metering, while shared base weights stay separate from tenant-scoped state.
Shared fleets are cheap only when tenant identity stays attached to routing, state, and billing through every shared step.

What is the central tension in a multi-tenant LLM platform?

Answer

The platform shares selected capacity to reduce duplicated cost, while tenant-scoped state, scheduler policy, measured latency objectives, and metering keep shared execution accountable.

Why shared capacity needs hard boundaries

Consider a design scenario with a dense 72-billion-parameter model stored in FP16 (16-bit floating point). Weight storage alone is about 144 GB in decimal units. One NVIDIA H100 SXM configuration has 80 GB of HBM3 memory.[1]Reference 1H100 GPUhttps://www.nvidia.com/en-us/data-center/h100/ In this scenario, one copy of the model weights exceeds one such GPU's memory before adding KV cache or serving overhead.

If each of one hundred tenants had a separate copy of those weights, weight memory alone would be 14.4 TB. Shared base weights can avoid that duplication, but they don't automatically isolate prompts, retrieved documents, adapters, caches, or billing state.

Make the scenario calculation runnable before discussing schedulers:

shared-weight-capacity.py
1def weight_storage_gb(parameters_billions: int, bytes_per_parameter: int) -> float: 2 return parameters_billions * bytes_per_parameter 3 4base_weight_gb = weight_storage_gb(parameters_billions=72, bytes_per_parameter=2) 5per_tenant_weight_tb = base_weight_gb * 100 / 1000 6 7assert base_weight_gb == 144 8assert per_tenant_weight_tb == 14.4 9print("one_fp16_weight_copy_gb:", base_weight_gb) 10print("one_hundred_copies_tb:", per_tenant_weight_tb)
Output
1one_fp16_weight_copy_gb: 144 2one_hundred_copies_tb: 14.4

Sharing introduces three concrete engineering tensions:

  1. Compute contention. All tenants want the GPU's CUDA cores during deploy windows, incidents, and review bursts.
  2. Memory contention. Every active conversation consumes KV cache memory. A tenant with a long design-review transcript can evict another tenant's chat if limits aren't enforced.
  3. Weight customization. Tenants want different behaviors. One needs terse API-reference answers; another needs incident-triage summaries. Loading a full model copy per customization wastes capacity, so we need scoped lightweight adapters or separate pools where required.

The rest of the article solves these three tensions in order.

Why is "one model copy per tenant" usually impractical for a large serving fleet?

Answer

Large dense models can require more memory than one GPU just for weights. Sharing base weights reduces duplication, but each shared-state mechanism still needs an explicit authorization and accounting boundary.

How we pack requests together: continuous batching

When a GPU processes a batch, one serving step applies the resident model weights across multiple requests. The simplest approach is static batching: collect eight requests, run them together, and wait until every single one finishes before starting a new batch. This is easy to implement but wasteful. If Tenant A's API-reference answer generates only 10 output tokens while Tenant B's incident retrospective generates 500 tokens, the GPU slot sits idle for Tenant A while Tenant B finishes the remaining 490 tokens.

Continuous batching (also called in-flight batching, described in the Orca paper[2]Reference 2Orca: A Distributed Serving System for Transformer-Based Generative Models.https://www.usenix.org/conference/osdi22/presentation/yu) replaces completed requests with queued work at iteration boundaries. When Tenant A reaches its EOS (End of Sequence) token, the scheduler can admit another request without waiting for Tenant B to finish.

The throughput gain depends on prompt lengths, decode lengths, admission policy, and scheduler overhead. The useful principle is that a finished request need not occupy a decode slot.

In a multi-tenant environment, the scheduler also has to respect priority and fairness. A high-tier tenant may have tighter latency SLOs. A tenant-aware continuous batcher therefore balances throughput (packing as many tokens as possible) against measured latency objectives for prioritized tenants. The rate-limiting and preemption sections below show how that works.

Static batching is a charter bus that waits until every passenger reaches their destination before returning to the depot. Continuous batching lets an empty seat be offered to the queue at the next scheduled stop.

This miniature schedule keeps a long request active while replacing a completed short request:

continuous-batch-slots.py
1from collections import deque 2 3active = {"tenant-a": 1, "tenant-b": 3} 4waiting = deque([("tenant-c", 2)]) 5 6for tenant in list(active): 7 active[tenant] -= 1 8 if active[tenant] == 0: 9 del active[tenant] 10 admitted, remaining_tokens = waiting.popleft() 11 active[admitted] = remaining_tokens 12 13assert active == {"tenant-b": 2, "tenant-c": 2} 14print("active_after_iteration:", active)
Output
1active_after_iteration: {'tenant-b': 2, 'tenant-c': 2}

Why does continuous batching need tenant awareness?

Answer

Pure throughput scheduling can let one tenant dominate decode slots or KV memory. Tenant-aware batching keeps the GPU full while honoring priority tiers, quotas, and latency SLOs.

How we customize behavior without duplicating weights: LoRA adapters

Tenants may need different behaviors without separate full model copies. LoRA (Low-Rank Adaptation[3]Reference 3LoRA: Low-Rank Adaptation of Large Language Models.https://arxiv.org/abs/2106.09685) learns low-rank matrices next to selected original weight layers. During inference, the base model remains fixed and the selected adapter contributes an additional projection.

Adapter size isn't one universal number: it depends on rank, target modules, model dimensions, and dtype. It's typically much smaller than a full base copy, but a platform must measure its chosen adapter footprint and decide how many adapters can remain resident alongside KV-cache budgets.

The S-LoRA (Serving Thousands of Concurrent LoRA Adapters) system[4]Reference 4S-LoRA: Serving Thousands of Concurrent LoRA Adapters.https://arxiv.org/abs/2311.03285 studies serving many concurrent adapters while keeping base weights shared. Adapter caching is only part of the problem; the runtime also has to execute requests with different adapters in shared serving steps. Multi-LoRA engines need runtime support that maps each request to its adapter while preserving base-model sharing.[5]Reference 5LoRA Adaptershttps://docs.vllm.ai/en/stable/features/lora/

Punica introduces Segmented Gather Matrix-Vector multiplication (SGMV) for batched LoRA serving and evaluates mixed-adapter overhead.[6]Reference 6Punica: Multi-Tenant LoRA Servinghttps://arxiv.org/abs/2310.18547 Production engines such as vLLM expose LoRA serving configuration including enabled adapters, resident adapter limits, and maximum supported rank.[5]Reference 5LoRA Adaptershttps://docs.vllm.ai/en/stable/features/lora/ Benchmark your actual adapter mix and hardware before treating the paper result as a fleet capacity plan.

Analogy: a shared compiler backend Imagine one compiler backend that loads a small approved plugin for each workspace. The backend (the base model) stays shared. The plugin (the adapter) is small, but the runtime must still verify which tenant is authorized to use it.

The platform stores adapters in an object-storage registry such as S3 (Simple Storage Service) and loads them into GPU memory on demand. Frequently used adapters stay in an LRU cache on the GPU; rarely used ones are evicted to host RAM or disk.

What does LoRA share and what does it customize?

Answer

The frozen base model weights are shared across tenants. Small adapter matrices customize tone, domain behavior, or tenant policy without loading a separate full model per tenant.

LoRA adapter routing for multi-tenant serving where tenant identity selects an authorized adapter, hot adapters stay in GPU cache, and one frozen base model stays shared across tenants.
LoRA routing shares the frozen base once while authorization selects a tenant's adapter and the cache keeps hot adapter weights on GPU.

The conceptual lifecycle below shows how the adapter manager takes a request, resolves the correct adapter, and runs inference. Multi-LoRA runtimes use adapter-aware kernels and memory management so requests with different adapters can share base-model serving steps. Adapter lookup, residency, and mixed-adapter execution still have measurable cost, so benchmark the actual mix:

lora-adapter-routing.py
1from collections import OrderedDict 2from dataclasses import dataclass 3 4@dataclass 5class Request: 6 tenant_id: str 7 requested_adapter: str 8 prompt: str 9 10@dataclass 11class Response: 12 text: str 13 14@dataclass 15class AdapterWeights: 16 adapter_id: str 17 18class AdapterStore: 19 def download(self, adapter_id: str) -> AdapterWeights: 20 print(f"Loading adapter {adapter_id} into GPU cache") 21 return AdapterWeights(adapter_id) 22 23class BaseModel: 24 def generate(self, request: Request, adapter: AdapterWeights) -> Response: 25 token_count = len(request.prompt.split()) 26 return Response( 27 f"tenant={request.tenant_id} adapter={adapter.adapter_id} " 28 f"prompt_tokens={token_count}" 29 ) 30 31class LoRAAdapterManager: 32 """Routes only authorized adapters on a shared base model.""" 33 def __init__(self, max_hot_adapters: int = 2): 34 self.base_model = BaseModel() 35 self.adapter_cache: OrderedDict[str, AdapterWeights] = OrderedDict() 36 self.adapter_store = AdapterStore() 37 self.max_hot_adapters = max_hot_adapters 38 self.authorized_adapter = { 39 "tenant-a": "docs-v2", 40 "tenant-b": "incident-v7", 41 "tenant-c": "code-review-v1", 42 } 43 44 def serve(self, request: Request) -> Response: 45 if self.authorized_adapter.get(request.tenant_id) != request.requested_adapter: 46 raise PermissionError("adapter is not authorized for tenant") 47 adapter_id = f"{request.tenant_id}/{request.requested_adapter}" 48 49 if adapter_id not in self.adapter_cache: 50 if len(self.adapter_cache) >= self.max_hot_adapters: 51 evicted_id, _ = self.adapter_cache.popitem(last=False) 52 print(f"Evicting adapter {evicted_id}") 53 adapter_weights = self.adapter_store.download(adapter_id) 54 self.adapter_cache[adapter_id] = adapter_weights 55 56 self.adapter_cache.move_to_end(adapter_id) 57 return self.base_model.generate(request, self.adapter_cache[adapter_id]) 58 59manager = LoRAAdapterManager(max_hot_adapters=2) 60for request in [ 61 Request("tenant-a", "docs-v2", "draft a migration note"), 62 Request("tenant-b", "incident-v7", "summarize the outage timeline"), 63 Request("tenant-c", "code-review-v1", "review this auth diff quickly"), 64]: 65 response = manager.serve(request) 66 67print(response.text) 68print("hot adapters:", list(manager.adapter_cache)) 69try: 70 manager.serve(Request("tenant-a", "code-review-v1", "use another policy")) 71except PermissionError as error: 72 print("blocked:", error)
Output
1Loading adapter tenant-a/docs-v2 into GPU cache 2Loading adapter tenant-b/incident-v7 into GPU cache 3Evicting adapter tenant-a/docs-v2 4Loading adapter tenant-c/code-review-v1 into GPU cache 5tenant=tenant-c adapter=tenant-c/code-review-v1 prompt_tokens=5 6hot adapters: ['tenant-b/incident-v7', 'tenant-c/code-review-v1'] 7blocked: adapter is not authorized for tenant

Adapters are much smaller than full base weights, but loading one from object storage into GPU memory is still measurable. If Tenant A and Tenant B alternate on every request, the adapter cache thrashes and latency spikes. Profile your actual adapter reuse patterns before assuming LRU is sufficient. Some platforms pin high-tier adapters permanently and only evict best-effort ones.

Why can an adapter cache become a latency problem?

Answer

If hot tenants alternate and the GPU adapter cache is too small, every request triggers an adapter load or eviction. Pin high-service-level-agreement (SLA) adapters or schedule with adapter locality to avoid cache thrash.

How we keep conversations separate: KV cache isolation

Every token the model generates relies on the KV cache, which stores intermediate key and value vectors from earlier tokens. Without it, autoregressive decoding repeats work. In a multi-tenant system, KV allocation is also sensitive state: the runtime must not attach Tenant A's live blocks or cache entries to Tenant B's request.

The memory cost in concrete numbers

KV-cache memory per request grows with context length. For a decoder using fixed-width KV heads, a useful estimate is:

KV memory per request=2×nlayers×nkv_heads×dhead×seq_len×dtype_bytes\begin{aligned} \text{KV memory per request} = &2 \times n_{\text{layers}} \times n_{\text{kv\_heads}} \times d_{\text{head}} \\ &\times \text{seq\_len} \times \text{dtype\_bytes} \end{aligned}KV memory per request=​2×nlayers​×nkv_heads​×dhead​×seq_len×dtype_bytes​

The factor of 2 counts keys and values. The other terms are model depth, KV heads, head dimension, request context length, and bytes per element. Grouped-query attention (GQA) stores fewer KV heads than attention heads, which is why the n_kv_heads term matters.

Before you memorize the symbols, calculate a concrete scenario. Suppose a model has 80 layers, 8 KV heads, head dimension 128, a 4,000-token context, and FP16 (2 bytes per element):

2×80×8×128×4,000×2=1,310,720,000 bytes≈1.22 GiB per request2 \times 80 \times 8 \times 128 \times 4{,}000 \times 2 = 1{,}310{,}720{,}000 \text{ bytes} \approx 1.22 \text{ GiB per request}2×80×8×128×4,000×2=1,310,720,000 bytes≈1.22 GiB per request

If the same model family used 16 KV heads instead of 8, that doubles to about 2.44 GiB. At high concurrency, this memory can become the admission constraint before compute throughput does.

kv-cache-budget.py
1def kv_gib(layers: int, kv_heads: int, head_dim: int, tokens: int, dtype_bytes: int = 2) -> float: 2 bytes_used = 2 * layers * kv_heads * head_dim * tokens * dtype_bytes 3 return bytes_used / (1024 ** 3) 4 5request_gib = kv_gib(layers=80, kv_heads=8, head_dim=128, tokens=4_000) 6double_heads_gib = kv_gib(layers=80, kv_heads=16, head_dim=128, tokens=4_000) 7 8assert round(request_gib, 2) == 1.22 9assert round(double_heads_gib, 2) == 2.44 10print("eight_kv_heads_gib:", round(request_gib, 2)) 11print("sixteen_kv_heads_gib:", round(double_heads_gib, 2))
Output
1eight_kv_heads_gib: 1.22 2sixteen_kv_heads_gib: 2.44

Why is KV cache both a capacity problem and a privacy boundary?

Answer

It grows with active context and can dominate VRAM. It also stores conversation-derived state, so allocation or cache-namespace bugs can violate isolation.

PagedAttention: paging for GPUs

PagedAttention (introduced in the vLLM paper[7]Reference 7Efficient Memory Management for Large Language Model Serving with PagedAttentionhttps://arxiv.org/abs/2309.06180) treats KV storage in blocks rather than reserving one long contiguous chunk for each request. A block table maps each request's logical sequence to physical blocks. The example figure uses 16-token blocks to make the mapping visible; production block sizes are runtime configuration and performance choices.

In that illustration, Tenant A's 47-token conversation occupies three physical blocks and Tenant B's 31-token conversation occupies two. PagedAttention improves memory allocation efficiency; it doesn't by itself enforce tenant authorization. The serving layer must associate request ownership with block tables, invalidate released references, and implement any required clearing policy before reallocation.

PagedAttention KV-cache trace where logical tenant sequences map through block tables into reused physical KV pages, while tenant-scoped access rules and page release policy stay outside the memory-packing mechanism.
PagedAttention packs KV blocks efficiently, but tenant-scoped table access and released-page handling still belong to platform policy.

HBM is the hottest KV tier, not the only possible tier

Local GPU HBM gives active decode steps their lowest transfer latency, but keeping every reusable or paused KV block there limits concurrency. A tiered design can keep active blocks in HBM, spill colder blocks to CPU DRAM, and place reusable KV in a remote cache service. Mooncake's disaggregated architecture uses CPU, DRAM, SSD, and RDMA-capable network resources to move and reuse KV cache independently of one inference worker.[8]Reference 8Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving.https://arxiv.org/abs/2407.00079

Disaggregation changes the bottleneck rather than deleting it:

PlacementUseful forCost paid
GPU HBMActive decode and latency-critical prefixesScarce accelerator memory
Local CPU DRAMPaused sequences or overflow near one workerHost-device transfer latency and bandwidth
Remote KV over an RDMA-capable fabricReuse across workers, prefill/decode separation, larger aggregate cacheNetwork transfer, cache lookup, replication, and failure handling

The scheduler should estimate whether the transfer time is cheaper than recomputing the prefix. A remote hit can be a loss for a short prompt or congested link. A token count alone is insufficient; admission needs byte-level KV budgets, measured transfer bandwidth, and a deadline check.

Tenant isolation follows the blocks off GPU. Remote keys must include the trust scope plus model, adapter, tokenizer, and KV layout version. The cache service must authorize reads before returning block metadata, encrypt traffic as required by the threat model, and invalidate ownership when a request or tenant is deleted. RDMA reduces CPU involvement in data movement; it doesn't provide application-level authorization by itself.

When should the scheduler restore remote KV instead of recomputing a prefix?

Answer

The scheduler should restore it only when the predicted lookup and transfer time fits the request deadline and is lower than the recomputation cost. Include network congestion, prefix size, compatible model state, and tenant authorization in the decision.

Prefix caching and the cross-tenant leak risk

Multi-tenant traffic often repeats the same system prompt, tool schema, or long retrieved prefix. Runtimes can cache those KV blocks and skip recomputing the shared prefix on later requests. SGLang introduced RadixAttention for this pattern, while vLLM's automatic prefix caching uses hashed KV blocks rather than a radix tree.[9]Reference 9SGLang: Efficient Execution of Structured Language Model Programshttps://arxiv.org/abs/2312.07104[10]Reference 10Automatic Prefix Cachinghttps://docs.vllm.ai/en/latest/features/automatic_prefix_caching/

Prefix caching mainly lowers TTFT (Time to First Token) because it eliminates repeated prefill work. It doesn't make decode itself cheaper.

The critical rule is isolation. Reuse prefixes only inside an authorized cache namespace, such as one tenant or an explicitly public shared prompt. vLLM's prefix-caching design docs describe an optional cache salt intended to isolate cache reuse across trust groups and mitigate timing-based probing; platform routing must also keep adapter and model compatibility consistent.[10]Reference 10Automatic Prefix Cachinghttps://docs.vllm.ai/en/latest/features/automatic_prefix_caching/

Enabling private-prefix reuse without a tenant or trust-group namespace is an isolation bug first, even if the original change was meant to improve performance. Scope private cache reuse by tenant or authorized trust group and compatible model, tokenizer, and adapter configuration.

What must be included in a safe prefix-cache namespace?

Answer

Tenant or explicit shared trust-group identity plus compatible base model, adapter, and tokenizer configuration. Public shared prompts need a deliberate shared namespace, not accidental reuse.

tenant-scoped-prefix-cache.py
1def cache_key(trust_group: str, model: str, adapter: str, tokenizer: str, prefix: str) -> tuple[str, ...]: 2 return trust_group, model, adapter, tokenizer, prefix 3 4prompt = "You are the internal docs assistant." 5cache = { 6 cache_key("tenant:tenant-a", "base-v3", "docs-v2", "tok-v3", prompt): "kv-7" 7} 8 9same_tenant = cache_key("tenant:tenant-a", "base-v3", "docs-v2", "tok-v3", prompt) 10other_tenant = cache_key("tenant:tenant-b", "base-v3", "docs-v2", "tok-v3", prompt) 11 12assert cache.get(same_tenant) == "kv-7" 13assert cache.get(other_tenant) is None 14print("authorized_hit:", same_tenant in cache) 15print("cross_tenant_hit:", other_tenant in cache)
Output
1authorized_hit: True 2cross_tenant_hit: False

Chunked prefill for multi-tenant fairness

The prefill phase processes the input prompt to build the initial KV cache. It's compute-intensive but doesn't generate tokens. The decode phase generates output tokens one at a time. It's memory-intensive but uses less compute.

Without scheduling controls, a long prefill from one tenant can delay decode operations from others. Chunked prefill (studied in Sarathi-Serve[11]Reference 11Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.https://arxiv.org/abs/2308.16369) breaks long prompts into bounded chunks so decode work from other requests can be scheduled between prefill chunks.

The chunk size is a tuning knob, not a fixed constant. It's controlled by the runtime's per-step token budget (max_num_batched_tokens in vLLM). Smaller budgets lower inter-token latency for in-flight decodes; larger budgets improve TTFT and prefill throughput. Current vLLM docs show chunked prefill enabled whenever possible in V1, example low-latency settings like 2,048 tokens, and throughput-oriented settings above 8,192 tokens, so tune it to your latency target instead of copying one number blindly.[11]Reference 11Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.https://arxiv.org/abs/2308.16369[12]Reference 12Optimization and Tuning.https://docs.vllm.ai/en/latest/configuration/optimization.html

In multi-tenant settings, chunked prefill is one useful noisy neighbor control. It still needs admission limits and a tenant-aware scheduler; chunking alone doesn't guarantee fair service.

Why does chunked prefill protect other tenants from a long prompt?

Answer

It breaks a long compute-heavy prefill into smaller slices that can interleave with decode work from other tenants, preventing one 32K prompt from blocking the whole batch.

Tenant-aware preemption

When admitted work approaches its KV budget, the scheduler may reject, queue, or preempt requests according to the published service policy. A priority tier can permit an enterprise request to displace best-effort work, but the choice must be metered and observable rather than hidden.

Current vLLM V1 docs default to RECOMPUTE rather than SWAP because recomputation has lower overhead in that architecture. A platform should still choose a recovery mode based on runtime support and measured cost.[12]Reference 12Optimization and Tuning.https://docs.vllm.ai/en/latest/configuration/optimization.html

The conceptual scheduler below demonstrates the decision logic. It sorts running requests by priority, then by KV footprint, then by tokens already generated. If a new request has higher priority than the lowest-priority running request, the scheduler preempts the victim and schedules the newcomer:

tenant-aware-preemption.py
1from dataclasses import dataclass 2from typing import Protocol 3 4class GPUAllocator(Protocol): 5 def get_num_free_blocks(self) -> int: ... 6 7@dataclass 8class Tenant: 9 name: str 10 priority: int # Larger number = higher priority 11 12@dataclass 13class Request: 14 tenant: Tenant 15 estimated_kv_memory: int 16 tokens_generated: int 17 can_recompute: bool = True 18 19class TenantAwareScheduler: 20 def __init__(self, gpu_allocator: GPUAllocator, block_size_mb: int): 21 self.running_requests: list[Request] = [] 22 self.gpu_allocator = gpu_allocator 23 self.block_size_mb = block_size_mb 24 25 def available_kv_memory(self) -> int: 26 return self.gpu_allocator.get_num_free_blocks() * self.block_size_mb 27 28 def evict_for_recompute(self, request: Request) -> None: 29 print( 30 f"Evicting KV cache for tenant={request.tenant.name} " 31 f"priority={request.tenant.priority}; " 32 "the request will be recomputed if resumed." 33 ) 34 35 def swap_to_cpu(self, request: Request) -> None: 36 print( 37 f"Swapping KV cache for tenant={request.tenant.name} " 38 f"priority={request.tenant.priority} " 39 "to host memory." 40 ) 41 42 def schedule(self, request: Request) -> None: 43 self.running_requests.append(request) 44 print( 45 f"Scheduling request tenant={request.tenant.name} " 46 f"priority={request.tenant.priority}." 47 ) 48 49 def preempt(self, request: Request) -> None: 50 if request.can_recompute: 51 self.evict_for_recompute(request) 52 else: 53 self.swap_to_cpu(request) 54 55 def preempt_if_needed(self, new_request: Request) -> None: 56 free_memory_mb = self.available_kv_memory() 57 if free_memory_mb >= new_request.estimated_kv_memory: 58 self.schedule(new_request) 59 return 60 61 candidates = sorted( 62 self.running_requests, 63 key=lambda r: ( 64 r.tenant.priority, # Lowest priority first 65 -r.estimated_kv_memory, # Free the biggest KV footprint first 66 r.tokens_generated, # Prefer to kill work that has done less decode 67 ), 68 ) 69 70 victims = [] 71 for candidate in candidates: 72 if new_request.tenant.priority <= candidate.tenant.priority: 73 continue 74 victims.append(candidate) 75 free_memory_mb += candidate.estimated_kv_memory 76 if free_memory_mb >= new_request.estimated_kv_memory: 77 break 78 79 if free_memory_mb < new_request.estimated_kv_memory: 80 print("Cannot free enough KV memory without preempting higher or equal priority requests.") 81 return 82 83 for victim in victims: 84 self.preempt(victim) 85 self.running_requests.remove(victim) 86 self.schedule(new_request) 87 88class FakeAllocator: 89 def __init__(self, free_blocks: int): 90 self.free_blocks = free_blocks 91 92 def get_num_free_blocks(self) -> int: 93 return self.free_blocks 94 95scheduler = TenantAwareScheduler(FakeAllocator(free_blocks=4), block_size_mb=16) 96scheduler.running_requests = [ 97 Request(Tenant("starter", priority=1), estimated_kv_memory=96, tokens_generated=8), 98 Request(Tenant("business", priority=2), estimated_kv_memory=80, tokens_generated=120), 99] 100 101incoming = Request(Tenant("enterprise", priority=4), estimated_kv_memory=80, tokens_generated=0) 102scheduler.preempt_if_needed(incoming) 103print("running tenants:", [request.tenant.name for request in scheduler.running_requests])
Output
1Evicting KV cache for tenant=starter priority=1; the request will be recomputed if resumed. 2Scheduling request tenant=enterprise priority=4. 3running tenants: ['business', 'enterprise']

The miniature loop accounts for released KV memory before admitting the newcomer. A production scheduler must make release and allocation atomic so concurrent scheduling decisions can't over-admit work.

What should a tenant-aware preemption policy optimize for?

Answer

Free enough KV memory while minimizing service-objective impact. Prefer lower-priority tenants, large KV footprints, and requests with less generated work, then recompute or swap depending on runtime support.

Hard per-tenant limits

Scheduling alone isn't enough. The platform also enforces hard quotas on context sizes and concurrent requests based on the tenant's tier. The illustration below summarizes the isolation spectrum from shared pools to stronger runtime boundaries:

Tenant isolation ladder from shared pool to namespace isolation to dedicated runtime boundary, trading cost for lower noisy-neighbor risk.
Isolation is a cost-vs-risk dial: shared pools are cheapest, and dedicated runtime boundaries remove more shared paths for sensitive workloads.

Example admission policy (numbers are scenario inputs, not universal tiers):

Tenant TierMax Concurrent RequestsMax Context LengthKV Cache Budget
Enterprise508K256 GB
Business204K80 GB
Starter52K20 GB

Derive each budget from max_concurrent × max_context × bytes_per_token, then add headroom for fragmentation, multi-node pools, and peak admission. Using the earlier ~1.22 GiB @ 4K-token estimate (~0.000305 GiB per token for that model shape):

  • Enterprise peak: 50×8000×0.000305≈12250 \times 8000 \times 0.000305 \approx 12250×8000×0.000305≈122 GiB raw KV; the 256 GB row is roughly 2× that for HBM packing waste, multi-worker share, and burst.
  • Business peak: 20×4000×0.000305≈2420 \times 4000 \times 0.000305 \approx 2420×4000×0.000305≈24 GiB raw; 80 GB is a multi-replica pool budget, not a single-request cap.
  • Starter peak: 5×2000×0.000305≈35 \times 2000 \times 0.000305 \approx 35×2000×0.000305≈3 GiB raw; 20 GB keeps small tenants from thrashing under noisy neighbors while still hard-capping spend.

Publish the formula and the measured bytes/token for your model; don't treat the table numbers as industry standards.

Apply those limits before scheduling GPU work:

admit-under-tenant-kv-budget.py
1TIERS = { 2 "enterprise": {"max_concurrent": 50, "max_context": 8_000, "kv_gib": 256.0}, 3 "starter": {"max_concurrent": 5, "max_context": 2_000, "kv_gib": 20.0}, 4} 5 6def admit(tier: str, active_requests: int, context_tokens: int, projected_kv_gib: float) -> str: 7 policy = TIERS[tier] 8 if active_requests >= policy["max_concurrent"]: 9 return "REJECT_CONCURRENCY_LIMIT" 10 if context_tokens > policy["max_context"]: 11 return "REJECT_CONTEXT_LIMIT" 12 if projected_kv_gib > policy["kv_gib"]: 13 return "REJECT_KV_BUDGET" 14 return "ADMIT" 15 16assert admit("starter", 5, 1_000, 3.0) == "REJECT_CONCURRENCY_LIMIT" 17assert admit("starter", 2, 2_400, 3.0) == "REJECT_CONTEXT_LIMIT" 18assert admit("starter", 2, 1_900, 22.0) == "REJECT_KV_BUDGET" 19assert admit("enterprise", 12, 7_500, 180.0) == "ADMIT" 20print("starter_at_capacity:", admit("starter", 5, 1_000, 3.0)) 21print("starter_long_prompt:", admit("starter", 2, 2_400, 3.0)) 22print("enterprise_request:", admit("enterprise", 12, 7_500, 180.0))
Output
1starter_at_capacity: REJECT_CONCURRENCY_LIMIT 2starter_long_prompt: REJECT_CONTEXT_LIMIT 3enterprise_request: ADMIT

Why are hard KV limits necessary even with fair scheduling?

Answer

Schedulers arbitrate admitted work, but a single oversized prompt can allocate too much memory before fairness helps. Hard limits reject or downsize requests before they consume shared KV blocks.

How we prevent one tenant from overwhelming the rest: rate limiting and fair queues

Rate limiting sits at the gateway, before a request ever reaches the GPU. It enforces two distinct budgets:

  • Requests per minute (RPM): Controls burst traffic to protect the API gateway from connection exhaustion.
  • Tokens per minute (TPM): Controls sustained throughput to protect GPU compute capacity.

The difference matters. A tenant sending one request with a 64K prompt consumes far more GPU time than a tenant sending one hundred requests with 100-token prompts, even though the first tenant uses fewer requests. RPM alone would let the 64K prompt through and monopolize the KV cache.

Distributed sliding-window enforcement

For RPM, a distributed sliding-window limiter using Redis with a Lua script gives consistent enforcement across all gateway nodes. A local in-memory limiter isn't enough because requests are load-balanced across many gateway instances.

The Lua script below removes entries older than the window, counts the remaining requests, and either allows the new request or rejects it. The key detail is using a unique sorted-set member (a request ID with a timestamp) instead of the raw timestamp alone. If two requests land in the same clock tick and you use the timestamp as both score and member, Redis collapses them into one entry and undercounts traffic:

distributed-sliding-window-enforcement.lua
1-- Redis Lua Script for RPM Sliding-Window Limiting 2local key = KEYS[1] 3local limit = tonumber(ARGV[1]) 4local window_ms = tonumber(ARGV[2]) -- e.g., 60_000 5local now_ms = tonumber(ARGV[3]) 6local member = ARGV[4] -- unique request id, e.g. "1713468123456:req-9f3c" 7 8-- Remove timestamped entries older than the window 9redis.call('ZREMRANGEBYSCORE', key, 0, now_ms - window_ms) 10 11-- Count current requests 12local count = redis.call('ZCARD', key) 13 14if count < limit then 15 redis.call('ZADD', key, now_ms, member) 16 redis.call('PEXPIRE', key, window_ms) 17 return 1 -- Allowed 18else 19 return 0 -- Rejected 20end

TPM is trickier because you don't know the final output length at admission time. In practice, reserve a budget based on prompt tokens plus max_output_tokens, then reconcile the counter with actual usage when the stream finishes.

For high-throughput services, strictly synchronized Redis limits can become a bottleneck. A platform may choose bounded burst allowance or approximate local counters, but that weakens strict limit semantics and must be documented and measured.

Token admission needs reservation and reconciliation. Reserve prompt plus maximum allowed output before execution, then release unused output capacity after the stream completes:

reserve-and-reconcile-token-budget.py
1class TokenBudget: 2 def __init__(self, remaining: int): 3 self.remaining = remaining 4 5 def reserve(self, prompt_tokens: int, max_output_tokens: int) -> int: 6 reservation = prompt_tokens + max_output_tokens 7 if reservation > self.remaining: 8 raise ValueError("TPM budget exceeded") 9 self.remaining -= reservation 10 return reservation 11 12 def reconcile(self, reservation: int, prompt_tokens: int, output_tokens: int) -> None: 13 self.remaining += reservation - (prompt_tokens + output_tokens) 14 15budget = TokenBudget(remaining=1_000) 16held = budget.reserve(prompt_tokens=300, max_output_tokens=400) 17budget.reconcile(held, prompt_tokens=300, output_tokens=120) 18 19assert budget.remaining == 580 20print("tokens_remaining_after_actual_usage:", budget.remaining)
Output
1tokens_remaining_after_actual_usage: 580

Fairness inside the scheduler

RPM and TPM protect the gateway edge, but they don't fully solve scheduler fairness inside the serving engine. A tenant with one 64K prompt can consume far more GPU time than dozens of tenants sending short chat turns.

Inside the runtime, keep per-tenant queues and charge a virtual token budget for every admitted prefill chunk and every decode step. Then schedule by priority tier plus virtual finish time, not raw request count. That gives each tenant forward progress while still letting higher-SLA traffic buy more share.

Don't collapse rate limiting and quota management into one counter. Rate limits prevent burst and protect the system, while quotas cap total usage and protect the budget. A tenant can stay under their RPM limit and still burn through their monthly token quota in one afternoon.

Why is RPM not enough for LLM rate limiting?

Answer

One request can contain a huge prompt and request many output tokens. TPM and KV budgets protect GPU work and memory, while RPM mostly protects the gateway from bursty connection pressure.

How we keep data private: the isolation stack

Every state-bearing layer needs an authorization boundary and a testable release policy. A cross-tenant retrieval, adapter, cache, or KV access is a security incident even if the other layers behaved correctly.

The RAG relevance vs. authorization gap

Many multi-tenant platforms augment LLMs with retrieval-augmented generation (RAG). A vector database finds the most relevant documents for a query. In multi-tenancy, "relevant" doesn't mean "allowed."

Tenant X searches for "incident-retention exception policy." The vector DB might find a highly relevant private postmortem that belongs to Tenant Y, because both teams use the same reliability vocabulary. Without a hard filter, the LLM could summarize Tenant Y's confidential incident details and return them to Tenant X.

Use authorization filtering in the retrieval operation itself. An application should pass authorized scope into the database query, and tests should fail if another tenant's result can cross that boundary:

tenant-filtered-retrieval.py
1documents = [ 2 {"tenant": "tenant-a", "text": "Retention exception policy A", "score": 0.88}, 3 {"tenant": "tenant-b", "text": "Private postmortem details B", "score": 0.99}, 4] 5 6def authorized_search(tenant: str, top_k: int) -> list[str]: 7 allowed = [doc for doc in documents if doc["tenant"] == tenant] 8 ranked = sorted(allowed, key=lambda doc: doc["score"], reverse=True) 9 return [doc["text"] for doc in ranked[:top_k]] 10 11results = authorized_search("tenant-a", top_k=1) 12assert results == ["Retention exception policy A"] 13assert all("postmortem details B" not in text for text in results) 14print("authorized_results:", results)
Output
1authorized_results: ['Retention exception policy A']

The authorization predicate is applied before candidate results leave the data layer. Post-filtering after a broad top-K query can return foreign identifiers, scores, or content to application memory and may also erase every authorized candidate.

Why must tenant filtering happen inside the vector database query?

Answer

Post-filtering can retrieve foreign document IDs, scores, or embeddings before dropping them. Authorization must be part of the retrieval predicate so disallowed evidence is never returned to the application.

Model and prompt isolation

  • LoRA adapter isolation. Authorize adapter IDs against the tenant before loading encrypted artifacts. If adapters contain sensitive tenant tuning, include residency and release rules in the data contract.
  • Prompt isolation. Keep raw prompts out of plaintext logs and authenticate/encrypt gateway-to-worker transport. The exact mTLS (mutual Transport Layer Security) and memory-boundary policy depends on the deployment threat model.
  • Harder runtime boundaries. For workloads whose threat model rules out shared workers, use dedicated node pools, MIG (Multi-Instance GPU) partitions, or VM boundaries as evaluated controls.[13]Reference 13Supported GPUshttps://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-gpus.html Kubernetes placement selects hardware; it doesn't create isolation by itself.

State sanitization

When a shared worker serves more than one tenant, state lifecycle rules matter:

  • KV reference lifecycle. Remove request access to released KV blocks when a sequence completes. If the threat model requires cleared memory before cross-tenant reuse, implement and verify that clearing policy rather than assuming the allocator provides it.
  • Batch construction policy. Multi-tenant batching is fine on a shared base model, but every row in the batch must keep its own tenant ID, adapter handle, KV block table, and metering context. For regulated workloads, the simpler answer is dedicated pools or MIG / VM boundaries instead of trying to harden every shared-kernel path.

PII masking as a tiered control

For regulated workloads that can tolerate redaction, requests can pass through a lightweight PII masking service (e.g., Presidio[14]Reference 14Presidio: Data Protection and De-identification SDK.https://github.com/microsoft/presidio) before they hit the model router. This reduces the chance that the LLM ever sees raw credit card numbers or Social Security numbers, while still letting downstream systems map placeholders back to original values when needed.

Tenant A request trace where auth, PII masking, tenant-filtered retrieval, and GPU worker state stay on one identity thread, while sensitive values are replaced with placeholders and runtime state remains isolated per tenant.
Masking lowers exposure, but tenant identity still has to survive retrieval, routing, adapter choice, and KV ownership.

When is PII masking helpful, and when is it insufficient?

Answer

It reduces exposure for data that can be safely replaced with placeholders. It's insufficient for regulated tenants that need hard hardware or VM isolation, strict audit controls, or full-fidelity private context.

How we attribute cost: per-tenant metering and chargeback

A shared fleet only stays profitable if you can answer one question per tenant: what did this workspace cost us, and what should we charge? Token counts alone are a weak proxy because two requests with the same token counts can consume very different GPU time depending on prompt-vs-output split, batch occupancy, preemptions, and cache hits.

A defensible metering record attaches to every request and carries: tenant_id, model and adapter version, prompt tokens, output tokens, cache-hit tokens, queue wait, prefill time, decode time, KV blocks held, preemption count, and GPU worker type. Cache-hit tokens matter because reused prefix blocks skip prefill compute. A public billing policy may choose a distinct cached-input rate, as current provider pricing documents illustrate.[15]Reference 15Prompt cachinghttps://developers.openai.com/api/docs/guides/prompt-caching[16]Reference 16Prompt caching.https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching

For internal cost allocation rather than customer billing, the honest unit is GPU-time, not tokens. A reasonable per-request cost estimate looks like:

costreq≈gpu_seconds×node_hourly_rate/3600+adapter_residency+storage\text{cost}_{\text{req}} \approx \text{gpu\_seconds} \times \text{node\_hourly\_rate} / 3600 + \text{adapter\_residency} + \text{storage}costreq​≈gpu_seconds×node_hourly_rate/3600+adapter_residency+storage

where gpu_seconds is the request's share of busy GPU time (prefill plus its decode steps, divided by batch occupancy so shared steps are split across co-batched tenants). Charge customers on the simpler token-and-tier dimensions, but reconcile against measured GPU-time so you can spot tenants whose traffic shape (long prompts, low batchability, adapter thrash) costs far more than their token bill suggests.

meter-shared-gpu-cost.py
1records = [ 2 {"tenant": "tenant-a", "gpu_seconds": 0.40, "cache_hit_tokens": 800}, 3 {"tenant": "tenant-b", "gpu_seconds": 1.25, "cache_hit_tokens": 0}, 4] 5NODE_HOURLY_RATE = 8.00 6 7def compute_dollars(record: dict[str, float]) -> float: 8 return record["gpu_seconds"] * NODE_HOURLY_RATE / 3600 9 10costs = {record["tenant"]: compute_dollars(record) for record in records} 11assert costs["tenant-b"] > costs["tenant-a"] 12print("metered_gpu_cost_usd:", {tenant: round(value, 6) for tenant, value in costs.items()})
Output
1metered_gpu_cost_usd: {'tenant-a': 0.000889, 'tenant-b': 0.002778}

Why is per-tenant token count not enough for cost attribution?

Answer

Equal token counts can map to very different GPU time depending on prompt/output split, batch occupancy, preemptions, and cache hits. Attribute internal cost in GPU-seconds and discount cache-hit tokens, then map that to a token-and-tier price.

How the system grows: scaling, canary, and fault tolerance

A production platform should handle spikes, deploy new models with release gates, and degrade predictably during failures.

Auto-scaling on queue depth

CPU utilization alone doesn't describe LLM serving pressure. Queue depth, admitted-token backlog, inference latency, and KV-cache utilization provide signals for capacity and admission decisions. When new workers can't become ready in time, shed or downgrade eligible best-effort work instead of silently violating every tenant's objective.

Cold-starting GPU workers includes making model weights available before the pool can serve requests. Depending on cost and latency objectives, a platform may keep warm capacity, forecast predictable demand, or reject low-priority excess load while workers start.

Worked example: steady demand needs 12 replicas at 4K decode tok/s each to hold target TTFT. Measured cold-start (pull weights + warm CUDA graphs) is 8 minutes, and traffic can ramp 30% in under 10 minutes at the top of a business day. Keep a warm buffer of about 0.3×12≈40.3 \times 12 \approx 40.3×12≈4 ready replicas (or scale on forecast before the ramp), and autoscale when p95 queue age exceeds, say, 200 ms for two consecutive windows instead of relying on average CPU alone. If the buffer is exhausted, shed starter-tier traffic before enterprise SLOs fail.

Model versioning and canary rollouts

Models and LoRA adapters require versioned rollout because a new artifact can degrade generation quality or latency.

In one canary rollout policy, when a tenant deploys a new adapter version (for example, v2), the router sends a controlled slice of eligible traffic to it while the rest continues on v1. Monitor quality evaluation, latency, error rates, and safety signals over a predefined observation window.

If gates pass, the router can increase exposure. If a gate fails, route new eligible requests back to v1; active streams and adapter residency still need explicit handling, so a routing change isn't a blanket zero-downtime promise.

For base model updates, the process is more complex. Unlike lightweight adapters, base models require spinning up entirely new GPU worker pools. The platform routes shadow traffic (duplicate asynchronous requests) to the new base model cluster to validate correctness and measure throughput before exposing it to real tenant traffic. Once validated, the gateway shifts live traffic to the new cluster and gracefully drains the old one.

Why are adapter canaries easier than base-model canaries?

Answer

Adapters are small and can usually be routed per tenant on an existing base model. Base-model changes require new worker pools, separate KV caches, shadow traffic, throughput validation, and drain logic.

Fault tolerance

System reliability relies on handling GPU failures and model crashes gracefully:

StrategyTrigger ConditionAction TakenArchitectural Impact
Dead Letter Queues (DLQ)Repeated CUDA out-of-memory (OOM) or crashesMove a repeatedly failing request to DLQ after a bounded retry countStops that request from causing an unbounded retry loop
Circuit BreakingModel/adapter error rate crosses a configured thresholdFast-fail new requests for that specific adapterLimits repeated work while operators investigate
Active Health ChecksMissed node heartbeats (e.g., stuck kernel)Mark unhealthy, stop new routing, drain or fail in-flight work according to policyRemoves a suspected worker from new admission
Zone RedundancyEntire Availability Zone failureShift eligible traffic to healthy zones, subject to spare capacityReduces zone-failure impact when capacity is available

These mechanisms need a control plane or equivalent coordination layer to track worker readiness, orchestrate model deployments, and update routing. Readiness checks reduce routing to unavailable workers; they don't prove model quality or prevent every runtime failure.

Why does a poison request need a dead-letter queue?

Answer

Repeated CUDA OOMs or malformed inputs can trigger retry storms and consume shared capacity. A DLQ removes the repeatedly failing request after bounded retries so it no longer consumes retries.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.A 72-billion-parameter FP16 model uses 2 bytes per parameter. What follows when serving 100 tenants on GPUs with 80 GB of memory each?

Correct answer: 144 GB per copy and 14.4 TB total; share weights but isolate tenant state.

The weights require 72 billion times 2 bytes, or about 144 GB. One hundred copies require 14.4 TB, and one copy already exceeds 80 GB before KV cache and serving overhead. Sharing weights saves memory, but prompts, adapters, KV state, retrieval, and billing remain tenant-scoped.

2.Using 2 * layers * kv_heads * head_dim * tokens * dtype_bytes, what KV memory is required for 80 layers, 8 KV heads, dimension 128, 4,000 tokens, and FP16? What if KV heads double?

Correct answer: About 1.22 GiB, then 2.44 GiB because KV heads scale memory.

The leading factor of 2 counts keys and values. Every term multiplies directly, so the initial request uses about 1.22 GiB and doubling only the KV-head count doubles the footprint to about 2.44 GiB.

3.A continuous batcher stays full, but one starter tenant occupies most decode slots and KV memory while enterprise requests miss latency targets. What policy addresses this?

Correct answer: Use tenant queues, virtual-token charging, hard KV caps, and priorities.

Continuous batching improves utilization by replacing completed requests, but it doesn't create fairness. Tenant queues and virtual-token accounting allocate progress, hard KV caps constrain memory, and published priorities determine when higher-tier work may preempt or outrank other traffic.

4.A two-entry LRU adapter cache receives authorized requests for A/docs, B/incident, and C/code-review, followed by unauthorized A/code-review. What should happen?

Correct answer: Load C and evict A's adapter, then reject A's code-review request.

After A and B fill the cache, loading C evicts the least recently used entry, A. The later request is rejected independently of residency because A is not authorized for C's code-review adapter. Cache presence never grants tenant permission.

5.A tenant has 1,000 TPM tokens left. A request has a 300-token prompt, reserves 400 output tokens, and emits 120. What remains, and why is RPM insufficient?

Correct answer: 580 tokens; RPM counts requests and misses token-heavy work.

Admission reserves 700 tokens, leaving 300. Actual usage is 420 tokens, so the unused 280-token output allowance is returned and 580 remain. RPM only counts requests, so it can't protect GPU work or KV pressure from a small number of token-heavy requests.

6.A global vector search exposes foreign candidates before post-filtering, and a private prefix cache is keyed only by text. Which change closes both leak paths?

Correct answer: Filter in the vector query and key cache entries by trust and model config.

Retrieval authorization must be part of the database predicate so foreign identifiers, scores, or content never reach the application. Private prefix reuse must also include the authorized tenant or trust group and compatible model, adapter, and tokenizer configuration.

7.Internal cost is gpu_seconds * hourly_rate / 3600. At $8 per hour, tenant A uses 0.40 GPU-seconds and tenant B uses 1.25. What are their costs?

Correct answer: A costs about 0.000889;Bcostsabout0.000889; B costs about 0.000889;Bcostsabout0.002778.

Divide the 8hourlyrateby3,600,thenmultiplybyeachmeasuredGPU−timeshare.Thisgivesabout8 hourly rate by 3,600, then multiply by each measured GPU-time share. This gives about 8hourlyrateby3,600,thenmultiplybyeachmeasuredGPU−timeshare.Thisgivesabout0.000889 for A and $0.002778 for B. GPU-seconds expose traffic-shape costs that equal token totals can hide.

8.A 32K-token prompt is delaying token generation for active short requests. Which change lets the long prompt continue while protecting inter-token latency?

Correct answer: Split prefill into bounded chunks and interleave decode between chunks.

Prefill is compute-heavy and can block active decodes. Chunking the prompt creates scheduling boundaries where decode work can run. Smaller token budgets favor decode latency, while larger budgets favor prefill throughput and TTFT, so the value must be tuned.

9.Ordinary tenants share batches, but one regulated tenant forbids shared workers. Which policy fits both cases?

Correct answer: Keep row-scoped state, and use a dedicated boundary when sharing is barred.

Each shared-batch row must retain its tenant ID, adapter handle, KV block table, and metering context. If the threat model rules out shared workers, the tenant needs an evaluated stronger boundary such as a dedicated pool, VM, or suitable MIG partition. Placement and PII masking alone don't create that boundary.

10.A paused long-context request has 6 GiB of KV in a remote cache. The scheduler predicts a 90 ms authorized transfer over the current fabric and a 35 ms prefix recomputation. Which action is appropriate?

Correct answer: Recompute the prefix, because remote KV is useful only when lookup and transfer beat recomputation within the request deadline.

Disaggregation expands aggregate KV capacity but adds lookup, network, and host-device transfer costs. The scheduler should compare the measured transfer time with recomputation and the deadline. Trust scope and model-layout compatibility must also pass before any remote blocks are returned.

10 questions remaining.

Next Step
Continue to LLM-Powered Search Engine

You'll design a search and answer system that combines retrieval, ranking, synthesis, and citation, while applying the tenant-scoped retrieval and context controls introduced here.

PreviousCode Completion System
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

H100 GPU

NVIDIA · 2026

https://www.nvidia.com/en-us/data-center/h100/

Orca: A Distributed Serving System for Transformer-Based Generative Models.

Yu, G.-I., et al. · 2022 · OSDI 2022

https://www.usenix.org/conference/osdi22/presentation/yu

LoRA: Low-Rank Adaptation of Large Language Models.

Hu, E. J., et al. · 2021 · ICLR

https://arxiv.org/abs/2106.09685

S-LoRA: Serving Thousands of Concurrent LoRA Adapters.

Sheng, Y., et al. · 2023 · arXiv preprint

https://arxiv.org/abs/2311.03285

LoRA Adapters

vLLM · 2026

https://docs.vllm.ai/en/stable/features/lora/

Punica: Multi-Tenant LoRA Serving

Chen, L., Ye, Z., Wu, Y., Zhuo, D., Ceze, L., & Krishnamurthy, A. · 2023

https://arxiv.org/abs/2310.18547

Efficient Memory Management for Large Language Model Serving with PagedAttention

Kwon, W., et al. · 2023 · SOSP 2023

https://arxiv.org/abs/2309.06180

Mooncake: A KVCache-centric Disaggregated Architecture for LLM Serving.

Qin, Y., et al. · 2024

https://arxiv.org/abs/2407.00079

SGLang: Efficient Execution of Structured Language Model Programs

Zheng, L., Yin, L., Xie, Z., et al. · 2023 · arXiv:2312.07104

https://arxiv.org/abs/2312.07104

Automatic Prefix Caching

vLLM · 2026

https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/

Sarathi-Serve: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills.

Agrawal, A., et al. · 2023 · arXiv preprint

https://arxiv.org/abs/2308.16369

Optimization and Tuning.

vLLM · 2026

https://docs.vllm.ai/en/latest/configuration/optimization.html

Supported GPUs

NVIDIA · 2026

https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-gpus.html

Presidio: Data Protection and De-identification SDK.

Microsoft Presidio. · 2023 · GitHub

https://github.com/microsoft/presidio

Prompt caching

OpenAI · 2026

https://developers.openai.com/api/docs/guides/prompt-caching

Prompt caching.

Anthropic. · 2026 · Official documentation

https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.