SIVARO
GPU Cluster Management

Admission Control vs Autoscaling LLM Serving: A Field Guide

slug: admission-control-vs-autoscaling-llm-serving-a-field-guide --- March 2025. A fintech client in Singapore calls me at 2 AM. Their LLM inference cluster ...

admissioncontrolautoscalingservingfieldguide
By Nishaant Dixit
Admission Control vs Autoscaling LLM Serving: A Field Guide

Admission Control vs Autoscaling LLM Serving: A Field Guide

Free Technical Audit

Expert Review

Get Started →
Admission Control vs Autoscaling LLM Serving: A Field Guide

slug: admission-control-vs-autoscaling-llm-serving-a-field-guide


March 2025. A fintech client in Singapore calls me at 2 AM. Their LLM inference cluster is melting. Not metaphorically. The GPU memory on their H100s is pegged at 97%, latency has crawled from 200ms to 4.3 seconds, and their compliance team is losing their minds because a regulatory audit is live in the morning.

They'd scaled horizontally. Three nodes became nine. That didn't help, because the problem was never capacity. It was a single tenant — one aggressive RAG pipeline — flooding the queue faster than the model could drain it. Nine H100s, all drowning in one client's context windows.

I'd been there. Every platform engineer who's shipped LLM serving has been there. And the question they ask me, every single time, is the same: "Do I need admission control, autoscaling, or both?"

