Published on

AutoGPT 무한루프 막는 종료조건·가드레일 설계

Authors

AutoGPT 스타일의 에이전트는 plan -> act(tool) -> observe -> reflect 루프를 돌며 목표를 달성합니다. 문제는 목표가 모호하거나, 툴이 불안정하거나, 관측(환경 상태)이 충분히 변하지 않을 때 에이전트가 동일한 시도를 계속 반복하며 비용과 시간을 태운다는 점입니다. 이 글에서는 “모델을 더 똑똑하게” 만드는 접근이 아니라, 종료조건(termination conditions)가드레일(guardrails) 을 제품/플랫폼 레벨에서 설계해 무한루프를 구조적으로 막는 방법을 다룹니다.

아래 내용은 AutoGPT에만 국한되지 않고, LangGraph, CrewAI, 자체 구현 에이전트 등 “툴 호출 기반 에이전트” 전반에 적용됩니다.

왜 무한루프가 생기나: 루프의 유형 분류

무한루프를 막으려면 먼저 루프를 관측 가능한 패턴으로 분해해야 합니다.

1) 상태 비변화 루프

  • 툴 호출 결과가 실패하거나(타임아웃, 5xx)
  • 성공해도 외부 상태가 변하지 않거나(권한 부족, 잘못된 파라미터)
  • 에이전트가 그 사실을 충분히 반영하지 못할 때

예: “API 호출이 계속 408 으로 실패하는데도 재시도만 반복”

관련해서 타임아웃을 재현하고 원인을 분해하는 접근은 다음 글과 결이 같습니다.

2) 계획-실행 불일치 루프

  • 계획은 바뀌는데 실행은 늘 같은 툴/같은 입력
  • 또는 실행은 바뀌는데 목표 함수가 없어서 평가가 불가능

3) 자기반성(Reflection) 과다 루프

  • “생각해보니 다시 계획을 세워야겠어”가 반복
  • 실제 환경에 영향을 주는 행동 없이 토큰만 소모

4) 재시도 폭주 루프

  • 실패한 툴 호출에 대해 지수 백오프 없이 즉시 재시도
  • 병렬 에이전트가 동시에 재시도하여 외부 시스템에 부하

이 패턴은 MSA에서 데드라인과 리트라이 정책을 잘못 잡으면 발생하는 “리트라이 폭주”와 구조가 동일합니다.

종료조건 설계: “언제 멈출지”를 명시적으로

에이전트가 멈추는 조건은 보통 “목표 달성”뿐인데, 실제 운영에서는 실패를 인정하고 멈추는 조건이 더 중요합니다. 종료조건은 크게 4종으로 나누는 것이 실용적입니다.

1) 하드 리밋: 스텝, 시간, 비용 예산

가장 강력하고 단순한 안전장치입니다.

  • 최대 스텝 수: max_steps
  • 벽시계 시간 제한: wall_clock_deadline_ms
  • 비용 제한: max_tokens, max_cost_usd

이 3가지는 반드시 서버 사이드에서 강제해야 합니다. 프롬프트에 “10번만 시도해”라고 적는 방식은 신뢰할 수 없습니다.

2) 목표 기반 종료: 성공 판정 함수

성공을 모델에게 묻게 되면(“성공했니?”) 자기합리화가 들어가기 쉽습니다. 가능하면 결정가능한 판정 함수로 바꾸세요.

예:

  • 파일이 생성되었는가
  • DB row가 특정 상태로 변경되었는가
  • API 응답이 특정 스키마를 만족하는가

3) 실패 기반 종료: 반복 실패, 영구 실패, 외부 신호

  • 동일 툴 호출이 n회 연속 실패
  • 동일 에러 코드가 n회 반복
  • 401/403 같이 “권한 문제”는 재시도해도 의미가 없으므로 즉시 중단
  • 운영자가 “중단” 플래그를 켜면 즉시 종료

4) 진행 기반 종료: 진척(Progress) 없으면 종료

가장 중요한데 구현이 까다로운 축입니다.

  • 최근 k 스텝 동안 “상태 변화”가 없으면 종료
  • 목표에 대한 점수(heuristic score)가 개선되지 않으면 종료

핵심은 “상태(state)”를 정의하는 것입니다. 에이전트 시스템에서는 대개 다음을 상태로 삼습니다.

  • 관측 결과 요약(정규화된 텍스트)
  • 주요 변수(예: 검색된 후보 수, 생성된 파일 수)
  • 마지막 툴 호출과 결과 코드

가드레일 설계: “어떻게 행동할지”를 제한하기

종료조건이 브레이크라면, 가드레일은 핸들입니다. 에이전트가 위험한 방향으로 가기 전에 행동 공간을 줄여야 합니다.

1) 툴 호출 정책: allowlist, rate limit, parameter validation

  • 툴 allowlist: 허용된 툴만 호출
  • 툴별 호출 빈도 제한: tool_rate_limit
  • 툴별 파라미터 검증: 스키마 기반

