에이전트 배포했더니 툭하면 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
'AI Agent' 카테고리의 다른 글
| OpenTelemetry로 LLM 모니터링 — 블랙박스 에이전트를 투명하게 만드는 법 (0) | 2026.05.21 |
|---|---|
| AI 에이전트 품질 관리 전략 — 프로덕션 킬러 1위가 품질인 이유 (0) | 2026.05.21 |
| Eval-Driven Development 완전 가이드 — AI 에이전트를 TDD처럼 개발하는 법 (0) | 2026.05.21 |
| AI 에이전트 메모리 관리 실전 — 세션 간 상태 유지, 컨텍스트 압축, 레포 재탐색 방지 (0) | 2026.05.21 |
| AI 에이전트 디버깅 실전 — Langfuse·AgentOps·Braintrust 언제 뭘 쓰나 (0) | 2026.05.21 |