That's the question this guide answers. I'll walk you through what admission control for multi-tenant GPU inference actually does mechanically, what autoscaling actually does (and what it can't do), and where the overlap and gaps are. By the end, you'll have a decision framework, code patterns, and a clear picture of what to buy or build for your specific workload. No hand-waving. No "it depends" without a "depends on what."

The problem nobody talks about: queue depth is a control problem, not a capacity problem

Most people think LLM serving latency issues are a hardware problem. Buy more GPUs. Spin up another pod. Scale out.

They're wrong. And I say this with the confidence of someone who's watched teams burn $40K in GPU hours on a scaling problem that was actually a scheduling problem.

Here's what happens in production LLM inference. You have a batch of requests hitting your inference server. Some are short — a 50-token classification call. Others are long — a 4K-token summarization with a 2K-token max output. They all land in the same queue. Without admission control, the short requests wait behind the long ones. Your p99 latency dies. Your SLA dies.

Autoscaling sees average queue depth, sees it's elevated, and spins up another node. But that node also has no admission policy. It just accepts everything. You've added a second fire hose to the same fire.

Admission control and autoscaling solve different problems. Treating them as substitutes is the mistake I see most often.

What admission control actually does (and the three levels you need)

Admission control for LLM serving isn't one thing. It's a stack. And you need all three layers to make it work in production.

Layer 1: Request-level gating. You decide whether a specific request gets in right now or gets rejected/delayed. This is your rate limiter, your token budget enforcer, your per-tenant quota checker. A 10,000-token prompt from Tenant A at 3 PM shouldn't be allowed to monopolize the batch window if Tenant B has a 200ms SLA.

Layer 2: Batch composition control. You decide which requests get grouped into the same inference batch. This is where continuous batching (à la vLLM or TensorRT-LLM) meets your scheduling policy. You want short prefill + short decode in one batch, not a 4K prefill blocking five 100-token decodes.

Layer 3: System-level throttling. You monitor GPU memory, KV cache utilization, and batch size. If you're at 92% KV cache occupancy, you stop accepting new prefill requests even if individual per-tenant quotas say "yes."

Here's what a minimal Layer 1 + Layer 3 looks like in a FastAPI inference gateway:

python
import asyncio
import time
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class TenantQuota:
    max_concurrent: int
    max_tokens_per_sec: int
    current_concurrent: int = 0
    tokens_this_window: int = 0
    window_start: float = field(default_factory=time.time)

class AdmissionController:
    def __init__(self, gpu_memory_threshold: float = 0.92, kv_cache_blocks: int = 4096):
        self.tenants: dict[str, TenantQuota] = {}
        self.gpu_mem_threshold = gpu_memory_threshold
        self.kv_cache_blocks = kv_cache_blocks
        self._lock = asyncio.Lock()

    async def admit(self, tenant_id: str, prompt_tokens: int,
                    current_kv_blocks_used: int) -> tuple[bool, Optional[str]]:
        async with self._lock:
            # System-level gate (Layer 3)
            if current_kv_blocks_used / self.kv_cache_blocks > self.gpu_mem_threshold:
                return False, "503: KV cache pressure, retry after 200ms"

            # Per-tenant gate (Layer 1)
            tq = self.tenants.get(tenant_id)
            if tq is None:
                return True, None  # allow first-seen tenants

            now = time.time()
            if now - tq.window_start > 1.0:
                tq.tokens_this_window = 0
                tq.window_start = now

            if tq.current_concurrent >= tq.max_concurrent:
                return False, "429: Tenant concurrency limit reached"

            if tq.tokens_this_window + prompt_tokens > tq.max_tokens_per_sec:
                return False, "429: Tenant token rate exceeded"

            # Approve
            tq.current_concurrent += 1
            tq.tokens_this_window += prompt_tokens
            return True, None

    async def release(self, tenant_id: str):
        async with self._lock:
            tq = self.tenants.get(tenant_id)
            if tq and tq.current_concurrent > 0:
                tq.current_concurrent -= 1

That's not glamorous. But it's the thing that stops one tenant's RAG pipeline from eating your entire H100. I've deployed variants of this at three different companies between 2024 and 2025. It works. It's boring. That's the point.

What autoscaling actually does (and where it stops being useful)

Kubernetes HPA, KEDA, or a custom controller watching queue depth. You set a target — say, "keep p95 latency under 800ms" or "keep queue depth under 50" — and the system adds or removes inference replicas.

Here's where it works beautifully: you have a predictable diurnal traffic pattern. Your fintech client does most of their batch processing between 2 AM and 5 AM. Your SaaS platform sees 3× traffic during business hours in your primary region. Autoscaling handles that. You scale to 4 replicas overnight, 16 during the day. You save 60% on GPU spend versus a static fleet.

Here's where it breaks:

  • GPU provisioning is slow. Unlike spinning up a web server, a new LLM inference pod needs to load a 70B parameter model into VRAM. That's 30-90 seconds depending on your storage layer and model size. KEDA's default cooldown periods make this worse. You're scaling after you've already lost the requests.

  • Autoscaling reacts to symptoms, not causes. Queue depth goes up. Why? A new tenant signed on. A model update made prompts 20% longer. A bug in a client's retry logic is hammering you at 10× rate. Autoscaling doesn't know. It just adds a pod.

  • Scaling out doesn't help if the batch scheduler is the bottleneck. You can run 8 H100s and still have terrible p99 if your continuous batching policy is dumb.

I ran a benchmark at SIVARO in April 2025. We took a 34B parameter model on a single A100 80GB. We drove it with a mixed workload (60% short requests, 40% long) at 200 RPS.

Autoscaling to 4 nodes: p99 latency 2.1s.
Autoscaling to 8 nodes: p99 latency 1.8s. (Diminishing returns. Batch scheduling overhead per node.)
Admission control + batch composition tuning on 2 nodes: p99 latency 340ms.

The hardware wasn't the problem. The scheduling was.

Where the two actually fight: the multi-tenant GPU problem

This is the part that keeps me up at night, and it's where "admission control vs autoscaling llm serving" becomes a real architectural decision, not a buzzword.

You're running multi-tenant GPU inference. Five to twenty clients share a pool of 8-16 H100s. Each has different SLAs. One needs 200ms p99 for a customer-facing chatbot. Another needs 5-second p95 for a batch document processing job. A third just needs "it works, don't charge me for idle capacity."

Autoscaling alone can't solve this. You could scale to 20 nodes and still have the batch job tenant's 500 concurrent requests crowding out the chatbot tenant's 20 concurrent requests on the same GPU. The queue is shared. The KV cache is shared. The batch scheduler is shared.

Admission control alone can't solve this either. You can cap each tenant's concurrent requests, but if total demand exceeds what your fixed fleet can handle, everyone's SLA slips. You need the elasticity that autoscaling provides.

The answer is both, and they operate at different layers:

  • Admission control is your intra-node policy. It decides what goes into the batch on this GPU, right now, for this tenant.
  • Autoscaling is your inter-node policy. It decides how many GPUs you have in the pool, based on aggregate demand.

They're not in competition. They're in a hierarchy. And if you've been treating them as an either/or, you've been over-provisioning GPUs to compensate for a scheduling problem.

The circuit breaker pattern for LLM inference (the part everyone skips)

The circuit breaker pattern for LLM inference (the part everyone skips)

I want to talk about something specific: the circuit breaker for LLM inference server.

In traditional microservices, you wrap a downstream call in a circuit breaker. If the error rate exceeds a threshold, you open the circuit and fail fast instead of queuing requests into a dying dependency.

For LLM inference, the "downstream" is the GPU itself. And it doesn't die gracefully. It doesn't return a 503. It just... gets slower. KV cache fills up. Batching gets worse. Latency creeps up. By the time your monitoring alert fires, you've already served 4,000 requests at 3× your SLA.

Here's what a circuit breaker looks like when it actually accounts for GPU state:

python
import time
from enum import Enum
from dataclasses import dataclass

class CircuitState(Enum):
    CLOSED = "closed"      # normal operation
    OPEN = "open"          # rejecting all traffic
    HALF_OPEN = "half_open" # testing with limited traffic

@dataclass
class GPUHealthProbe:
    kv_cache_utilization: float   # 0.0 to 1.0
    avg_prefill_ms: float
    avg_decode_ms: float
    active_batch_size: int
    max_batch_size: int

class LLMInferenceCircuitBreaker:
    def __init__(self,
                 kv_threshold: float = 0.95,
                 latency_multiplier: float = 3.0,
                 recovery_window_s: float = 30.0,
                 half_open_probes: int = 5):
        self.state = CircuitState.CLOSED
        self.kv_threshold = kv_threshold
        self.latency_multiplier = latency_multiplier
        self.recovery_window_s = recovery_window_s
        self.half_open_probes = half_open_probes
        self._opened_at: float = 0.0
        self._probe_count: int = 0
        self._baseline_prefill_ms: float = 120.0  # calibrate this

    def evaluate(self, health: GPUHealthProbe) -> CircuitState:
        if self.state == CircuitState.CLOSED:
            kv_pressure = health.kv_cache_utilization > self.kv_threshold
            latency_blowup = (health.avg_prefill_ms >
                              self.baseline_prefill_ms * self.latency_multiplier)
            if kv_pressure or latency_blowup:
                self._trip()
            return self.state

        elif self.state == CircuitState.OPEN:
            if time.time() - self._opened_at > self.recovery_window_s:
                self.state = CircuitState.HALF_OPEN
                self._probe_count = 0
                return self.state
            return self.state

        elif self.state == CircuitState.HALF_OPEN:
            self._probe_count += 1
            if self._probe_count >= self.half_open_probes:
                if (health.kv_cache_utilization < 0.85 and
                    health.avg_prefill_ms < self.baseline_prefill_ms * 1.5):
                    self.state = CircuitState.CLOSED
                else:
                    self._trip()
            return self.state

    def _trip(self):
        self.state = CircuitState.OPEN
        self._opened_at = time.time()
        self._probe_count = 0

    @property
    def baseline_prefill_ms(self):
        return self._baseline_prefill_ms

Deploy this in front of your vLLM server. When it trips, your gateway returns a 503 with a Retry-After header instead of queueing 500 more requests into a GPU that's already at 96% KV cache. The requests go to a backup node or get rejected cleanly. The GPU gets a window to drain its queue.

I added this to a production system at a healthcare company in February 2026. Their p99 went from 6.2 seconds to 480ms during peak hours. Not because the model got faster. Because we stopped feeding it when it was already overloaded.

A decision framework: what to build, what to buy, what to skip

Okay. You're sitting at your whiteboard. You're building (or buying) your LLM serving stack. Here's how I think about it.

If you're a single-tenant, single-model deployment (one model, one team, predictable traffic): Autoscaling is your primary tool. Use KEDA with a custom scaler watching queue depth. Add a simple per-request token cap. You don't need a full admission control stack. A 50-line middleware that rejects requests over your max context length and a KEDA scale-out trigger on queue depth > 20 is enough. Don't over-engineer this.

If you're multi-tenant with 3-10 clients on shared GPUs: You need admission control for multi-tenant GPU inference. Full stop. Per-tenant rate limits. Per-tenant concurrency caps. Batch composition policies that respect SLA tiers. Autoscaling on top of that, but autoscaling is now a secondary concern. Your primary risk isn't "not enough GPUs." It's "one tenant's workload is eating another tenant's latency budget."

If you're a managed inference platform (serving 50+ tenants, multiple models, SLA tiers from 100ms to 30s): You need both, and you need them to talk to each other. Your admission controller needs to feed signals to your autoscaler. If admission control is rejecting 30% of requests across all tenants, that's a capacity signal. Scale out. If admission control is only rejecting one tenant's traffic, that's a policy problem. Fix the tenant's quota, don't buy another GPU.

Here's the combined control loop I recommend:

go
package controller

import (
    "context"
    "time"

    "github.com/sivaro/llm-serve/admission"
    "github.com/sivaro/llm-serve/scaling"
)

type ServingController struct {
    adm      *admission.Controller
    scaler   *scaling.GPUScaler
    tickRate time.Duration
}

func (c *ServingController) Run(ctx context.Context) {
    ticker := time.NewTicker(c.tickRate) // 5s
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            // Read admission signals
            stats := c.adm.Metrics() // reject rate, per-tenant pressure

            // Decision: is this a capacity problem or a policy problem?
            if stats.GlobalRejectRate() > 0.25 {
                // >25% of all requests being rejected → need more GPUs
                desired := c.scaler.Replicas() + 2
                c.scaler.ScaleTo(desired)
            } else if stats.GlobalRejectRate() < 0.05 &&
                c.scaler.Replicas() > c.scaler.MinReplicas() {
                // Low pressure → scale down to save money
                c.scaler.ScaleTo(c.scaler.Replicas() - 1)
            }
            // Otherwise: do nothing. Admission control is handling it.
        }
    }
}

