Starphix
Agentic Engineering / Sources
The Lab / Sources / Agentic Engineering / Production/Scaling-Strategies.md

Scaling Strategies

Patterns for scaling agent systems: horizontal, vertical, queue-based, and adaptive.


Scaling Taxonomy

Scaling Strategies
├── Horizontal (Scale Out)
│   ├── Stateless Replicas
│   ├── Sharding
│   ├── Partitioning
│   └── Load Balancing
├── Vertical (Scale Up)
│   ├── Resource Limits
│   ├── Instance Sizing
│   └── Hardware Acceleration
├── Queue-Based
│   ├── Work Queues
│   ├── Priority Queues
│   ├── Dead Letter Queues
│   └── Backpressure
├── Adaptive
│   ├── Predictive Scaling
│   ├── Schedule-Based
│   ├── Metric-Based
│   └── ML-Based
└── Database
    ├── Read Replicas
    ├── Sharding
    ├── Connection Pooling
    └── Caching Layers

Horizontal Scaling

Stateless Agent Replicas

# Kubernetes HPA for agent replicas
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: agent-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: agent-service
  minReplicas: 3
  maxReplicas: 100
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Pods
    pods:
      metric:
        name: http_requests_per_second
      target:
        type: AverageValue
        averageValue: "50"
  - type: External
    external:
      metric:
        name: queue_depth
        selector:
          matchLabels:
            queue: agent-tasks
      target:
        type: Value
        value: "100"
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
      - type: Percent
        value: 10
        periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 0
      policies:
      - type: Percent
        value: 100
        periodSeconds: 15
      - type: Pods
        value: 4
        periodSeconds: 15
      selectPolicy: Max

Sharding Strategies

class ShardingStrategy:
    def __init__(self, num_shards: int):
        self.num_shards = num_shards

    def get_shard(self, key: str) -> int:
        """Consistent hashing for session affinity"""
        return int(hashlib.md5(key.encode()).hexdigest(), 16) % self.num_shards

    def get_shard_for_user(self, user_id: str) -> int:
        """User-based sharding for data locality"""
        return self.get_shard(f"user:{user_id}")

    def get_shard_for_session(self, session_id: str) -> int:
        """Session-based sharding"""
        return self.get_shard(f"session:{session_id}")

class ShardedAgentPool:
    def __init__(self, num_shards: int, agent_factory: Callable):
        self.shards = [agent_factory() for _ in range(num_shards)]
        self.strategy = ShardingStrategy(len(self.shards))

    async def execute(self, task: Task, shard_key: str = None) -> Result:
        if shard_key is None:
            shard_key = task.session_id or task.user_id

        shard_id = self.strategy.get_shard(shard_key)
        agent = self.shards[shard_id]
        return await agent.execute(task)

Load Balancing

class LoadBalancer:
    def __init__(self, agents: list[Agent]):
        self.agents = agents
        self.strategy = "least_connections"

    async def route(self, request: Request) -> Agent:
        if self.strategy == "round_robin":
            return self.round_robin()
        elif self.strategy == "least_connections":
            return min(self.agents, key=lambda a: a.active_connections)
        elif self.strategy == "least_latency":
            return min(self.agents, key=lambda a: a.avg_latency_ms)
        elif self.strategy == "weighted":
            return self.weighted_random()
        elif self.strategy == "consistent_hash":
            return self.consistent_hash(request.session_id)

    def round_robin(self) -> Agent:
        agent = self.agents[self.current_index]
        self.current_index = (self.current_index + 1) % len(self.agents)
        return agent

    def weighted_random(self) -> Agent:
        total_weight = sum(a.weight for a in self.agents)
        r = random.uniform(0, total_weight)
        for agent in self.agents:
            r -= agent.weight
            if r <= 0:
                return agent
        return self.agents[-1]

Vertical Scaling

Resource Limits

# Kubernetes resource management
resources:
  requests:
    memory: "512Mi"
    cpu: "250m"
    nvidia.com/gpu: "1"  # For GPU workloads
  limits:
    memory: "2Gi"
    cpu: "2000m"
    nvidia.com/gpu: "1"

# Vertical Pod Autoscaler
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: agent-service-vpa
spec:
  targetRef:
    apiVersion: "apps/v1"
    kind: Deployment
    name: agent-service
  updateMode: "Auto"
  resourcePolicy:
    containerPolicies:
    - containerName: agent
      minAllowed:
        memory: "256Mi"
        cpu: "100m"
      maxAllowed:
        memory: "4Gi"
        cpu: "4000m"

Instance Sizing Guidelines

