AI 에이전트가 같은 실패를 반복하지 않게: 재시도 계층 나누기, 도구별 서킷 브레이커, 반복 호출 차단

Claude SDK 기본 재시도와 겹치지 않게 재시도를 나누고, 도구 실패를 is_error로 돌려주며 루프에 상한을 거는 Python 구현

에이전트 재시도와 서킷 브레이커 썸네일

핵심 요약

  1. Claude Python SDK는 연결 오류·408·409·429·5xx를 기본 2번 재시도한다. 그 위에 재시도 루프를 또 감싸면 시도 횟수가 곱해지므로 API 호출 재시도는 SDK의 max_retries 하나로만 정한다.
  2. 도구 실행 재시도는 일시적 오류에만 걸고, 대기 시간은 0~min(cap, base×2^n) 사이 무작위(full jitter)로 둔다. 404·권한 없음·잘못된 인자는 재시도하지 않는다.
  3. 도구마다 서킷 브레이커를 두고 연속 실패가 기준을 넘으면 실제 호출 없이 is_error 결과를 돌려준다. 메시지에는 다음에 할 일을 적는다.
  4. 반복 판단은 모델의 설명이 아니라 도구 이름 + 정렬된 입력 JSON의 해시로 한다. 같은 입력 3번째 호출부터 막고, 루프 전체에도 턴 상한을 둔다.

도구를 호출하는 에이전트는 실패 하나를 여러 겹으로 키우기 쉽다. SDK가 API 요청을 재시도하고, 도구 래퍼가 다시 재시도하고, 모델은 실패 메시지를 보고 같은 도구를 같은 입력으로 또 부른다. 이 글은 Claude Python SDK를 쓰는 에이전트를 기준으로 재시도를 계층별로 나누고, 도구별 서킷 브레이커와 반복 호출 차단, 루프 상한을 코드로 구현한 뒤 일부러 실패를 만들어 동작을 확인한다.

실패가 커지는 세 계층과 각자의 멈춤 조건

에이전트 한 번의 실행에는 성격이 다른 실패 지점이 세 군데 있다. 재시도를 한곳에서 모두 처리하려고 하면 계층끼리 겹치므로, 계층마다 재시도 주체와 멈춤 조건을 따로 정한다.

계층대표 실패재시도 주체멈춤 조건
모델 API 호출연결 끊김, 429, 500, 529SDK 내장 재시도max_retries 소진, 재시도 대상이 아닌 4xx
도구 실행외부 API 타임아웃, 파일 없음도구 래퍼(retry_with_jitter)영구 오류, 서킷 브레이커 open
에이전트 루프같은 도구·같은 입력 반복, 끝나지 않는 대화루프 코드같은 호출 허용 횟수, 최대 턴 수

API 호출 계층의 비용 한도와 retry-after 처리는 AI API 비용과 Rate Limit 설계에서 다뤘으므로, 이 글은 그 위의 도구와 루프 계층에 집중한다.

API 호출 재시도는 SDK 하나로만

Python SDK 문서에 따르면 연결 오류, 408, 409, 429, 500 이상의 응답은 짧은 지수 백오프로 기본 2번 자동 재시도된다. 기본 타임아웃은 10분이고, 타임아웃난 요청도 2번 재시도된다. 이 동작을 모르고 messages.create 호출을 다른 재시도 라이브러리로 3번 감싸면, 한 번의 요청이 최대 3 × 3 = 9번 나갈 수 있다. 그래서 API 호출 재시도 횟수는 클라이언트 설정 한 곳에서만 정한다.

from anthropic import Anthropic

# API 호출 재시도·타임아웃은 여기서만 정한다(바깥에 재시도 루프를 또 두지 않는다).
client = Anthropic(max_retries=3, timeout=60.0)

# 특정 요청만 바꿀 때
fast = client.with_options(max_retries=0, timeout=10.0)

SDK가 재시도를 마친 뒤에도 실패하면 예외가 올라온다. API 오류 문서의 상태 코드를 기준으로, 루프에서 받았을 때 할 일을 정리하면 다음과 같다.

