Published on

AutoGPT 무한루프 막는 Tool 호출 제한·메모리 설계

Authors

AutoGPT 스타일의 에이전트(계획 수립 → Tool 호출 → 결과 관찰 → 다음 행동)는 한 번 잘못 설계되면 같은 행동을 계속 반복하는 무한루프에 빠지기 쉽습니다. 특히 검색·브라우징·코드 실행 같은 Tool이 붙는 순간, 모델은 “조금만 더 확인하면 될 것 같다”는 방향으로 스스로를 정당화하며 호출을 늘립니다.

이 글은 “왜 루프가 생기는지”를 시스템적으로 분해하고, Tool 호출 제한(예산) + 종료 조건 + 메모리(기억) 설계를 조합해 루프를 끊는 방법을 정리합니다. 구현 예시는 TypeScript 기준으로 설명합니다.

참고로, 에이전트가 외부 API를 호출할 때 요청 형식이 조금만 어긋나도 재시도 루프가 생깁니다. OpenAI 쪽 요청 에러를 먼저 정리하고 싶다면 OpenAI Responses API 400 에러 원인 8가지도 같이 보면 좋습니다.

AutoGPT가 무한루프에 빠지는 5가지 전형적 원인

1) 종료 조건이 “정성적”이고 검증 가능한 형태가 아님

예: “충분히 조사했으면 종료” 같은 문구만 있고, 충분함을 판정하는 체크리스트가 없습니다. 그러면 모델은 계속 조사하려고 합니다.

해결 핵심은 종료를 다음처럼 검증 가능한 조건으로 바꾸는 것입니다.

  • 산출물 형태가 명확: 예) JSON 스키마, 테이블, 파일 목록
  • 완료 판정이 가능: 예) “필수 항목 8개가 모두 채워졌는가”
  • 실패 판정도 가능: 예) “3회 시도 후도 값이 없으면 UNKNOWN으로 확정”

2) Tool 결과가 불완전하거나 비결정적이라 재시도가 합리적으로 보임

검색 결과가 매번 달라지거나, 웹 페이지가 403/429로 막히면 모델은 “다시 하면 될지도”라고 판단합니다.

  • 네트워크 계열 Tool은 에러를 구조화해서 반환해야 합니다.
  • “재시도 가능”과 “재시도 불가”를 구분해 모델에게 알려야 합니다.

3) 관찰(Observation)을 메모리에 쌓지만, ‘중복’과 ‘진전’을 구분하지 못함

에이전트는 매 스텝마다 로그를 남기지만, 그 로그가 “새로운 정보인지” 판단하지 못하면 같은 결론을 반복합니다.

  • 최근 N개의 행동이 동일하면 루프 의심
  • 새로 얻은 사실(팩트)이 증가하지 않으면 루프 의심

4) Tool 호출 비용(토큰/시간/돈) 예산이 없거나 느슨함

“최대한 잘해봐”는 사실상 무제한 호출을 허용합니다. 예산은 안전장치이자 설계의 일부입니다.

5) 실패 모드가 설계되지 않음

성공만 정의하고, 실패 시 무엇을 반환할지 없으면 에이전트는 계속 시도합니다.

  • PARTIAL 결과를 허용
  • UNKNOWN을 명시적으로 허용
  • “추가 권한/키/데이터가 필요” 같은 요구사항을 산출물로 내게 하기

1단계: Tool 호출 제한을 ‘카운트’가 아니라 ‘예산’으로 설계하기

단순히 “최대 10번 호출”은 현실에서 취약합니다. 어떤 Tool은 1회 호출이 매우 비싸고, 어떤 Tool은 가볍습니다. 그래서 예산(budget) 개념이 더 안전합니다.

  • 시간 예산: 전체 30초, Tool당 5초
  • 비용 예산: API 비용 추정치 합산
  • 토큰 예산: 입력/출력 토큰 상한
  • 위험 예산: 파일 삭제/결제 같은 위험 Tool은 1회만

