Published on

AutoGPT 툴 호출 무한루프 차단 - ReAct+가드레일

Authors

서버에서 AutoGPT 스타일 에이전트를 운영하다 보면 가장 자주 터지는 장애 중 하나가 툴 호출 무한루프입니다. 비용은 비용대로 새고, 레이트 리밋은 터지고, 사용자는 결과를 못 받습니다. 문제는 이게 단순히 max_steps 하나로 끝나지 않는다는 점입니다. 에이전트는 “합리적으로” 다음 액션을 결정하고 있다고 믿지만, 실제로는 관측(Observation) 품질, 목표 정의, 상태 관리, 검증(Verification) 이 허술하면 같은 툴을 계속 두드리며 빠져나오지 못합니다.

이 글에서는 ReAct(Reasoning+Acting) 스타일 흐름을 기준으로, AutoGPT 계열에서 발생하는 무한루프를 구조적으로 차단하는 방법을 정리합니다. 핵심은 ReAct를 “프롬프트 패턴”이 아니라 런타임 정책(가드레일) + 상태머신으로 승격시키는 것입니다.

아울러 추론(CoT) 노출을 막으면서도 검증을 넣는 패턴은 아래 글과 맞닿아 있으니 함께 참고하면 좋습니다.

무한루프는 왜 생기나: ReAct 관점에서 해부

ReAct 루프는 대략 다음 형태입니다.

  1. 목표/컨텍스트를 읽는다
  2. 다음 액션(툴 호출)을 결정한다
  3. 툴을 호출하고 Observation을 받는다
  4. Observation을 바탕으로 목표 달성 여부를 판단한다
  5. 미달성이라면 2로 돌아간다

무한루프는 대부분 4번(판단)과 2번(액션 선택)이 깨지면서 발생합니다. 실무에서 흔한 원인은 다음과 같습니다.

1) Observation이 애매하거나 비결정적이다

예: 검색 툴이 매번 비슷한 결과를 주고, 에이전트는 “더 찾아보자”만 반복합니다.

  • 관측값에 진행도 신호(progress signal) 가 없다
  • 실패/재시도 조건이 명확하지 않다
  • 동일한 쿼리를 조금씩 바꾸며 사실상 같은 호출을 반복한다

2) 목표가 ‘끝 조건’을 포함하지 않는다

예: “최신 동향을 조사해줘”는 종료 조건이 없습니다. 결과를 언제 멈춰야 하는지 모델이 결정해야 하므로, 루프를 만들기 쉽습니다.

3) 상태가 누락되어 ‘이미 시도한 것’을 잊는다

에이전트가 “이 쿼리는 이미 검색했다”, “이 URL은 이미 읽었다”, “이 API는 3번 실패했다” 같은 에피소드 상태를 기억하지 못하면 반복은 필연입니다.

4) 툴 실패가 ‘관측’으로 정상화된다

툴이 429/500을 반환했는데도 관측을 단순 문자열로 넣으면, 모델은 이를 “새로운 정보”로 오해하고 계속 재시도합니다.

이건 운영 장애 패턴으로 보면 systemd 의 무한 재시작과 유사합니다. 실패를 실패로 분류하지 못하면 재시도만 반복됩니다.

해결 전략 개요: ReAct + 가드레일 5종 세트

무한루프를 실전에서 줄이려면, 아래를 조합해야 합니다.

  1. 예산(Budget): step/time/token/tool-call 상한
  2. 상태머신(State machine): 종료 조건과 전이 규칙을 코드로 강제
  3. 중복 감지(Dedup): 동일/유사 툴 호출 반복 차단
  4. 실패 분류(Failure taxonomy): 재시도 가능/불가능/대기 필요를 구조화
  5. 검증자(Verifier): “이제 충분하다/종료해라”를 별도 정책으로 판정

ReAct는 “생각하고 행동한다”가 아니라, 행동하기 전에 정책을 통과해야 한다로 바뀌어야 합니다.

1) 예산 가드레일: max_steps 만으로 부족한 이유

많은 프레임워크가 max_steps 를 제공합니다. 하지만 실제 장애는 다음처럼 발생합니다.

  • step은 남아 있는데 같은 툴을 초당 수십 번 호출
  • step은 적지만 한 번의 툴이 매우 비싸거나 느림
  • 토큰 폭증으로 비용/지연이 먼저 터짐

