HinterBuild logoHinterBuild
AI Systems · 16 min read

AI Platform on AWS EKS: Bedrock + vLLM Hybrid Architecture

Build a production AI platform on AWS EKS that routes between Bedrock and self-hosted vLLM: cluster setup, gateway, routing logic, and cost tracking.

Muhammad Abdul Sami, author

Muhammad Abdul Sami

· 16 min read

  • vLLM
  • LLM Serving
  • Kubernetes
  • Cost Optimization
  • Architecture
  • AWS Bedrock

Building an AI platform on AWS EKS means answering one question before any YAML gets written: which requests go to a managed model on Bedrock, and which go to a self-hosted model on vLLM? Get that split right and the platform is both cheap and boring to operate. Get it wrong and you either pay per-token prices on traffic that a 7B model could handle, or you run a GPU fleet to serve queries that needed a frontier model anyway. This guide walks through the hybrid architecture we deploy: an EKS cluster with GPU node groups, vLLM behind a unified FastAPI gateway, routing logic, cost tracking, and the monitoring that keeps both backends honest.

Table of Contents:

The AI Platform Problem: When to Use Bedrock vs Self-Hosted

Short answer: AWS Bedrock provides managed models with zero ops, but per-token pricing that can run 10-50x the amortized cost of self-hosted vLLM at high utilization. The solution is a hybrid platform: route to Bedrock for low-volume, high-value queries and to vLLM for high-volume, cost-sensitive ones.

An AI SaaS served 2M requests/month. 100% on Bedrock Claude cost $28,000/month. We built a hybrid platform—70% traffic routed to self-hosted Llama on vLLM ($4,200 infra cost), 30% on Bedrock for complex queries. New total cost: $11,400 (59% reduction) with quality maintained.

The numbers above are one engagement, not a law. The general shape holds, though: Bedrock's cost is linear in tokens, vLLM's cost is linear in GPU-hours. Below a certain volume the GPU sits idle and Bedrock wins; above it the GPU is saturated and vLLM wins by a wide margin. The self-hosting decision framework covers how to find that crossover for your traffic. This post assumes you've found it and need to build the platform that serves both sides.

Key Takeaways:

  • Bedrock for low-volume, high-value queries (instant scaling, managed)
  • vLLM for high-volume, cost-sensitive workloads (10x cheaper at scale)
  • Unified API abstracts model backends from application code
  • Model routing sends queries to optimal backend by complexity/cost
  • EKS infrastructure provides GPU autoscaling and observability
  • Monitoring tracks costs, latency, quality across both backends

For production AI systems, hybrid architectures balance cost and operational complexity.


Architecture: Bedrock + vLLM Hybrid

Hybrid platform combines managed and self-hosted models.

┌─────────────────────────────────────────────────────────────┐
│                     Application Layer                        │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
│  │   API Client │  │   AI Agent   │  │  Batch Jobs  │      │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘      │
│         │                 │                 │                │
│         └─────────────────┼─────────────────┘                │
│                           │                                  │
└───────────────────────────┼──────────────────────────────────┘
                            │
                   ┌────────▼────────┐
                   │  Unified API     │  (FastAPI Gateway)
                   │  Gateway (EKS)   │  - Authentication
                   └────────┬─────────┘  - Routing logic
                            │            - Rate limiting
              ┌─────────────┴─────────────┐
              │                           │
      ┌───────▼────────┐         ┌───────▼────────┐
      │  AWS Bedrock    │         │   vLLM Cluster │
      │  (Managed)      │         │   (Self-hosted)│
      │                 │         │                 │
      │ - Claude Sonnet │         │ - Llama 3 70B  │
      │ - Claude Opus   │         │ - Mistral 7B   │
      │ - Titan         │         │ - Custom LoRA  │
      └─────────────────┘         └────────┬───────┘
                                           │
                                  ┌────────▼────────┐
                                  │   GPU Nodes     │
                                  │   (g5.12xlarge) │
                                  │   AutoScaling   │
                                  └─────────────────┘

Decision matrix:

Workload TypeVolumeLatency SLABackendReason
Complex reasoningLow5sBedrock ClaudeQuality first
Simple QAHigh1svLLM LlamaCost efficient
Code generationMedium3sBedrock ClaudeSpecialized
ClassificationVery high500msvLLM Mistral 7BThroughput
Batch inferenceHighBest-effortvLLM BatchCheapest

How a request flows through the hybrid platform

Every request enters through the unified gateway, which does four things in order: authenticates the caller, applies per-tenant rate limits, picks a backend, and records cost and latency for the response. The gateway is stateless and runs on CPU nodes, so it scales independently of the GPU fleet. That separation matters: a burst of cheap classification traffic should scale the gateway and the Mistral replicas, not the Llama 70B replicas or your Bedrock spend.