예산 기반 정책 예시 (TypeScript)

type ToolName = "search" | "browser" | "code" | "db";

type Budget = {
  maxSteps: number;
  maxToolCalls: number;
  maxCostUsd: number;
  maxWallTimeMs: number;
  perToolMaxCalls: Partial<Record<ToolName, number>>;
};

type Usage = {
  steps: number;
  toolCalls: number;
  costUsd: number;
  startedAt: number;
  perToolCalls: Partial<Record<ToolName, number>>;
};

export function canContinue(budget: Budget, usage: Usage, tool?: ToolName) {
  const now = Date.now();
  if (usage.steps >= budget.maxSteps) return { ok: false, reason: "maxSteps" };
  if (usage.toolCalls >= budget.maxToolCalls) return { ok: false, reason: "maxToolCalls" };
  if (usage.costUsd >= budget.maxCostUsd) return { ok: false, reason: "maxCostUsd" };
  if (now - usage.startedAt >= budget.maxWallTimeMs) return { ok: false, reason: "maxWallTime" };

  if (tool) {
    const used = usage.perToolCalls[tool] ?? 0;
    const limit = budget.perToolMaxCalls[tool];
    if (limit !== undefined && used >= limit) return { ok: false, reason: `perToolMaxCalls:${tool}` };
  }

  return { ok: true as const };
}

이 정책의 포인트는 “루프를 막는다”에 그치지 않고, 중단 사유가 명확히 기록된다는 점입니다. 중단 사유가 있어야 프롬프트/Tool/메모리 설계를 개선할 수 있습니다.

2단계: 종료 조건을 ‘스키마’로 강제해 루프를 끊기

AutoGPT류 루프는 대부분 “언제 멈춰야 하는지”가 불명확해서 생깁니다. 해결책은 최종 응답을 구조화하고, 구조가 채워지면 종료하게 만드는 것입니다.

예: 조사형 태스크라면 다음처럼 종료 스키마를 둡니다.

  • answer: 최종 결론
  • evidence: 근거 3개 이상
  • unknowns: 끝내 확인 불가한 항목
  • next_steps: 사람이 해야 할 일
type FinalReport = {
  answer: string;
  evidence: Array<{ source: string; quote: string }>;
  unknowns: string[];
  nextSteps: string[];
  status: "COMPLETE" | "PARTIAL";
};

그리고 에이전트 루프 내부에서 “스키마를 채울 수 있는가”를 매 스텝 평가합니다.

  • evidence가 3개 미만이면 Tool 호출 허용
  • unknowns가 늘기만 하고 evidence가 안 늘면 종료(부분 완료)

이렇게 하면 “계속 더 조사”가 아니라 “스키마를 채우기 위한 최소 행동”으로 수렴합니다.

3단계: 루프 감지(Loop Detection) — ‘중복 행동’과 ‘진전 없음’을 신호로 사용

루프는 보통 다음 두 형태로 나타납니다.

  • 같은 Tool을 같은 인자로 반복 호출
  • 다른 Tool을 호출해도 새 정보가 추가되지 않음

(A) 행동 시그니처로 중복 감지

import crypto from "crypto";

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

export function actionSignature(action: Action) {
  const json = JSON.stringify(action);
  return crypto.createHash("sha256").update(json).digest("hex");
}

export function isLooping(recentSignatures: string[], threshold = 3) {
  if (recentSignatures.length < threshold) return false;
  const tail = recentSignatures.slice(-threshold);
  return tail.every((x) => x === tail[0]);
}

(B) “새 팩트 수”가 증가하는지 감시

관찰 결과에서 팩트를 추출해 facts 집합 크기가 늘지 않으면, 모델은 사실상 제자리입니다.

  • 팩트 추출은 완벽할 필요는 없습니다.
  • 단순 키워드/엔티티/URL 기준만으로도 효과가 큽니다.

