Published on

AutoGPT 도구 호출 무한루프 차단 - FSM·가드레일

Authors

에이전트형 LLM(예: AutoGPT 스타일)은 plan -> tool -> observe -> plan 사이클을 반복하며 목표를 달성합니다. 문제는 이 루프가 “유한한 종료 조건”을 갖지 못할 때입니다. 대표적으로 같은 도구를 동일 인자로 재호출하거나, 에러를 복구하지 못한 채 재시도만 반복하거나, 관찰 결과를 잘못 해석해 같은 분기를 다시 타는 경우가 있습니다.

이 글에서는 무한루프를 “프롬프트로 설득”하는 수준이 아니라, 런타임에서 강제로 차단하고 복구 가능한 방향으로 유도하는 패턴을 다룹니다. 핵심은 두 가지입니다.

  • FSM(유한상태기계)로 에이전트 실행을 상태/전이로 모델링해 불가능한 전이를 막고, 종료 상태를 명시한다.
  • 가드레일(guardrails)로 도구 호출을 정량적 한도(예: budget, retry, TTL)정성적 규칙(예: 동일 호출 금지, 결과 검증) 으로 감싼다.

또한 도구 출력 포맷이 어긋나면 모델이 “계속 고치려고” 재호출을 반복하는 경우가 많습니다. 이때는 도구 출력 스키마를 엄격히 맞추는 게 우선입니다. 관련해서는 OpenAI Responses API 400 invalid_tool_output 해결법도 함께 참고하면 좋습니다.

무한루프가 생기는 전형적인 패턴

1) 동일 입력의 재호출

  • 예: search("foo")를 10번 반복
  • 원인: 관찰 결과를 “새 정보”로 인식하지 못하거나, 다음 행동 선택 로직이 빈약함

2) 에러 재시도 폭주

  • 예: 429/5xx/timeout 발생 후 즉시 재시도, backoff 없음
  • 원인: 재시도 정책이 LLM에게만 맡겨져 있음

3) 관찰 결과 검증 부재

  • 예: 도구가 빈 결과를 반환했는데도 성공으로 간주하거나, 반대로 성공인데 실패로 해석
  • 원인: 도구 결과에 대한 success/error의 명확한 계약이 없음

4) 종료 조건 부재

  • 예: “완료”에 대한 정의가 없고, 모델이 계속 개선/확장하려 함
  • 원인: 목표가 측정 불가능하거나, 종료 상태(terminal state)가 코드에 없음

해결 전략 1: FSM으로 실행을 ‘상태 기계’로 고정

FSM을 쓰는 이유는 단순합니다. 에이전트는 본질적으로 상태를 갖습니다.

  • 계획 수립 중인지
  • 도구 실행 중인지
  • 관찰을 평가 중인지
  • 완료/중단 상태인지

이걸 코드로 명시하면, “모델이 하고 싶어 하는 행동”이 아니라 “시스템이 허용하는 전이”만 실행됩니다.

상태 설계 예시

  • IDLE: 시작 전
  • PLANNING: 다음 행동(도구 호출 또는 종료) 결정
  • TOOL_RUNNING: 도구 실행
  • EVALUATING: 관찰 결과 평가 및 다음 전이 결정
  • DONE: 목표 달성
  • FAILED: 회복 불가
  • ESCALATED: 사람에게 넘김(또는 안전한 중단)

핵심은 DONE/FAILED/ESCALATED 같은 종료 상태를 반드시 두는 것입니다.

TypeScript 예시: FSM 스켈레톤

아래 코드는 “도구 호출 루프”를 FSM으로 감싼 최소 구조입니다. 실제로는 도구 레지스트리, 메시지 히스토리, 메트릭 등을 더 붙입니다.

type State =
  | "IDLE"
  | "PLANNING"
  | "TOOL_RUNNING"
  | "EVALUATING"
  | "DONE"
  | "FAILED"
  | "ESCALATED";

type ToolCall = {
  name: string;
  args: Record<string, unknown>;
};

type Observation = {
  ok: boolean;
  data?: unknown;
  error?: { code: string; message: string };
};

