AI 에이전트 — 모델이 아니라 그래프 · 실패·재시도·예산 · 이론
다시 할 것인가, 그리고 얼마까지 쓸 것인가
한 줄 요약
재시도는 한 줄로 켜지지만, 무엇을 다시 할지 정하지 않은 재시도는 같은 실패를 상한까지 반복하는 장치일 뿐이다. 그리고 그 반복은 그대로 호출 예산을 먹는다.
왜 이게 필요했나
에이전트를 그래프로 짜고 나면 어느 날 이런 회고를 쓰게 된다. "결제 게이트웨이가 30초쯤 흔들렸는데, 그 30초 동안 들어온 건이 전부 실패로 끝났다."
그래서 재시도를 켠다. LangGraph 에서는 노드 하나에 한 줄이면 된다.
graph.add_node("settle", settle, retry=RetryPolicy(max_attempts=3))그리고 다음 주에 같은 회고를 또 쓴다. 두 가지 중 하나다.
- 재시도가 안 걸렸다. 정책을 붙였는데 시도 기록에는 여전히 한 줄뿐이다.
- 재시도가 너무 잘 걸렸다. 카드가 거절된 건까지 세 번씩 다시 보냈고, 그 사이 정상 건이 쓸 호출 몫이 사라졌다. 어떤 건은 두 번 청구됐다.
이 세 가지 — 무엇을 다시 할지, 같은 요청이 두 번 반영되지 않게 하는 것, 얼마까지 쓸 것인지 — 는 따로 배우면 각각 쉬운데, 묶어 두지 않으면 반드시 하나가 빠진다.
기본값은 생각보다 적게 재시도한다
RetryPolicy 의 인자를 그대로 적어 보면 이렇다. 이 실습 환경의 langgraph 0.2.60 에서 직접 확인한 값이다.
RetryPolicy(initial_interval=0.5, backoff_factor=2.0, max_interval=128.0, max_attempts=3, jitter=True, retry_on=<기본 함수>)눈여겨볼 것은 마지막 retry_on 이다. 기본 함수의 몸통을 열어 보면 이렇게 되어 있다.
def default_retry_on(exc): if isinstance(exc, ConnectionError): return True if isinstance(exc, (ValueError, TypeError, ArithmeticError, ImportError, LookupError, NameError, SyntaxError, RuntimeError, ReferenceError, StopIteration, StopAsyncIteration, OSError)): return False ... return True즉 기본 정책은 ValueError 를 다시 하지 않는다. RuntimeError 도, OSError 도 마찬가지다. 다시 하는 것은 ConnectionError 와 5xx 로 끝난 HTTP 응답 쪽이다. 생각해 보면 합리적인 기본값이다 — ValueError 나 TypeError 는 대개 우리 코드가 잘못 쓴 것이고, 그런 것은 백 번 다시 해도 같은 결과다.
문제는 우리가 만든 예외가 대개 ValueError 나 RuntimeError 를 물려받는다는 점이다. "게이트웨이가 잠깐 안 된다" 를 class GatewayBusy(RuntimeError) 로 두면, 정책을 붙여 놓고도 한 번도 다시 하지 않는다. 오류도 경고도 나지 않는다. 그래서 정책을 붙였다는 사실만 믿고 넘어가면 몇 주 뒤에 같은 회고를 쓴다.
고치는 방법은 retry_on 을 직접 적는 것이다. 예외 클래스의 튜플을 주거나, 함수를 준다.
RetryPolicy(retry_on=(GatewayBusy,), max_attempts=3)RetryPolicy(retry_on=lambda exc: isinstance(exc, GatewayBusy), max_attempts=3)max_attempts 는 총 시도 횟수이지 추가 재시도 횟수가 아니다. 3 이면 처음 한 번 + 다시 두 번이다. 그리고 세 번을 다 쓰고도 실패하면 마지막 예외가 그대로 올라온다 — 재시도했다는 흔적이 예외에 붙지는 않는다. 그 흔적은 여러분이 직접 세어 남겨야 한다.
다시 해도 되는 실패와 아닌 실패
여기가 실제로 어려운 자리다. 실패를 종류로 나눠 두지 않으면 재시도는 둘 중 하나로 잘못 간다 — 아무것도 다시 안 하거나, 뭐든 다시 하거나.
기준은 하나면 된다. 같은 요청을 그대로 다시 보냈을 때 다른 답이 나올 가능성이 있는가.
- 있다 → 연결이 끊겼다, 시간이 지나 끊겼다, 5xx 가 왔다, 사용량 제한에 걸렸다. 다시 한다.
- 없다 → 인자가 잘못됐다, 없는 계좌다, 승인이 거절됐다, 권한이 없다. 다시 하지 않는다.
영구 실패에 재시도를 걸면 비용이 세 배가 되고 끝난다. 이것은 말로 믿을 일이 아니라 횟수로 봐야 한다. 시도 기록을 세어 보면 영구 실패 한 건에 시도가 세 줄 쌓이는 것이 그대로 보인다. 그 숫자가 곧 남에게 설명할 근거가 된다.
다시 하기 전에 멱등해야 한다
재시도가 안전하려면 조건이 하나 더 필요하다. 같은 요청을 두 번 보내도 한 번만 반영되어야 한다.
시간이 지나 끊긴 요청이 특히 위험하다. 응답을 못 받았을 뿐 저쪽에서는 이미 처리됐을 수 있다. 이때 그대로 다시 보내면 두 번 청구된다. 그래서 요청마다 열쇠를 하나 붙이고, 받는 쪽이 그 열쇠로 "이미 한 건인가" 를 본다. 열쇠는 요청을 만든 쪽이 정해야 한다 — 받는 쪽이 매번 새로 만들면 두 요청이 같은 요청인지 알 길이 없다.
열쇠를 무엇으로 삼느냐가 중요하다. 주문 번호처럼 그 일 하나를 가리키는 값이어야 한다. 시각이나 무작위 값을 쓰면 재시도할 때마다 새 열쇠가 되어 멱등성이 사라진다.
예산은 재시도까지 센다
마지막이 예산이다. 에이전트는 사람이 예상한 것보다 훨씬 많이 부른다. 한 건을 처리하는 데 도구를 몇 번 부르는지 정해 두지 않으면, 한 건이 남의 몫까지 다 쓴다.
여기서 자주 빠뜨리는 것이 재시도도 호출이라는 사실이다. 한 건에 세 번까지 시도한다고 해 두고 열 건을 한 예산 안에서 돌리면, 앞의 두 건이 흔들리기만 해도 뒤의 건은 시도조차 못 한다. 그러니 예산은 "몇 건" 이 아니라 "도구 몸통이 몇 번 돌았는가" 로 세는 편이 정확하다.
그리고 예산이 바닥났을 때 무엇을 할지가 남는다. 답은 재시도가 아니다. 포기하되 결과를 남긴다. 예외를 그대로 올려 보내면 호출한 쪽에는 스택 추적만 남고, 무엇이 되었고 무엇이 안 됐는지는 아무도 모른다. 예산 초과는 실패이지만 예상된 실패이므로 결과 기록의 한 줄이어야 한다.
이 실습은 [Types 레퍼런스](https://reference.langchain.com/python/langgraph/types/)의 RetryPolicy 와 [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api)의 노드 설정을 쓴다. 도구를 어떻게 부르고 결과를 어떻게 검증하는지는 도구 모듈에서 따로 다루고, 여기서는 부른 뒤 실패했을 때 만 본다. 사유를 보고 인자를 고쳐 다시 부르는 것은 그래프 안에서 하는 일이고, 여기서 다루는 것은 같은 요청을 그대로 다시 보내는 틀 쪽의 재시도다 — 층이 다르다.
현장에서 만나는 모습
첫째, 정책을 붙였는데 재시도가 안 된다. 예외가 ValueError 나 RuntimeError 계열이라 기본 retry_on 이 걸러 낸 것이다. 시도 기록이 한 줄뿐인 것으로 알 수 있다.
둘째, 카드 거절에도 세 번씩 보낸다. retry_on 을 너무 넓게 잡았다. 게이트웨이 쪽 사용량 제한에 먼저 걸려서, 정작 다시 해야 할 건이 밀린다.
셋째, 같은 건이 두 번 청구된다. 시간이 지나 끊긴 요청을 다시 보냈는데 받는 쪽에 멱등 열쇠가 없었다. 이 사고는 대개 회계 쪽에서 먼저 발견한다.
넷째, 배치 뒷부분이 통째로 안 돌았다. 앞쪽 몇 건의 재시도가 예산을 다 먹었다. 로그에는 "예산 초과" 도 안 보인다 — 그냥 아무 기록이 없다.
다섯째, 한도를 올려 덮는다. max_attempts 를 10 으로 올리면 그날은 넘어가지만, 영구 실패에 걸리는 비용이 10배가 된다. 올릴 것은 한도가 아니라 가르는 기준이다.
실무에서 진짜 중요한 것
retry_on을 직접 적는다. 기본값은 우리가 만든 예외를 대개 다시 하지 않는다.- 다시 해도 되는 실패만 다시 한다. 기준은 "같은 요청을 그대로 다시 보내면 다른 답이 나올 수 있는가" 하나다.
- 다시 하기 전에 멱등 열쇠를 붙인다. 열쇠는 요청을 만든 쪽이 정하고, 그 일 하나를 가리키는 값이어야 한다.
- 예산은 재시도까지 센다. 건수가 아니라 도구 몸통이 돈 횟수다.
- 포기하더라도 결과를 남긴다. 예산 초과는 예상된 실패이고, 예상된 실패는 기록의 한 줄이어야 한다.
다음 실습에서 할 것
/root/work/agbudget/budget.py 를 한 단계씩 키운다. 먼저 재시도 없는 그래프에서 실패가 한 번만 시도되는 것을 재현하고, RetryPolicy 를 붙여도 기본 retry_on 이 그 실패를 다시 하지 않는다는 것을 시도 횟수로 확인한다. 이어 retry_on 을 명시해 다시 하게 만들고, 영구 실패가 상한까지 반복되는 것을 횟수로 본 뒤 가르는 함수를 넣어 그 숫자를 1로 되돌린다. 그다음 멱등 열쇠로 같은 요청이 두 번 반영되지 않게 하고, 호출 예산을 세어 바닥나면 포기하되 결과를 남기며, 마지막으로 여러 건이 한 예산을 나눠 쓸 때 앞 건의 재시도가 뒤 건의 몫을 먹는 것을 확인한다. 채점기는 여러분의 모듈을 실제로 불러 매번 다른 열쇠·금액·실패 계획으로 돌리고, 시도 횟수를 자기가 따로 계산한 값과 대조한다.