예를 들어 “웹 검색” 툴은 1분에 10회까지만, “결제/삭제” 툴은 인간 승인 후에만 실행.

2) 재시도 정책: 지수 백오프 + 지터 + 에러 분류

무한루프의 70%는 재시도 정책 부재에서 시작합니다.

  • 5xx, 429, 408은 재시도 대상일 수 있음
  • 4xx401/403/404는 보통 즉시 중단 또는 다른 전략 필요
  • 백오프: base * 2^attempt
  • 지터: 랜덤 분산으로 동시 재시도 방지

3) 반성(Reflection) 제한: 빈도, 길이, 목적을 강제

  • 매 스텝마다 반성하지 않기
  • 반성은 “다음 행동의 변경”이 있을 때만 허용
  • 반성 결과가 다음 툴 호출 파라미터에 반영되지 않으면 반성 금지

4) 인간 개입 지점(HITL) 설계

무한루프를 완전히 자동으로 막기 어렵다면, 안전한 지점에서 인간에게 넘기는 것이 최적입니다.

  • 예산 80% 소모 시 needs_review
  • 동일 실패 3회 시 needs_review
  • 위험 툴 호출 전 approval_required

구현 예시: 루프 감시자(Loop Watchdog) 코드

아래는 Node.js/TypeScript로 “에이전트 실행 루프”를 감싸서 종료조건과 가드레일을 강제하는 예시입니다. 실제 LLM 호출부는 추상화했고, 포인트는 상태 해시로 반복을 감지하고, 스텝/시간/실패를 강제하는 구조입니다.

type ToolResult = {
  ok: boolean;
  status?: number;
  errorCode?: string;
  observation: string;
};

type Step = {
  tool: string;
  input: unknown;
};

type AgentConfig = {
  maxSteps: number;
  deadlineMs: number;
  maxConsecutiveFailures: number;
  noProgressWindow: number; // 최근 k 스텝
  maxRepeatedState: number; // 동일 상태 반복 허용 횟수
};

function stableHash(s: string): string {
  // 간단 예시: 실제로는 sha256 같은 안정 해시 권장
  let h = 0;
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
  return String(h);
}

function normalizeObservation(obs: string): string {
  return obs
    .trim()
    .replaceAll(/\s+/g, " ")
    .slice(0, 2000);
}

function classifyRetryable(r: ToolResult): "retry" | "stop" | "change_strategy" {
  const status = r.status ?? 0;
  if (!r.ok) {
    if (status === 401 || status === 403) return "stop";
    if (status === 404) return "change_strategy";
    if (status === 408 || status === 429 || (status >= 500 && status <= 599)) return "retry";
    return "change_strategy";
  }
  return "change_strategy";
}

async function runAgentLoop(
  config: AgentConfig,
  proposeNextStep: (ctx: { history: ToolResult[] }) => Promise<Step>,
  runTool: (step: Step) => Promise<ToolResult>
) {
  const started = Date.now();
  const history: ToolResult[] = [];

  let consecutiveFailures = 0;
  const stateCounts = new Map<string, number>();
  const recentStates: string[] = [];

  for (let i = 0; i < config.maxSteps; i++) {
    if (Date.now() - started > config.deadlineMs) {
      return { status: "terminated", reason: "deadline_exceeded", history };
    }

    const step = await proposeNextStep({ history });

    // Tool allowlist 예시
    const allowedTools = new Set(["web_search", "http_request", "write_file"]);
    if (!allowedTools.has(step.tool)) {
      return { status: "terminated", reason: "tool_not_allowed", history };
    }

    const result = await runTool(step);
    history.push(result);

    // 실패 카운트
    if (!result.ok) consecutiveFailures++;
    else consecutiveFailures = 0;

    if (consecutiveFailures >= config.maxConsecutiveFailures) {
      return { status: "terminated", reason: "too_many_failures", history };
    }

    // 상태 반복 감지
    const normalized = normalizeObservation(result.observation);
    const stateKey = stableHash(step.tool + "|" + normalized);

    const c = (stateCounts.get(stateKey) ?? 0) + 1;
    stateCounts.set(stateKey, c);

    if (c >= config.maxRepeatedState) {
      return { status: "terminated", reason: "repeated_state_loop", history };
    }

    recentStates.push(stateKey);
    if (recentStates.length > config.noProgressWindow) recentStates.shift();

    // 진행 없음 감지: 최근 k개 상태가 모두 동일하거나 매우 적게 변하면 중단
    const unique = new Set(recentStates);
    if (recentStates.length === config.noProgressWindow && unique.size <= 1) {
      return { status: "terminated", reason: "no_progress", history };
    }

    // 에러 분류 기반 중단/전략 변경 힌트
    if (!result.ok) {
      const action = classifyRetryable(result);
      if (action === "stop") {
        return { status: "terminated", reason: "non_retryable_error", history };
      }
      // retry/change_strategy는 proposeNextStep에서 history를 보고 결정
    }

    // 성공 판정은 가능하면 외부에서 결정가능하게
    // 예: 특정 파일 존재 여부, DB 상태, API 응답 스키마 만족 등
  }

  return { status: "terminated", reason: "max_steps_reached", history };
}

