AI Agent

LLM 에이전트 Capacity Engineering — 프로덕션 오류의 1/3이 rate limit인 이유

cell-devlog 2026. 5. 21. 13:40
반응형

에이전트 배포했더니 툭하면 429 터지고, backoff 넣었더니 오히려 더 심해진 경험 있으시죠?

핵심 요약
→ 2026년 3월 Datadog 분석: LLM 오류의 1/3이 rate limit (840만 건)
→ Exponential Backoff는 단일 요청엔 유효, 시스템 전체엔 무력
→ 에이전트는 호출 10~20개를 연속으로 쏨 → 기존 REST 인프라로 감당 불가
→ Token Bucket: 요청 수가 아닌 '토큰 수' 기준으로 속도 제어
→ Priority Lane: 사용자 응답(P0) / API(P1) / 배치(P2) 분리 필수
→ Circuit Breaker: 429 임계치 초과시 즉시 차단, 빠른 복구
→ Load Shedding: 한도 초과 시 낮은 우선순위 요청 드롭
→ 핵심 원칙: Reactive(사후 대응) → Proactive(사전 제어)

실전 1 — 왜 Exponential Backoff만으론 안 되나

Exponential Backoff가 뭔지는 다 알죠. 429 받으면 기다렸다가 재시도, 또 받으면 두 배 기다리는 것.

근데 에이전트 환경에선 이게 오히려 Thundering Herd 문제를 만들어냄.

# ❌ 이렇게만 하면 안 됨 — 단일 호출엔 유효, 시스템 전체는 폭발
import time
import random

def call_llm_with_backoff(prompt, max_retries=5):
    for attempt in range(max_retries):
        try:
            return llm_client.complete(prompt)
        except RateLimitError:
            wait = (2 ** attempt) + random.uniform(0, 1)  # 지수 + 지터
            time.sleep(wait)
    raise Exception("Max retries exceeded")

# 문제: 워커 50개가 동시에 429 받음
# → 전부 같은 시점에 backoff 시작
# → rate limit 창 리셋되면 50개가 동시에 재요청
# → 또 429 → 무한 반복 (Sustained Oscillation)
# 더 깊은 문제: 토큰 수를 무시함
# 50토큰 요청 ≠ 10,000토큰 요청 (비용은 200배 차이)
# RPM 한도 이전에 TPM 한도가 먼저 터짐
# → 요청 수 기반 backoff는 근본 원인을 못 잡음
개념 정리
→ RPM (Requests Per Minute): 분당 요청 수 제한
→ TPM (Tokens Per Minute): 분당 토큰 수 제한
→ 에이전트 워크플로우: 한 작업에 호출 10~20회, 요청당 500토큰 이상이 기본
→ 기존 REST API 트래픽 패턴과 완전히 다름 → 인프라 재설계 필요

실전 2 — Token Bucket Queuing: 토큰 기준 속도 제어

핵심 아이디어: 429 받은 다음에 대응하지 말고, 처음부터 토큰 예산 안에서만 요청을 내보냄.

import threading
import time
from collections import deque
from dataclasses import dataclass

@dataclass
class LLMRequest:
    prompt: str
    estimated_tokens: int      # 요청 전 토큰 수 추정
    max_output_tokens: int
    priority: int = 1
    callback: callable = None

