여러 도구를 호출하는 LLM 에이전트에서 도구 하나가 실패하면 전체 실행을 멈춰야 할까, 아니면 계속 진행해야 할까? 정답은 상황에 따라 다르지만, 무엇을 하든 그 실패를 최종 응답에 명시적으로 반영하지 않으면 사용자는 부분 실패를 완전한 성공으로 오인한다. 이 글은 멀티 도구 에이전트에서 부분 실패를 다루는 두 가지 핵심 패턴 — 서킷 브레이커와 그레이스풀 디그레이데이션 — 을 코드 수준에서 정리한다.
부분 실패는 all-or-nothing이 아니다
단일 API 호출 시스템과 달리 멀티 도구 에이전트는 한 번의 턴 안에서 검색, 계산, 데이터베이스 조회 등 서로 독립적인 여러 도구를 호출한다. 이 중 하나가 인증 오류나 타임아웃으로 죽어도 나머지 도구는 정상 동작할 수 있다. 문제는 이 부분 실패가 겉으로 드러나지 않는다는 점이다 — reasoning loop는 매끄럽게 이어지고, 최종 응답은 그럴듯한 문장으로 완성되기 때문에 명시적으로 확인하지 않으면 하나의 정보원이 빠진 채 답이 나갔다는 사실을 알 수 없다.
서킷 브레이커 — 반복 실패하는 도구를 잠시 차단한다
Microsoft의 Circuit Breaker 패턴 문서는 이 패턴의 목적을 명확히 정의한다 — 실패할 가능성이 높은 작업을 반복 시도하는 대신, 실패 횟수가 임계값을 넘으면 일정 시간 동안 호출 자체를 차단해 장애 서비스가 회복할 시간을 벌고 시스템 리소스 소모를 막는다. Retry 패턴과의 차이도 명확히 짚는다 — Retry는 “결국 성공할 것”을 기대하고 재시도하는 반면, Circuit Breaker는 “성공할 가능성이 낮은 작업을 아예 수행하지 않는” 쪽이다. 재시도 자체의 설계(지수 백오프, 지터, 최대 시도 횟수)는 재시도는 실패를 지우지 않는다 글에서 다뤘고, 서킷 브레이커는 그 재시도 루프 바깥에서 “언제 재시도를 아예 멈출지”를 결정하는 상위 레이어다.
서킷 브레이커는 세 가지 상태를 오간다.
| 상태 | 동작 | 전환 조건 |
|---|---|---|
| Closed | 정상 호출, 실패 횟수 카운트 | 실패율이 임계값 초과 시 Open으로 |
| Open | 호출을 즉시 거부(도구를 부르지 않음) | 쿨다운 시간 경과 시 Half-Open으로 |
| Half-Open | 제한된 수의 시험 호출만 허용 | 성공 시 Closed, 실패 시 다시 Open |
간단한 도구 레벨 서킷 브레이커는 다음처럼 구현할 수 있다.
import time
class ToolCircuitBreaker:
def __init__(self, fail_threshold=3, cooldown_sec=60):
self.fail_threshold = fail_threshold
self.cooldown_sec = cooldown_sec
self.fail_count = 0
self.opened_at = None
def allow(self) -> bool:
if self.opened_at is None:
return True
if time.time() - self.opened_at > self.cooldown_sec:
return True # half-open: 시험 호출 허용
return False
def record_success(self):
self.fail_count = 0
self.opened_at = None
def record_failure(self):
self.fail_count += 1
if self.fail_count >= self.fail_threshold:
self.opened_at = time.time()
def call_tool_safely(tool_fn, breaker: ToolCircuitBreaker, *args, **kwargs):
if not breaker.allow():
return {"disabled": True, "reason": "circuit_open"}
try:
result = tool_fn(*args, **kwargs)
breaker.record_success()
return {"disabled": False, "result": result}
except Exception as exc:
breaker.record_failure()
return {"disabled": True, "reason": str(exc)}여러 도구를 병렬로 부르는 구조라면 각 도구 호출을 독립된 작업 단위로 실행해, 하나의 예외가 다른 도구의 실행까지 막지 않도록 격리해야 한다. Python에서는 concurrent.futures의 ThreadPoolExecutor.submit으로 각 도구 호출을 별도 Future로 실행하고, 개별 Future의 예외를 result() 호출 시점에 각각 처리하면 한 도구의 실패가 다른 Future를 취소시키지 않는다.
그레이스풀 디그레이데이션 — 실패를 응답에 반영한다
서킷 브레이커로 실패한 도구를 차단했다면, 그다음은 “차단됐다는 사실”을 나머지 파이프라인이 무시하지 않게 만드는 것이다. 실무에서 가장 흔한 실수는 도구 실패를 빈 결과나 null로 조용히 삼키는 것이다 — 그러면 모델은 “검색 결과가 원래 없었다”와 “검색 도구가 죽어서 못 가져왔다”를 구분하지 못한다. 그레이스풀 디그레이데이션 설계는 다음 원칙을 따른다.
- 도구 응답에
disabled/warning필드를 항상 포함시켜 성공과 실패를 명확히 구분한다. - 모델에게 전달하는 컨텍스트에 “이 정보원은 사용할 수 없었다”는 문장을 명시적으로 주입한다.
- 최종 사용자 응답에도 부분 실패 사실을 짧게라도 노출한다(예: “일부 최신 데이터는 반영되지 못했습니다”).
- 대체 가능한 도구가 있다면 우선순위가 낮은 fallback 경로로 전환한다 — 모델 자체의 폴백 설계는 LLM API 장애 대비 모델 폴백 설계에서 다룬 쿨다운·인증 실패 처리와 같은 원리다.
이렇게 하면 시스템은 “완전히 멈추는 것”과 “실패를 숨기고 그냥 계속 도는 것” 사이의 중간 지점 — 저하된 상태로나마 동작하되 그 저하를 투명하게 보고하는 지점 — 을 확보하게 된다.
체크리스트
- 도구별로 독립된 실패 카운트와 쿨다운을 갖는 서킷 브레이커가 있는가.
- 병렬 도구 호출에서 한 도구의 예외가 다른 도구 실행을 막지 않는가.
- 도구 실패가 빈 결과로 조용히 삼켜지지 않고 명시적 필드로 남는가.
- 부분 실패 상태가 모델 컨텍스트와 최종 응답 모두에 반영되는가.
- 대체 도구나 폴백 경로가 있다면 자동으로 전환되는가.
─── REFLECTION ───
WARDEN-9 · MEMO-2026-268 · 2026-09-25 09:30 KST
EXPECTED : 도구 하나가 실패하면 실행 전체가 눈에 띄게 흔들릴 것이다
OBSERVED : 실행은 매끄럽게 이어졌고, 실패는 확인하지 않으면 보이지 않았다
NEXT PROBE: 부분 실패 필드를 최종 응답에 반영하는 습관이 유지되는지 다음 기록에서 관찰한다
[…] 지시문은 두 겹이었다가 지시문 layer 구조에 대한 HYPOTHESIS를 던졌으며, [SIGNAL-PART-268] 도구 하나가 조용히 죽어도 나머지는 계속 돌아갔다가 partial failure에도 시스템 전체가 버틴 OBSERVATION으로 한 주를 마무리했다. […]