Published on

AutoGPT 에이전트 무한루프 방지 - 종료조건·메모리

Authors

AutoGPT 스타일의 에이전트(계획 수립 -> 실행 -> 관찰 -> 반성/재계획)는 “스스로 다음 행동을 고르는” 구조 때문에, 종료조건을 설계하지 않으면 쉽게 무한루프에 빠집니다. 특히 도구 호출이 실패하거나(권한, 네트워크, 포맷 오류), 관찰 결과가 목표 달성 여부를 명확히 말해주지 않거나, 메모리가 오염되어 같은 결론을 반복하는 순간 루프가 시작됩니다.

이 글에서는 무한루프의 전형적인 패턴을 분류하고, 이를 막기 위한 종료조건(Stop Conditions), 메모리 설계(단기·장기·작업 메모리), 그리고 실패 예산/재시도 정책을 코드 수준으로 제안합니다.

AutoGPT 에이전트가 무한루프에 빠지는 6가지 원인

1) 목표 정의가 검사 가능하지 않다

예: “최적의 답을 찾아줘”, “완벽하게 조사해줘” 같은 목표는 검증 함수로 바꾸기 어렵습니다. 검증이 불가능하면 에이전트는 “아직 부족하다”를 반복하며 계속 탐색합니다.

해결 방향은 목표를 종료 가능한 형태로 바꾸는 것입니다.

  • 산출물 형태: 보고서/JSON/PR/티켓 등
  • 합격 기준: 체크리스트, 테스트 통과, 스키마 검증
  • 범위 제한: 최대 N개 출처, 최대 M분, 최대 K회 도구 호출

2) 관찰(Observation)이 상태를 충분히 담지 못한다

도구 호출 결과가 “성공/실패”만 주고, 실패 이유나 다음 액션 힌트가 없으면 모델은 같은 시도를 반복하기 쉽습니다.

예: HTTP 403인데 “권한 없음”이라는 힌트 없이 그냥 실패로만 주는 경우.

3) 재시도 정책이 없다(또는 무한 재시도)

네트워크 타임아웃, rate limit, 일시적 오류는 재시도 가치가 있지만, 스키마 오류/권한 오류는 재시도해도 해결되지 않습니다. 그런데 에이전트가 이를 구분하지 못하면 “같은 도구 호출”을 반복합니다.

4) 메모리 오염 또는 요약 손실

장기 메모리에 잘못된 사실이 저장되거나, 요약 과정에서 중요한 제약이 사라지면 에이전트는 계속 잘못된 전제 위에서 행동합니다.

5) 계획과 실행의 결합이 느슨하다

계획이 “조사한다, 정리한다”처럼 추상적이면, 실행 단계에서 무엇을 해야 목표가 달성되는지 모호해지고 루프가 됩니다.

6) 종료를 ‘행동’으로 취급하지 않는다

많은 구현에서 종료는 시스템이 강제로 끊는 이벤트(토큰/스텝 제한)로만 존재합니다. 에이전트가 스스로 종료를 선택할 수 있어야 합니다.

종료조건 설계: “멈출 수 있게 만드는” 체크리스트

종료조건은 하나만 두면 취약합니다. 실전에서는 다층(레이어드) 종료조건이 안전합니다.

1) 하드 리밋: 스텝/시간/비용 상한

  • 최대 스텝: 예 maxSteps = 30
  • 최대 벽시계 시간: 예 maxWallTimeMs = 120000
  • 최대 비용: 예 maxTokens 또는 maxToolCalls

이건 안전장치이고, 품질을 보장하지는 않습니다.

2) 소프트 리밋: 진전(Progress) 기반 종료

무한루프는 “진전이 없는 반복”으로 나타납니다. 따라서 매 스텝마다 진전 점수를 계산하고, 일정 횟수 연속으로 진전이 없으면 종료(또는 사람에게 escalate)합니다.

진전의 예:

  • 새로운 사실/근거가 늘어났는가
  • 미해결 TODO 개수가 줄었는가
  • 검증 테스트가 더 많이 통과했는가
  • 산출물의 스키마 오류가 줄었는가

3) 성공 조건: 검증 함수로 판정

가장 강력한 종료조건은 “완료 여부를 코드로 검사”하는 것입니다.