루프 감지 시에는 다음 중 하나를 강제합니다.

  • 전략 전환 프롬프트 삽입: “다른 접근을 시도하라”
  • Tool 차단: 같은 Tool을 일정 시간 금지
  • 부분 완료로 종료: status = PARTIAL

4단계: 메모리 설계 — ‘다 쌓기’가 아니라 ‘계층화’가 핵심

무한루프를 막는 메모리는 “기억을 많이 저장”하는 게 아니라 기억이 의사결정에 영향을 주도록 구조화하는 것입니다.

메모리의 3계층

  1. Working Memory (단기)
  • 최근 대화/최근 Tool 결과
  • 길이 제한이 명확해야 함 (예: 최근 10개 이벤트)
  1. Episodic Memory (에피소드)
  • 이번 태스크에서 얻은 핵심 사실, 실패한 시도, 확정된 결론
  • “무엇을 했고, 왜 실패했는지”가 포함돼야 재시도를 막음
  1. Semantic Memory (장기 지식)
  • 재사용 가능한 규칙/가이드
  • 예: “이 API는 429가 잦으니 백오프 필요”

중요한 원칙: ‘실패도 메모리로 승격’해야 루프가 줄어든다

많은 에이전트가 성공한 정보만 저장합니다. 하지만 루프를 막는 건 오히려 실패 기록입니다.

  • “이 URL은 403이라 접근 불가”
  • “이 검색 쿼리는 결과가 없었음”
  • “이 DB 쿼리는 권한 부족”

이 실패 메모리가 다음 스텝의 행동 공간을 줄여줍니다.

5단계: Tool 설계 — 결과를 모델 친화적으로, 재시도 정책을 함께 반환

Tool이 단순히 문자열을 반환하면 모델은 맥락을 오해합니다. 다음처럼 구조화된 응답이 좋습니다.

  • ok: 성공 여부
  • retryable: 재시도 가능 여부
  • cooldownMs: 재시도까지 대기 시간
  • data: 성공 데이터
  • error: 실패 사유(코드 포함)
type ToolResult<T> =
  | { ok: true; data: T }
  | {
      ok: false;
      error: { code: string; message: string };
      retryable: boolean;
      cooldownMs?: number;
    };

async function searchTool(query: string): Promise<ToolResult<{ urls: string[] }>> {
  try {
    // ... call provider
    return { ok: true, data: { urls: ["https://example.com"] } };
  } catch (e: any) {
    const code = e?.code ?? "UNKNOWN";
    const retryable = code === "RATE_LIMIT" || code === "ETIMEDOUT";
    return {
      ok: false,
      error: { code, message: String(e?.message ?? e) },
      retryable,
      cooldownMs: retryable ? 1500 : undefined
    };
  }
}

이렇게 하면 에이전트가 “왜 실패했는지”를 이해하고, 무의미한 즉시 재시도를 줄일 수 있습니다.

스트리밍 기반으로 Tool 결과를 흘려보내는 구조라면, 중복 토큰/끊김이 루프 트리거가 되는 경우도 있습니다. 이 경우 LangChain 스트리밍 끊김·중복 토큰 해결법에서 소개한 것처럼 출력 조립 로직을 먼저 안정화하는 게 좋습니다.

6단계: “계획-실행”을 분리하고, 실행은 결정론적으로 만들기

무한루프는 모델이 “계획도 만들고 실행도 판단”할 때 더 자주 발생합니다. 완화 전략은 다음입니다.

  • 모델은 계획(Plan) 만 생성
  • 실행기는 정책(Policy) 으로만 Tool 호출
  • 정책은 예산/루프감지/재시도 규칙을 강제

즉, 모델의 자유도를 “행동 선택”에서 빼고 “의도 표현” 쪽으로 옮깁니다.

Plan 스키마 예시

type PlanStep = {
  goal: string;
  tool: "search" | "browser" | "code" | "none";
  input: Record<string, any>;
  successCriteria: string[];
};

type Plan = {
  steps: PlanStep[];
  stopWhen: string[];
};