Workload CPU Memory GPU Use Case
Light 0.5-1 vCPU 1-2 GiB - Simple chat, lookup
Standard 2-4 vCPU 4-8 GiB - RAG, tools, coding
Heavy 4-8 vCPU 16-32 GiB - Complex reasoning, long context
GPU Light 4-8 vCPU 16-32 GiB 1x T4/A10G Small model inference
GPU Heavy 16-32 vCPU 64-128 GiB 1-4x A100/H100 Large model, training
Memory Optimized 8-16 vCPU 64-128 GiB - Large context, embeddings

Queue-Based Scaling

Work Queue Architecture

class WorkQueue:
    def __init__(self, redis: Redis, queue_name: str):
        self.redis = redis
        self.queue_name = queue_name
        self.dlq_name = f"{queue_name}:dlq"

    async def enqueue(self, task: Task, priority: int = 0) -> str:
        task_id = str(uuid4())
        task_data = json.dumps({
            "id": task_id,
            "task": task.to_dict(),
            "created_at": datetime.utcnow().isoformat(),
            "retries": 0
        })

        # Priority queue: higher score = higher priority
        score = -priority  # Negative for max-heap behavior
        await self.redis.zadd(self.queue_name, {task_data: score})
        return task_id

    async def dequeue(self, worker_id: str, count: int = 10) -> list[Task]:
        # Atomic pop with Lua script
        lua_script = """
        local tasks = redis.call('ZRANGE', KEYS[1], 0, ARGV[1]-1)
        if #tasks > 0 then
            redis.call('ZREM', KEYS[1], unpack(tasks))
        end
        return tasks
        """
        tasks_json = await self.redis.eval(
            lua_script, 1, self.queue_name, count
        )
        return [json.loads(t) for t in tasks_json]

    async def requeue(self, task: Task, error: str):
        task.retries += 1
        if task.retries >= 3:
            await self.move_to_dlq(task, error)
        else:
            await self.enqueue(task, priority=-1)  # Lower priority for retry

class WorkerPool:
    def __init__(self, queue: WorkQueue, agent_factory: Callable, 
                 max_workers: int = 10):
        self.queue = queue
        self.agent_factory = agent_factory
        self.max_workers = max_workers
        self.workers: list[asyncio.Task] = []

    async def start(self):
        for i in range(self.max_workers):
            worker = asyncio.create_task(self.worker_loop(f"worker-{i}"))
            self.workers.append(worker)

    async def worker_loop(self, worker_id: str):
        agent = self.agent_factory()
        while True:
            tasks = await self.queue.dequeue(worker_id, count=5)
            for task_data in tasks:
                try:
                    task = Task.from_dict(task_data["task"])
                    result = await agent.execute(task)
                    await self.handle_result(task_data["id"], result)
                except Exception as e:
                    await self.queue.requeue(Task.from_dict(task_data["task"]), str(e))

Priority Queues

class PriorityQueue:
    def __init__(self, redis: Redis, base_name: str):
        self.redis = redis
        self.queues = {
            "critical": f"{base_name}:critical",
            "high": f"{base_name}:high",
            "normal": f"{base_name}:normal",
            "low": f"{base_name}:low"
        }
        self.weights = {"critical": 100, "high": 10, "normal": 1, "low": 0.1}

    async def enqueue(self, task: Task, priority: str = "normal"):
        await self.redis.zadd(self.queues[priority], 
                             {json.dumps(task.to_dict()): time.time()})

    async def dequeue(self, count: int = 10) -> list[Task]:
        # Weighted round-robin across priorities
        for priority in ["critical", "high", "normal", "low"]:
            tasks = await self.redis.zpopmin(self.queues[priority], count)
            if tasks:
                return [json.loads(t[0]) for t in tasks]
        return []

Backpressure & Rate Limiting

class BackpressureController:
    def __init__(self, max_queue_size: int = 10000, 
                 max_latency_ms: int = 5000):
        self.max_queue_size = max_queue_size
        self.max_latency_ms = max_latency_ms
        self.current_load = 0

    async def check_backpressure(self) -> BackpressureSignal:
        queue_size = await self.get_queue_size()
        p99_latency = await self.get_p99_latency()

        if queue_size > self.max_queue_size * 0.9:
            return BackpressureSignal.REJECT
        elif queue_size > self.max_queue_size * 0.7:
            return BackpressureSignal.SLOW_DOWN
        elif p99_latency > self.max_latency_ms:
            return BackpressureSignal.SLOW_DOWN
        return BackpressureSignal.NORMAL

    async def apply_backpressure(self, signal: BackpressureSignal):
        if signal == BackpressureSignal.REJECT:
            # Return 503 with Retry-After
            raise BackpressureException(retry_after=30)
        elif signal == BackpressureSignal.SLOW_DOWN:
            # Add delay
            await asyncio.sleep(0.1)

