에이전트가 무한 루프에 빠지는 이유: 4가지 근본 원인과 해결 방법
요약
AI 에이전트가 무한 루프에 빠져 API 비용을 폭증시키는 4가지 근본 원인과 해결책을 다룹니다. 프롬프트 템플릿의 재귀적 구조와 상태 머신의 종료 조건 누락 문제를 중심으로 기술적 방어 전략을 제시합니다.
핵심 포인트
- 프롬프트 템플릿 내 재귀적 플레이스홀더 사용 주의
- 런타임 토큰 카운터를 통한 실시간 패턴 탐지 및 경고
- 상태 머신 설계 시 모든 외부 호출의 예외 상태 처리 필수
- 토큰 증가량(delta) 모니터링을 통한 루프 조기 발견
지난주 라이브 데모 도중, 저희의 영업 보조 에이전트(sales-assistant agent)가 4분 동안 동일한 확인 질문을 반복하며 API 할당량(quota)을 모두 소진했고, 고객에게 2,180달러의 추가 비용을 발생시켰습니다.
1️⃣ 프롬프트 템플릿(Prompt Templates)에서의 무제한 재귀(Unbounded Recursion)
템플릿 자기 참조가 나선형으로 악화되는 이유
프롬프트 엔지니어링(Prompt engineering)은 퍼즐을 맞추는 것과 비슷해 보이지만, 자신의 출력을 다음 요청에 다시 입력으로 제공하는 단 하나의 플레이스홀더(placeholder)가 전형적인 함정이 될 수 있습니다. 템플릿에 {{conversation_history}}와 같은 항목이 포함되어 있고, 매 턴마다 이전 프롬프트 전체를 연결(concatenate)하면 토큰(token)이 기하급수적으로 증가하는 급수(geometric series)를 만들게 됩니다. 모델은 깨끗한 "중단(stop)" 조건을 결코 보지 못하며, 제공업체가 설정한 최대 토큰(max-tokens) 상한선에 도달하여 절단(truncation)되거나 타임아웃(timeout)이 발생할 때까지 컨텍스트(context)를 계속 확장할 뿐입니다.
저희의 여행 예약 에이전트(travel-booking agent)의 경우, 이 버그는 반복당 12개의 토큰이 증가하는 방식으로 나타났습니다. 10번의 사이클이 지나자 프롬프트는 120개의 토큰이 불어났고, 요청이 4K 토큰 제한을 초과하여 429 Rate-limit 에러를 발생시켰습니다. 오케스트레이터(orchestrator)는 이를 "더 많은 세부 정보가 필요함"으로 해석했습니다. 루프는 사용자에게 동일한 질문을 계속 다시 던졌고, UI는 얼어붙었습니다.
토큰 카운터(token counters)를 통한 패턴 탐지
첫 번째 방어선은 저렴한 런타임 토큰 카운터(runtime token counter)입니다. 프롬프트를 렌더링할 때마다 게이지(gauge)를 증가시키고 이를 정적 상한선(예: 3,500 토큰)과 비교하십시오. 만약 연속된 두 호출 사이의 차이(delta)가 작은 임계값(약 30 토큰)을 초과하면 경고를 발생시키십시오. 대부분의 팀은 이 지표가 모델의 응답 페이로드(response payload) 외부에 있기 때문에 무시하지만, 일단 이를 Prometheus 대시보드에 표시하면 그 증가세는 놓칠 수 없게 됩니다.
데이터 포인트: 모든 타임아웃 경고의 38%는 프롬프트 문자열 내의 단일 재귀적 플레이스홀더(recursive placeholder)로 거슬러 올라갑니다.
실제 사례
여행 예약 에이전트(travel-booking agent)가 다음 프롬프트에 전체 사용자 쿼리를 다시 주입(re-inject)하여, 모델이 최대 토큰 제한(max token limit)에 도달할 때까지 반복(iteration)당 토큰 수가 12개씩 증가하는 문제가 발생했습니다. 해결 방법은 사용자 쿼리를 별도의 변수로 격리하고 새로운 시스템 지침(system instructions)만 추가하도록 하여, 반복당 증가량을 0으로 줄이는 것이었습니다.
2️⃣ 상태 머신(State Machines)의 종료 조건 누락
유한 상태(Finite-state) vs. 이벤트 기반(event-driven) 루프
대부분의 오케스트레이션 레이어(orchestration layers)는 실행 경로를 명확하게 보여주기 때문에 유한 상태 머신(Finite-state machines, FSMs)을 기반으로 구축됩니다. 하지만 개발자들은 종종 모든 외부 호출(external call)이 결국 "성공" 이벤트를 생성할 것이라고 가정합니다. 다운스트림 서비스(downstream service)가 예상치 못한 상태 코드(status code)를 반환할 때, FSM은 조용히 동일한 상태로 재진입하여 보이지 않는 루프를 생성할 수 있습니다.
저희의 주문 이행(order-fulfillment) 워크플로우는 결제 확인을 기다리고 있었습니다. 결제 게이트웨이(payment gateway)가 짧은 장애 기간 동안 예상했던 200 OK 대신 HTTP 202 Accepted("처리 중"을 의미)를 반환했습니다. 상태 머신은 200 코드에 대해서만 전이(transition)하도록 코딩되어 있었기 때문에, 동일한 "결제 대기(await-payment)" 노드로 되돌아갔고, 이는 즉시 요청을 재발행했습니다. 이는 저희가 WhatsApp 에이전트 스택 및 AI 신뢰 감사(AI trust audits)에서 기록한 내용과 유사한 현상입니다. 이 루프로 인해 사이클당 약 2초가 추가되었습니다.
Prometheus 카운터를 이용한 계측(Instrumentation)
상태 전이(state transition)마다 카운터를 추가하십시오: state_transitions_total{from="await_payment",to="await_payment"}. 특정 엣지(edge)가 기준치(예: > 0.1 Hz) 이상으로 급증하면 경고(alert)를 발생시키십시오. 저희의 경우, 숨겨진 자기 루프(self-loop)가 사이클당 2초를 추가하여 전체 실행 시간(runtime)을 13배나 부풀렸습니다.
데이터 포인트: 모니터링 대시보드는 전이당 평균 지연 시간(latency)이 187ms임을 보여주었으나, 숨겨진 자기 루프가 사이클당 2초를 추가하여 전체 실행 시간을 13배 부풀렸습니다.
실제 사례
주문 이행 (order-fulfillment) 워크플로우가 결제 서비스로부터 200 OK 대신 202 Accepted를 반환받을 때, “결제 대기 (await-payment)” 상태에서 결코 벗어나지 못하는 사례가 있습니다. 해결책은 모든 2xx 응답을 최종 성공으로 처리하거나, 30초 후에 “결제 실패 (payment-failed)” 폴백 (fallback)으로 강제 전환하도록 명시적인 타임아웃 (timeout)을 추가하는 것이었습니다.
3️⃣ 일시적 오류에 대한 과도한 재시도 정책 (Over-eager Retry Policies)
지수 백오프 (Exponential backoff) 설정 오류
재시도 로직 (Retry logic)은 불안정한 엔드포인트 (endpoints)를 보완하기 위한 것이지만, 백오프 곡선 (backoff curve)이 너무 공격적이면 오케스트레이터 (orchestrator) 자체의 타임아웃이 발생할 때까지 동일한 서비스를 계속해서 두드리는 결과를 초래합니다. 흔한 실수는 초기 지연 시간을 100ms로, 승수 (multiplier)를 1.5로 설정한 뒤 최대 지연 시간 (max delay)의 상한을 설정하는 것을 잊는 것입니다. 429 Too Many Requests 응답이 발생하면 에이전트는 계속 회전하게 되며, 각 재시도가 다시 429를 받는 또 다른 요청을 생성하여 이 사이클이 결코 끊이지 않게 됩니다.
안전망으로서의 서킷 브레이커 (Circuit-breaker)
N번의 연속된 실패(예: 5번) 후에 작동하고, 설정 가능한 냉각 기간 (cool-down period) 동안 열려 있는 서킷 브레이커 (circuit-breaker)를 구현하십시오. 브레이커는 유한 상태 머신 (FSM)이 동일한 단계로 조용히 되돌아가는 대신, FSM이 처리할 수 있는 별도의 오류 코드를 노출해야 합니다.
데이터 포인트: 에이전트가 불안정한 벡터 검색 (vector-search) 엔드포인트를 요청당 15번씩 재시도했을 때, 월 $4,200의 추가 비용이 발생했습니다.
실제 사례
추천 에이전트가 모든 429 응답을 재시도 가능한 것으로 처리하여, 동일한 쿼리가 캐시 레이어 (cache layer)를 무한히 루프 도는 연쇄 반응이 발생했습니다. 해결 방법은 Retry-After 헤더 파서 (parser)를 추가하고, 재시도를 3회로 제한하며, 서킷이 열려 있을 때 요청을 폴백 인덱스 (fallback index)로 라우팅하는 것이었습니다.
4️⃣ 에이전트 간의 공유 메모리 누수 (Shared Memory Leaks)
전역 컨텍스트 오염 (Global context pollution)
여러 에이전트가 네임스페이싱 (Namespacing) 없이 동일한 키-값 저장소 (Key-value store)에 읽기 및 쓰기를 수행할 때, 이들은 사실상 하나의 전역 두뇌 (Global brain)를 공유하게 됩니다. 마케팅 봇이 작성한 오래된 “last_intent” 항목을 몇 분 후 지원 봇이 읽게 되면, 지원 봇은 사용자가 여전히 이전 대화 분기 (Conversation branch)에 있다고 판단하게 됩니다. 그 결과, 공유 캐시 (Shared cache)가 쓸모없는 데이터 블롭 (Blobs)으로 가득 차면서 부하가 걸릴 때만 나타나는 유령 루프 (Phantom loop)가 발생합니다.
스코프 컨텍스트 패턴 (Scoped context patterns)
해결책은 엄격한 스코핑 (Scoping)을 강제하는 것입니다. 모든 캐시 키 앞에 고유한 세션 ID 또는 에이전트 식별자를 접두사로 붙이십시오 (session:{session_id}:last_intent). 또한, 예상 대화 길이에 맞춰 TTL (Time-to-Live)을 설정하십시오 (보통 5~10분). 주기적인 캐시 제거 (Cache eviction) 작업을 통해 저장소가 비대해지는 것을 방지할 수 있습니다.
데이터 포인트: 공유 Redis 캐시가 1.3GB의 오래된 세션 블롭으로 커진 후, 12개의 배포 환경에서 교차 통신 (Cross-talk) 버그가 발생했습니다.
실제 사례
두 개의 독립적인 챗봇이 동일한 “last_intent” 키를 읽고 쓰면서, 한 봇이 다른 봇의 폴백 루프 (Fallback loop)를 재트리거하는 문제가 발생했습니다. 네임스페이스가 지정된 키 스키마 (Namespaced key schema)로 전환하고 600초의 TTL을 추가한 후, 이 문제는 사라졌습니다. 이 패턴은 현재 우리의 내부 베스트 프랙티스 가이드에 문서화되어 있으며, voice agent platform에서 새로운 에이전트를 생성할 때마다 참조하고 있습니다.
5️⃣ 해결책: 가드레일, 타임아웃, 그리고 멱등적 설계 (Idempotent Design)
프롬프트의 정적 분석 (Static analysis of prompts)
프롬프트 템플릿에서 하나 이상의 플레이스홀더 (Placeholder)가 나타나거나, 이전 프롬프트 전체를 참조하는 플레이스홀더를 찾아내는 린터 (Linter)를 실행하십시오. promptlint와 같은 도구를 CI 파이프라인 (CI pipeline)에 통합할 수 있습니다. 린트 작업이 실패하면 버그가 있는 프롬프트가 운영 환경에 반영되기 전에 머지 (Merge)를 중단시킵니다.
런타임 와치독 (Runtime watchdogs)
와치독 (Watchdog)은 각 단계의 경과 시간과 반복 횟수를 모니터링하는 경량 비동기 (async) 래퍼 (wrapper)입니다. 만약 단계가 max_iterations 또는 timeout_sec를 초과하면, 래퍼는 작업을 중단하고 agent ops in production에서 문서화한 것과 유사하게 LoopAbort 메트릭 (metric)을 보고합니다. 당사의 운영 환경 (production fleet)에서는 5초 와치독을 도입한 결과, 무한 루프 사고가 다음 스프린트에서 주당 9건에서 0건으로 감소했습니다.
멱등적 액션 계약 (Idempotent action contracts)
모든 외부 호출은 멱등적 (idempotent)이어야 하며, 그렇지 않은 경우 명시적으로 비멱등적 (non-idempotent)이라고 표시해야 합니다. 액션이 재시도될 때, 다운스트림 서비스 (downstream service)는 중복 요청을 안전하게 무시하거나 결정론적인(deterministic) "이미 완료됨" 응답을 반환해야 합니다. 이를 통해 오케스트레이터 (orchestrator)가 재시도 성공 여부를 추측해야 하는 필요성을 제거할 수 있으며, 이는 숨겨진 루프가 발생하는 빈번한 원인입니다.
데이터 포인트: 5초 와치독을 구현하여 다음 스프린트에서 무한 루프 사고를 주당 9건에서 0건으로 줄였습니다 — 전체 분석 내용은 our voice stack을 참조하세요.
실제 사례
오케스트레이터에 loop_counter 변수를 추가하고 이 값이 7을 초과할 때 중단하도록 설정함으로써, 데모 도중 영업 어시스턴트 (sales-assistant)가 루프에 빠지는 것을 방지했습니다. 이 카운터는 Prometheus 게이지 (gauge, agent_loop_counter)로 노출되어, 특정 요청이 안전 한계치에 도달하고 있는지 한눈에 확인할 수 있습니다.
재사용 가능한 가드: @loop_guard 데코레이터 (decorator)
import asyncio
import time
from prometheus_client import Counter, Gauge
...
위의 코드 스니펫 (snippet)은 현재 외부 시스템과 통신하는 모든 비동기 (async) 단계에 적용하고 있는 방식입니다. Prometheus 카운터 (counter)는 가드가 작동할 때 명확한 신호를 제공하며, LoopAbort 예외 (exception)는 오케스트레이터로 전파되어 안전한 폴백 (fallback) 상태로 전환됩니다.
요약 (TL;DR)
모든 오케스트레이션 홉 (orchestration hop)에 계측 (instrument)을 수행하고, 엄격한 반복 횟수 제한을 강제하며, 타임아웃 (timeout)을 일급 실패 (first-class failure)로 취급하십시오. 이것만으로도 당사의 운영 환경에서 발생하는 모든 폭주 루프 (runaway loops)를 제거할 수 있었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기