실행기는 tool = none이거나 stopWhen을 만족하면 종료합니다. 모델이 “계속 해도 된다”고 말해도, 정책이 아니면 실행되지 않습니다.

7단계: 운영 관점 체크리스트 — 루프를 ‘장애’로 다루기

에이전트 무한루프는 비용/지연을 유발하는 운영 장애입니다. 따라서 다음을 권장합니다.

  • 스텝/Tool 호출/비용/시간을 메트릭으로 노출
  • 중단 사유(maxSteps, maxWallTime 등)를 태그로 남김
  • 같은 태스크에서 PARTIAL 비율이 올라가면 프롬프트/Tool 품질 이슈로 분류

이 관점은 고루틴 누수나 리소스 누수와 닮았습니다. “계속 살아있는 작업”을 조기에 감지하고 차단해야 합니다. 비슷한 운영 마인드셋은 Go 고루틴 누수 원인 8가지와 진단법에서도 얻을 수 있습니다.

종합 설계 패턴: 무한루프를 실용적으로 막는 조합

현장에서 가장 효과가 좋았던 조합은 아래 순서입니다.

  1. 예산(Budget) 강제: maxSteps, maxToolCalls, maxWallTimeMs, Tool별 상한
  2. 종료 스키마: COMPLETE 또는 PARTIAL을 명시
  3. 루프 감지: 행동 시그니처 중복 + 팩트 증가율 감시
  4. 메모리 계층화: 실패 기록을 에피소드 메모리로 승격
  5. Tool 결과 구조화: retryable, cooldownMs 포함
  6. 계획-실행 분리: 모델은 계획, 실행기는 정책

이렇게 설계하면 “모델이 똑똑해져서 루프를 안 돈다”가 아니라, 루프가 돌 수 없는 시스템이 됩니다. AutoGPT류 에이전트를 제품에 넣을 때 필요한 건 더 강한 모델보다, 이런 제약과 관측 가능성(Observability)입니다.


부록: 최소 실행 루프 예시(의사코드)

아래는 위 아이디어를 한 번에 묶은 간단한 루프 구조입니다.

type State = {
  usage: Usage;
  recentActionSigs: string[];
  facts: Set<string>;
  report: FinalReport;
};

async function runAgent(initial: State, budget: Budget) {
  const state = initial;

  while (true) {
    state.usage.steps += 1;
    const cont = canContinue(budget, state.usage);
    if (!cont.ok) {
      state.report.status = "PARTIAL";
      state.report.unknowns.push(`Stopped by budget: ${cont.reason}`);
      return state.report;
    }

    if (state.report.evidence.length >= 3 && state.report.answer.trim().length > 0) {
      state.report.status = "COMPLETE";
      return state.report;
    }

    const action: Action = await decideNextAction(state); // model output
    const sig = actionSignature(action);
    state.recentActionSigs.push(sig);

    if (isLooping(state.recentActionSigs, 3)) {
      state.report.status = "PARTIAL";
      state.report.unknowns.push("Loop detected: repeated action signature");
      return state.report;
    }

    const contTool = canContinue(budget, state.usage, action.tool as any);
    if (!contTool.ok) {
      state.report.status = "PARTIAL";
      state.report.unknowns.push(`Tool blocked: ${contTool.reason}`);
      return state.report;
    }

    state.usage.toolCalls += 1;
    state.usage.perToolCalls[action.tool as ToolName] =
      (state.usage.perToolCalls[action.tool as ToolName] ?? 0) + 1;

    const obs = await callTool(action);
    applyObservationToMemory(state, obs);

    if (noNewFactsRecently(state)) {
      state.report.status = "PARTIAL";
      state.report.unknowns.push("No progress: facts not increasing");
      return state.report;
    }
  }
}

decideNextAction, applyObservationToMemory, noNewFactsRecently 같은 함수는 팀/도메인별로 달라지지만, 예산 + 종료 스키마 + 루프 감지의 3종 세트는 거의 모든 케이스에서 효과가 있습니다.