type Ctx = {
  state: State;
  step: number;
  maxSteps: number;
  lastToolCall?: ToolCall;
  toolCallHistory: Array<{ fingerprint: string; atStep: number }>;
  observation?: Observation;
};

function fingerprintToolCall(call: ToolCall): string {
  // JSON stringify는 키 순서 이슈가 있어 안정화가 필요할 수 있음
  return `${call.name}:${JSON.stringify(call.args)}`;
}

function assertTransition(from: State, to: State) {
  const allowed: Record<State, State[]> = {
    IDLE: ["PLANNING"],
    PLANNING: ["TOOL_RUNNING", "DONE", "ESCALATED", "FAILED"],
    TOOL_RUNNING: ["EVALUATING", "FAILED"],
    EVALUATING: ["PLANNING", "DONE", "ESCALATED", "FAILED"],
    DONE: [],
    FAILED: [],
    ESCALATED: [],
  };
  if (!allowed[from].includes(to)) {
    throw new Error(`Invalid transition: ${from} -> ${to}`);
  }
}

function transition(ctx: Ctx, to: State): Ctx {
  assertTransition(ctx.state, to);
  return { ...ctx, state: to };
}

이제 “에이전트가 상태를 넘어서는 행동”은 원천 차단됩니다. 예를 들어 DONE 상태에서 도구를 또 호출하려 하면 전이 자체가 막힙니다.

해결 전략 2: 가드레일로 도구 호출을 ‘검문소’ 통과시키기

FSM이 큰 틀이라면, 가드레일은 각 도구 호출 전후에 붙는 검문소입니다. 가드레일은 보통 아래를 포함합니다.

  • 스텝/시간 예산: maxSteps, deadline, toolBudget
  • 중복 호출 차단: 동일 호출 fingerprint 제한
  • 재시도 정책: 지수 백오프, 최대 재시도, 에러 코드별 분기
  • 결과 검증: 스키마 검증, 빈 결과 처리, 성공 조건 평가
  • 서킷 브레이커: 특정 도구가 계속 실패하면 잠시 차단

1) 스텝/시간 예산(하드 리밋)

가장 확실한 안전장치입니다.

  • maxSteps 초과 시 ESCALATED 또는 FAILED
  • 전체 실행 deadline 초과 시 중단
function guardStepBudget(ctx: Ctx): Ctx {
  if (ctx.step >= ctx.maxSteps) {
    return transition(ctx, "ESCALATED");
  }
  return ctx;
}

실무에서는 “무한루프 차단”의 1차 방어선이 이 하드 리밋입니다. 다만 이것만으로는 사용자 경험이 나쁘므로, 아래의 정교한 가드레일이 필요합니다.

2) 동일 도구 호출 반복 차단(중복 fingerprint)

동일한 도구/인자 조합을 계속 호출하는 경우, 일정 횟수 이상이면 막고 다른 전략을 선택하게 해야 합니다.

function guardDuplicateToolCall(
  ctx: Ctx,
  call: ToolCall,
  limit: number
): { ok: true } | { ok: false; reason: string } {
  const fp = fingerprintToolCall(call);
  const count = ctx.toolCallHistory.filter(h => h.fingerprint === fp).length;
  if (count >= limit) {
    return { ok: false, reason: `Duplicate tool call blocked: ${fp}` };
  }
  return { ok: true };
}

function recordToolCall(ctx: Ctx, call: ToolCall): Ctx {
  const fp = fingerprintToolCall(call);
  return {
    ...ctx,
    lastToolCall: call,
    toolCallHistory: [...ctx.toolCallHistory, { fingerprint: fp, atStep: ctx.step }],
  };
}

여기서 중요한 포인트는 “완전 동일”만 막으면 부족하다는 점입니다. 예를 들어 검색 쿼리가 약간씩만 바뀌며 무한히 확장될 수 있습니다. 그래서 다음의 보완 규칙이 자주 필요합니다.

  • 유사도 기반(Levenshtein, embedding cosine)으로 “사실상 동일”을 감지
  • 도구별로 limit를 다르게 설정(검색은 3회, 결제/삭제는 1회 등)