상태 · 오류 타입SDK 예외루프에서 할 일
400 invalid_request_errorBadRequestError요청 형식 수정. 조직·워크스페이스 지출 한도 도달도 400으로 온다
401 authentication_error, 403 permission_errorAuthenticationError, PermissionDeniedError즉시 중단하고 키·권한 확인
413 request_too_largeAPIStatusError대화 기록·첨부를 줄여 다시 구성
429 rate_limit_errorRateLimitErrorSDK 재시도 후에도 실패면 중단. 등급 월 지출 한도 429는 retry-after가 없다
500 api_error, 529 overloaded_errorInternalServerErrorSDK 재시도 후에도 실패면 잠시 뒤 작업 단위로 다시 실행
504 timeout_errorInternalServerError긴 응답이면 스트리밍으로 전환

문서는 오류 메시지 문자열을 비교하지 말고 SDK의 예외 클래스를 구체적인 것부터 잡으라고 권한다. 지원 문의에 쓸 수 있도록 응답 객체의 _request_id도 로그에 남긴다.

도구 실행 재시도: 일시적 오류에만, 지터를 넣어서

도구 함수가 부르는 외부 서비스는 SDK의 보호를 받지 않으므로 직접 재시도한다. 먼저 예외를 두 종류로 나눈다. TransientToolError는 타임아웃이나 상대 서버 5xx처럼 잠시 뒤 성공할 수 있는 실패, PermanentToolError는 파일 없음·권한 없음·잘못된 인자처럼 입력이 바뀌지 않으면 계속 실패하는 오류다.

대기 시간은 AWS 아키텍처 블로그의 백오프·지터 글이 설명한 full jitter 방식, 즉 지수적으로 늘어나는 상한 안에서 무작위로 고른다. 이 글은 같은 자원을 두고 경쟁하는 여러 클라이언트를 시뮬레이션해, 지터를 넣은 백오프가 지터 없는 백오프보다 전체 호출 작업량을 크게 줄인다는 결과를 보이고 이를 원격 클라이언트의 표준 방식으로 권한다. 지터 방식 중에서는 equal jitter가 가장 불리했고, full jitter와 decorrelated jitter는 작업량과 소요 시간을 서로 주고받는 관계로 정리했다.

import random
import time


class TransientToolError(Exception):
    """잠시 뒤 다시 하면 성공할 수 있는 실패(타임아웃, 상대 서버 5xx 등)."""


class PermanentToolError(Exception):
    """입력이나 권한이 바뀌지 않으면 계속 실패하는 오류(404, 권한 없음, 잘못된 인자)."""


def retry_with_jitter(fn, *, attempts=3, base=0.5, cap=8.0, sleep=time.sleep):
    """TransientToolError만 재시도한다. 대기 시간은 0 ~ min(cap, base * 2^n) 사이 무작위(full jitter)."""
    for n in range(attempts):
        try:
            return fn()
        except TransientToolError:
            if n == attempts - 1:
                raise
            sleep(random.uniform(0, min(cap, base * 2 ** n)))

PermanentToolError는 잡지 않으므로 첫 실패에서 바로 올라간다. 재시도해도 결과가 같은 오류에 대기 시간을 쓰지 않기 위해서다.

도구별 서킷 브레이커 구현

재시도를 마쳐도 실패하는 도구를 모델이 계속 부르면, 매번 재시도 대기까지 합쳐 시간이 길어진다. Martin Fowler의 CircuitBreaker 글은 세 상태로 이를 막는다.

  1. closed: 호출을 그대로 통과시키고 실패를 센다. 성공하면 실패 수를 0으로 되돌린다.
  2. open: 실패 수가 기준에 닿으면 열린다. 실제 호출 없이 바로 오류를 돌려준다.
  3. half-open: 마지막 실패 뒤 재시험 대기 시간이 지나면 시험 호출 한 번을 통과시킨다. 성공하면 closed, 실패하면 다시 open이다.
class CircuitBreaker:
    """closed → (연속 실패 threshold번) → open → (reset_timeout 경과) → half_open → 시험 호출 1번."""

    def __init__(self, threshold=3, reset_timeout=30.0, clock=time.monotonic):
        self.threshold = threshold
        self.reset_timeout = reset_timeout
        self.clock = clock
        self.failures = 0
        self.state = "closed"
        self.opened_at = 0.0

    def allow(self):
        if self.state == "open" and self.clock() - self.opened_at >= self.reset_timeout:
            self.state = "half_open"
        return self.state != "open"

    def record_success(self):
        self.failures = 0
        self.state = "closed"

    def record_failure(self):
        self.failures += 1
        if self.state == "half_open" or self.failures >= self.threshold:
            self.state = "open"
            self.opened_at = self.clock()