Adaptive Scaling

Predictive Scaling

class PredictiveScaler:
    def __init__(self, history_window: int = 3600):  # 1 hour
        self.history_window = history_window
        self.model = None

    async def train(self, metrics_history: list[MetricsPoint]):
        """Train forecasting model on historical data"""
        # Features: time_of_day, day_of_week, recent_load, trends
        # Target: required_replicas
        X, y = self.prepare_features(metrics_history)
        self.model = GradientBoostingRegressor()
        self.model.fit(X, y)

    async def predict_replicas(self, current_metrics: MetricsPoint) -> int:
        if not self.model:
            return self.default_replicas

        features = self.extract_features(current_metrics)
        predicted = self.model.predict([features])[0]

        # Clamp to bounds
        return max(self.min_replicas, min(self.max_replicas, predicted))

    def extract_features(self, m: MetricsPoint) -> list[float]:
        now = datetime.utcnow()
        return [
            m.current_rps,
            m.avg_latency_ms,
            m.queue_depth,
            m.error_rate,
            m.cpu_utilization,
            m.memory_utilization,
            now.hour,
            now.weekday(),
            # Rolling stats
            self.rolling_avg(m.rps_history, 5),
            self.rolling_avg(m.latency_history, 5),
            self.trend(m.rps_history, 10)
        ]

Schedule-Based Scaling

class ScheduleScaler:
    def __init__(self):
        self.schedules: list[ScheduleRule] = []

    def add_schedule(self, rule: ScheduleRule):
        self.schedules.append(rule)

    def get_desired_replicas(self, now: datetime = None) -> int:
        now = now or datetime.utcnow()

        for rule in self.schedules:
            if rule.matches(now):
                return rule.replicas

        return self.default_replicas

@dataclass
class ScheduleRule:
    name: str
    cron: str  # Cron expression
    replicas: int
    timezone: str = "UTC"

    def matches(self, dt: datetime) -> bool:
        tz = pytz.timezone(self.timezone)
        local_dt = dt.astimezone(tz)
        return croniter.match(self.cron, local_dt)

Metric-Based Scaling (KEDA)

# ScaledObject for KEDA
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: agent-service-scaledobject
spec:
  scaleTargetRef:
    name: agent-service
  pollingInterval: 15
  cooldownPeriod: 300
  minReplicaCount: 3
  maxReplicaCount: 50
  triggers:
  - type: prometheus
    metadata:
      serverAddress: http://prometheus:9090
      metricName: http_requests_per_second
      query: sum(rate(http_requests_total{job="agent-service"}[1m])) by (pod)
      threshold: "50"
      activateOnValue: "10"
  - type: cpu
    metadata:
      type: Utilization
      value: "70"
  - type: memory
    metadata:
      type: Utilization
      value: "80"
  - type: external
    metadata:
      scalerName: keda-scaler-kafka
      topic: agent-tasks
      consumerGroup: agent-workers
      lagThreshold: "100"

Database Scaling

Read Replicas

class DatabasePool:
    def __init__(self, primary: Engine, replicas: list[Engine]):
        self.primary = primary
        self.replicas = replicas
        self.replica_index = 0

    def get_connection(self, read_only: bool = True) -> Connection:
        if read_only and self.replicas:
            # Round-robin
            conn = self.replicas[self.replica_index]
            self.replica_index = (self.replica_index + 1) % len(self.replicas)
            return conn
        return self.primary

    async def execute_read(self, query: str, params: dict = None):
        async with self.get_connection(read_only=True) as conn:
            return await conn.execute(query, params)

    async def execute_write(self, query: str, params: dict = None):
        async with self.primary.begin() as conn:
            return await conn.execute(query, params)

Connection Pooling (PgBouncer)

# pgbouncer.ini
[databases]
agent_db = host=primary.db.example.com port=5432 dbname=agent_db

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 100
min_pool_size = 10
reserve_pool_size = 10
reserve_pool_timeout = 5.0

# Connection limits
max_db_connections = 200
max_user_connections = 100

# Timeouts
server_lifetime = 3600
server_idle_timeout = 600
server_connect_timeout = 15
server_login_retry = 15
query_timeout = 30
query_wait_timeout = 120

# Logging
log_connections = 1
log_disconnections = 1
log_pooler_errors = 1

Sharding

