← 모든 글

Tool Use Loop 가 멈추지 않을 때 — 안전장치 설계

에이전트 Tool Use Loop가 멈추지 않으면 결제·정산 도메인에서는 중복 청구·이중 정산 위험으로 직결된다. HEDVION 팀의 47초 루프 실사고와 세 겹 안전장치 설계, 즉시 쓸 수 있는 체크리스트.

결제·정산 현장에서 무한 루프는 단순 버그가 아니다

일반적인 소프트웨어에서 무한 루프는 CPU를 잡아먹고 서버를 느리게 만드는 문제다. 불편하지만 고치면 끝난다. 그런데 결제·정산·자동화를 직접 운영하는 팀 입장에서 에이전트 워크플로우의 무한 루프는 차원이 다른 위험을 품고 있다. 루프가 도는 동안 외부 결제 API가 반복 호출되면 중복 청구가 발생할 수 있고, 정산 집계 쿼리가 계속 실행되면 중간 상태로 DB 쓰기가 일어날 수 있다. "에러도 없고 정상적으로 보이는 루프"가 가장 위험한 이유가 여기 있다 — 아무것도 멈추지 않는다.

우리 팀이 에이전트를 프로덕션에 올리기 시작했을 때, 첫 한 달간 가장 많은 시간을 쓴 문제가 바로 이것이었다. 모델이 도구를 호출하고 결과를 받아 다시 도구를 호출하는 루프 구조는 보기엔 우아하지만, 종료 조건을 잘못 설계하면 프로덕션에서 예상치 못한 방식으로 실패한다. 특히 정산처럼 "상태가 연속적으로 변하는" 도메인에서는 모델이 루프 탈출 시점을 스스로 판단하게 놔두는 것이 얼마나 위험한지, 실제로 겪고 나서야 알았다.

루프가 멈추지 않는 원인 — 우리가 놓쳤던 세 번째까지

기존에 정리한 두 가지 원인은 맞다. 하지만 운영하면서 세 번째가 더 있다는 걸 알게 됐다.

첫째, 목표 불명확. "필요한 정보를 모두 모아라"처럼 종료 조건이 모호한 프롬프트는 모델이 언제 멈춰야 할지 스스로 판단하기 어렵게 만든다. 비교적 수정이 쉬운 원인이다. "3개의 결제 수단 데이터를 조회하면 종료한다"처럼 수치화된 조건을 주면 확연히 나아진다. 둘째, 도구 결과 해석 실패. 도구가 빈 배열이나 null을 반환했을 때, 모델이 이를 "아직 못 찾은 것"으로 해석하고 같은 도구를 다른 인자로 다시 호출하는 경우다. 우리 정산 에이전트에서 실제로 이런 일이 있었다 — 특정 날짜 구간에 거래가 없어서 빈 배열이 반환됐는데, 모델이 "쿼리 조건이 잘못됐다"고 해석하고 날짜 범위를 조금씩 바꿔가며 6번 연속 재호출했다.

셋째, 외부 상태와 내부 인식의 불일치. 이게 가장 교묘하다. 에이전트가 "A를 완료했다"고 판단하고 다음 단계로 넘어가는데, 실제로 외부 시스템(은행 API, PG사 콜백)에서는 아직 A가 완료 처리되지 않은 상태인 경우다. 에이전트는 완료 확인을 위해 다시 조회하고, 미완료 상태를 보고 A를 다시 실행하려 하고, 루프가 발생한다. 정산 도메인에서 이 세 번째 원인이 가장 위험하다. 중복 정산으로 이어질 수 있기 때문이다.

실제로 겪은 사고: 정산 에이전트 47초 루프

지난해 말, 월정산 검증 에이전트를 처음 운영에 붙였을 때였다. 에이전트의 역할은 "이번 달 미정산 건을 찾아 집계하고 담당자에게 슬랙으로 보고"하는 것이었다. 설계상 예상 실행 시간은 4~6초, 도구 호출은 최대 5회 정도였다.

그런데 특정 조건(전월 이월 거래 + 당월 취소 건이 겹치는 케이스)에서 에이전트가 47초 동안 돌았다. 도구 호출 횟수는 23회. 미정산 건을 조회하고 → 집계하고 → 다시 미정산 건을 조회하고(집계 이후 상태가 달라졌을 수 있다고 판단) → 숫자가 달라지면 다시 집계하는 패턴이었다. 에러는 없었다. 슬랙 메시지도 결국 나갔다 — 단, 같은 보고서가 세 번 전송됐다. 보고 도구가 "전송 완료" 확인을 기다리는 동안 에이전트가 완료 조건을 충족했다고 판단하고 세 번 호출했기 때문이다. 이 사고의 직접 비용은 작았다. 슬랙 메시지 중복 3개, API 호출 23회. 하지만 같은 패턴이 "정산 실행" 도구에서 발생했다면? 그 생각을 하고서 안전장치 설계를 전면 재검토했다.

안전장치 세 겹 설계

기존 글에서 소개한 세 가지를 우리는 실제로 모두 운영하고 있고, 각각에 현장 경험에서 나온 디테일이 붙어 있다.

1단계: 호출 횟수 제한 — 숫자를 도메인에 맞게 설정하라.

# 역할별 상한 — 20이라는 일률적 숫자는 쓰지 않는다
PAYMENT_AGENT_MAX_CALLS     = 8   # 결제: 이 이상 돌 이유가 없다
SETTLEMENT_AGENT_MAX_CALLS  = 12  # 정산: 조회 단계가 길어질 수 있음
NOTIFICATION_AGENT_MAX_CALLS = 5  # 알림: 단순 호출, 5 이상이면 이상