class TokenBucketQueue:
    def __init__(self, tpm_limit: int, rpm_limit: int):
        # 버킷 설정: 프로바이더 한도의 90%로 여유 확보
        self.tpm_limit = tpm_limit * 0.9
        self.rpm_limit = rpm_limit * 0.9
        
        self.token_bucket = tpm_limit * 0.9   # 현재 토큰 잔량
        self.request_bucket = rpm_limit * 0.9
        
        self.refill_rate_tokens = tpm_limit / 60  # 초당 토큰 보충량
        self.refill_rate_requests = rpm_limit / 60
        
        self.queue = deque()
        self.lock = threading.Lock()
        self.last_refill = time.time()
    
    def _refill(self):
        now = time.time()
        elapsed = now - self.last_refill
        
        # 경과 시간만큼 토큰 보충 (최대값 초과 안 함)
        self.token_bucket = min(
            self.tpm_limit,
            self.token_bucket + elapsed * self.refill_rate_tokens
        )
        self.request_bucket = min(
            self.rpm_limit,
            self.request_bucket + elapsed * self.refill_rate_requests
        )
        self.last_refill = now
    
    def can_send(self, request: LLMRequest) -> bool:
        self._refill()
        needed_tokens = request.estimated_tokens + request.max_output_tokens
        return (self.token_bucket >= needed_tokens and 
                self.request_bucket >= 1)
    
    def consume(self, request: LLMRequest):
        needed_tokens = request.estimated_tokens + request.max_output_tokens
        self.token_bucket -= needed_tokens
        self.request_bucket -= 1

    def enqueue(self, request: LLMRequest):
        with self.lock:
            self.queue.append(request)
    
    def process_queue(self, llm_client):
        while True:
            with self.lock:
                if self.queue and self.can_send(self.queue[0]):
                    request = self.queue.popleft()
                    self.consume(request)
                    # 실제 LLM 호출
                    result = llm_client.complete(request.prompt,
                                                  max_tokens=request.max_output_tokens)
                    if request.callback:
                        request.callback(result)
            time.sleep(0.05)  # 50ms 폴링

# 사용 예시
queue = TokenBucketQueue(tpm_limit=100_000, rpm_limit=500)

queue.enqueue(LLMRequest(
    prompt="코드 리뷰해줘",
    estimated_tokens=500,
    max_output_tokens=1000,
    priority=1
))
개념 정리
→ Token Bucket vs Leaky Bucket
→ Token Bucket: 순간 버스트 허용 후 정상 속도로 복귀 → 인터랙티브 트래픽에 적합
→ Leaky Bucket: 모든 트래픽을 일정 속도로 평탄화 → 배치 작업에 적합
→ 응답 헤더에서 실제 잔량 동기화: x-ratelimit-remaining-tokens, retry-after

실전 3 — Priority Lane: 요청 우선순위 분리

Token Bucket으로 속도 제어는 됐는데, 큐 안에서 순서를 어떻게 정하냐가 문제임.

기본 FIFO면 밤새 돌리는 배치 작업이 사용자 채팅을 막아버림.

import heapq
from enum import IntEnum

class Priority(IntEnum):
    INTERACTIVE = 0    # P0: 사용자 직접 대화 — 5초 이내 응답 필수
    API = 1            # P1: 자동화 API 호출 — 수십 초 허용
    BATCH = 2          # P2: 배치/인덱싱 — 몇 시간 허용

@dataclass
class PrioritizedRequest:
    priority: Priority
    timestamp: float
    request: LLMRequest
    
    def __lt__(self, other):
        # 우선순위 같으면 먼저 들어온 것 먼저
        if self.priority == other.priority:
            return self.timestamp < other.timestamp
        return self.priority < other.priority

class PriorityQueueManager:
    def __init__(self):
        self._heap = []
        self._lock = threading.Lock()
    
    def enqueue(self, request: LLMRequest, priority: Priority):
        with self._lock:
            item = PrioritizedRequest(
                priority=priority,
                timestamp=time.time(),
                request=request
            )
            heapq.heappush(self._heap, item)
    
    def dequeue(self) -> LLMRequest | None:
        with self._lock:
            if self._heap:
                return heapq.heappop(self._heap).request
            return None

# 사용 패턴
pq = PriorityQueueManager()

# 사용자 채팅 — 즉시 처리
pq.enqueue(chat_request, Priority.INTERACTIVE)

# GitHub PR 자동 리뷰 — 좀 기다려도 됨
pq.enqueue(pr_review_request, Priority.API)

