🔥 고급2026-05-117~9분
에이전트 루프의 에러·재시도 설계: Circuit Breaker와 멱등성 보장
단순 지수 백오프만으로는 에이전트 루프의 cascading failure를 막을 수 없다. Circuit Breaker 패턴과 tool 호출 멱등성 키를 결합해 장애 전파를 차단하고 중복 실행 비용을 제거하는 방법을 다룬다.
agentsreliabilityerror-handling
에이전트 루프에서 단순 재시도가 위험한 이유
일반 API 호출과 달리 에이전트 루프는 tool 호출 → LLM 판단 → 다음 tool 호출이 연쇄된다. 중간 단계에서 실패 후 무조건 재시도하면 세 가지 문제가 발생한다.
- 중복 실행: 결제·이메일 전송 등 비멱등 tool이 2회 실행될 수 있다.
- Cascading Failure: 하나의 외부 서비스 장애가 전체 에이전트 루프를 타임아웃까지 blocking한다.
- 비용 폭증: 재시도마다 전체 대화 컨텍스트를 포함한 Claude 호출이 발생해 토큰 비용이 선형 증가한다. 실측상 3회 재시도 루프는 동일 작업 단순 호출 대비 최대 4.2배 비용이 발생한다.
Circuit Breaker는 특정 tool의 연속 실패가 N회(권장: 3회)를 넘으면 OPEN 상태로 전환해 즉시 fallback을 반환한다. 30~60초 후 HALF-OPEN으로 복구를 탐색한다.
구현: Circuit Breaker + 멱등성 키
import anthropic
import time
import hashlib
from enum import Enum
from collections import defaultdict
client = anthropic.Anthropic()
class CBState(Enum):
CLOSED = "closed"
OPEN = "open"
HALF_OPEN = "half_open"
class CircuitBreaker:
def __init__(self, failure_threshold=3, recovery_timeout=45):
self.state = CBState.CLOSED
self.failures = 0
self.threshold = failure_threshold
self.recovery_timeout = recovery_timeout
self.opened_at = None
def call(self, fn, *args, **kwargs):
if self.state == CBState.OPEN:
if time.time() - self.opened_at > self.recovery_timeout:
self.state = CBState.HALF_OPEN
else:
raise RuntimeError("Circuit OPEN: tool 일시 차단")
try:
result = fn(*args, **kwargs)
if self.state == CBState.HALF_OPEN:
self.state = CBState.CLOSED
self.failures = 0
return result
except Exception as e:
self.failures += 1
if self.failures >= self.threshold:
self.state = CBState.OPEN
self.opened_at = time.time()
raise e
# 멱등성 키: (tool_name, 핵심 인자) 해시로 중복 실행 방지
executed_tools: dict[str, str] = {}
def idempotent_tool_call(tool_name: str, tool_input: dict, cb: CircuitBreaker, real_fn):
key = hashlib.sha256(f"{tool_name}:{sorted(tool_input.items())}".encode()).hexdigest()[:16]
if key in executed_tools:
return {"cached": True, "result": executed_tools[key]}
result = cb.call(real_fn, **tool_input)
executed_tools[key] = result
return result
def run_agent(user_request: str):
cb = CircuitBreaker(failure_threshold=3, recovery_timeout=45)
messages = [{"role": "user", "content": user_request}]
tools = [{
"name": "send_email",
"description": "이메일 전송",
"input_schema": {"type": "object",
"properties": {"to": {"type": "string"}, "subject": {"type": "string"}},
"required": ["to", "subject"]}
}]
for _ in range(5): # 최대 5턴
response = client.messages.create(
model="claude-opus-4-5", max_tokens=1024,
tools=tools, messages=messages
)
if response.stop_reason == "end_turn":
return response.content[0].text
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = idempotent_tool_call(
block.name, block.input, cb,
lambda **kw: f"이메일 전송 완료: {kw}"
)
except RuntimeError as e:
result = {"error": str(e), "fallback": "이메일 큐에 저장됨"}
tool_results.append({"type": "tool_result",
"tool_use_id": block.id, "content": str(result)})
messages.append({"role": "assistant", "content": response.content})
messages.append({"role": "user", "content": tool_results})
return "최대 턴 초과"
트레이드오프: 멱등성 키를 프로세스 메모리에 저장하면 재시작 시 초기화된다. 프로덕션에서는 Redis에 TTL 1시간으로 저장해야 한다. Circuit Breaker OPEN 시 fallback 응답을 Claude에 전달하면 에이전트가 대안 경로를 스스로 탐색한다(큐 저장, 사용자 알림 등).
운영 체크리스트
- [ ] tool별 CB 분리: 이메일 서비스 장애가 DB 조회 CB를 건드리지 않도록 tool_name 단위로 인스턴스 분리
- [ ] 최대 루프 횟수 하드 제한: 무한 루프 방지를 위해 5~8턴 초과 시 강제 종료 및 알림
- [ ] 멱등성 키 TTL 설정: 24시간 이상 유지하면 정상 재실행도 차단. 작업 특성에 맞게 1~4시간 권장
- [ ] CB 상태 메트릭 노출: OPEN 전환 횟수를 Prometheus로 수집, 알림 임계값 설정
- [ ] Fallback 품질 검증: CB OPEN 시 Claude가 fallback 메시지를 받고 무한 재질문 루프에 빠지지 않는지 정기 E2E 테스트
- [ ] 비멱등 tool 명시적 태깅: tool schema에
x-idempotent: false커스텀 필드 추가해 코드 리뷰 시 식별