Published on

LangChain Tool Calling 무한루프 끊는 7가지

Authors

서빙 환경에서 LangChain Tool Calling을 붙이면, 모델이 툴을 한 번 호출하고 끝나는 대신 같은 툴을 계속 재호출하거나, 서로 다른 툴을 핑퐁하며 결론 없이 토큰만 태우는 문제가 자주 발생합니다. 특히 에러가 났는데도 재시도 규칙이 없거나, 툴 결과가 모델에게 충분히 “종료 신호”로 전달되지 않으면 무한루프가 매우 쉽게 만들어집니다.

이 글은 “왜 루프가 생기는지”를 원인 단위로 쪼개고, 코드로 바로 적용 가능한 7가지 차단법을 정리합니다. 운영 관점에서는 비용 폭탄과 응답 지연이 동시에 터지기 때문에, 아래 패턴을 최소 2~3개는 반드시 함께 적용하는 것을 권합니다.

관련해서 분산 시스템에서 상태 꼬임을 디버깅하는 감각은 LLM 에이전트에도 그대로 유효합니다. 트랜잭션 흐름이 엉키는 패턴을 다룬 글도 함께 보면 도움이 됩니다. MSA Saga 보상 트랜잭션 꼬임 디버깅 실전

1) 하드 스톱: 최대 스텝, 최대 툴 호출, 최대 토큰

무한루프를 “완전히” 막는 유일한 방법은 강제 종료 장치를 두는 것입니다. 모델이 아무리 이상하게 굴어도 시스템이 멈추게 해야 합니다.

적용 포인트

  • 에이전트 실행 단위에 maxIterations 또는 recursionLimit 설정
  • 툴 호출 횟수 카운터를 별도로 두고 초과 시 중단
  • LLM 응답 토큰 상한을 낮추고, 긴 대화는 요약 후 재진입

예시 코드 (LangGraph 스타일)

아래처럼 실행 옵션에 제한을 걸고, 실행 중 툴 호출 수를 별도 카운팅합니다.

import { StateGraph } from "@langchain/langgraph";

type AgentState = {
  toolCalls: number;
  messages: Array<{ role: string; content: string }>;
};

const graph = new StateGraph<AgentState>();

// ... 노드 구성 생략

const app = graph.compile();

const result = await app.invoke(
  { toolCalls: 0, messages: [{ role: "user", content: "..." }] },
  {
    recursionLimit: 12, // 하드 스톱
    // 일부 런타임은 maxIterations 같은 이름을 사용
  }
);

운영 팁

강제 종료 시에는 “부분 결과”라도 사용자에게 돌려주는 것이 중요합니다. 예를 들어 현재까지 확인한 내용추가로 필요한 정보 를 반환하면 UX가 덜 깨집니다.

2) 소프트 스톱: 종료 조건을 툴 결과에 명시적으로 심기

모델이 툴을 반복 호출하는 가장 흔한 이유는 “툴 결과만으로는 다음 행동이 불명확”하기 때문입니다. 툴 결과에 종료 조건을 구조적으로 포함시키면 루프가 크게 줄어듭니다.

패턴

  • 툴 응답에 done: truenext_action: "final" 같은 필드를 포함
  • 툴이 실패했으면 retryable: false 를 명시
  • 툴 결과에 “결론에 필요한 핵심 값”을 한 번에 담기

예시 코드 (툴 반환 스키마 강화)

type ToolResult = {
  done: boolean;
  retryable: boolean;
  summary: string;
  data?: unknown;
};

async function fetchOrderStatus(orderId: string): Promise<ToolResult> {
  // ...
  return {
    done: true,
    retryable: false,
    summary: `주문 ${orderId} 상태는 배송중입니다. 추가 조회 불필요합니다.`,
    data: { status: "SHIPPING" },
  };
}

모델 프롬프트에는 “done: true 면 최종 답변을 작성하고 툴을 더 호출하지 말라”는 규칙을 강하게 넣습니다.

3) 툴 호출 가드: 동일 입력 반복, 동등 호출 반복을 감지해 차단

루프는 보통 “같은 인자”로 같은 툴을 재호출하면서 시작합니다. 이를 감지해 차단하면 비용을 즉시 줄일 수 있습니다.