def agent_loop(task, tools, max_calls):
    call_count = 0
    while True:
        response = model.generate(task)
        if response.is_final:
            return response
        if call_count >= max_calls:
            log_structured({
                "event": "max_calls_exceeded",
                "call_count": call_count,
                "last_tool": response.tool_call.name,
                "last_args": response.tool_call.args,
            })
            return error_response("최대 호출 횟수 초과")
        result = tools.execute(response.tool_call)
        task = task.append_result(result)
        call_count += 1

단순히 20이라는 숫자를 쓰는 게 아니라 에이전트 역할별로 다른 상한을 둔다. 결제 에이전트가 8회를 넘는다면 뭔가 잘못된 거다. 제한 초과 시에는 반드시 구조화된 로그를 남겨야 한다. 이 로그를 2주간 분석해 어떤 (도구, 인자) 조합에서 루프가 발생하는지 패턴을 찾아낼 수 있었다.

2단계: 중복 호출 감지 — 연속이 아닌 윈도우 기반으로. 처음엔 "직전 호출과 동일하면 중단" 방식이었다. 그런데 이는 ABAB 패턴(A 호출 → B 호출 → A 호출 → B 호출...)을 잡지 못한다. 실제로 우리 에이전트 하나가 이 패턴으로 12번 루프를 돌았다. 지금은 최근 5회 호출 기록을 윈도우로 유지하면서, 그 안에서 동일한 (도구, 인자) 쌍이 2회 이상 등장하면 루프로 판정하고 종료한다.

3단계: 명시적 종료 도구 — 완료 이유를 강제로 쓰게 만들라. finish_task(reason: str, summary: dict) 형태의 종료 도구를 모든 에이전트에 붙여뒀다. 모델이 이 도구를 호출하지 않으면 루프가 끝나지 않는다. 핵심은 reason 필드 — 모델이 "왜 완료라고 판단했는지"를 명시적으로 써야 한다. 이게 나중에 디버깅에서 결정적인 단서가 된다. 단순히 루프를 멈추는 게 아니라 에이전트의 의도를 기록하는 수단으로 쓰고 있다.

트레이드오프: 안전장치가 너무 빡빡하면

안전장치를 과하게 설정하면 정반대 문제가 생긴다. SETTLEMENT_AGENT_MAX_CALLS를 처음에 6으로 설정했다가 정상 케이스에서도 제한에 걸리는 일이 있었다. 전월 이월 거래가 많은 월말 정산에서는 조회 단계가 자연스럽게 8~10회 호출을 요구했기 때문이다. 이 false positive 때문에 에이전트 결과를 신뢰하지 못하게 되고, 수동 검토가 되레 늘었다.

우리가 선택한 해법은 단계별 호출 예산 방식이다. 전체 상한(12)을 두되, 조회 단계 최대 7회 / 집계 단계 최대 3회 / 보고 단계 최대 2회로 나눈다. 각 단계가 예산을 초과하면 해당 단계만 에러 처리하고 에이전트 전체를 죽이지 않는다. 구현 복잡도는 올라가지만 false positive가 눈에 띄게 줄었다. 명시적 종료 도구의 트레이드오프도 있다. 모델이 불확실한 상황에서 조기 종료를 선택하는 경향이 생겼다 — "아직 더 확인해야 하는데 finish_task를 불러버리는" 케이스. 프롬프트에 "완료 도구는 모든 집계가 수렴했을 때만 호출한다"는 명시적 조건을 추가해서 해결했다.

지금 바로 적용할 수 있는 체크리스트

에이전트를 프로덕션에 올리기 전, 아래 항목을 반드시 확인한다. "나중에 붙이면 되지"라고 미루면 첫 번째 사고는 운영 중에 만나게 된다.

프롬프트 검토

  • 종료 조건이 수치화되어 있는가? ("충분히" 대신 "N개")
  • 빈 결과·null·에러 반환 시 동작을 명시했는가?
  • 외부 시스템의 상태 지연 가능성(PG 콜백 딜레이 등)을 고려했는가?

코드 검토

  • 에이전트별 MAX_TOOL_CALLS가 역할에 맞게 개별 설정되어 있는가?
  • 중복 호출 감지가 "직전 1회"가 아닌 윈도우(최근 N회) 기반인가?
  • 종료 도구 호출 시 reason 필드를 강제하는가?
  • 제한 초과 시 구조화된 로그(tool, args, call_count)가 남는가?

운영 검토

  • 제한 초과 알림이 슬랙/PagerDuty로 즉시 가는가?
  • 2주치 로그에서 루프 발생 패턴을 주기적으로 분석하는가?
  • 결제·정산 관련 외부 API 호출 도구에 멱등성(idempotency key) 이 보장되어 있는가?

마지막 항목이 특히 중요하다. 아무리 루프를 잘 잡아도 외부 API 호출 도구 자체가 멱등하지 않으면, 안전장치가 걸리기 전에 중복 호출이 이미 발생한다. 에이전트 실행 컨텍스트에서 멱등 키를 생성하고 모든 외부 호출 도구에 자동으로 붙이는 미들웨어를 쓰는 게 가장 확실한 방어선이다. 에이전트 루프 설계는 "얼마나 잘 동작하는가"뿐 아니라 "얼마나 안전하게 실패하는가"로 평가해야 한다. 돈이 오가는 도메인에서는 특히.

* 위 링크는 인프런 affiliate 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.

📚 추천 강의
한 입 크기로 잘라먹는 바이브코딩 (with Claude Code)
Claude Code로 바이브코딩, 개발자라면 꼭 들어야 할 필수 강의
강의 보러가기 →

* 위 추천 링크는 쿠팡파트너스 활동의 일환이며, 일정액의 수수료를 제공받을 수 있습니다.