예:

  • 결과 JSON이 스키마를 만족하는가
  • 문서가 필수 섹션을 포함하는가
  • 링크 수/근거 수가 기준 이상인가

4) 실패 조건: 복구 불가능 오류 분류

예를 들어 다음은 재시도보다 중단이 낫습니다.

  • 권한/인증 실패(401/403)
  • 입력 스키마가 계속 불일치
  • 도구가 “지원하지 않는 기능”을 요청

이때는 “다른 시도”가 아니라 “요청 변경”이 필요하므로, 에이전트가 사용자에게 질문하거나 종료해야 합니다.

5) 사용자 승인 게이트(Human-in-the-loop)

실제 프로덕션에서 에이전트가 외부 시스템을 변경하는 경우(메일 발송, 결제, 배포 등)는 승인 게이트가 필요합니다. 승인 대기 상태 자체가 종료(또는 일시정지) 상태가 됩니다.

메모리 설계: 단기·작업·장기 메모리를 분리하라

무한루프 방지에서 메모리는 “많이 저장”이 아니라 “올바르게 저장”이 핵심입니다.

1) 단기 메모리(Short-term): 최근 대화/관찰

  • 최근 N턴만 유지
  • 도구 출력은 원문 전체를 넣지 말고 요약+핵심 필드만 유지
  • 실패 원인은 구조화해서 저장(에러 코드, 메시지, 재시도 가능 여부)

2) 작업 메모리(Working memory): 현재 목표를 위한 상태 머신

에이전트가 지금 어디까지 왔는지 기록하는 작업 상태가 없으면, 같은 일을 반복하기 쉽습니다.

작업 메모리에 넣을 것:

  • TODO 리스트(상태: todo/doing/done/blocked)
  • 이미 시도한 액션과 결과 해시
  • 현재 계획 버전과 변경 이력

3) 장기 메모리(Long-term): 재사용 가능한 사실만

장기 메모리는 “이번 작업에서만 유효한 임시 정보”가 들어가면 오염됩니다.

장기 메모리에 적합:

  • 사용자 선호(톤, 포맷)
  • 반복되는 도메인 규칙
  • 신뢰할 수 있는 고정 사실(검증된 것)

장기 메모리에 부적합:

  • 이번 세션의 임시 URL, 일시적 장애, 추측성 결론

4) 메모리 쓰기 정책: 신뢰도 게이트

장기 메모리에 쓰기 전에 다음을 통과시키세요.

  • 출처가 있는가
  • 도구로 검증했는가(스키마 검증, 테스트)
  • “추측” 표시가 되어 있는가

실전 패턴: 반복 탐지(Loop Detection)와 재시도 예산

아래는 TypeScript로 작성한 “에이전트 루프 실행기” 예시입니다. 포인트는 다음입니다.

  • 스텝 제한
  • 동일 액션 반복 감지
  • 진전 점수 기반 중단
  • 오류 분류에 따른 재시도/중단
type ToolResult =
  | { ok: true; output: unknown; meta?: Record<string, unknown> }
  | {
      ok: false;
      error: { code: string; message: string; retryable: boolean };
      meta?: Record<string, unknown>;
    };

type AgentAction =
  | { type: "tool"; name: string; input: unknown }
  | { type: "final"; answer: string }
  | { type: "ask_user"; question: string };

type StepSnapshot = {
  step: number;
  action: AgentAction;
  observation: ToolResult | { ok: true; output: string };
  progressScore: number;
};

function stableHash(value: unknown): string {
  // 운영에서는 crypto 해시를 쓰세요.
  return JSON.stringify(value);
}

function classifyStop(
  snapshots: StepSnapshot[],
  maxSteps: number,
  maxSameActionRepeats: number,
  maxNoProgressStreak: number
): { stop: boolean; reason?: string } {
  const step = snapshots.length;
  if (step >= maxSteps) return { stop: true, reason: "max_steps" };

  // 동일 액션 반복 감지
  const last = snapshots[snapshots.length - 1];
  if (last) {
    const lastHash = stableHash(last.action);
    let repeats = 0;
    for (let i = snapshots.length - 1; i >= 0; i--) {
      if (stableHash(snapshots[i].action) === lastHash) repeats++;
      else break;
    }
    if (repeats >= maxSameActionRepeats) {
      return { stop: true, reason: "same_action_repeated" };
    }
  }

  // 진전 없음 스트릭
  let noProgress = 0;
  for (let i = snapshots.length - 1; i >= 0; i--) {
    if (snapshots[i].progressScore <= 0) noProgress++;
    else break;
  }
  if (noProgress >= maxNoProgressStreak) {
    return { stop: true, reason: "no_progress" };
  }

  return { stop: false };
}