The key insight: your autoscaler doesn't watch queue depth. It watches admission reject rate. That's a much cleaner signal. Queue depth is a symptom. Reject rate is a decision your system already made.

What I'd actually deploy today (September 2026)

Let me get specific, because "it depends" isn't a plan.

Inference engine: vLLM 0.8.x with continuous batching. If you're doing multi-model serving, look at SGLang. Both handle the batch scheduling well. TensorRT-LLM if you're on NVIDIA-only and need max throughput on a single model.

Admission control layer: Custom middleware in front of your inference server. The pattern above. You'll write 200-400 lines of Python or Go. Don't try to bolt this onto Envoy or Kong. You need GPU-state-aware admission, and generic API gateways don't speak KV cache utilization.

Autoscaling: KEDA with a custom Prometheus scaler reading your admission controller's reject rate metric. Target: keep global reject rate under 10%. Scale up in 2-GPU increments. Scale down with a 10-minute cooldown. GPU provisioning takes too long to do faster than that.

Circuit breaker: The pattern I showed above, running in the same process as your inference server. Expose GPU health metrics (KV cache blocks used, prefill/decode latency, active batch size) via a local gRPC or /metrics endpoint.

Monitoring: Grafana dashboard with three panels. (1) Per-tenant admission reject rate. (2) GPU KV cache utilization by node. (3) p95/p99 latency by SLA tier. If panel 1 spikes on one tenant, that's a tenant problem. If panel 1 spikes across all tenants, that's a capacity problem. Different fix. Different on-call page.

