AI 에이전트가 무한 루프에 빠지는 이유와 해결법

발행: (2026년 5월 23일 AM 10:26 GMT+9)
11 분 소요
원문: Dev.to

제목: 3시 새벽 악몽 같은 툴 호출 루프

지난달에 고객 지원 트리아지를 처리하도록 ReAct 스타일 에이전트를 배포했습니다. 새벽 3시가 되자 알림이 울렸습니다: 한 사용자 세션이 단일 대화에서 무려 47,000 토큰을 소모한 것이었습니다. 에이전트는 search_knowledge_base 툴을 73번 연속 호출했으며, 매번 약간씩 다른 쿼리를 사용했지만 멈추지 않았습니다.

툴을 사용하는 에이전트를 만든 적이 있다면, 아마도 이런 패턴을 본 적이 있을 겁니다. 에이전트가 루프에 갇혀 같은 행동을 반복하거나 두 행동 사이를 오가며 흔들립니다. 토큰이 사라지고, 비용이 급등하며, 사용자는 결코 오지 않을 응답을 무한히 기다리게 됩니다.

이것은 모델 자체의 문제가 아니라 아키텍처 문제입니다. 루프 안에서 실제로 무슨 일이 일어나고 있는지를 이해하면 해결책은 매우 간단합니다.

전형적인 에이전트 루프 (예시)

def naive_agent_loop(user_query):
    messages = [{"role": "user", "content": user_query}]

    while True:
        response = llm.chat(messages, tools=AVAILABLE_TOOLS)

        # 모델이 종료를 결정했을 때
        if response.finish_reason == "stop":
            return response.content

        # 그렇지 않다면 툴을 실행하고 결과를 다시 전달
        tool_call = response.tool_calls[0]
        result = execute_tool(tool_call.name, tool_call.args)

        messages.append(response.message)
        messages.append({"role": "tool", "content": str(result)})

모델이 행동을 생성하고, 우리는 그 행동을 실행한 뒤 결과를 컨텍스트에 추가합니다. 그리고 다시 모델에게 다음에 무엇을 할지 물어보는 식으로 반복합니다. 모델이 “끝났어”라고 말할 때까지 말이죠.

문제는 바로 그 “끝날 때까지” 조건에 있습니다. 흔히 발생하는 세 가지 오류는 다음과 같습니다.

  1. 모델은 “이미 시도해 본 적이 있다”는 개념이 없습니다. 매 반복마다 대화 기록을 보지만, 기록에 실패한 검색이 열 번이라도 있으면 모델은 이를 “검색을 더 열심히 해야 한다”는 신호로 해석하고, “이 방법은 안 된다”는 판단을 하지 못합니다.
  2. 툴 오류가 조용하거나 모호합니다. 검색 결과가 빈 리스트일 때, 이것이 “검색 결과가 없음”인지 “툴이 고장 났음”인지 모델은 구분하지 못해 또다시 다른 표현으로 시도합니다.
  3. 종료 조건이 암묵적입니다. 많은 구현에서는 모델이 최종 답변 메시지를 만들 때만 루프를 멈춥니다. 모델이 언제든지 최종 답변을 만들도록 강제하는 메커니즘이 없습니다.

개선된 패턴 (간소화 버전)

아래 코드는 어떤 챗‑컴플리션 API에도 적용 가능한 순수 파이썬 구현입니다.

import hashlib
import json

MAX_STEPS = 10

def hash_action(name: str, args: dict) -> str:
    # {a:1, b:2}와 {b:2, a:1}이 같은 해시값을 갖도록 정규화된 JSON 사용
    payload = json.dumps({"name": name, "args": args}, sort_keys=True)
    return hashlib.sha256(payload.encode()).hexdigest()

def safer_agent_loop(user_query):
    messages = [{"role": "user", "content": user_query}]
    seen_actions = set()

    for step in range(MAX_STEPS):
        response = llm.chat(messages, tools=AVAILABLE_TOOLS)

        if response.finish_reason == "stop":
            return response.content

        tool_call = response.tool_calls[0]
        action_id = hash_action(tool_call.name, tool_call.args)

        if action_id in seen_actions:
            # 툴을 다시 실행하지 말고 중복임을 모델에 알림
            observation = {
                "status": "duplicate",
                "message": "이미 같은 인자로 이 툴을 호출했습니다. 다른 시도를 하거나 답변을 마무리하세요."
            }
        else:
            seen_actions.add(action_id)
            observation = execute_tool(tool_call.name, tool_call.args)

        messages.append(response.message)
        messages.append({"role": "tool", "content": json.dumps(observation)})

    # 강제 종료: 예외를 발생시키지 말고 현재까지 만든 부분 답변을 반환
    return synthesize_partial_answer(messages)