Bedrock calls go out through the AWS SDK using the cluster's IAM role (via IRSA), so no long-lived keys sit in pods. vLLM calls stay inside the VPC on a ClusterIP service. The gateway exposes an OpenAI-compatible shape, which means application teams use the same client library whether the response came from Claude or Llama. The OpenAI-compatible vLLM deployment guide covers that contract in detail.

What Bedrock gives you that vLLM doesn't

It's tempting to frame this as "Bedrock is expensive, vLLM is cheap" and stop there. The honest trade-off list is longer:

  • Frontier model access. Claude Opus and Sonnet aren't available as open weights. If a workload needs that quality, Bedrock (or the vendor API) is the only option.
  • Zero capacity planning. Bedrock absorbs traffic spikes without you pre-warming GPUs. vLLM replicas take 5-10 minutes to load a 70B model, so your autoscaler has to lead the traffic curve.
  • Compliance surface. Bedrock inherits AWS's compliance certifications and keeps data in-region. Self-hosting gives you the same in principle, but you own the audit trail.
  • Throughput quotas. Bedrock enforces per-model TPM and RPM quotas per account. At high volume you'll hit them and need provisioned throughput, which changes the cost math again.

vLLM's advantages are the mirror image: predictable cost at saturation, custom or fine-tuned weights (including multiple LoRA adapters on one server), no external rate limits, and full control over latency tuning.


EKS Cluster Setup

EKS cluster with GPU and CPU node groups.

Cluster Configuration

yaml
apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: ai-platform
  region: us-west-2
  version: "1.28"

iam:
  withOIDC: true

vpc:
  cidr: 10.0.0.0/16
  nat:
    gateway: HighlyAvailable

managedNodeGroups:
  # CPU node group for API gateway
  - name: cpu-workers
    instanceType: c6i.2xlarge
    minSize: 2
    maxSize: 10
    desiredCapacity: 3
    
    labels:
      workload: api-gateway
    
    tags:
      k8s.io/cluster-autoscaler/enabled: "true"
      k8s.io/cluster-autoscaler/ai-platform: "owned"
    
    iam:
      withAddonPolicies:
        autoScaler: true
        cloudWatch: true
        albIngress: true

  # GPU node group for vLLM
  - name: gpu-inference
    instanceType: g5.12xlarge  # 4x A10G GPUs
    minSize: 0
    maxSize: 20
    desiredCapacity: 2
    
    labels:
      workload: vllm-inference
      gpu: "true"
    
    taints:
      - key: nvidia.com/gpu
        value: "true"
        effect: NoSchedule
    
    # Use spot instances for cost savings
    instancesDistribution:
      instanceTypes:
        - g5.12xlarge
        - g5.24xlarge
      onDemandBaseCapacity: 1
      onDemandPercentageAboveBaseCapacity: 0
      spotInstancePools: 3
    
    tags:
      k8s.io/cluster-autoscaler/enabled: "true"
      k8s.io/cluster-autoscaler/ai-platform: "owned"

addons:
  - name: vpc-cni
  - name: coredns
  - name: kube-proxy
  - name: aws-ebs-csi-driver

cloudWatch:
  clusterLogging:
    enableTypes:
      - api
      - audit
      - authenticator
      - controllerManager
      - scheduler
bash
# Create cluster
eksctl create cluster -f eks-cluster.yaml

# Install NVIDIA device plugin
kubectl apply -f https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/v0.14.5/nvidia-device-plugin.yml

# Install cluster autoscaler
eksctl create iamserviceaccount \
  --cluster=ai-platform \
  --namespace=kube-system \
  --name=cluster-autoscaler \
  --attach-policy-arn=arn:aws:iam::aws:policy/AutoScalingFullAccess \
  --approve

kubectl apply -f https://raw.githubusercontent.com/kubernetes/autoscaler/master/cluster-autoscaler/cloudprovider/aws/examples/cluster-autoscaler-autodiscover.yaml

Node group design decisions

The cluster config above encodes several choices worth making explicit.

Separate CPU and GPU node groups. The gateway, cost tracker, and observability stack run on c6i instances. Putting them on GPU nodes wastes the most expensive compute in the cluster on work that doesn't need it, and it means a GPU node scale-down can evict your gateway pods.

Taints on GPU nodes. The nvidia.com/gpu=true:NoSchedule taint guarantees that only pods with a matching toleration land on GPU instances. Without it, a DaemonSet or a misconfigured deployment can quietly consume GPU node memory. The NVIDIA device plugin advertises nvidia.com/gpu as a schedulable resource; the taint is the second half of the isolation.