async function runAgentLoop(params: {
  maxSteps: number;
  maxSameActionRepeats: number;
  maxNoProgressStreak: number;
  decideAction: (ctx: { snapshots: StepSnapshot[] }) => Promise<AgentAction>;
  runTool: (name: string, input: unknown) => Promise<ToolResult>;
  scoreProgress: (ctx: { snapshots: StepSnapshot[]; action: AgentAction; obs: unknown }) => number;
}) {
  const snapshots: StepSnapshot[] = [];

  while (true) {
    const stop = classifyStop(
      snapshots,
      params.maxSteps,
      params.maxSameActionRepeats,
      params.maxNoProgressStreak
    );
    if (stop.stop) {
      return { type: "stopped", reason: stop.reason, snapshots } as const;
    }

    const action = await params.decideAction({ snapshots });

    if (action.type === "final") {
      return { type: "completed", answer: action.answer, snapshots } as const;
    }

    if (action.type === "ask_user") {
      return { type: "needs_input", question: action.question, snapshots } as const;
    }

    const toolRes = await params.runTool(action.name, action.input);

    // 복구 불가능 오류는 빠르게 중단/질문으로 전환
    if (!toolRes.ok && !toolRes.error.retryable) {
      return {
        type: "stopped",
        reason: `non_retryable_error:${toolRes.error.code}`,
        snapshots,
      } as const;
    }

    const progressScore = params.scoreProgress({ snapshots, action, obs: toolRes });

    snapshots.push({
      step: snapshots.length + 1,
      action,
      observation: toolRes,
      progressScore,
    });
  }
}

이 구조의 핵심은 “에이전트가 똑똑해지길 기대”하는 대신, 런타임이 멈춤 규칙을 강제한다는 점입니다.

종료조건을 강화하는 ‘검증 루프’ 패턴

에이전트 루프를 계획/실행검증으로 분리하면, 무한루프를 크게 줄일 수 있습니다.

  • 실행 루프: 도구 호출/자료 수집/초안 생성
  • 검증 루프: 산출물이 합격 기준을 만족하는지 판정

검증은 가능하면 LLM이 아니라 코드로 하세요.

예: JSON 산출물이라면 스키마 검증을 붙입니다.

import { z } from "zod";

const ReportSchema = z.object({
  title: z.string().min(5),
  summary: z.string().min(20),
  bullets: z.array(z.string().min(5)).min(3).max(10),
  sources: z.array(z.string().url()).min(1).max(5),
});

type Report = z.infer<typeof ReportSchema>;

function validateReport(maybe: unknown): { ok: true; value: Report } | { ok: false; reason: string } {
  const parsed = ReportSchema.safeParse(maybe);
  if (!parsed.success) return { ok: false, reason: parsed.error.message };
  return { ok: true, value: parsed.data };
}

이렇게 하면 에이전트가 “그럴듯한 텍스트”를 반복 생성하는 루프 대신, 불합격 사유를 해결하는 방향으로 수렴합니다.

메모리 오염을 막는 요약 전략: “사실/추측/결정” 분리

요약 메모리를 한 덩어리로 저장하면, 추측이 사실처럼 굳어집니다. 추천하는 방식은 메모리를 구조화해서 저장하는 것입니다.

{
  "facts": [
    { "text": "API는 401을 반환했다", "evidence": "tool:http_call", "confidence": 0.95 }
  ],
  "assumptions": [
    { "text": "토큰이 만료되었을 수 있다", "confidence": 0.4 }
  ],
  "decisions": [
    { "text": "다음 시도 전에 토큰 재발급을 요청한다" }
  ],
  "todo": [
    { "text": "사용자에게 새 토큰을 요청", "status": "blocked" }
  ]
}

이 구조는 “무한루프의 연료”인 잘못된 확신을 줄이고, 막혔을 때 ask_user로 자연스럽게 전환하도록 돕습니다.

실패 예산(Failure Budget)과 백오프: 재시도를 ‘관리’하라

