Published on

LangChain Tool Calling 무한루프 끊는 6패턴

Authors

서버에 올린 LangChain 에이전트가 갑자기 CPU를 태우고, 로그에는 같은 툴 호출이 끝없이 쌓이는 경험을 한 번이라도 했다면 이 글이 바로 필요한 상황입니다. Tool Calling 기반 에이전트의 무한루프는 단순히 max_iterations 를 낮추는 것으로 “증상”만 줄일 뿐, 근본 원인(종료 조건 부재, 잘못된 관찰값, 비결정적 툴, 상태 누락, 재시도 폭주)을 해결하지 못합니다.

이 글에서는 LangChain에서 Tool Calling 무한루프를 끊는 6가지 패턴을 소개합니다. 각 패턴은 단독으로도 효과가 있지만, 운영 환경에서는 2~3개를 조합하는 것이 안전합니다.

참고: 에이전트 설계에서 로그/프롬프트에 민감 정보나 추론 과정이 과도하게 남는 것도 운영 리스크입니다. 이 주제는 Chain-of-Thought 유출 막는 프롬프트·로그 설계도 함께 보세요.

무한루프가 생기는 대표 징후

아래 중 하나라도 보이면 “루프 차단 장치”가 필요합니다.

  • 동일한 툴이 같은 인자(또는 거의 동일)로 반복 호출됨
  • 툴 결과가 비어있거나, 에이전트가 결과를 해석하지 못해 같은 질문을 다시 던짐
  • 외부 API가 간헐적으로 실패하면서 재시도가 중첩되어 호출 폭주
  • 검색/RAG에서 근거가 부족한데도 계속 재검색만 수행

RAG 기반 루프(검색만 반복)라면, 검색 결과의 품질/드리프트 문제도 함께 의심해야 합니다. 운영 중 벡터DB 재색인이나 하이브리드 검색으로 루프가 줄어드는 경우가 많습니다. 관련해서는 RAG 벡터DB 드리프트 잡는 재색인·하이브리드 검색도 참고하세요.

패턴 1) “종료 계약(Stop Contract)”을 프롬프트와 스키마로 강제

가장 흔한 루프 원인은 “언제 멈추는지”가 모델에게 명확하지 않은 것입니다. 해결책은 종료 조건을 자연어로만 쓰지 말고, 출력 스키마까지 포함한 종료 계약으로 강제하는 것입니다.

핵심은 다음 2가지입니다.

  • 모델이 최종 답을 내릴 때 반드시 final_answer 를 채우고, 툴 호출이 필요할 때만 tool_calls 를 채우도록 구조화
  • “툴 호출이 실패하거나 결과가 불충분하면, 추가 질문 1개만 하고 종료” 같은 상한 규칙을 포함
// TypeScript 예시: 출력 스키마를 강제하는 방식(개념 예시)
// 실제 구현은 사용하는 LLM wrapper/structured output에 맞게 조정하세요.

type AgentDecision =
  | { type: "tool"; toolName: string; args: Record<string, unknown>; reason: string }
  | { type: "final"; finalAnswer: string; citations?: string[] };

const STOP_CONTRACT = `
너는 툴을 사용할 수 있다.
규칙:
1) 툴 호출이 정말 필요할 때만 tool 결정을 내려라.
2) 같은 툴을 같은 인자로 2번 이상 호출하지 마라.
3) 툴 결과가 부족하면 사용자에게 확인 질문을 1개만 하고 final로 종료하라.
4) 최종 답을 낼 수 있으면 반드시 final로 종료하라.
`;

이 패턴은 “모델이 계속 툴을 쓰려는 성향”을 줄입니다. 하지만 툴이 비결정적이거나, 관찰값이 매번 바뀌는 경우(예: now() 기반)에는 여전히 루프가 날 수 있습니다. 그때는 아래 패턴과 조합합니다.

패턴 2) “반복 감지기(Loop Detector)”로 동일 호출/동일 관찰값 차단