Spot with an on-demand base. onDemandBaseCapacity: 1 keeps at least one on-demand GPU node so a spot reclamation wave can't take the whole vLLM fleet down. Spot pricing for g5 instances is typically 60-70% below on-demand, but reclamation notices give you two minutes—not enough to reload a 70B model. Size the on-demand base to the minimum replica count you need for SLA, and let spot cover the burst.

minSize: 0 on the GPU group. The Kubernetes cluster autoscaler can scale a node group to zero when no pending pods request GPUs. For dev or low-traffic environments this alone cuts the bill dramatically. For production, pair it with a GPU-aware HPA as described in the Kubernetes GPU autoscaling guide.

If you're starting fresh, Karpenter is a strong alternative to cluster autoscaler on EKS: it provisions nodes directly from pod requirements rather than from pre-defined node groups, which handles mixed GPU instance types more gracefully. We still use eksctl-managed node groups in this guide because the failure modes are better understood and easier to debug.

Connect to Kubernetes platform engineering services.


vLLM Deployment on EKS

Deploy vLLM for self-hosted models with high throughput.

vLLM Deployment Manifest

yaml
# vllm-llama3-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vllm-llama3-70b
  namespace: ai-platform
  labels:
    app: vllm-llama3
    model: llama-3-70b
spec:
  replicas: 2
  selector:
    matchLabels:
      app: vllm-llama3
  template:
    metadata:
      labels:
        app: vllm-llama3
    spec:
      nodeSelector:
        workload: vllm-inference
      
      tolerations:
      - key: nvidia.com/gpu
        operator: Equal
        value: "true"
        effect: NoSchedule
      
      initContainers:
      # Download model from S3 on startup
      - name: download-model
        image: amazon/aws-cli
        command:
        - sh
        - -c
        - |
          aws s3 sync s3://ai-models/llama-3-70b-instruct /models/llama-3-70b --no-progress
        volumeMounts:
        - name: model-cache
          mountPath: /models
        env:
        - name: AWS_REGION
          value: us-west-2
      
      containers:
      - name: vllm
        image: vllm/vllm-openai:v0.3.1
        
        command:
        - python3
        - -m
        - vllm.entrypoints.openai.api_server
        - --model
        - /models/llama-3-70b
        - --tensor-parallel-size
        - "4"
        - --max-model-len
        - "8192"
        - --gpu-memory-utilization
        - "0.95"
        - --enable-prefix-caching
        - --disable-log-stats
        - --host
        - "0.0.0.0"
        - --port
        - "8000"
        
        ports:
        - containerPort: 8000
          name: http
        
        resources:
          requests:
            nvidia.com/gpu: 4
            memory: 160Gi
            cpu: 32
          limits:
            nvidia.com/gpu: 4
            memory: 160Gi
        
        volumeMounts:
        - name: model-cache
          mountPath: /models
        - name: shm
          mountPath: /dev/shm
        
        env:
        - name: VLLM_WORKER_MULTIPROC_METHOD
          value: spawn
        
        livenessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 600
          periodSeconds: 30
          timeoutSeconds: 10
        
        readinessProbe:
          httpGet:
            path: /health
            port: 8000
          initialDelaySeconds: 600
          periodSeconds: 10
      
      volumes:
      - name: model-cache
        emptyDir:
          sizeLimit: 200Gi
      - name: shm
        emptyDir:
          medium: Memory
          sizeLimit: 32Gi
---
apiVersion: v1
kind: Service
metadata:
  name: vllm-llama3-service
  namespace: ai-platform
  labels:
    app: vllm-llama3
spec:
  selector:
    app: vllm-llama3
  ports:
  - port: 8000
    targetPort: 8000
  type: ClusterIP
bash
# Deploy vLLM
kubectl apply -f vllm-llama3-deployment.yaml

# Wait for pods ready
kubectl wait --for=condition=ready pod -l app=vllm-llama3 -n ai-platform --timeout=600s

# Test inference
kubectl port-forward -n ai-platform svc/vllm-llama3-service 8000:8000

curl http://localhost:8000/v1/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "/models/llama-3-70b",
    "prompt": "Explain Kubernetes in one sentence:",
    "max_tokens": 50
  }'

Sizing GPUs for the model you're actually loading

The manifest above requests four A10G GPUs (96 GB total VRAM) for a 70B model. That only works with a quantized checkpoint: Llama 3 70B in bf16 needs roughly 140 GB for weights alone, before KV cache. Your options, from cheapest to most capable:

ConfigurationVRAMFits 70B?Notes
g5.12xlarge (4x A10G 24GB)96 GBOnly 4-bit AWQ/GPTQAdd --quantization awq; ~35-40 GB weights, rest for KV cache
g5.48xlarge (8x A10G 24GB)192 GBbf16 with TP=8Tight on KV cache; reduce --max-model-len
p4d.24xlarge (8x A100 40GB)320 GBbf16 with TP=4 or 8Better interconnect (NVLink), higher throughput
p5.48xlarge (8x H100 80GB)640 GBComfortableExpensive; use for latency-critical 70B+ workloads