브레이커는 도구마다 하나씩 둔다. 문서 검색 도구가 고장 나도 파일 읽기 도구까지 막을 이유는 없기 때문이다. 영구 오류는 도구 상태가 아니라 입력 문제이므로 실패로 세지 않는다.

같은 입력 반복 감지와 is_error 결과

모델은 "다른 방법으로 다시 시도하겠다"고 설명하면서 실제로는 같은 입력을 보내는 경우가 있다. 그래서 반복 여부는 도구 이름과 키를 정렬한 입력 JSON의 해시로 판단한다.

도구 실패는 예외로 루프를 끊지 말고 tool_result로 돌려준다. 도구 호출 처리 문서는 "is_error": true와 함께 오류 내용을 넣으라고 안내하고, "failed" 같은 일반 문구 대신 무엇이 잘못됐고 다음에 무엇을 하면 되는지 적으라고 권한다. 또 필수 인자가 빠진 잘못된 호출에는 Claude가 2~3번 고쳐서 다시 시도한 뒤 사과한다고 설명한다. 같은 입력 허용 횟수를 2로 두면 이 범위와 맞는다.

import hashlib
import json


def call_key(name, tool_input):
    """도구 이름 + 입력(키 정렬 JSON)의 해시. 모델의 설명이 아니라 실제 입력으로 반복을 판단한다."""
    raw = name + json.dumps(tool_input, sort_keys=True, ensure_ascii=False)
    return hashlib.sha256(raw.encode()).hexdigest()[:16]


class ToolGuard:
    def __init__(self, tools, *, max_same_call=2, breaker_factory=CircuitBreaker, sleep=time.sleep):
        self.tools = tools
        self.max_same_call = max_same_call
        self.breakers = {name: breaker_factory() for name in tools}
        self.seen = {}
        self.sleep = sleep

    def run(self, name, tool_input):
        """tool_result 블록에 넣을 (content, is_error)를 돌려준다. 예외를 루프 밖으로 던지지 않는다."""
        key = call_key(name, tool_input)
        self.seen[key] = self.seen.get(key, 0) + 1
        if self.seen[key] > self.max_same_call:
            return (f"같은 입력으로 {name}을(를) 이미 {self.max_same_call}번 호출했습니다. "
                    "입력을 바꾸거나 다른 방법을 쓰고, 방법이 없으면 사용자에게 필요한 정보를 물어보세요.", True)
        breaker = self.breakers[name]
        if not breaker.allow():
            return (f"{name} 도구가 연속 실패로 잠시 중단됐습니다(서킷 브레이커 open). "
                    "이 도구 없이 진행하거나, 지금까지 확인한 내용으로 답하세요.", True)
        try:
            result = retry_with_jitter(lambda: self.tools[name](**tool_input), sleep=self.sleep)
        except TransientToolError as e:
            breaker.record_failure()
            return (f"일시적 오류로 {name} 실행 실패(재시도 후): {e}", True)
        except PermanentToolError as e:
            # 영구 오류는 도구 상태 문제가 아니라 입력 문제이므로 브레이커를 열지 않는다.
            return (f"{name} 실행 실패(재시도해도 같은 결과): {e}. 입력을 고쳐야 합니다.", True)
        breaker.record_success()
        return (result, False)

도구 쪽 오류를 모델이 고칠 수 있게 나눠 반환하는 설계와, 재시도해도 결과가 한 번만 생기게 하는 멱등성은 MCP 도구 인터페이스 설계에서 더 자세히 다뤘다.

에이전트 루프에 붙이기

준비한 부품을 tool use 루프에 연결하는 순서다.

  1. Anthropic(max_retries=..., timeout=...)으로 API 재시도를 한곳에 고정한다.
  2. 도구 함수를 이름별 딕셔너리로 묶어 ToolGuard에 넘긴다.
  3. 응답의 stop_reason이 tool_use인 동안 tool_use 블록마다 guard.run()을 호출해 tool_result를 만든다.
  4. 결과 블록들을 사용자 메시지의 맨 앞에 넣어 다음 요청을 보낸다(문서상 tool_result가 텍스트보다 먼저 와야 한다).
  5. max_turns에 닿으면 루프를 끝내고, 지금까지 확인한 내용과 멈춘 이유를 함께 반환한다.