에이전트 루프는 대부분 같은 입력으로 같은 툴을 반복하거나, 관찰값이 사실상 동일한데도 다시 시도하는 형태입니다. 따라서 호출 시그니처를 해시로 저장하고, 반복되면 즉시 중단하거나 다른 경로로 유도합니다.

구현 포인트:

  • toolName + stableJson(args) 를 키로 만들기
  • N회 이상 반복 시 final 로 전환하거나, “다른 접근 필요” 메시지로 종료
  • 관찰값까지 포함해 “같은 결과 반복”도 감지
import crypto from "crypto";

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

function signature(toolName: string, args: any) {
  const raw = `${toolName}:${stableStringify(args)}`;
  return crypto.createHash("sha256").update(raw).digest("hex");
}

type CallRecord = { sig: string; toolName: string; args: any; observationHash?: string };

class LoopGuard {
  private calls: CallRecord[] = [];

  constructor(private maxSameCall = 2, private maxTotalCalls = 12) {}

  addCall(toolName: string, args: any, observation?: string) {
    const sig = signature(toolName, args);
    const observationHash = observation
      ? crypto.createHash("sha256").update(observation).digest("hex")
      : undefined;

    this.calls.push({ sig, toolName, args, observationHash });

    if (this.calls.length > this.maxTotalCalls) {
      throw new Error("LoopGuard: maxTotalCalls exceeded");
    }

    const sameSigCount = this.calls.filter(c => c.sig === sig).length;
    if (sameSigCount > this.maxSameCall) {
      throw new Error("LoopGuard: repeated identical tool call");
    }

    if (observationHash) {
      const sameObsCount = this.calls.filter(c => c.observationHash === observationHash).length;
      if (sameObsCount >= 3) {
        throw new Error("LoopGuard: repeated identical observation");
      }
    }
  }
}

운영에서는 throw 로 끊는 대신, “사용자 확인 질문”을 생성해 final 로 종료시키는 편이 UX가 좋습니다.

패턴 3) “상태머신(State Machine)”으로 에이전트의 단계 수를 제한

툴 호출을 자유롭게 허용하면, 모델은 스스로 계획을 계속 수정하며 툴을 반복할 수 있습니다. 특히 “검색→요약→검증→재검색” 같은 루프가 전형적입니다.

이럴 때는 에이전트를 “자유형”이 아니라 단계형 상태머신으로 바꿔서, 각 단계에서 가능한 툴을 제한하고 단계 수를 고정합니다.

예시 단계:

  1. 입력 정규화
  2. 정보 수집(검색/DB 조회는 최대 1~2회)
  3. 검증(근거 부족 시 질문 1회)
  4. 최종 답변
type State =
  | { step: "normalize"; query: string }
  | { step: "gather"; query: string; attempts: number }
  | { step: "verify"; query: string; context: string }
  | { step: "final"; answer: string };

async function runAgent(initialQuery: string) {
  let state: State = { step: "normalize", query: initialQuery };

  while (state.step !== "final") {
    if (state.step === "normalize") {
      state = { step: "gather", query: state.query.trim(), attempts: 0 };
      continue;
    }

    if (state.step === "gather") {
      if (state.attempts >= 2) {
        // 정보 수집은 2회까지만 허용
        state = {
          step: "final",
          answer: "추가 조회 없이 확답이 어렵습니다. 어떤 시스템/데이터를 기준으로 답해야 하나요?"
        };
        continue;
      }

      const context = await searchTool({ q: state.query });
      state = { step: "verify", query: state.query, context };
      continue;
    }

    if (state.step === "verify") {
      const ok = contextLooksSufficient(state.context);
      if (!ok) {
        // verify에서 gather로 되돌아가더라도 attempts를 증가
        state = { step: "gather", query: state.query, attempts: 1 };
        continue;
      }

      const answer = await synthesizeAnswer(state.query, state.context);
      state = { step: "final", answer };
      continue;
    }
  }

  return state.answer;
}