For a 7B or 8B model, a single A10G is enough in bf16, and one g5.xlarge per replica gives you the finest-grained autoscaling. The vLLM documentation covers tensor parallelism and quantization flags in detail; see the vLLM production guide for how PagedAttention and continuous batching affect real throughput numbers.

Startup and readiness failure modes

The initialDelaySeconds: 600 on both probes exists because a 70B model takes several minutes to sync from S3 and load into GPU memory. Set it too low and Kubernetes restarts the pod in a loop, each restart re-downloading the model. Three related failures we see repeatedly:

  • emptyDir model cache on spot nodes. When a spot node is reclaimed, the model cache goes with it. The replacement pod re-downloads from S3, adding 5-10 minutes to recovery. A persistent volume or a node-local cache (hostPath with a DaemonSet warmer) fixes this.
  • /dev/shm too small. Tensor-parallel workers communicate through shared memory. The 32Gi shm volume in the manifest is deliberate; the Docker default of 64 MB will make vLLM hang at startup with no useful error.
  • Readiness passing before the first real request. /health returns 200 once the server is up, but the first request still triggers CUDA graph capture. Send a warm-up request from a postStart hook or accept a slow first request per pod.

Unified API Gateway

FastAPI gateway provides single endpoint for Bedrock + vLLM.

python
# api_gateway.py
from fastapi import FastAPI, HTTPException, Header
from pydantic import BaseModel
import boto3
import httpx
import time
from enum import Enum

app = FastAPI(title="AI Platform Gateway")

# Backends
bedrock_client = boto3.client("bedrock-runtime", region_name="us-west-2")
vllm_endpoint = "http://vllm-llama3-service.ai-platform.svc.cluster.local:8000"

class ModelBackend(str, Enum):
    BEDROCK = "bedrock"
    VLLM = "vllm"

class CompletionRequest(BaseModel):
    prompt: str
    model: str = "auto"  # auto, bedrock/claude-3-5-sonnet, vllm/llama-3-70b
    max_tokens: int = 512
    temperature: float = 0.7

class CompletionResponse(BaseModel):
    text: str
    model: str
    backend: ModelBackend
    latency_ms: float
    estimated_cost_usd: float

# Model routing configuration
ROUTING_CONFIG = {
    "bedrock/claude-3-5-sonnet": {
        "backend": ModelBackend.BEDROCK,
        "bedrock_model_id": "anthropic.claude-3-5-sonnet-20240620-v1:0",
        "cost_per_1k_input": 0.003,
        "cost_per_1k_output": 0.015,
    },
    "bedrock/claude-opus-4": {
        "backend": ModelBackend.BEDROCK,
        "bedrock_model_id": "anthropic.claude-opus-4-20250514-v1:0",
        "cost_per_1k_input": 0.015,
        "cost_per_1k_output": 0.075,
    },
    "vllm/llama-3-70b": {
        "backend": ModelBackend.VLLM,
        "vllm_model": "/models/llama-3-70b",
        "cost_per_1k_input": 0.0003,  # Amortized infra cost
        "cost_per_1k_output": 0.0003,
    },
}

def route_model(request: CompletionRequest) -> str:
    """Determine which model to use."""
    
    if request.model != "auto":
        return request.model
    
    # Auto-routing logic
    prompt_len = len(request.prompt.split())
    
    # Complex queries to Bedrock
    if any(kw in request.prompt.lower() for kw in ["analyze", "explain", "design", "compare"]):
        return "bedrock/claude-3-5-sonnet"
    
    # Short queries to vLLM
    if prompt_len < 100:
        return "vllm/llama-3-70b"
    
    # Default to vLLM for cost
    return "vllm/llama-3-70b"

async def call_bedrock(model_config: dict, prompt: str, max_tokens: int, temperature: float) -> dict:
    """Call AWS Bedrock."""
    
    body = {
        "anthropic_version": "bedrock-2023-05-31",
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": max_tokens,
        "temperature": temperature,
    }
    
    import json
    response = bedrock_client.invoke_model(
        modelId=model_config["bedrock_model_id"],
        body=json.dumps(body),
    )
    
    result = json.loads(response["body"].read())
    
    return {
        "text": result["content"][0]["text"],
        "input_tokens": result["usage"]["input_tokens"],
        "output_tokens": result["usage"]["output_tokens"],
    }