위 코드에서 핵심은 다음입니다.

  • 서버가 강제하는 하드 리밋: maxSteps, deadlineMs
  • 반복 상태 감지: stateKey를 만들고 동일 상태 반복을 제한
  • 진척 없음 감지: 최근 k 스텝이 사실상 같은 상태면 중단
  • 재시도 가능/불가능 에러 분류: 401/403 등은 즉시 중단

“상태”를 어떻게 잡아야 반복 탐지가 잘 되나

반복 탐지의 품질은 상태 정의에 달려 있습니다. 너무 넓으면(원문 전체) 매번 해시가 달라져 루프를 못 잡고, 너무 좁으면(에러 코드만) 정상 흐름도 루프로 오탐합니다.

추천하는 상태 구성은 다음 3요소 조합입니다.

  1. 마지막 툴 이름
  2. 결과의 핵심 코드(예: HTTP status, DB error code)
  3. 관측의 정규화 요약(길이 제한, 공백 정리)

또한 관측 텍스트에는 타임스탬프, 랜덤 ID가 섞이는 경우가 많습니다. 이런 값은 정규식으로 제거해야 해시가 안정화됩니다.

function scrubNonDeterministic(obs: string): string {
  return obs
    .replaceAll(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\b/g, "TIMESTAMP")
    .replaceAll(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "UUID")
    .replaceAll(/\breq_[A-Za-z0-9]+\b/g, "REQID");
}

운영 관점 가드레일: 관측성, 알림, 자동 중단

에이전트 무한루프는 “버그”이기도 하지만 “운영 장애”입니다. 따라서 SRE 관점의 가드레일도 같이 들어가야 합니다.

1) 메트릭: 루프를 수치로 만들기

최소한 아래는 Prometheus 같은 곳에 내보내는 것을 권장합니다.

  • agent_steps_total{agent_id, run_id}
  • agent_tool_calls_total{tool}
  • agent_failures_total{tool, status}
  • agent_cost_total_usd{model} 또는 agent_tokens_total{model}
  • agent_no_progress_events_total

2) 알림: “예산 소모” 기반이 가장 빠르다

무한루프는 성공/실패보다 비용 곡선이 먼저 이상해집니다.

  • 분당 토큰 사용량 급증
  • 동일 툴 호출 비율이 특정 임계치 초과
  • run_id 단위 비용이 상한 초과

3) 자동 중단: circuit breaker

외부 API가 불안정한데 에이전트가 계속 두드리면, 상대 시스템에도 피해를 줍니다. 툴 호출을 감싸는 서킷 브레이커를 두면 루프가 “외부 장애”로 증폭되는 것을 막을 수 있습니다.

타임아웃과 데드라인을 명확히 두는 습관은 분산 시스템에서도 동일하게 중요합니다.

프롬프트만으로는 부족한 이유: 강제력의 위치

많은 팀이 다음처럼 프롬프트에 적습니다.

  • “같은 행동을 반복하지 마라”
  • “10번 이상 시도하지 마라”

하지만 이것은 소프트 룰입니다. 모델이 맥락을 잃거나, 툴 실패가 길어지거나, 프롬프트가 잘려나가면 쉽게 무력화됩니다. 따라서 종료조건과 가드레일은 아래 우선순위를 가져야 합니다.

  1. 런타임 강제(서버 코드)
  2. 툴 레이어 강제(allowlist, rate limit, circuit breaker)
  3. 프롬프트/정책(가이드)

실전 체크리스트: 무한루프를 막는 최소 세트

프로덕션에서 “최소한 이 정도는 있어야 한다” 기준으로 정리하면 다음과 같습니다.

  • max_stepsdeadline_ms를 서버에서 강제
  • max_tokens 또는 max_cost 예산 강제
  • 동일 툴/동일 상태 반복 탐지(해시 기반)
  • 최근 k 스텝 진척 없음 탐지
  • 에러 분류 기반 재시도 정책(408/429/5xx만 제한적으로)
  • 백오프 + 지터
  • 위험 툴은 인간 승인(HITL)
  • 메트릭/알림(특히 비용·호출량)

마무리: “에이전트는 멈추는 법부터 배워야 한다”

AutoGPT류 에이전트의 무한루프는 모델 성능 문제가 아니라, 대부분 시스템 설계의 빈틈에서 발생합니다. 종료조건은 브레이크이고, 가드레일은 차선을 만드는 일입니다. 이 둘을 런타임에서 강제하고, 실패를 관측 가능하게 만들면 “가끔 미친 듯이 돈을 태우는 에이전트”가 아니라 “예측 가능한 자동화 컴포넌트”로 운영할 수 있습니다.

다음 단계로는, 여러분의 에이전트가 사용하는 각 툴에 대해 timeout, retry, circuit breaker, idempotency key를 표준화하고, run 단위 비용 상한을 제품 정책으로 못 박는 것을 권장합니다.