def run_agent(client, guard, tools_schema, user_text, *, model="claude-opus-5-5", max_turns=15):
    messages = [{"role": "user", "content": user_text}]
    for turn in range(max_turns):
        response = client.messages.create(
            model=model, max_tokens=2048, tools=tools_schema, messages=messages
        )
        messages.append({"role": "assistant", "content": response.content})
        if response.stop_reason != "tool_use":
            return response, f"완료({turn + 1}턴)"
        results = []
        for block in response.content:
            if block.type == "tool_use":
                content, is_error = guard.run(block.name, block.input)
                results.append({"type": "tool_result", "tool_use_id": block.id,
                                "content": content, "is_error": is_error})
        messages.append({"role": "user", "content": results})
    return None, f"최대 턴({max_turns}) 도달 — 마지막 도구 결과와 함께 사용자에게 확인 요청"

여러 에이전트가 역할을 나눠 일하는 구조라면, 이 루프를 각 Worker 안에 두고 멈춘 이유를 Orchestrator에 넘기는 방식이 Orchestrator와 Worker 패턴과 잘 맞는다.

실패를 일부러 만들어 동작 확인하기

모델 API 없이 ToolGuard만 실행해 정책이 의도대로 걸리는지 확인할 수 있다. 항상 타임아웃나는 search_docs, 항상 파일이 없는 read_file, 정상인 get_status를 만들고, 시계와 sleep을 가짜로 바꿔 대기 없이 돌린다.

from agent_guard import ToolGuard, TransientToolError, PermanentToolError, CircuitBreaker

waits = []
now = [0.0]

def search_docs(query):
    raise TransientToolError("docs API timeout (5s)")

def read_file(path):
    raise PermanentToolError(f"{path} 파일이 없습니다")

def get_status():
    return "ok"

guard = ToolGuard(
    {"search_docs": search_docs, "read_file": read_file, "get_status": get_status},
    breaker_factory=lambda: CircuitBreaker(threshold=3, reset_timeout=30, clock=lambda: now[0]),
    sleep=waits.append,
)

Python 3.13.15에서 실행한 결과는 다음과 같다.

1. search_docs{'query': 'retry'} is_error=True | 일시적 오류로 search_docs 실행 실패(재시도 후): docs API timeout (5s)
2. search_docs{'query': 'backoff'} is_error=True | 일시적 오류로 search_docs 실행 실패(재시도 후): docs API timeout (5s)
3. search_docs{'query': 'jitter'} is_error=True | 일시적 오류로 search_docs 실행 실패(재시도 후): docs API timeout (5s)
4. search_docs{'query': 'circuit'} is_error=True | search_docs 도구가 연속 실패로 잠시 중단됐습니다(서킷 브레이커 open). 이 도구 없이 진행하거나, 지금까지 확인한 내용으로 답하세요.
5. read_file{'path': 'a.md'} is_error=True | read_file 실행 실패(재시도해도 같은 결과): a.md 파일이 없습니다. 입력을 고쳐야 합니다.
6. read_file{'path': 'a.md'} is_error=True | read_file 실행 실패(재시도해도 같은 결과): a.md 파일이 없습니다. 입력을 고쳐야 합니다.
7. read_file{'path': 'a.md'} is_error=True | 같은 입력으로 read_file을(를) 이미 2번 호출했습니다. 입력을 바꾸거나 다른 방법을 쓰고, 방법이 없으면 사용자에게 필요한 정보를 물어보세요.
8. get_status{} is_error=False | ok
search_docs breaker: open | 실제 도구 실행 대기 횟수: 6
31초 뒤: open is_error= True

결과에서 확인할 점은 네 가지다.

  • search_docs는 입력이 매번 달라도 연속 3번 실패하자 4번째 호출부터 실제 실행 없이 막혔다. 대기는 3번 호출 × 2번 = 6번만 일어났다.
  • read_file은 영구 오류라 재시도 대기 없이 바로 실패했고, 같은 입력 3번째 호출에서 반복 차단에 걸렸다.
  • get_status는 다른 도구의 브레이커와 상관없이 정상 실행됐다.
  • 시계를 31초 뒤로 옮기자 half-open 시험 호출이 한 번 나갔고, 여전히 실패해 다시 open이 됐다.