async def call_vllm(model_config: dict, prompt: str, max_tokens: int, temperature: float) -> dict:
    """Call vLLM endpoint."""
    
    async with httpx.AsyncClient(timeout=60.0) as client:
        response = await client.post(
            f"{vllm_endpoint}/v1/completions",
            json={
                "model": model_config["vllm_model"],
                "prompt": prompt,
                "max_tokens": max_tokens,
                "temperature": temperature,
            },
        )
        response.raise_for_status()
        result = response.json()
    
    return {
        "text": result["choices"][0]["text"],
        "input_tokens": result["usage"]["prompt_tokens"],
        "output_tokens": result["usage"]["completion_tokens"],
    }

@app.post("/v1/completions", response_model=CompletionResponse)
async def create_completion(
    request: CompletionRequest,
    authorization: str = Header(None),
) -> CompletionResponse:
    """Unified completion endpoint."""
    
    # Authenticate (simplified)
    if not authorization or not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Invalid authorization")
    
    start_time = time.perf_counter()
    
    # Route to model
    model_key = route_model(request)
    
    if model_key not in ROUTING_CONFIG:
        raise HTTPException(status_code=400, detail=f"Unknown model: {model_key}")
    
    model_config = ROUTING_CONFIG[model_key]
    backend = model_config["backend"]
    
    # Call backend
    try:
        if backend == ModelBackend.BEDROCK:
            result = await call_bedrock(
                model_config,
                request.prompt,
                request.max_tokens,
                request.temperature,
            )
        else:  # VLLM
            result = await call_vllm(
                model_config,
                request.prompt,
                request.max_tokens,
                request.temperature,
            )
        
        # Calculate cost
        cost_usd = (
            (result["input_tokens"] / 1000) * model_config["cost_per_1k_input"] +
            (result["output_tokens"] / 1000) * model_config["cost_per_1k_output"]
        )
        
        latency_ms = (time.perf_counter() - start_time) * 1000
        
        # Log metrics (Prometheus)
        from prometheus_client import Counter, Histogram
        request_counter = Counter("api_requests_total", "Total requests", ["model", "backend"])
        latency_histogram = Histogram("api_latency_seconds", "Request latency", ["model", "backend"])
        
        request_counter.labels(model=model_key, backend=backend.value).inc()
        latency_histogram.labels(model=model_key, backend=backend.value).observe(latency_ms / 1000)
        
        return CompletionResponse(
            text=result["text"],
            model=model_key,
            backend=backend,
            latency_ms=latency_ms,
            estimated_cost_usd=cost_usd,
        )
    
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/health")
async def health():
    """Health check."""
    return {"status": "healthy"}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8080)

Deploy API Gateway

yaml
# api-gateway-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-gateway
  namespace: ai-platform
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-gateway
  template:
    metadata:
      labels:
        app: api-gateway
    spec:
      nodeSelector:
        workload: api-gateway
      
      serviceAccountName: api-gateway-sa
      
      containers:
      - name: gateway
        image: <your-registry>/ai-gateway:latest
        
        ports:
        - containerPort: 8080
          name: http
        
        env:
        - name: AWS_REGION
          value: us-west-2
        - name: VLLM_ENDPOINT
          value: http://vllm-llama3-service.ai-platform.svc.cluster.local:8000
        
        resources:
          requests:
            memory: 512Mi
            cpu: 500m
          limits:
            memory: 1Gi
            cpu: 1000m
        
        livenessProbe:
          httpGet:
            path: /health
            port: 8080
          periodSeconds: 10
        
        readinessProbe:
          httpGet:
            path: /health
            port: 8080
          periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: api-gateway
  namespace: ai-platform
spec:
  selector:
    app: api-gateway
  ports:
  - port: 80
    targetPort: 8080
  type: LoadBalancer

What the gateway owns beyond proxying

The gateway code above is intentionally minimal. In production it also owns:

  • Fallback. If Bedrock returns a throttling error, retry once with backoff, then fall back to the vLLM 70B model for that request. Log the fallback so you can see how often Bedrock quotas bite.
  • Streaming. Both Bedrock (invoke_model_with_response_stream) and vLLM (stream: true) support token streaming. The gateway should pass it through as SSE; the FastAPI SSE guide shows the pattern.
  • Per-tenant quotas. Token budgets per tenant per day, enforced before routing, so one customer can't burn the Bedrock budget for everyone.
  • Request shaping. Clamp max_tokens, reject prompts over the configured context limit early, and normalize the response shape so the client can't tell which backend answered.

One design rule we hold to: the gateway never contains model-specific prompt logic. Prompt templates live with the application, versioned; the gateway only routes. Mixing the two makes every routing change a prompt regression risk.

Connect to backend API engineering.


Model Routing Logic

Intelligent routing optimizes for cost, latency, and quality.

python
# advanced_routing.py
from dataclasses import dataclass
from typing import Optional
import numpy as np

@dataclass
class RoutingDecision:
    model: str
    confidence: float
    reason: str

