Published on

ReAct+툴콜 루프 무한반복 끊는 프롬프트 설계

Authors

서버에 에이전트를 붙이고 ReAct(Reason+Act) 스타일로 툴을 호출하게 만들면, 생각보다 자주 툴콜-결과-툴콜-결과가 끝없이 반복되는 상황을 만납니다. 겉보기엔 모델이 “열심히” 일하는데, 실제로는 같은 검색을 다시 하거나, 같은 API를 다른 파라미터로 재시도하다가 토큰과 비용만 태웁니다. 이 글은 그 루프를 프롬프트 설계로 1차 차단하고, 런타임 가드레일로 2차 차단하는 방법을 ReAct+툴콜 조합 기준으로 정리합니다.

왜 ReAct+툴콜에서 무한 루프가 생기나

무한 루프는 대개 “종료 조건이 모델의 내부 판단에만 의존”할 때 생깁니다. ReAct는 본질적으로 생각(계획)행동(툴 호출)을 번갈아 반복하며 문제를 해결합니다. 그런데 다음 조건이 겹치면 종료가 어렵습니다.

  1. 성공/실패 신호가 애매함
  • 툴 결과가 ok인지 not found인지 모호하거나, 데이터가 비어 있어도 “다시 검색하면 나올 것”처럼 보이는 경우
  1. 행동 비용(예산) 개념이 없음
  • 모델은 비용을 체감하지 못하므로, “조금만 더”가 무한히 이어질 수 있음
  1. 동일 행동을 반복해도 페널티가 없음
  • 같은 쿼리/같은 엔드포인트/같은 입력을 호출해도 시스템이 막지 않으면 모델은 재시도를 합리화함
  1. 에러/레이트리밋을 ‘추론으로’ 해결하려 함
  • 429나 타임아웃을 만나면 “다시 해보자”가 자연스러운 다음 행동이 됨

레이트리밋/재시도는 특히 루프를 강화합니다. 재시도 자체는 필요하지만, 정책이 프롬프트에만 있거나(모델이 지키길 기대) 백오프가 없으면 폭발합니다. 관련해서는 OpenAI API 429 재시도·백오프 패턴 실전 가이드도 함께 읽으면 좋습니다.

핵심 원칙: 종료 조건을 “명시적 계약”으로 만들기

ReAct+툴콜 루프를 끊는 가장 강력한 방법은, 모델에게 “언제 멈춰야 하는지”를 추상적인 문장(예: 적절할 때 종료)으로 주지 않고, 검증 가능한 규칙으로 계약하는 것입니다.

다음 4가지를 프롬프트에 반드시 포함시키는 것을 권장합니다.

1) 목표의 완료 조건(Definition of Done)

  • 출력 형식과 포함해야 할 필드를 명시
  • “추가 툴 호출 없이 지금 답변할 수 있으면 즉시 답변” 같은 규칙 포함

2) 툴 호출 예산(Budget)

  • 최대 툴 호출 횟수
  • 최대 반복 스텝 수
  • 시간 제한(옵션)

3) 중복 행동 금지(Dedup)

  • 동일한 입력으로 같은 툴을 재호출하지 말 것
  • 재호출이 필요하다면 “무엇이 달라졌는지”를 반드시 명시

4) 실패 시 종료 전략(Fallback)

  • N회 실패하면 “가정/제약을 밝히고” 답변으로 전환
  • 필요한 정보가 없으면 “무엇이 부족한지”를 사용자에게 질문

이 4가지가 없으면 모델은 계속 툴로 문제를 해결하려고 합니다. 반대로 4가지가 있으면, 툴은 “답을 만들기 위한 수단”으로 제한됩니다.

프롬프트 템플릿: ReAct+툴콜 루프 차단용 시스템 규칙

아래는 시스템 메시지(또는 최상위 정책)로 넣기 좋은 템플릿입니다. MDX에서 부등호가 JSX로 오인될 수 있으니, 제네릭/화살표/부등호는 모두 코드 블록 안에만 둡니다.

[Agent Policy]
- You may call tools to gather facts.
- Hard limits:
  - Max tool calls: 6
  - Max reasoning steps: 10
- Dedup rule:
  - Do NOT call the same tool with the same normalized input more than once.
  - If you must call again, explain what changed and why it can produce new information.
- Stop conditions (must stop immediately and answer):
  1) You already have enough information to produce the final output.
  2) A tool returns empty or not-found twice for the same intent.
  3) You hit any tool error 2 times in total.
  4) You reached max tool calls.
- Failure fallback:
  - If you cannot obtain missing facts within the limits, produce the best possible answer with:
    - Assumptions
    - Unknowns
    - Next questions for the user
- Output discipline:
  - After each tool result, update a short state summary.
  - Never loop: each tool call must add new information or terminate.

이 템플릿의 포인트는 “멈추는 이유”를 모델이 스스로 판단하는 게 아니라, 정량 규칙으로 강제한다는 점입니다.

상태머신 관점: 프롬프트만으로 부족한 이유