3) 재시도 가드레일: 에러 코드별 정책

LLM에게 “재시도해”를 맡기면 보통 즉시 재시도 폭주로 이어집니다. 도구 실행 계층에서 다음을 강제하세요.

  • 429: 지수 백오프 + jitter
  • 5xx: 제한된 재시도
  • 4xx(유효성 오류): 재시도 금지, 즉시 PLANNING으로 돌아가 프롬프트/인자 수정 유도
type RetryPolicy = {
  maxRetries: number;
  baseDelayMs: number;
};

async function withRetry<T>(
  fn: () => Promise<T>,
  classify: (e: unknown) => { retryable: boolean; code: string },
  policy: RetryPolicy
): Promise<T> {
  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (e) {
      const c = classify(e);
      if (!c.retryable || attempt >= policy.maxRetries) throw e;
      const delay = policy.baseDelayMs * Math.pow(2, attempt);
      const jitter = Math.floor(Math.random() * 200);
      await new Promise(r => setTimeout(r, delay + jitter));
      attempt++;
    }
  }
}

이렇게 하면 “도구 호출 무한루프”의 상당 부분이 “재시도 무한루프”였다는 사실이 드러납니다.

4) 결과 검증: 스키마와 성공 조건을 분리

관찰 결과(Observation)는 최소한 아래를 만족해야 합니다.

  • 항상 ok를 포함
  • 실패 시 error.code, error.message를 포함
  • 성공 시 data의 스키마가 고정

도구 출력이 흔들리면 모델이 출력을 해석하지 못해 같은 호출을 반복합니다. 특히 OpenAI 계열 툴콜에서 tool_output 포맷이 어긋나면 400이 나고, 모델이 “수정 시도”를 반복하며 루프가 됩니다. 이때는 먼저 출력 계약부터 고치세요. 자세한 케이스는 OpenAI Responses API 400 invalid_tool_output 해결법에 정리돼 있습니다.

아래는 zod로 결과를 검증하는 예시입니다.

import { z } from "zod";

const SearchResultSchema = z.object({
  items: z.array(
    z.object({
      title: z.string(),
      url: z.string().url(),
      snippet: z.string().optional(),
    })
  ),
});

type SearchResult = z.infer<typeof SearchResultSchema>;

function validateSearchObservation(obs: Observation):
  | { ok: true; value: SearchResult }
  | { ok: false; reason: string } {
  if (!obs.ok) return { ok: false, reason: obs.error?.message ?? "unknown error" };
  const parsed = SearchResultSchema.safeParse(obs.data);
  if (!parsed.success) return { ok: false, reason: parsed.error.message };
  return { ok: true, value: parsed.data };
}

검증 실패 시에는 “같은 호출 반복”이 아니라 PLANNING으로 되돌려 인자를 수정하거나 다른 도구로 전환하게 해야 합니다.

FSM + 가드레일 결합 실행 흐름

아래는 전체 실행 루프의 예시 골격입니다.

async function runAgent(initialCtx: Ctx) {
  let ctx = transition(initialCtx, "PLANNING");

  while (true) {
    ctx = guardStepBudget(ctx);
    if (["DONE", "FAILED", "ESCALATED"].includes(ctx.state)) return ctx;

    if (ctx.state === "PLANNING") {
      // LLM에게 다음 행동을 요청: tool call 또는 done
      // (여기서는 의사 코드)
      const decision = await decideNextAction(ctx);

      if (decision.type === "done") {
        ctx = transition(ctx, "DONE");
        continue;
      }

      if (decision.type === "tool") {
        const dup = guardDuplicateToolCall(ctx, decision.call, 2);
        if (!dup.ok) {
          // 동일 호출 반복이면 에스컬레이션 또는 다른 전략 유도
          ctx = transition(ctx, "ESCALATED");
          continue;
        }

        ctx = recordToolCall(ctx, decision.call);
        ctx = transition(ctx, "TOOL_RUNNING");
        continue;
      }

      ctx = transition(ctx, "FAILED");
      continue;
    }

    if (ctx.state === "TOOL_RUNNING") {
      const call = ctx.lastToolCall!;

      const obs = await executeTool(call);
      ctx = { ...ctx, observation: obs };
      ctx = transition(ctx, "EVALUATING");
      continue;
    }

    if (ctx.state === "EVALUATING") {
      const obs = ctx.observation!;

      // 도구별 검증/평가
      const evalResult = await evaluateObservation(ctx, obs);

      if (evalResult.type === "done") ctx = transition(ctx, "DONE");
      else if (evalResult.type === "replan") ctx = transition(ctx, "PLANNING");
      else if (evalResult.type === "escalate") ctx = transition(ctx, "ESCALATED");
      else ctx = transition(ctx, "FAILED");

      ctx = { ...ctx, step: ctx.step + 1 };
      continue;
    }
  }
}