class ModelRouter:
    """Advanced model routing logic."""
    
    def __init__(self):
        self.complexity_classifier = self._load_complexity_classifier()
    
    def route(
        self,
        prompt: str,
        user_tier: str = "free",
        latency_sla_ms: Optional[int] = None,
    ) -> RoutingDecision:
        """Route request to optimal model."""
        
        # Classify query complexity
        complexity = self._classify_complexity(prompt)
        
        # Check user tier
        if user_tier == "enterprise":
            # Enterprise users get best quality
            if complexity == "high":
                return RoutingDecision(
                    model="bedrock/claude-opus-4",
                    confidence=0.95,
                    reason="High complexity + enterprise tier",
                )
            else:
                return RoutingDecision(
                    model="bedrock/claude-3-5-sonnet",
                    confidence=0.90,
                    reason="Enterprise tier",
                )
        
        # Free/Pro users: optimize for cost
        if complexity == "low":
            return RoutingDecision(
                model="vllm/mistral-7b",  # Cheapest, fastest
                confidence=0.85,
                reason="Low complexity query",
            )
        
        elif complexity == "medium":
            # vLLM Llama 3 70B handles most queries well
            return RoutingDecision(
                model="vllm/llama-3-70b",
                confidence=0.80,
                reason="Medium complexity, cost-optimized",
            )
        
        else:  # high complexity
            # Use Bedrock for quality
            return RoutingDecision(
                model="bedrock/claude-3-5-sonnet",
                confidence=0.90,
                reason="High complexity requires reasoning",
            )
    
    def _classify_complexity(self, prompt: str) -> str:
        """Classify query complexity (low/medium/high)."""
        
        # Simple heuristics (replace with ML classifier in production)
        prompt_lower = prompt.lower()
        
        # High complexity indicators
        if any(kw in prompt_lower for kw in [
            "analyze", "design", "architect", "compare multiple",
            "explain why", "what are the tradeoffs",
        ]):
            return "high"
        
        # Low complexity indicators
        if any(kw in prompt_lower for kw in [
            "what is", "define", "list", "who", "when", "where",
        ]):
            return "low"
        
        # Default to medium
        return "medium"
    
    def _load_complexity_classifier(self):
        """Load ML classifier (placeholder)."""
        # In production: load fine-tuned classifier
        return None

# Usage
router = ModelRouter()

queries = [
    ("What is Kubernetes?", "free"),
    ("Design a distributed caching architecture", "free"),
    ("Explain Kubernetes networking", "enterprise"),
]

for query, tier in queries:
    decision = router.route(query, user_tier=tier)
    print(f"{tier:10} | {decision.model:25} | {query[:50]}")
    print(f"           Reason: {decision.reason}\n")

Routing failure modes to design against

Keyword heuristics like the ones above are a fine starting point, but they fail in predictable ways:

  • Prompt injection into the router. A user who learns that "analyze" routes to Claude will prepend it to every query. Cap the fraction of traffic per tier that can reach the premium backend, regardless of what the classifier says.
  • Silent quality regression. Routing a query to Mistral 7B that Llama 70B used to handle saves money and produces a plausible but worse answer. Nobody notices until a customer complains. Sample 1-2% of routed requests, send them to both backends, and score the delta with an LLM judge.
  • Latency inversion. Under load, a queued vLLM request can take longer than a Bedrock call. Route on observed p95 latency per backend, not just on cost.

A trained classifier on your own traffic beats keyword rules within a week of data collection. The cheapest-model-that-works routing guide covers cascade routing (try cheap, escalate on low confidence), which is often simpler than upfront classification. See also model routing in production for the automatic-selection approach.


Cost Optimization Strategy

Track and optimize costs across Bedrock and vLLM.

python
# cost_tracker.py
from datetime import datetime, timezone
from dataclasses import dataclass
import asyncpg

@dataclass
class RequestCost:
    timestamp: datetime
    model: str
    backend: str
    input_tokens: int
    output_tokens: int
    cost_usd: float
    latency_ms: float
    user_id: str