구현 아이디어

  • 툴 이름과 인자를 정규화한 뒤 해시로 저장
  • 동일 해시가 N 회 이상 나오면 중단 또는 다른 전략으로 전환
  • “동등 호출”의 기준을 정해야 함
    • 예: 공백, 날짜 포맷, 소수점 반올림 등을 정규화

예시 코드 (중복 호출 차단 미들웨어)

import crypto from "crypto";

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

class ToolCallGuard {
  private counts = new Map<string, number>();

  check(toolName: string, args: unknown) {
    const key = crypto
      .createHash("sha256")
      .update(`${toolName}:${stableStringify(args)}`)
      .digest("hex");

    const next = (this.counts.get(key) ?? 0) + 1;
    this.counts.set(key, next);

    if (next >= 3) {
      throw new Error(
        `Tool call loop suspected: ${toolName} same args repeated ${next} times`
      );
    }
  }
}

이 가드는 “모델이 이상해졌을 때”를 빠르게 감지하는 안전벨트입니다. 에러를 곧바로 사용자에게 노출하지 말고, 아래 6번의 폴백 전략과 함께 쓰는 것이 좋습니다.

4) 상태를 단일 소스로 만들기: 메모리와 툴 결과 병합 규칙 고정

LangChain 기반 에이전트가 루프에 빠질 때, 의외로 자주 나오는 원인이 상태 불일치입니다.

  • 툴 결과를 메시지로만 남기고, 구조화된 상태에는 반영하지 않음
  • 이전 턴 상태를 덮어쓰거나, 반대로 오래된 값을 계속 참조
  • 요약 메모리가 툴 결과의 핵심을 누락해 모델이 “다시 조회”를 선택

해결책

  • 상태를 하나의 구조체로 두고, 툴 결과는 반드시 그 구조체에 병합
  • 병합 규칙을 결정적으로 만들기
    • 예: “가장 최신 타임스탬프 우선” 또는 “성공 응답만 채택”
  • 모델이 결론을 낼 수 있게, 상태에 facts 혹은 final_inputs 같은 필드를 둠

예시 코드 (툴 결과를 상태에 병합)

type AgentState = {
  facts: Record<string, unknown>;
  messages: Array<{ role: string; content: string }>;
};

function mergeToolResult(state: AgentState, toolName: string, result: any) {
  return {
    ...state,
    facts: {
      ...state.facts,
      [toolName]: {
        result,
        updatedAt: Date.now(),
      },
    },
  };
}

이렇게 “사실 저장소”를 분리해두면, 모델이 같은 정보를 다시 얻기 위해 툴을 호출할 유인이 줄어듭니다.

5) 에러 처리 설계: 재시도 가능한 실패와 불가능한 실패를 분리

루프의 2번째로 흔한 원인은 “실패했으니 다시 해보자”가 무한히 반복되는 경우입니다. 특히 네트워크 오류, 429 레이트 리밋, 5xx 일시 장애에서 잘 터집니다.

인프라 레벨의 일시 장애를 다루는 감각은 LLM 에이전트에서도 중요합니다. 예를 들어 ALB나 네트워크 리셋류는 재시도 정책이 없으면 루프가 됩니다. EKS ALB Ingress 502 Target reset 원인과 해결

체크리스트

  • 툴 오류를 retryablenon-retryable 로 분류
  • 재시도는 최대 횟수와 백오프를 둠
  • 실패 시 모델에게 “무엇을 바꿔서 재시도해야 하는지”를 알려줌
    • 예: 파라미터가 잘못되었으면 필요한 입력을 사용자에게 요청

예시 코드 (지수 백오프 + 재시도 상한)

async function withRetry<T>(fn: () => Promise<T>, opts?: { max?: number }) {
  const max = opts?.max ?? 3;
  let lastErr: any;

  for (let i = 0; i < max; i++) {
    try {
      return await fn();
    } catch (e: any) {
      lastErr = e;
      const delay = Math.min(2000, 200 * Math.pow(2, i));
      await new Promise((r) => setTimeout(r, delay));
    }
  }
  throw lastErr;
}

중요한 점은 “모델이 재시도 여부를 결정”하게 두지 말고, 애플리케이션 코드가 결정해야 한다는 것입니다.