따라서 예산은 최소 4개로 쪼개는 게 안전합니다.

  • max_steps: 전체 루프 횟수
  • max_tool_calls: 툴 호출 총량
  • max_same_tool_calls: 동일 툴 호출 상한
  • deadline_ms: 전체 실행 데드라인

Python 예시: 실행 예산 객체

from dataclasses import dataclass
import time

@dataclass
class Budget:
    max_steps: int = 12
    max_tool_calls: int = 20
    max_same_tool_calls: int = 6
    deadline_ms: int = 25_000

class BudgetExceeded(Exception):
    pass

class BudgetGuard:
    def __init__(self, budget: Budget):
        self.budget = budget
        self.started = time.time()
        self.steps = 0
        self.tool_calls = 0
        self.tool_counts = {}

    def tick_step(self):
        self.steps += 1
        self._check_deadline()
        if self.steps > self.budget.max_steps:
            raise BudgetExceeded("max_steps exceeded")

    def tick_tool(self, tool_name: str):
        self.tool_calls += 1
        self.tool_counts[tool_name] = self.tool_counts.get(tool_name, 0) + 1
        self._check_deadline()
        if self.tool_calls > self.budget.max_tool_calls:
            raise BudgetExceeded("max_tool_calls exceeded")
        if self.tool_counts[tool_name] > self.budget.max_same_tool_calls:
            raise BudgetExceeded(f"too many calls to tool: `{tool_name}`")

    def _check_deadline(self):
        elapsed_ms = int((time.time() - self.started) * 1000)
        if elapsed_ms > self.budget.deadline_ms:
            raise BudgetExceeded("deadline exceeded")

이렇게 하면 “끝이 없는 탐색”을 물리적으로 차단할 수 있습니다. 하지만 여전히 max_steps 에 도달하기 전까지 같은 호출을 반복할 수 있으니 다음 단계가 필요합니다.

2) 상태머신으로 전이 규칙을 강제하기

에이전트가 자유롭게 다음 액션을 고르는 구조는 편하지만, 운영에서는 위험합니다. 특히 다음을 코드로 강제해야 합니다.

  • 어떤 상태에서 어떤 툴이 허용되는지
  • 실패 시 재시도 가능한지
  • 목표가 달성되었는지 판단하는 기준

간단한 상태 예시

  • PLAN: 계획 수립(툴 호출 금지)
  • FETCH: 자료 수집(검색/크롤링만)
  • SYNTH: 요약/정리(외부 툴 금지)
  • VERIFY: 결과 검증(검증자 모델 또는 룰)
  • DONE: 종료

Python 예시: 상태 전이 및 툴 허용 목록

from enum import Enum

class State(str, Enum):
    PLAN = "plan"
    FETCH = "fetch"
    SYNTH = "synth"
    VERIFY = "verify"
    DONE = "done"

ALLOWED_TOOLS = {
    State.PLAN: set(),
    State.FETCH: {"web_search", "http_get"},
    State.SYNTH: set(),
    State.VERIFY: {"verifier"},
    State.DONE: set(),
}

def ensure_tool_allowed(state: State, tool_name: str):
    if tool_name not in ALLOWED_TOOLS[state]:
        raise ValueError(f"tool `{tool_name}` not allowed in state `{state}`")

이렇게 하면 “요약 단계에서 검색을 또 한다” 같은 루프 유발 행동을 구조적으로 줄일 수 있습니다.

3) 중복 감지: 동일 호출을 ‘정책’으로 막기

무한루프의 70%는 “사실상 같은 요청”을 반복하는 문제입니다. 이를 막으려면 툴 호출을 정규화(normalize) 하고, 최근 호출과 비교해 차단해야 합니다.

핵심 포인트는 두 가지입니다.

  • 툴 입력을 canonical form으로 만들기(공백/정렬/기본값 채우기)
  • 동일성 비교 키를 만들기(툴명 + 정규화된 인자)

Python 예시: 툴 호출 지문(fingerprint)

import json
import hashlib

def fingerprint(tool_name: str, args: dict) -> str:
    normalized = json.dumps(args, sort_keys=True, ensure_ascii=False)
    raw = f"{tool_name}:{normalized}".encode("utf-8")
    return hashlib.sha256(raw).hexdigest()