class CostTracker:
    """Track costs across backends."""
    
    def __init__(self, db_pool):
        self.db = db_pool
    
    async def record_request(self, cost: RequestCost) -> None:
        """Record request cost."""
        
        await self.db.execute(
            """
            INSERT INTO request_costs 
            (timestamp, model, backend, input_tokens, output_tokens, cost_usd, latency_ms, user_id)
            VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
            """,
            cost.timestamp, cost.model, cost.backend,
            cost.input_tokens, cost.output_tokens,
            cost.cost_usd, cost.latency_ms, cost.user_id,
        )
    
    async def get_cost_breakdown(self, days: int = 7) -> dict:
        """Analyze costs by backend and model."""
        
        from datetime import timedelta
        start_date = datetime.now(timezone.utc) - timedelta(days=days)
        
        rows = await self.db.fetch(
            """
            SELECT 
                backend,
                model,
                COUNT(*) as request_count,
                SUM(cost_usd) as total_cost,
                AVG(cost_usd) as avg_cost_per_request,
                AVG(latency_ms) as avg_latency_ms
            FROM request_costs
            WHERE timestamp >= $1
            GROUP BY backend, model
            ORDER BY total_cost DESC
            """,
            start_date,
        )
        
        results = {}
        for row in rows:
            results[f"{row['backend']}/{row['model']}"] = {
                "request_count": row["request_count"],
                "total_cost_usd": float(row["total_cost"]),
                "avg_cost_per_request": float(row["avg_cost_per_request"]),
                "avg_latency_ms": float(row["avg_latency_ms"]),
            }
        
        return results
    
    async def calculate_savings(self, days: int = 7) -> dict:
        """Calculate savings from routing vs 100% Bedrock."""
        
        from datetime import timedelta
        start_date = datetime.now(timezone.utc) - timedelta(days=days)
        
        # Actual cost
        actual_cost = await self.db.fetchval(
            "SELECT SUM(cost_usd) FROM request_costs WHERE timestamp >= $1",
            start_date,
        )
        
        # Hypothetical cost if all on Bedrock Claude
        total_requests = await self.db.fetchval(
            "SELECT COUNT(*) FROM request_costs WHERE timestamp >= $1",
            start_date,
        )
        
        avg_bedrock_cost = 0.02  # $0.02 per request estimate
        hypothetical_bedrock_cost = total_requests * avg_bedrock_cost
        
        savings = hypothetical_bedrock_cost - float(actual_cost)
        savings_pct = (savings / hypothetical_bedrock_cost) * 100
        
        return {
            "actual_cost_usd": float(actual_cost),
            "hypothetical_bedrock_cost_usd": hypothetical_bedrock_cost,
            "savings_usd": savings,
            "savings_percentage": savings_pct,
        }

# Usage example
async def analyze_costs():
    pool = await asyncpg.create_pool(database="ai_platform")
    tracker = CostTracker(pool)
    
    breakdown = await tracker.get_cost_breakdown(days=7)
    print("Cost breakdown (last 7 days):")
    for model, stats in breakdown.items():
        print(f"  {model:30} ${stats['total_cost_usd']:8.2f} ({stats['request_count']:,} requests)")
    
    savings = await tracker.calculate_savings(days=7)
    print(f"\nSavings vs 100% Bedrock: ${savings['savings_usd']:.2f} ({savings['savings_percentage']:.1f}%)")

Where the money actually goes

The cost tracker gives you the data; the harder part is reading it correctly. A few things that distort the picture:

Amortized vLLM cost depends entirely on utilization. The $0.0003/1K tokens figure in the routing config assumes a saturated GPU. At 20% utilization the real per-token cost is 5x higher and the Bedrock comparison flips for smaller models. Compute it from actual GPU-hours and actual tokens served, weekly, and update the routing config.

Bedrock input tokens dominate. For RAG-style workloads, input tokens are typically 10-20x output tokens. Prompt caching on Bedrock (where supported) and semantic caching at the gateway both attack this directly. Check current per-model rates on the Bedrock pricing page; the numbers in the code above are illustrative.

Idle GPU nodes are the silent cost. A g5.12xlarge on-demand runs in the neighborhood of $5-6/hour; a replica that sits idle overnight costs more than a day of Bedrock calls for a small tenant. Scale-to-zero outside business hours, or shift batch work into the idle window.

Integrate with cloud infrastructure cost optimization.


Monitoring and Observability

Monitor latency, costs, and quality across both backends.

Prometheus Metrics

python
# metrics.py
from prometheus_client import Counter, Histogram, Gauge

# Request metrics
requests_total = Counter(
    "ai_requests_total",
    "Total AI requests",
    ["model", "backend", "status"],
)

request_latency = Histogram(
    "ai_request_latency_seconds",
    "Request latency",
    ["model", "backend"],
    buckets=[0.1, 0.5, 1.0, 2.0, 5.0, 10.0],
)

request_cost = Histogram(
    "ai_request_cost_usd",
    "Request cost in USD",
    ["model", "backend"],
    buckets=[0.0001, 0.001, 0.01, 0.1, 1.0],
)

# Token metrics
tokens_processed = Counter(
    "ai_tokens_total",
    "Total tokens processed",
    ["model", "backend", "type"],  # type: input/output
)

# Backend health
backend_available = Gauge(
    "ai_backend_available",
    "Backend availability (1=up, 0=down)",
    ["backend"],
)