프롬프트는 1차 방어선이지만, 런타임에서 2차 방어선이 필요합니다. 이유는 간단합니다.

  • 모델은 규칙을 “대체로” 따르지만 100%는 아님
  • 툴 결과가 길거나 복잡하면 규칙을 잊거나 우선순위를 바꿀 수 있음
  • 외부 API는 불안정하며, 에러는 예외 케이스를 무한히 만든다

따라서 운영 환경에서는 에이전트를 상태머신으로 보고, 다음을 코드로 강제해야 합니다.

  • 스텝 카운터, 툴콜 카운터
  • 동일 호출 감지(해시)
  • 에러 누적 카운터
  • “진전(progress)” 측정(새 정보가 늘었는지)

이 접근은 쿠버네티스에서 CrashLoopBackOff를 단순 재시작으로 해결하지 않고, 원인별로 프로브/리소스/로그를 점검하듯이(루프의 구조를 끊듯이) 생각하면 이해가 쉽습니다. 운영 관점의 루프 진단 패턴은 Kubernetes CrashLoopBackOff 원인별 로그·Probe·리소스 디버깅와도 결이 같습니다.

런타임 가드레일 구현: 중복 툴콜 차단과 종료

아래 예시는 Node.js/TypeScript 스타일의 간단한 오케스트레이터입니다. 핵심은 toolName + normalizedArgs를 해시로 저장해 중복을 막고, 에러/빈 결과를 누적해 종료로 전환하는 것입니다.

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

type ToolResult = {
  ok: boolean;
  data?: unknown;
  error?: string;
};

function stableStringify(obj: unknown): string {
  return JSON.stringify(obj, Object.keys(obj as any).sort());
}

function callKey(call: ToolCall): string {
  const normalized = stableStringify(call.args);
  return `${call.name}::${normalized}`;
}

type LoopState = {
  step: number;
  toolCalls: number;
  toolErrors: number;
  emptyHitsByIntent: Map<string, number>;
  seenCalls: Set<string>;
};

const LIMITS = {
  maxSteps: 10,
  maxToolCalls: 6,
  maxToolErrors: 2,
  maxEmptySameIntent: 2,
};

function isEmptyResult(r: ToolResult): boolean {
  if (!r.ok) return false;
  if (r.data == null) return true;
  if (Array.isArray(r.data) && r.data.length === 0) return true;
  if (typeof r.data === "string" && r.data.trim() === "") return true;
  return false;
}

function shouldStop(state: LoopState): { stop: boolean; reason?: string } {
  if (state.step >= LIMITS.maxSteps) return { stop: true, reason: "maxSteps" };
  if (state.toolCalls >= LIMITS.maxToolCalls) return { stop: true, reason: "maxToolCalls" };
  if (state.toolErrors >= LIMITS.maxToolErrors) return { stop: true, reason: "tooManyToolErrors" };
  return { stop: false };
}

async function runAgentLoop() {
  const state: LoopState = {
    step: 0,
    toolCalls: 0,
    toolErrors: 0,
    emptyHitsByIntent: new Map(),
    seenCalls: new Set(),
  };

  while (true) {
    state.step += 1;

    const stopCheck = shouldStop(state);
    if (stopCheck.stop) {
      return { kind: "final", reason: stopCheck.reason };
    }

    // 1) LLM에게 다음 행동을 요청 (tool call or final)
    const next = await decideNextActionFromLLM();

    if (next.kind === "final") {
      return next;
    }

    const toolCall: ToolCall = next.toolCall;
    const intent: string = next.intent;

    // 2) 중복 툴콜 차단
    const key = callKey(toolCall);
    if (state.seenCalls.has(key)) {
      return {
        kind: "final",
        reason: "duplicateToolCallBlocked",
        message: "동일 입력의 반복 툴 호출이 감지되어 종료합니다.",
      };
    }
    state.seenCalls.add(key);

    // 3) 툴 실행
    state.toolCalls += 1;
    const result: ToolResult = await executeTool(toolCall);

    if (!result.ok) {
      state.toolErrors += 1;
    }

    if (isEmptyResult(result)) {
      const prev = state.emptyHitsByIntent.get(intent) ?? 0;
      const nextCount = prev + 1;
      state.emptyHitsByIntent.set(intent, nextCount);
      if (nextCount >= LIMITS.maxEmptySameIntent) {
        return {
          kind: "final",
          reason: "emptyResultTwice",
          message: "동일 의도의 빈 결과가 반복되어 종료하고 대안 답변으로 전환합니다.",
        };
      }
    }

    // 4) 결과를 LLM에 전달하고 다음 루프로
    await appendToolResultToConversation(result);
  }
}

위 코드에서 decideNextActionFromLLM()가 ReAct의 “Act 선택”을 담당합니다. 중요한 점은, 모델이 중복 호출을 선택하더라도 런타임에서 강제 종료할 수 있다는 것입니다.

프롬프트 설계 디테일: 루프를 유발하는 문장 패턴 제거