이 패턴은 “모델이 도구를 마음대로 고르는 자유”를 줄이는 대신, 예측 가능성과 운영 안정성이 크게 올라갑니다.

패턴 4) “Idempotency + 캐시”로 재시도 폭주를 무력화

무한루프는 모델의 문제만이 아니라, 네트워크 오류나 타임아웃으로 인한 재시도가 에이전트 레벨에서 중첩될 때도 발생합니다. 이때는 툴 자체를 멱등하게 만들고, 동일 요청에 대해 같은 결과를 반환하도록 캐시를 둡니다.

구현 포인트:

  • idempotencyKey 를 툴 입력에 포함(또는 wrapper에서 주입)
  • 외부 API 호출 전후로 결과를 저장
  • 실패한 호출도 “실패 캐시”를 짧게 둬서 즉시 재시도 루프를 막기
type ToolArgs = { q: string; idempotencyKey: string };

const memoryCache = new Map<string, { ok: boolean; value: string; ts: number }>();

async function cachedSearchTool(args: ToolArgs) {
  const key = `search:${args.idempotencyKey}`;
  const cached = memoryCache.get(key);

  if (cached && Date.now() - cached.ts < 60_000) {
    if (!cached.ok) throw new Error("cached failure");
    return cached.value;
  }

  try {
    const value = await searchTool({ q: args.q });
    memoryCache.set(key, { ok: true, value, ts: Date.now() });
    return value;
  } catch (e: any) {
    memoryCache.set(key, { ok: false, value: String(e?.message ?? e), ts: Date.now() });
    throw e;
  }
}

분산 환경이면 인메모리 대신 Redis 같은 외부 캐시를 권장합니다. 이 패턴은 Saga에서 보상 중복을 막는 것과 구조적으로 유사합니다. 이벤트/보상 호출이 반복되는 문제를 다뤘던 글인 Kafka Saga에서 보상 누락·중복 방지 구현법도 같은 결로 참고할 만합니다.

패턴 5) “툴 결과 검증기(Output Validator)”로 빈 관찰값/형식 오류를 즉시 종료

에이전트는 툴 결과가 기대 형식이 아니면 해석에 실패하고, “다시 해보자”로 회귀합니다. 특히 아래가 흔합니다.

  • 툴이 빈 문자열을 반환
  • JSON을 기대했는데 텍스트가 옴
  • 필수 필드가 누락
  • 에러 메시지를 정상 데이터로 오인

해결책은 툴 wrapper에서 결과를 강하게 검증하고, 실패 시 에이전트에게 “재시도”가 아니라 “경로 변경” 또는 “질문 후 종료”를 유도하는 것입니다.

import { z } from "zod";

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

type SearchResult = z.infer<typeof SearchResultSchema>;

async function searchToolValidated(args: { q: string }): Promise<SearchResult> {
  const raw = await searchToolRaw(args); // 외부 API 호출

  let parsed: any;
  try {
    parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
  } catch {
    throw new Error("tool output is not valid JSON");
  }

  const result = SearchResultSchema.safeParse(parsed);
  if (!result.success) {
    throw new Error("tool output schema mismatch");
  }

  return result.data;
}

여기서 중요한 운영 팁은 “검증 실패 시 재시도”가 아니라, 상위 레이어에서 즉시 종료 또는 사용자 확인 질문으로 보내는 정책을 세우는 것입니다. 재시도를 자동으로 붙이면 다시 루프의 재료가 됩니다.

패턴 6) “관측성(Observability) + 서킷브레이커”로 루프를 조기 차단

마지막 패턴은 기술적으로 가장 실용적입니다. 루프는 100% 예방이 어렵기 때문에, 빨리 감지하고 자동으로 끊는 장치가 필요합니다.

필수 관측 지표:

  • 대화(또는 run) 단위 툴 호출 횟수
  • 툴별 호출 분포(특정 툴만 과도하게 호출되는지)
  • 동일 시그니처 반복 횟수
  • 툴 실패율과 타임아웃
  • run 당 토큰 사용량 급증