# Queue depth
queue_depth = Gauge(
    "ai_queue_depth",
    "Number of queued requests",
    ["backend"],
)

Grafana Dashboard

yaml
# grafana-dashboard.json (excerpt)
{
  "dashboard": {
    "title": "AI Platform Overview",
    "panels": [
      {
        "title": "Requests per Second by Backend",
        "targets": [
          {"expr": "rate(ai_requests_total[5m])"}
        ]
      },
      {
        "title": "Cost per Hour",
        "targets": [
          {"expr": "sum(rate(ai_request_cost_usd[1h])) * 3600"}
        ]
      },
      {
        "title": "P95 Latency by Model",
        "targets": [
          {"expr": "histogram_quantile(0.95, ai_request_latency_seconds_bucket)"}
        ]
      },
      {
        "title": "Backend Distribution",
        "targets": [
          {"expr": "sum(rate(ai_requests_total[5m])) by (backend)"}
        ]
      }
    ]
  }
}

Alerts that matter for a hybrid platform

Dashboards are for humans reading them; alerts are what keep you from finding out about a problem from a customer. The ones that have paid for themselves:

  • Backend share drift. If the Bedrock fraction of traffic rises above its budgeted ceiling for 15 minutes, something in routing changed or a fallback is firing constantly.
  • vLLM queue depth. Sustained queue depth above the batch size means you're under-provisioned; the autoscaler should already be reacting, and if it isn't, you're about to see latency SLA breaches.
  • Cost rate. sum(rate(ai_request_cost_usd[1h])) * 3600 against a per-hour budget. A prompt template change that doubles input tokens shows up here before it shows up on the invoice.
  • Bedrock throttling rate. A rising ThrottlingException count means you're at quota and the fallback path is now load-bearing.

Trace individual requests across gateway and backend with OpenTelemetry; the LLM tracing with OpenTelemetry guide covers span design for LLM calls.

Deploy with observability services.


Frequently Asked Questions

When should I use Bedrock vs self-hosted vLLM?

Use Bedrock when volume is low, the workload needs a frontier model, or your team can't absorb GPU operations. Use self-hosted vLLM when volume is high enough to keep GPUs saturated and an open-weight model meets the quality bar. Most platforms end up hybrid, with a routing layer deciding per request.

How much does vLLM save compared to Bedrock?

At high utilization, self-hosted vLLM is typically 10-50x cheaper per token than Bedrock frontier models. The gap shrinks fast as GPU utilization drops, because vLLM cost is per GPU-hour while Bedrock cost is per token. Measure your own utilization before assuming the savings.

Can I run an AI platform on EKS without GPUs?

Yes, if every model call goes to Bedrock. The EKS cluster then hosts only the gateway, caching, and observability, all on CPU nodes. Add GPU node groups later when volume justifies self-hosting; the gateway abstraction means application code doesn't change.

How do I handle Bedrock rate limits?

Implement exponential backoff with jitter, queue excess requests, and fail over to a vLLM model when Bedrock throttles. For sustained high volume, request a quota increase or purchase provisioned throughput. Track the throttling rate as a first-class metric.

Should I cache LLM responses at the gateway?

Yes, for any workload with repeated or near-duplicate queries. Exact-match caching handles identical prompts; semantic caching catches paraphrases and commonly reaches 40-60% hit rates on support and search workloads. Cache before routing so hits cost nothing on either backend.

How do I monitor model quality across two backends?

Log user feedback, sample a small fraction of requests to both backends, and score them with an LLM judge. Track quality per model and per routing decision, not just per endpoint. A routing change that shifts traffic to a cheaper model should show up as a measurable quality delta, not as a surprise.

Does the gateway need to be OpenAI-compatible?

It doesn't have to be, but it saves a lot of integration work. Most client libraries, agent frameworks, and evaluation tools already speak the OpenAI chat completions shape. vLLM serves it natively, and translating Bedrock responses into it is a small adapter.


Conclusion

AI platform on AWS EKS with Bedrock + vLLM hybrid architecture balances cost and ops:

  • Bedrock provides managed models with zero infrastructure
  • vLLM on EKS cuts costs 10-50x for high-volume workloads
  • Unified API abstracts backends from application code
  • Intelligent routing sends queries to optimal backend
  • Cost tracking identifies optimization opportunities
  • Monitoring ensures quality across both backends

Hybrid architecture typically saves 50-70% vs Bedrock-only for workloads with a meaningful share of high-volume, simple traffic.

If you're planning an AI platform on AWS EKS and want a second opinion on the Bedrock/vLLM split, contact HinterBuild or see our Kubernetes platform engineering services.

Free consultation

Book a free consultation call on AI platform architecture on AWS

30-minute call with the HinterBuild team. Discuss your project, architecture questions, or next steps — no obligation.

Book a meeting

Keep reading