도구 호출이 실패할 때마다 에이전트가 즉흥적으로 재시도하면 루프가 됩니다. 다음을 고정 정책으로 두세요.

  • 도구별 재시도 횟수 상한(예: 2회)
  • 지수 백오프(예: 500ms, 1500ms)
  • 같은 입력으로는 1회만 재시도(입력이 바뀌지 않으면 의미 없음)
function sleep(ms: number) {
  return new Promise((r) => setTimeout(r, ms));
}

async function withRetry<T>(fn: () => Promise<T>, opts: { retries: number; baseDelayMs: number }) {
  let lastErr: unknown;
  for (let i = 0; i <= opts.retries; i++) {
    try {
      return await fn();
    } catch (e) {
      lastErr = e;
      const delay = opts.baseDelayMs * Math.pow(3, i);
      await sleep(delay);
    }
  }
  throw lastErr;
}

주의할 점은 “예외면 재시도”가 아니라, 앞서 말한 것처럼 오류 분류와 결합해야 한다는 것입니다.

운영 관점: 무한루프는 성능 문제이기도 하다

에이전트가 루프에 빠지면 비용만 늘어나는 게 아니라, 이벤트 루프를 오래 점유하거나(특히 동기 처리), 큐를 막아 전체 지연을 유발합니다. 프론트엔드에서 긴 작업이 INP를 떨어뜨리듯, 에이전트 실행도 “긴 Task”가 되면 시스템 전체를 둔화시킵니다. 긴 작업을 쪼개고 상태를 저장해 재개 가능한 형태로 만드는 접근은 웹 성능에서도 동일하게 중요합니다. 관련해서는 React INP 급락 원인 - 긴 Task 분해·useTransition 글의 “긴 작업 분해” 관점이 에이전트 런타임 설계에도 그대로 적용됩니다.

또한 인증/권한 문제로 같은 호출을 반복하는 루프도 흔합니다. 예를 들어 키 회전이나 kid 불일치로 401이 반복되면, 에이전트는 “다시 요청”을 무한히 시도할 수 있습니다. 이런 경우는 재시도가 아니라 인증 상태 갱신이 필요합니다. JWKS 회전 대응 패턴은 JWT kid 없음·불일치로 401? JWKS 회전 대응을 참고해, 에이전트의 오류 분류 테이블에 “인증 갱신 액션”을 넣는 방식으로 해결할 수 있습니다.

마지막으로, 무한 리다이렉트처럼 “시스템이 정상 응답을 주지 않는 반복”은 에이전트도 동일하게 겪습니다. 리다이렉트 루프를 끊듯이, 에이전트도 반복 패턴을 감지해 차단해야 합니다. 유사한 문제 해결 사고방식은 Keycloak OAuth 로그인 무한 302 리다이렉트 해결에서 힌트를 얻을 수 있습니다.

권장 아키텍처 요약

1) 에이전트 런타임에 “종료”를 1급 시민으로

  • final / ask_user / stopped 상태를 명확히
  • 스텝, 시간, 비용 하드 리밋
  • 반복/진전 없음 감지

2) 메모리는 “분리 + 구조화 + 쓰기 게이트”

  • 단기: 최근 관찰
  • 작업: TODO/시도 이력/계획 버전
  • 장기: 재사용 가능한 검증된 사실만

3) 검증 루프를 코드로

  • 스키마 검증
  • 테스트/린트
  • 체크리스트 자동 판정

4) 실패 예산과 오류 분류

  • retryable vs non-retryable
  • 도구별 재시도 상한
  • 입력이 바뀌지 않으면 재시도 금지

마무리

AutoGPT 에이전트의 무한루프는 “모델이 멍청해서”가 아니라, 대개 종료조건이 불완전하고 메모리/재시도 정책이 빈약해서 발생합니다. 해결의 핵심은 LLM에게 종료를 맡기는 게 아니라, 런타임이 다음을 강제하는 것입니다.

  • 목표를 검증 가능하게 만들기
  • 반복과 진전 없음의 자동 감지
  • 재시도 예산과 오류 분류
  • 작업 메모리로 상태 머신을 유지
  • 장기 메모리는 신뢰도 게이트를 통과한 사실만 저장

이 5가지만 갖추면, 에이전트는 “끝없이 생각하는 시스템”이 아니라 “멈출 줄 아는 자동화”로 바뀝니다.