class DedupGuard:
    def __init__(self, window: int = 10):
        self.window = window
        self.recent = []
        self.set_recent = set()

    def check_and_remember(self, fp: str):
        if fp in self.set_recent:
            raise RuntimeError("duplicate tool call detected")
        self.recent.append(fp)
        self.set_recent.add(fp)
        if len(self.recent) > self.window:
            old = self.recent.pop(0)
            self.set_recent.remove(old)

여기에 “유사”까지 막고 싶다면, 검색 쿼리처럼 텍스트 입력은 토큰화 후 Jaccard 유사도나 SimHash를 붙일 수 있습니다. 운영 관점에서는 완벽한 유사도보다 과금/폭주를 막는 실용 임계치가 중요합니다.

4) 실패 분류: 재시도는 ‘조건부’여야 한다

툴이 실패했을 때의 관측값을 단순 텍스트로 LLM에 넘기면, 모델은 이를 “새 정보”로 보고 계속 시도합니다. 따라서 실패는 구조화해서 분류해야 합니다.

  • RETRYABLE: 네트워크 타임아웃, 503 등
  • RATE_LIMITED: 429, 쿼터 초과
  • FATAL: 400 스키마 오류, 인증 실패, 권한 문제
  • NO_PROGRESS: 성공했지만 결과가 이전과 동일

그리고 각 분류별 정책을 둡니다.

  • RETRYABLE: 지수 백오프 + 최대 횟수
  • RATE_LIMITED: 쿨다운 후 재시도 또는 즉시 종료
  • FATAL: 즉시 종료 + 사용자/운영자에게 원인 보고
  • NO_PROGRESS: 다른 전략으로 전환 또는 종료

스키마 오류는 특히 “계속 고쳐보려다” 루프가 나기 쉬운데, 실제로는 툴 스키마/호출 정의를 먼저 고쳐야 합니다. 관련해서는 아래 글이 문제 재현과 해결에 도움이 됩니다.

5) Verifier(검증자) 가드레일: 종료를 ‘모델’이 아니라 ‘정책’이 결정

ReAct의 약점은 “충분히 했는지” 판단을 같은 모델이 한다는 점입니다. 같은 모델이 계획도 세우고 실행도 하고 평가도 하면, 자기 확증 루프에 빠질 수 있습니다.

해결책은 검증자(Verifier) 를 분리하는 것입니다.

  • 실행 모델(Agent): 수집/정리/행동
  • 검증 모델(Verifier) 또는 룰: 종료 조건 판정, 품질 체크

중요한 점은 검증자가 CoT를 요구하지 않도록 하고, 체크리스트 기반의 짧은 판정만 하게 만드는 것입니다. CoT 누출 방지와 함께 쓰면 안전합니다.

Verifier 프롬프트 예시(체크리스트)

아래는 검증자에게 줄 수 있는 “출력 계약” 예시입니다. 본문에 부등호를 쓰면 MDX에서 문제가 될 수 있으니, JSON 스키마는 코드 블록으로만 제시합니다.

역할: 너는 에이전트 실행 결과를 검증하는 검증자다.
입력: goal, current_answer, evidence_summaries, tool_call_stats
출력: 아래 JSON만 출력
- decision: "done" | "need_more"
- reason: 2문장 이내
- next_action: 필요 시 다음에 할 1가지 액션(툴명과 핵심 인자)
체크리스트:
1) goal의 필수 산출물이 모두 포함되었나
2) 근거가 최소 2개 이상이며 서로 독립적인가
3) 최근 5회 툴 호출이 중복 또는 무진전이었나
4) 비용/시간 예산을 초과할 위험이 있나

이 구조를 쓰면 “더 찾아볼까?” 같은 막연한 반복을 줄이고, 종료를 명시적으로 강제할 수 있습니다.

통합 예시: ReAct 루프에 가드레일 끼우기

아래는 개념적으로 ReAct 실행 루프에 예산/중복/상태/실패분류/검증을 끼운 형태입니다.