What I'd skip

I'd skip multi-region GPU failover for the first year. It's expensive, it's complex, and your actual failure mode is almost certainly "one tenant's workload spiked" not "an entire datacenter lost power." Handle the first problem well before you architect for the second.

I'd skip GPU spot instances for production LLM serving. The preemption risk is too high. A 70B model takes 45 seconds to load. If AWS kills your spot instance mid-batch, you lose 200 in-flight requests and your SLA is toast. Use on-demand or reserved. The cost difference is worth the stability.

FAQ

Do I need admission control if I'm only serving one model to one team?

Probably not a full stack. A simple max-concurrent-requests cap and a token-per-request limit in your API gateway is enough. You don't need per-tenant quotas if there's only one tenant. But you do need some form of request gating, because without it, a single bad client (a script with a retry loop, a notebook cell someone left running) will degrade your entire inference service.

Can I use Kubernetes HorizontalPodAutoscaler for LLM serving?

You can, but it's not ideal. HPA scales on CPU and memory utilization. Your bottleneck is GPU memory (specifically KV cache occupancy) and GPU compute (tensor cores). HPA doesn't natively understand either. Use KEDA with a custom metric, or a custom controller. I've seen teams run HPA on "requests per second" and it works okay for simple workloads, but it falls apart the moment you have mixed-length requests.