6) 폴백 플랜: 에이전트 모드에서 실패하면 체인 모드로 강등

실서비스에서는 에이전트가 항상 정답 경로로만 움직이지 않습니다. 특정 조건에서 에이전트를 포기하고, 결정적 체인으로 강등하는 폴백이 무한루프를 실질적으로 끝냅니다.

강등 트리거 예시

  • 툴 호출 가드가 중복을 감지했을 때
  • recursionLimit 에 근접했을 때
  • 같은 질문을 2회 이상 재확인할 때

폴백 방식 예시

  • “질문 재정의” 프롬프트로 한 번만 리라이트
  • 필요한 툴을 고정 순서로 1회씩만 실행
  • 그 결과를 템플릿에 끼워 최종 답변 생성

예시 코드 (가드 실패 시 체인으로 전환)

async function answerWithFallback(input: string) {
  try {
    return await runAgent(input); // 툴 호출 기반
  } catch (e: any) {
    // 폴백: 툴을 고정 순서로 1회만 실행
    const a = await fetchA();
    const b = await fetchB();
    return await runDeterministicChain({ input, a, b });
  }
}

운영에서는 “완벽한 에이전트”보다 “항상 끝나는 시스템”이 더 중요합니다.

7) 관측 가능성: 툴 호출 트레이싱과 루프 원인 로그를 남기기

무한루프는 재현이 어렵고, 재현이 되더라도 “왜 그 선택을 했는지”가 보이지 않으면 고치기 힘듭니다. 따라서 처음부터 트레이싱을 심어야 합니다.

최소 로깅 항목

  • 요청 traceId
  • 각 스텝의 모델 출력 요약
  • 툴 이름, 인자, 결과 요약, 소요 시간
  • 재시도 횟수와 종료 사유
    • 예: STOP_REASON=duplicate_tool_call 같은 값

예시 코드 (간단 트레이스 이벤트)

type TraceEvent = {
  traceId: string;
  step: number;
  kind: "llm" | "tool" | "stop";
  name?: string;
  payload?: any;
  ms?: number;
};

function emit(ev: TraceEvent) {
  console.log(JSON.stringify(ev));
}

// tool 호출 전후
const t0 = Date.now();
emit({ traceId, step, kind: "tool", name: "search", payload: { args } });
const out = await search(args);
emit({ traceId, step, kind: "tool", name: "search", payload: { summary: out.summary }, ms: Date.now() - t0 });

이런 이벤트가 쌓이면, “특정 툴에서만 루프가 난다”거나 “특정 에러 코드에서 재시도가 폭주한다” 같은 패턴이 금방 보입니다. CI에서 회귀 테스트로 묶어두면 더 좋습니다. 대규모 테스트를 병렬화하는 방법은 GitHub Actions 매트릭스로 CI 시간 50% 줄이기 도 참고할 만합니다.

실전 조합 가이드

위 7가지는 각각 단독으로도 효과가 있지만, 운영에서 추천하는 조합은 다음과 같습니다.

  • 필수 안전장치: 1번 하드 스톱 + 3번 중복 호출 가드
  • 품질 개선: 2번 종료 신호 포함 + 4번 상태 병합 규칙
  • 장애 내성: 5번 재시도 정책 + 6번 폴백 강등
  • 장기 운영: 7번 트레이싱으로 루프 원인 축적

마무리: 루프는 “모델 문제”가 아니라 “시스템 설계 문제”인 경우가 많다

Tool Calling 무한루프는 모델이 멍청해서가 아니라, 시스템이 종료 조건과 상태, 실패 처리, 관측 가능성을 충분히 제공하지 못해서 생기는 경우가 많습니다. 특히 툴 결과가 애매하게 전달되거나, 실패가 재시도로만 표현되면 모델은 같은 행동을 반복하기 쉽습니다.

이 글의 핵심은 “모델이 알아서 멈추길 기대하지 말고, 애플리케이션이 멈추게 하라”입니다. 하드 스톱과 가드를 먼저 넣고, 종료 신호와 상태 설계를 보강하고, 마지막으로 트레이싱으로 루프 패턴을 수집하면 Tool Calling 기반 에이전트를 안정적으로 운영할 수 있습니다.