Enterprise LLM Serving: Scaling to Millions with vLLM and Ray
It is easy to run a Large Language Model (LLM) on your laptop. You download Ollama, run `ollama run llama3`, and you're chatting. But serving that same model to 10,000 concurrent users with sub-200ms latency is a completely different beast.
In the enterprise, the bottleneck isn't usually compute—it's memory bandwidth. The naive approach of loading a model into a Flask app and processing one request at a time (sequential processing) is catastrophically inefficient. The GPU sits idle 90% of the time waiting for data to move from VRAM to the compute cores.
This guide is for the engineers tasked with building the "inference engine" for their company. We will move beyond Hugging Face Pipelines and dive deep into vLLM, Continuous Batching, and Ray Serve to build a production-grade inference cluster that rivals OpenAI's throughput.
The Problem: LLMs are Memory Bound
Understanding LLM performance requires understanding the KV Cache. When an LLM generates the 100th token, it needs to attend to (look at) all 99 previous tokens. We cache the Key and Value matrices for these tokens so we don't have to recompute them. This is the KV Cache.
This cache grows linearly with sequence length. For long contexts (e.g., 128k tokens), the KV Cache can consume gigabytes of VRAM per request. If you allocate memory statically (reserving a contiguous block for the max possible length), you waste massive amounts of VRAM due to fragmentation. This "internal fragmentation" was the primary killer of LLM throughput until 2023.
Theory: PagedAttention & KV Cache Management
The breakthrough that put vLLM on the map is PagedAttention. It borrows a 50-year-old idea from operating systems: Virtual Memory and Paging.
How OS Paging Works vs. PagedAttention
OS: Programs think they have a contiguous block of memory. In reality, the OS breaks memory into non-contiguous "pages" and maps them via a Page Table. This eliminates external fragmentation.
PagedAttention: Instead of reserving a contiguous VRAM block for a user's entire 4k context window, vLLM breaks the KV cache into blocks (e.g., 16 tokens per block). These blocks are stored in non-contiguous physical memory. When a user generates more tokens, vLLM simply allocates a new block from anywhere in memory and updates the block table.
This results in near-zero memory waste. If a request finishes early, its blocks are freed immediately. This allows vLLM to pack 20x more concurrent requests into the same GPU compared to naive Hugging Face implementations.
Memory Mapping Visualization
Logical KV Cache (Tokens) Physical Block Table (VRAM)
------------------------- ---------------------------
[Seq A: Token 0-15 ] -----> [ Block 7 (Addr 0x00A0) ]
[Seq A: Token 16-31] -----> [ Block 42 (Addr 0x0FE0) ]
[Seq B: Token 0-15 ] -----> [ Block 2 (Addr 0x0010) ]
Just like virtual memory allows your 16GB RAM laptop to run Chrome (which thinks it has 100GB), PagedAttention allows the GPU to "oversubscribe" memory safely.
Theory: Continuous Batching (Iteration-level Scheduling)
In traditional "Static Batching," if you batch 4 requests together, the batch is only finished when the longest request finishes. If Request A generates 5 tokens and Request B generates 500 tokens, the GPU slots for Request A sit idle for 495 steps.
Continuous Batching (or Orca-style scheduling) solves this. The scheduler works at the iteration level, not the request level.
- At step T, we generate one token for all active requests.
- If Request A finishes at step T, we immediately evict it from the batch.
- At step T+1, we insert a new waiting Request C into the empty slot.
- The GPU is always fully utilized.
Advanced Theory: Speculative Decoding
Even with batching, LLMs are limited by the speed of memory access (memory bandwidth). Generating one token requires reading all 70B parameters from VRAM.
Speculative Decoding uses a small "Draft Model" (e.g., a 120M parameter model) to quickly guess the next 5 tokens. Then, the big "Target Model" (70B) verifies all 5 tokens in a single forward pass.
Because checking 5 tokens in parallel costs about the same as generating 1 token (due to matrix math parallelism), if the Draft Model is accurate, we get a 2x-3x speedup in latency for free.
Python Implementation: Ray Serve Cluster with vLLM
We will use Ray Serve to orchestrate our inference. Ray allows us to scale across multiple nodes and handle autoscaling.
Why Ray?
Ray provides the "Control Plane" that vLLM lacks. vLLM is just an engine (like a V8 engine). Ray is the car. Ray handles:
- Replica Management: Spinning up 10 copies of the model across 4 different EC2 instances.
- Autoscaling: Scaling from 1 replica to 50 based on queue depth.
- Fault Tolerance: If a GPU overheats and kills a replica, Ray restarts it instantly.
from typing import Dict
from ray import serve
from vllm import AsyncLLMEngine, EngineArgs, SamplingParams
import uuid
# Define the Ray deployment
@serve.deployment(num_replicas=2, ray_actor_options={"num_gpus": 1})
class VLLMDeployment:
def __init__(self):
# Initialize vLLM engine with PagedAttention
args = EngineArgs(
model="meta-llama/Meta-Llama-3-8B-Instruct",
tensor_parallel_size=1, # 1 GPU per replica
gpu_memory_utilization=0.90, # Use 90% VRAM
max_num_batched_tokens=4096
)
self.engine = AsyncLLMEngine.from_engine_args(args)
async def __call__(self, request: starlette.requests.Request):
json_input = await request.json()
prompt = json_input.get("prompt")
stream = json_input.get("stream", False)
request_id = str(uuid.uuid4())
sampling_params = SamplingParams(temperature=0.7, max_tokens=500)
results_generator = self.engine.generate(
prompt, sampling_params, request_id
)
if stream:
return StreamingResponse(self._stream_results(results_generator))
# Non-streaming wait for final result
final_output = None
async for request_output in results_generator:
final_output = request_output
return {"text": final_output.outputs[0].text}
async def _stream_results(self, results_generator):
async for request_output in results_generator:
text = request_output.outputs[0].text
yield text.encode("utf-8")
# Deploy the application
deployment = VLLMDeployment.bind()
This Python code runs inside the Ray cluster. Ray handles the HTTP ingress, load balances requests between the 2 replicas, and restarts them if they crash.
Java Implementation: The Spring Boot Gateway
Your Ray cluster should not be exposed directly to the public internet. You need a Spring Boot Gateway to handle Authentication, Rate Limiting, and Logging before forwarding to Ray.
package com.devmetrix.llmgateway;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Configuration
public class GatewayConfig {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("llm_route", r -> r.path("/v1/chat/completions")
.filters(f -> f
.filter(new ApiKeyAuthFilter()) // Custom Auth
.requestRateLimiter(c -> c.setRateLimiter(redisRateLimiter())) // Redis Rate Limiting
.rewritePath("/v1/chat/completions", "/") // Rewrite to match Ray's endpoint
)
.uri("http://ray-cluster-service:8000")) // Internal K8s DNS
.build();
}
}
// Simple Auth Filter Example
class ApiKeyAuthFilter implements GatewayFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String apiKey = exchange.getRequest().getHeaders().getFirst("X-API-Key");
if (apiKey == null || !isValid(apiKey)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
}
Security: Model Weights Protection & DoS Prevention
Running inference at scale introduces unique security challenges.
The "Model Extraction" Attack
If an attacker can query your model unlimited times with carefully crafted inputs, they can train a copycat model on your outputs (Distillation Attack). They effectively steal your IP (the fine-tuned weights) for the cost of API calls.
Defense Strategies
- Hard Rate Limits: Implement strict per-user rate limits. No normal user needs to generate 1M tokens per day.
- Watermarking (Output): Subtly alter the probability distribution of tokens in a way that is undetectable to humans but detectable statistically. If a competitor releases a model that matches your watermark, you have proof of theft.
- Input Length Bounds (DoS): A common DoS attack against Transformers is sending a request with 100,000 tokens of context. The quadratic complexity of attention (O(N^2)) will cause the GPU to OOM (Out of Memory) and crash the replica. Always enforce a hard `max_model_len` at the API gateway level.