# 문서 전체 임베딩 — 밤새 처리
pq.enqueue(bulk_embed_request, Priority.BATCH)
개념 정리
→ P0 없을 때 P1·P2 처리 → P0 들어오면 즉시 선점
→ IBM Research: 우선순위 스케줄링으로 SLO 달성률 40~90% 향상
→ vLLM 같은 self-hosted inference: P2 KV캐시 보존 후 중단, P0 완료 후 재개
→ P99 latency 기준으로 모니터링 — 평균 응답속도는 의미 없음

실전 4 — Circuit Breaker: 토큰 인식 차단기

429가 일정 횟수 이상 오면 요청을 아예 막고, 일정 시간 후에 조금씩 재개하는 패턴.

from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"      # 정상 — 요청 통과
    OPEN = "open"          # 차단 — 요청 즉시 거절
    HALF_OPEN = "half_open"  # 복구 시도 중 — 일부만 통과

class TokenAwareCircuitBreaker:
    def __init__(
        self,
        failure_threshold: int = 5,     # 연속 실패 N회시 차단
        token_budget_threshold: float = 0.1,  # 잔여 토큰 10% 미만시 차단
        recovery_timeout: float = 60.0,  # 60초 후 복구 시도
        half_open_max_calls: int = 3
    ):
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.failure_threshold = failure_threshold
        self.token_budget_threshold = token_budget_threshold
        self.recovery_timeout = recovery_timeout
        self.half_open_max_calls = half_open_max_calls
        self.half_open_calls = 0
        self.last_failure_time = None
    
    def can_proceed(self, remaining_token_ratio: float) -> bool:
        if self.state == CircuitState.OPEN:
            # 복구 시간이 지났으면 HALF_OPEN으로 전환
            if time.time() - self.last_failure_time > self.recovery_timeout:
                self.state = CircuitState.HALF_OPEN
                self.half_open_calls = 0
            else:
                return False  # ❌ 아직 차단
        
        if self.state == CircuitState.HALF_OPEN:
            if self.half_open_calls >= self.half_open_max_calls:
                return False  # 한도 초과, 아직 테스트 중
        
        # 토큰 잔량이 너무 적으면 선제 차단
        if remaining_token_ratio < self.token_budget_threshold:
            self._trip()
            return False
        
        return True
    
    def on_success(self):
        if self.state == CircuitState.HALF_OPEN:
            self.half_open_calls += 1
            if self.half_open_calls >= self.half_open_max_calls:
                self.state = CircuitState.CLOSED  # ✅ 완전 복구
                self.failure_count = 0
    
    def on_failure(self):
        self.failure_count += 1
        if self.failure_count >= self.failure_threshold:
            self._trip()
    
    def _trip(self):
        self.state = CircuitState.OPEN
        self.last_failure_time = time.time()
        print(f"[Circuit Breaker] OPEN — {self.recovery_timeout}초 후 복구 시도")

# 실제 호출 흐름
cb = TokenAwareCircuitBreaker()
token_bucket = TokenBucketQueue(tpm_limit=100_000, rpm_limit=500)

def safe_llm_call(prompt: str, estimated_tokens: int):
    remaining_ratio = token_bucket.token_bucket / token_bucket.tpm_limit
    
    if not cb.can_proceed(remaining_ratio):
        raise Exception("Circuit OPEN — 요청 차단됨")
    
    try:
        result = llm_client.complete(prompt)
        cb.on_success()
        return result
    except RateLimitError:
        cb.on_failure()
        raise
개념 정리
→ CLOSED → OPEN → HALF_OPEN → CLOSED 사이클
→ 기존 Circuit Breaker: 실패 횟수만 봄
→ Token-Aware: 남은 토큰 비율까지 봄 → 429 받기 전에 선제 차단
→ HALF_OPEN 단계: 소량 요청으로 복구 여부 확인 후 완전 재개