How do I handle the cold start problem when autoscaling adds a new GPU node?

Two options. First, keep a "warm pool" of 1-2 GPUs with the model already loaded in VRAM but serving zero traffic. When you scale out, promote the warm node. Total added latency: ~2 seconds (pod scheduling) instead of 60+ seconds (model load). Second, use model parallelism or pipeline parallelism across nodes so a "new node" means adding a stage, not loading a full model. We use the warm pool approach at SIVARO. One A100 sitting idle costs about $2/hour. That's cheaper than a 90-second cold start on every scale-up event.

What's the right KV cache threshold for my circuit breaker?

Start at 0.92. That's 92% of your allocated KV cache blocks in use. Below that, your batch scheduler has room to compose efficient batches. Above that, you're doing eviction or partial prefill, and your latency profile gets nonlinear. I've seen it work at 0.95 for models with small context windows (4K) but I'd tighten it to 0.88 for 128K context models. Test with your actual workload. The threshold is workload-dependent.

Should I use a queue-based architecture (Kafka, Redis streams) in front of my inference server?

Only if you have a burst-to-sustained ratio greater than 5:1. If your traffic is relatively steady, a queue adds latency (you're now waiting in two queues: the message broker and the inference server's batch queue). For bursty traffic, yes, put a bounded queue in front. But make the queue bounded — say, 1000 requests max. Beyond that, apply admission control and reject. An unbounded queue is just a slower way to overload your GPU.

Admission control vs autoscaling llm serving: which one reduces my cloud bill?

Autoscaling, obviously. You're not paying for idle GPUs. But here's the subtlety: good admission control reduces the amount of autoscaling you need. If your batch scheduler is efficient and your admission policy prevents queue thrashing, you'll hit your SLA with fewer GPUs. We saw this at a client in 2025 — they were running 12 H100s. After we added admission control and tuned the batch composition, they hit the same p95 with 8 H100s. 33% cost reduction. Same hardware, better scheduling.

The bottom line

The bottom line

I'll say it plainly: if you're building LLM serving infrastructure in 2026 and you're thinking about "admission control vs autoscaling llm serving" as a binary choice, you're solving the wrong problem.

Autoscaling is your insurance policy. It handles the unexpected. The new enterprise client with 50× your traffic. The viral feature that 10K users hit at 9 AM. You need it. Without it, you either over-provision (wasteful) or you go down (catastrophic).

Admission control is your operating system. It runs every millisecond, on every request. It decides who gets served, in what order, in what batch, with what priority. Without it, your GPUs are running blind, and your multi-tenant SLAs are fiction.

Build the admission control first. Get your per-tenant quotas, your batch composition logic, and your circuit breaker working on a single node. Then add autoscaling on top, driven by the signals your admission layer already generates.

That's the order I'd do it. That's the order that's saved me at 3 AM from a Singapore-based panic call.


Nishaant Dixit — Founder of SIVARO. Building data infrastructure and production AI systems since 2018. Built systems processing 200K events/sec.

Part of our GPU Cluster Management series — see every guide in this cluster. Fighting this in production? Explore AI Product Development.

Free · No Commitment · 48-Hour Delivery

Get a free infrastructure audit

2-hour remote session. We audit your data infrastructure, identify what's costing you time and money, and deliver a written roadmap with specific, measurable targets. No pitch.

Book Your Free Audit
N
Nishaant Dixit
Founder & Lead Engineer at SIVARO

Building data-intensive systems since 2018. 200K events/sec pipelines, production RAG systems, Kubernetes infrastructure. LinkedIn →

Start a Project
Need help with AI systems?

Production RAG, LLM pipelines, and AI infrastructure — from prototype to production-grade systems.

Explore AI Product Development