바뀐 점 세 가지

  1. 강제 단계 제한 – 모델이 무엇을 결정하든 MAX_STEPS 이후에는 루프가 종료됩니다. 실제 작업에 맞는 숫자를 선택하세요. 트리아지에서는 8, 연구 워크플로우에서는 20 정도가 일반적입니다.
  2. 행동 중복 제거 – 툴 호출 전 (툴, 인자) 쌍을 해시하고, 이미 수행한 적이 있으면 실제 실행 대신 “중복”이라는 관찰값을 모델에 전달합니다.
  3. 구조화된 오류 래퍼 – 툴이 반환하는 값은 원시 문자열이 아니라 타입이 명시된 객체입니다. 모델은 status: "no_results"status: "error" 그리고 status: "ok"를 구분해 더 나은 판단을 할 수 있습니다.

의미적 중복 탐지

단순히 해시값이 같은 경우만 잡아도 명백한 중복은 차단됩니다. 하지만 에이전트는 “search(‘authentication errors’)”, “search(‘auth errors’)”, “search(‘login failures’)”처럼 의미는 같지만 문법이 다른 쿼리로도 루프에 빠질 수 있습니다.

이를 방어하는 간단한 방법은 최근 N개의 툴 호출을 추적하고 진행 상황을 확인하는 것입니다.

from collections import deque

class ProgressTracker:
    def __init__(self, window: int = 4):
        self.window = window
        self.recent_tools = deque(maxlen=window)

    def record(self, tool_name: str) -> None:
        self.recent_tools.append(tool_name)

    def is_stuck(self) -> bool:
        # 마지막 N번 호출이 모두 같은 툴이면 루프에 빠졌다고 판단
        if len(self.recent_tools) < self.window:
            return False
        return len(set(self.recent_tools)) == 1

완벽하지는 않습니다—임베딩 기반 의미 유사도 검사가 더 견고하겠지만—실제 운영 환경에서 80% 정도의 진동(oscillation) 케이스를 별도 유사도 모델 없이 잡아낼 수 있습니다.

프레임워크와 실전 팁

여러 인기 에이전트 프레임워크를 사용해 보았습니다. 대부분은 max_iterations 파라미터만 제공하고는 합니다. 이는 필요 최소값일 뿐, 상한은 아닙니다.

데모 수준을 넘어선 시스템을 구축한다면 반드시 다음을 구현해야 합니다.

  • 툴별 쿼터 – 전역 단계 제한이 아니라 각 툴마다 별도 제한을 둡니다.
  • 전체 행동/관찰 로그 – 사후 디버깅을 위해 전체 트레이스를 남깁니다.
  • “이미 시도했음” 컨텍스트 주입 – 모델에게 이전 시도를 알려주는 메커니즘.
  • 우아한 종료 경로 – 제한에 도달했을 때 예외를 던지는 대신 부분 답변을 반환합니다.

GitHub에 커뮤니티가 유지하고 있는 Agent‑Learning‑Hub(https://github.com/Agent-Learning-Hub)에는 이러한 패턴을 더 깊이 다루는 자료와, 계획·반성에 관한 학술 논문 링크가 정리돼 있습니다.

3시 알림 이후 몸에 밴 습관

  1. 모든 행동·관찰을 타임스탬프와 함께 로그 – 운영 중 문제가 생겼을 때 최종 상태만이 아니라 전체 흐름을 확인할 수 있어야 합니다.
  2. 대화당 토큰 예산을 서버 측에서 강제 – 에이전트가 스스로 제한을 지키게 두지 마세요.
  3. 의미 있는 오류를 반환하는 툴 작성 – “쿼리 X에 대한 결과가 없습니다. 더 일반적인 용어를 시도해 보세요.” 같은 메시지는 빈 리스트 []보다 훨씬 유용합니다.
  4. 적대적 프롬프트로 테스트 – 에이전트를 혼란스럽게 만들 입력을 의도적으로 넣어, 정상적으로 탈출하는지 검증합니다.
  5. 툴 호출 엔트로피 추적 – 대화가 진행될수록 툴 호출 분포의 분산이 급격히 감소한다면, 이는 에이전트가 고착됐다는 전조입니다.

운영 중 에이전트 루프가 실패하는 경우는 거의 상태 부족, 피드백 부족, 제한 부족 중 하나입니다. 모델이 고장 난 것이 아니라, 프롬프트와 아키텍처가 그렇게 행동하도록 설계했기 때문이죠. 아키텍처를 고치면 루프 문제는 사라집니다.

가장 어려운 부분은 “모델이 스스로 언제 멈출지 결정하게 두자”는 생각이 전략이 아니라는 점을 받아들이는 일입니다. 루프를 작성하는 사람은 바로 여러분이며, 종료 로직을 직접 책임져야 합니다.

0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.