def run_agent(goal: str, llm, tools: dict):
    budget = BudgetGuard(Budget())
    dedup = DedupGuard(window=12)
    state = State.PLAN

    context = {"goal": goal, "notes": [], "evidence": []}

    while state != State.DONE:
        budget.tick_step()

        if state == State.PLAN:
            plan = llm.plan(goal)
            context["notes"].append(plan)
            state = State.FETCH
            continue

        if state == State.FETCH:
            action = llm.next_tool_action(context)  # {tool_name, args}
            tool_name, args = action["tool_name"], action["args"]

            ensure_tool_allowed(state, tool_name)
            budget.tick_tool(tool_name)

            fp = fingerprint(tool_name, args)
            dedup.check_and_remember(fp)

            result = tools[tool_name](**args)
            classified = classify_result(result)  # RETRYABLE/RATE_LIMITED/FATAL/OK/NO_PROGRESS

            if classified.kind == "FATAL":
                return {"status": "failed", "error": classified.message}

            if classified.kind in {"RETRYABLE", "RATE_LIMITED"}:
                handle_backoff(classified)
                continue

            if classified.kind == "NO_PROGRESS":
                state = State.VERIFY
                continue

            context["evidence"].append(classified.data)

            if len(context["evidence"]) >= 3:
                state = State.SYNTH
            continue

        if state == State.SYNTH:
            answer = llm.summarize(context)
            context["current_answer"] = answer
            state = State.VERIFY
            continue

        if state == State.VERIFY:
            verdict = tools["verifier"](
                goal=context["goal"],
                current_answer=context.get("current_answer", ""),
                evidence_summaries=context["evidence"],
                tool_call_stats=budget.tool_counts,
            )
            if verdict["decision"] == "done":
                state = State.DONE
            else:
                # 검증자가 제안한 다음 액션을 제한적으로 반영
                state = State.FETCH
            continue

    return {"status": "ok", "answer": context.get("current_answer", "")}

위 코드의 포인트는 “모델이 하고 싶은 대로 툴을 부르는 것”이 아니라, 정책이 허용하는 범위에서만 툴을 호출하게 만든다는 점입니다.

운영에서 자주 쓰는 추가 방어선

A) 관측값에 진행도 신호를 넣기

툴 응답에 다음을 포함시키면 루프가 줄어듭니다.

  • result_count
  • is_same_as_previous
  • next_page_token 유무
  • confidence 또는 coverage

즉, LLM이 “이번 호출이 진전이었는지” 판단할 수 있는 숫자/플래그를 제공합니다.

B) 호출 트레이싱: 어떤 루프가 도는지 로그로 남기기

무한루프는 사후 분석이 핵심입니다. 요청 단위로 trace_id 를 붙이고, 툴 호출/상태/예산을 함께 로깅하세요. Python에서는 ContextVar 로 비동기 환경에서도 추적이 깔끔해집니다.

C) 레이트 리밋 가드레일을 별도 계층으로

에이전트 레벨에서 막아도, 동시 요청이 많으면 외부 API 레이트 리밋이 터집니다. 따라서 툴 호출 래퍼에 토큰 버킷/슬라이딩 윈도우를 넣어 전역 제한을 걸어야 합니다.

(웹 API를 직접 운영한다면 429 폭증 대응 패턴과 유사합니다.)

체크리스트: 무한루프 차단을 위한 최소 요건

  • max_steps 뿐 아니라 max_tool_calls, deadline_ms 를 둔다
  • 상태머신으로 단계별 허용 툴을 제한한다
  • 툴 호출 fingerprint로 중복을 차단한다
  • 실패를 RETRYABLE/FATAL/RATE_LIMITED 로 구조화한다
  • Verifier로 종료 판정을 분리하고 체크리스트 기반으로 강제한다
  • 관측값에 진행도 신호를 포함한다
  • 트레이싱으로 루프 패턴을 재현 가능하게 만든다

마무리: ReAct는 프롬프트가 아니라 런타임 설계다

AutoGPT 스타일 에이전트의 무한루프는 “모델이 멍청해서”가 아니라, 종료 조건과 상태가 코드로 강제되지 않아서 생기는 경우가 많습니다. ReAct를 도입했다면 다음 단계는 ReAct의 각 단계(Plan/Act/Observe/Verify)를 정책과 계측이 있는 런타임으로 만들고, 툴 호출은 항상 예산과 검증을 통과하도록 설계하는 것입니다.

이렇게 구성하면 무한루프는 대부분 “발생”이 아니라 “시도하다가 차단”되는 이벤트로 바뀌고, 운영 비용과 장애율이 눈에 띄게 내려갑니다.