이 구조의 장점은 다음과 같습니다.

  • 무한루프가 발생해도 maxSteps에서 반드시 끝남
  • 중복 호출은 fingerprint로 조기에 차단
  • 실패가 누적되면 ESCALATED로 빠져 “안전한 실패”가 가능

운영 관점: 루프는 장애 패턴이다

도구 호출 무한루프는 단순한 기능 버그를 넘어, 비용 폭주(토큰/외부 API), 레이트리밋, 장애 전파로 이어집니다. 특히 쿠버네티스에서 에이전트 워커를 운영한다면, 비정상 루프가 프로세스 재시작과 결합해 더 큰 문제를 만들 수 있습니다. 애플리케이션이 계속 죽고 뜨는 패턴은 K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기처럼 인프라 레벨의 증상으로도 관측됩니다.

따라서 아래 메트릭을 추천합니다.

  • 에이전트 실행당 tool_calls_total
  • 도구별 tool_errors_totalerror.code 분포
  • duplicate_tool_call_blocked_total
  • agent_terminated_reason (done, failed, escalated, budget_exceeded)

이런 지표가 있어야 “프롬프트를 바꿔서 좋아졌다”가 아니라, 실제로 루프가 줄었는지 확인할 수 있습니다.

실전 팁: 프롬프트만으로 막지 말 것

프롬프트에 “같은 도구를 반복 호출하지 마라”를 넣는 건 도움이 되지만, 신뢰할 수 없습니다. 다음을 반드시 코드 레벨에서 강제하세요.

  1. maxStepsdeadline
  2. 도구 호출 fingerprint 기반 중복 차단
  3. 에러 코드 기반 재시도 정책(특히 4xx는 재시도 금지)
  4. 도구 출력 스키마 검증(성공/실패 계약)
  5. 종료 상태(DONE/ESCALATED/FAILED)를 FSM에 명시

추가로, 도구 호출이 많아 성능이 문제라면 모델 추론 자체를 최적화해야 하는 경우도 있습니다. 로컬 LLM을 운영 중이라면 Transformers 로컬 LLM 속도 2배 - FlashAttention2 적용처럼 추론 최적화가 “루프의 비용”을 줄이는 데도 직접적인 영향을 줍니다.

체크리스트

  • 실행 루프가 FSM으로 구현되어 있고, 종료 상태가 명확한가
  • maxSteps/deadline이 하드 리밋으로 동작하는가
  • 동일 도구 호출이 fingerprint 또는 유사도 기준으로 제한되는가
  • 도구 출력이 ok/error 계약을 만족하며 스키마 검증이 있는가
  • 4xx/429/5xx에 대한 재시도 정책이 코드에 고정되어 있는가
  • ESCALATED 경로(사람에게 넘김, 안전한 중단)가 준비되어 있는가

무한루프는 “모델이 멍청해서”가 아니라, 시스템이 종료 조건과 실패 처리의 책임을 모델에게 떠넘길 때 생깁니다. FSM으로 실행을 구조화하고, 가드레일로 도구 호출을 제어하면 AutoGPT 스타일 에이전트를 운영 가능한 소프트웨어로 끌어올릴 수 있습니다.