그리고 차단 정책(서킷브레이커)을 둡니다.

  • 특정 툴이 N초 동안 K회 이상 실패하면 해당 툴을 “열림(open)” 상태로 전환해 즉시 실패 처리
  • open 상태에서는 모델에게 “현재 툴 사용 불가, 다른 방법 또는 질문으로 종료”를 지시
type CircuitState = { openUntil: number; failures: number };

class ToolCircuitBreaker {
  private state = new Map<string, CircuitState>();

  constructor(
    private failureThreshold = 3,
    private openMs = 30_000
  ) {}

  canCall(toolName: string) {
    const s = this.state.get(toolName);
    if (!s) return true;
    return Date.now() > s.openUntil;
  }

  recordSuccess(toolName: string) {
    this.state.delete(toolName);
  }

  recordFailure(toolName: string) {
    const s = this.state.get(toolName) ?? { openUntil: 0, failures: 0 };
    s.failures += 1;
    if (s.failures >= this.failureThreshold) {
      s.openUntil = Date.now() + this.openMs;
    }
    this.state.set(toolName, s);
  }
}

async function callToolWithBreaker<T>(
  breaker: ToolCircuitBreaker,
  toolName: string,
  fn: () => Promise<T>
): Promise<T> {
  if (!breaker.canCall(toolName)) {
    throw new Error("circuit open: tool temporarily disabled");
  }

  try {
    const v = await fn();
    breaker.recordSuccess(toolName);
    return v;
  } catch (e) {
    breaker.recordFailure(toolName);
    throw e;
  }
}

이 패턴은 루프뿐 아니라 외부 장애 시 “에이전트가 장애를 증폭시키는 문제”를 줄여줍니다.

실전 조합 레시피: 운영에서 가장 많이 쓰는 3종 세트

현업에서 효과가 좋은 조합은 보통 아래입니다.

  1. 패턴 2 반복 감지기(동일 호출 차단)
  2. 패턴 4 idempotency + 캐시(재시도 폭주 무력화)
  3. 패턴 6 관측성 + 서킷브레이커(장애 증폭 방지)

여기에 “답변 품질이 중요하고 흐름이 정형화된 업무”라면 패턴 3 상태머신을 추가하세요. RAG 기반 에이전트라면 패턴 5 결과 검증기까지 넣는 것이 안전합니다.

디버깅 체크리스트

무한루프가 이미 발생했다면, 아래 순서로 원인을 좁히면 빠릅니다.

  1. 동일 toolName 과 동일 인자가 반복되는가
  2. 툴 결과가 비어있거나 파싱 실패가 반복되는가
  3. 타임아웃/429/5xx로 인한 재시도가 중첩되는가
  4. 검색 결과가 매번 바뀌거나(비결정성), 근거가 부족한데도 재검색만 하는가
  5. 모델이 종료 조건을 이해했는가(프롬프트에 종료 계약이 있는가)

또한 RAG를 쓰는 경우, 환각을 줄이려는 장치(출처 검증, 재랭킹)가 “불충분한 근거일 때 재검색”을 유발하며 루프를 만들기도 합니다. 이 경우에는 재검색 횟수 상한과 함께, 출처 품질 기준을 명시하는 편이 좋습니다. 관련 접근은 LangChain RAG 환각 줄이기 - 출처검증·재랭킹에서 더 확장해볼 수 있습니다.

마무리

LangChain Tool Calling 무한루프는 모델이 멍청해서가 아니라, 시스템이 “멈추는 법”과 “실패를 다루는 법”을 명시하지 않았기 때문에 발생하는 경우가 많습니다.

  • 종료 계약으로 모델의 의사결정 공간을 줄이고
  • 반복 감지기로 동일 호출을 차단하며
  • idempotency/캐시와 서킷브레이커로 장애 증폭을 막고
  • 필요하면 상태머신으로 흐름을 고정

이 6패턴을 적용하면, max_iterations 같은 임시 처방을 넘어 운영 가능한 에이전트로 한 단계 올라갈 수 있습니다.