운영에서는 브레이커 상태 변화와 is_error 횟수를 호출 로그에 남겨야 이 정책이 너무 빡빡하거나 느슨한지 판단할 수 있다. 어떤 필드를 남길지는 LLMOps 모니터링 항목의 호출 로그 스키마를 참고한다.

운영에 넣기 전에 정할 값

아래 값은 공식 권장값이 아니라 이 글의 예제가 쓴 시작값이다. 도구의 평균 응답 시간과 실패 비용을 보고 조정한다.

설정예제 값정할 때 볼 것
SDK max_retries3 (기본 2)사용자가 기다릴 수 있는 시간, 다른 재시도 루프가 없는지
SDK timeout60초 (기본 10분)가장 긴 정상 응답 시간. 긴 응답은 스트리밍
도구 attempts / base / cap3회 / 0.5초 / 8초외부 API의 rate limit 정책, 한 턴에 허용할 지연
브레이커 threshold / reset_timeout3회 / 30초외부 서비스 장애가 보통 얼마나 이어지는지
max_same_call2회잘못된 호출에 대한 모델의 자체 수정 횟수(문서상 2~3회)
max_turns15턴정상 작업이 끝나는 턴 수의 분포

초보자가 자주 실수하는 포인트

  • SDK 기본 재시도(2번)를 모른 채 tenacity 같은 재시도 래퍼로 다시 3번 감싸, 한 번의 요청이 최대 9번까지 나가게 만드는 경우
  • 429를 모두 같은 재시도 대상으로 보는 경우 — 등급 월 지출 한도에 걸린 429에는 retry-after 헤더가 없고 한도가 풀릴 때까지 계속 실패한다.
  • 도구 예외를 그대로 루프 밖으로 던져 에이전트 전체가 멈추게 하는 경우 — tool_result의 is_error로 돌려주면 모델이 다른 방법을 찾을 수 있다.
  • 입력 오류(파일 없음)까지 서킷 브레이커 실패로 세어, 정상인 도구를 막아 버리는 경우

체크리스트

  • Anthropic 클라이언트의 max_retries와 timeout을 명시했고, 같은 호출을 감싸는 다른 재시도 루프가 없다.
  • 도구 예외를 일시적 오류와 영구 오류 두 클래스로 나눴다.
  • 도구별 서킷 브레이커의 실패 기준 횟수와 재시험 대기 시간을 정했다.
  • 같은 입력 반복 허용 횟수와 루프 최대 턴 수가 코드에 있다.
  • is_error 메시지에 실패 이유와 다음에 할 일이 함께 들어 있다.

자주 묻는 질문

SDK의 Tool Runner를 쓰면 이 코드가 필요 없나요?

Python SDK의 tool_runner는 도구 호출 결과를 다음 요청에 자동으로 넣어 주는 반복 처리를 대신합니다. 도구별 서킷 브레이커나 같은 입력 반복 차단 같은 정책은 도구 함수 안에서 직접 구현해야 하므로, 이 글의 ToolGuard.run을 도구 함수 안에서 호출하는 식으로 함께 쓸 수 있습니다.

스트리밍 응답 도중 오류가 나면 SDK가 재시도하나요?

공식 오류 문서에 따르면 SSE 스트리밍에서는 200 응답 뒤에도 오류 이벤트가 올 수 있고, 이 경우는 일반 HTTP 오류 처리 방식을 따르지 않습니다. 스트림 중간 오류는 스트리밍 문서의 Error events 형식에 맞춰 별도로 처리해야 합니다.

OpenAI 등 다른 모델 API에도 같은 구조를 쓸 수 있나요?

도구 재시도, 서킷 브레이커, 반복 감지, 턴 상한은 모델 API와 무관한 계층이라 그대로 쓸 수 있습니다. 다만 SDK의 기본 재시도 횟수와 재시도 대상 상태 코드는 SDK마다 다르므로 해당 SDK 문서에서 다시 확인해야 합니다.

참고 자료 · 검증 기준

위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.

이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.