다음 문장들은 에이전트를 “끝까지 파고드는 모드”로 고정해 루프를 유발합니다.

  • “확실해질 때까지 검색해”
  • “정보가 부족하면 계속 시도해”
  • “가능한 모든 소스를 확인해”

대신 다음처럼 바꿔야 합니다.

  • “핵심 근거 2개를 확보하면 종료”
  • “툴 호출은 최대 6회, 동일 의도에서 빈 결과 2회면 종료”
  • “부족하면 질문 1~3개로 사용자에게 확인”

즉, “완벽”을 요구하지 말고 “충분”을 정의해야 합니다.

툴 스키마 설계로 루프 줄이기: 결과에 종료 힌트 포함

툴이 반환하는 데이터 구조에 “다음 행동을 줄이는 신호”를 넣으면 루프가 크게 줄어듭니다.

예를 들어 검색 툴이라면 단순 리스트 대신 다음 필드를 포함합니다.

  • found: boolean
  • items: array
  • confidence: 0~1
  • retryable: boolean
  • reason: string

모델은 retryable=false를 보면 재시도를 정당화하기 어려워집니다.

{
  "found": false,
  "items": [],
  "confidence": 0.2,
  "retryable": false,
  "reason": "Index has no documents for this tenant"
}

이렇게 “재시도 가능/불가능”을 툴이 판정하게 만들면, 모델이 추론으로 재시도를 남발하는 문제를 줄일 수 있습니다.

재시도는 프롬프트가 아니라 정책으로: 429/타임아웃 분리

무한 루프의 큰 비중은 네트워크/레이트리밋 계열입니다. 이건 ReAct의 “문제 해결 루프”와 성격이 다릅니다.

  • 429: 시스템이 백오프 후 재시도(지수 백오프 + 지터)
  • 타임아웃: 제한 횟수 내 재시도 후 실패를 모델에 전달
  • 5xx: 서킷 브레이커 또는 빠른 실패

이 로직을 모델에게 맡기면, 모델은 “다시 해볼까”를 선택하며 루프가 강화됩니다. 재시도는 애플리케이션 정책으로 빼고, 모델에게는 “이번 호출은 실패했고 재시도 예산을 소진했다” 같은 결과만 전달하는 편이 안정적입니다.

실전 프롬프트 예시: 답변 우선, 툴은 최소

아래는 블로그 주제처럼 “설계 가이드”를 쓰는 에이전트에 적합한 사용자 프롬프트 예시입니다.

목표: ReAct+툴콜 루프 무한반복을 끊는 프롬프트 설계 가이드를 작성한다.

완료 조건:
- 원인 분석, 프롬프트 템플릿, 런타임 가드레일, 코드 예제를 포함한다.
- 툴 호출 없이도 작성 가능한 범위는 즉시 서술한다.

툴 사용 규칙:
- 툴 호출은 최대 2회.
- 동일 의도에서 결과가 비거나 실패하면 추가 호출 없이 대안(가정/질문/제약)을 제시하고 마무리한다.
- 동일 파라미터 재호출 금지.

출력 형식:
- 마크다운 소제목 구조로 작성.
- 마지막에 체크리스트를 제공.

이 프롬프트의 의도는 “툴을 쓰지 말라”가 아니라, 툴이 없어도 가능한 부분은 먼저 생산하고, 툴은 빈틈만 메우는 방식으로 제한하는 것입니다.

체크리스트: 루프를 끊는 12가지 점검 항목

  • 완료 조건이 측정 가능하게 정의되어 있는가
  • 최대 툴 호출 수가 명시되어 있는가
  • 최대 스텝 수가 명시되어 있는가
  • 동일 툴+동일 입력 재호출 금지가 있는가
  • 재호출이 필요할 때 “변경점”을 강제하는가
  • 빈 결과/Not found 반복 시 종료 규칙이 있는가
  • 에러 누적 시 종료 규칙이 있는가
  • 실패 시 대안 답변(가정/미확인/질문) 템플릿이 있는가
  • 재시도(429/타임아웃)는 앱 정책으로 분리했는가
  • 툴 결과 스키마에 retryable 같은 힌트가 있는가
  • 런타임에서 중복 호출을 해시로 차단하는가
  • “진전(progress)”이 없으면 종료하도록 설계했는가

마무리: 프롬프트는 계약, 런타임은 집행

ReAct+툴콜 루프는 모델이 멍청해서가 아니라, 시스템이 “멈춤”을 계약하지 않았기 때문에 생깁니다. 프롬프트에는 완료 조건·예산·중복 금지·폴백을 명시하고, 런타임에서는 카운터·중복 해시·에러 누적·빈 결과 반복을 강제 종료로 연결하세요. 이 두 겹의 설계가 들어가면, 에이전트는 더 적은 호출로 더 예측 가능하게 동작합니다.

추가로, RAG나 검색 기반 에이전트라면 “툴을 더 돌리는 것”보다 “검색 품질을 올려 한 번에 끝내는 것”이 루프를 줄이는 지름길일 때가 많습니다. 관련 튜닝은 RAG 환각 줄이기 - 하이브리드검색+재랭커 튜닝도 참고할 만합니다.