실전 5 — Load Shedding: 마지막 방어선

위 모든 패턴이 다 작동해도 트래픽 폭증 상황이 오면, 낮은 우선순위 요청을 아예 드롭해야 함. 차라리 P2 요청에 즉시 에러를 돌려줘서 P0를 살리는 전략.

class LoadSheddingLayer:
    def __init__(self, token_bucket: TokenBucketQueue):
        self.token_bucket = token_bucket
    
    def should_shed(self, priority: Priority) -> bool:
        remaining_ratio = (self.token_bucket.token_bucket / 
                          self.token_bucket.tpm_limit)
        
        # ❌ 잔여 토큰 50% 미만 → P2 드롭
        if remaining_ratio < 0.5 and priority == Priority.BATCH:
            return True
        
        # ❌ 잔여 토큰 20% 미만 → P1도 드롭
        if remaining_ratio < 0.2 and priority == Priority.API:
            return True
        
        # ✅ P0는 절대 드롭 안 함
        return False
    
    def process(self, request: LLMRequest, priority: Priority):
        if self.should_shed(priority):
            # 즉시 에러 반환 (대기 없음)
            raise ServiceUnavailableError(
                f"Load shedding active — retry after capacity recovers",
                retry_after=30
            )
        
        # 정상 처리
        return safe_llm_call(request.prompt, request.estimated_tokens)

# ✅ 완성된 스택 요약
# 요청 진입 → Load Shedding → Circuit Breaker → Priority Queue → Token Bucket → LLM API
개념 정리
→ Load Shedding은 에러를 반환하지만 '빠른 에러'임
→ 느린 타임아웃보다 즉각 거절이 시스템 전체에 유리
→ P0 인터랙티브만 100% 보장, 나머지는 여유 있을 때 처리
→ 클라이언트에게 retry-after 헤더로 언제 재시도할지 알려줘야 함

마무리

✅ 이 글 적용 후
→ Token Bucket으로 사전에 TPM 초과 방지
→ Priority Lane으로 사용자 응답 지연 없음
→ Circuit Breaker로 연쇄 429 차단
→ Load Shedding으로 중요 트래픽 100% 보호

❌ 적용 안 하면
→ Exponential Backoff만으로 버티다 Thundering Herd 발생
→ 배치 작업이 사용자 응답 막음
→ 840만 건 rate limit 오류 중 상당수가 내 서비스에서 나옴
→ 에이전트 프로덕션 신뢰성 바닥

관련글

 

AI 에이전트 롤백 전략 완전 가이드 — 에이전트가 망쳤을 때 복구하는 법

에이전트가 프로덕션 DB를 잘못 수정했습니다. 파일 200개를 잘못 덮어썼습니다. 되돌릴 방법이 없습니다. 이 상황을 구조적으로 막는 법을 정리했습니다.[핵심 요약]→ 문제: AI 에이전트는 실수

cell-devlog.tistory.com

 

 

LLM 모델 라우팅 완전 가이드 — 분류기, 캐스케이딩, 시맨틱 캐시 실전

LLM을 프로덕션에 올리면 첫 달 청구서가 이렇게 나와요.예상: $300/월실제: $2,400/월원인 분석해보면 이래요.고객: "배송 얼마나 걸려요?"→ Claude Opus 4.6 응답 ($0.015/1K토큰)고객: "안녕하세요"→ Claud

cell-devlog.tistory.com

 

 

AI 에이전트 옵저버빌리티 완전 가이드 — 에이전트가 뭘 하는지 추적하는 법

AI 에이전트를 프로덕션에 배포하면 이런 일이 생겨요.새벽 3시 알람:"월간 LLM 비용 $2,000 초과"원인 파악 시도:- 로그 확인 → "에러 없음"- API 응답 확인 → "200 OK"- 에이전트 출력 확인 → "정상처

cell-devlog.tistory.com

 


 

반응형