class ShardedDatabase:
    def __init__(self, shards: list[DatabasePool]):
        self.shards = shards
        self.router = ShardingRouter(len(shards))

    def get_shard(self, key: str) -> DatabasePool:
        shard_id = self.router.get_shard(key)
        return self.shards[shard_id]

    async def execute(self, key: str, query: str, params: dict = None, 
                      read_only: bool = True):
        shard = self.get_shard(key)
        return await shard.execute_read(query, params) if read_only \
               else await shard.execute_write(query, params)

# Sharding strategies
class ShardingRouter:
    def __init__(self, num_shards: int, strategy: str = "hash"):
        self.num_shards = num_shards
        self.strategy = strategy

    def get_shard(self, key: str) -> int:
        if self.strategy == "hash":
            return int(hashlib.md5(key.encode()).hexdigest(), 16) % self.num_shards
        elif self.strategy == "range":
            return self.range_based(key)
        elif self.strategy == "consistent_hash":
            return self.consistent_hash(key)
        return 0

Caching Layers

Multi-Level Caching

class MultiLevelCache:
    def __init__(self):
        self.l1 = LRUCache(maxsize=1000)      # In-memory
        self.l2 = RedisCache(ttl=300)         # Redis
        self.l3 = DatabaseCache(ttl=3600)     # Database

    async def get(self, key: str) -> Any:
        # L1
        value = self.l1.get(key)
        if value is not None:
            return value

        # L2
        value = await self.l2.get(key)
        if value is not None:
            self.l1.set(key, value)
            return value

        # L3
        value = await self.l3.get(key)
        if value is not None:
            self.l1.set(key, value)
            await self.l2.set(key, value)
            return value

        return None

    async def set(self, key: str, value: Any, ttl: int = 300):
        self.l1.set(key, value)
        await self.l2.set(key, value, ttl=ttl)
        await self.l3.set(key, value, ttl=ttl)

Cache Invalidation

class CacheInvalidator:
    def __init__(self, cache: MultiLevelCache, pubsub: PubSub):
        self.cache = cache
        self.pubsub = pubsub
        await self.pubsub.subscribe("cache:invalidate", self.on_invalidate)

    async def on_invalidate(self, message: dict):
        keys = message.get("keys", [])
        tags = message.get("tags", [])

        for key in keys:
            await self.cache.delete(key)

        # Tag-based invalidation
        if tags:
            keys_to_invalidate = await self.get_keys_by_tags(tags)
            for key in keys_to_invalidate:
                await self.cache.delete(key)

    async def invalidate(self, keys: list[str] = None, tags: list[str] = None):
        message = {"keys": keys or [], "tags": tags or []}
        await self.pubsub.publish("cache:invalidate", message)

Cost-Aware Scaling

Cost-Aware Autoscaler

class CostAwareAutoscaler:
    def __init__(self, max_budget_usd_per_hour: float = 100.0):
        self.max_budget = max_budget_usd_per_hour
        self.instance_costs = {
            "small": 0.05,    # $0.05/hr
            "medium": 0.15,   # $0.15/hr
            "large": 0.40,    # $0.40/hr
            "xlarge": 0.80,   # $0.80/hr
            "gpu": 2.50       # $2.50/hr
        }

    def calculate_optimal_replicas(self, 
                                   load: LoadMetrics,
                                   current_replicas: int) -> int:
        # Estimate required capacity
        required_capacity = self.estimate_capacity(load)

        # Find cheapest combination
        best_combo = self.find_optimal_combination(required_capacity)

        # Check budget
        estimated_cost = sum(self.instance_costs[t] * c 
                            for t, c in best_combo.items())

        if estimated_cost > self.max_budget:
            # Scale down to fit budget
            return self.scale_to_budget(current_replicas, self.max_budget)

        return sum(best_combo.values())

    def find_optimal_combination(self, capacity: float) -> dict[str, int]:
        # Dynamic programming for knapsack-like problem
        # Maximize capacity within budget
        dp = {}
        # ... implementation
        return best_combination


Sources: Kubernetes HPA/VPA, KEDA, Prometheus Adaptive Scaling, AWS Auto Scaling, GCP Autoscaler, Azure Autoscale, Netflix Scaling, Uber Scaling, Uber Michelangelo, Uber Michelangelo, Lyft Scaling, DoorDash Scaling, Shopify Scaling, Kubernetes Best Practices, HPA/VPA Best Practices, KEDA Best Practices, Prometheus Adaptive Scaling, Custom Metrics Autoscaling, Cluster Autoscaler, Node Autoscaling, Pod Disruption Budgets, Pod Topology Spread Constraints, Priority Classes, Resource Quotas, Limit Ranges, Quality of Service Classes.

From the Agentic Engineering research vault · CC BY 4.0 · all notes · raw markdown