Published on

LangChain Tool 무한루프 끊는 7가지 수문장

Authors

서버 로그를 보면 같은 Tool이 몇 초 간격으로 계속 호출되고, 토큰은 녹아내리며, 사용자는 답을 못 받는 상황이 있습니다. LangChain 기반 에이전트에서 흔히 말하는 tool calling infinite loop 입니다.

이 문제는 단순히 max_iterations 하나로 끝나지 않습니다. 루프는 보통 정책(프롬프트)상태(메모리)실행기(AgentExecutor)도구(툴 구현) 사이의 경계가 헐거워서 생깁니다. 이 글에서는 운영 환경에서 효과가 컸던 7가지 “수문장(guard)”을 제시하고, 각각을 어디에 어떻게 꽂아야 하는지 코드로 보여줍니다.

아래 내용은 LangChain JS/TS 기준으로 설명하지만, 파이썬도 개념은 동일합니다.

왜 Tool 무한루프가 생기나: 대표 증상 4가지

  1. Tool 결과가 에이전트에게 “완료 신호”로 해석되지 않음

    • 예: Tool이 에러 문자열을 반환하지만 HTTP 200이라 에이전트는 “재시도하면 되겠다”로 판단
  2. 관측 불가능한 실패로 인한 자동 재시도

    • 예: 네트워크 타임아웃, 레이트리밋, 파싱 실패가 계속 발생
  3. 메모리/상태 오염

    • 예: 이전 단계의 잘못된 계획이 메모리에 남아 계속 같은 툴로 회귀
  4. 프롬프트가 “도구를 쓰는 것” 자체를 목표로 만들었음

    • 예: “필요하면 검색해”가 아니라 “반드시 검색해”로 고정되어 반복 호출

이제부터는 루프를 끊는 실전 수문장 7개를 소개합니다. 가능하면 1~3번은 기본값으로, 나머지는 시스템 성격에 따라 추가하세요.

수문장 1: 하드 리미트 maxIterations + 타임박스

가장 먼저 해야 할 것은 실행기의 상한을 걸어 “최악의 경우”를 제한하는 것입니다. 다만 반복 횟수만 제한하면, 느린 Tool에서 오래 버티는 문제가 남습니다. 그래서 **시간 제한(타임박스)**도 같이 둡니다.

import { AgentExecutor } from "langchain/agents";

const executor = new AgentExecutor({
  agent,
  tools,
  maxIterations: 6,
  // LangChain 버전에 따라 옵션명이 다를 수 있습니다.
  // 없다면 아래 수문장 6의 AbortSignal 패턴으로 타임박스를 구현하세요.
});

운영 팁:

  • maxIterations는 “정상 플로우”에서 필요한 단계 수를 측정한 뒤 +2 정도 여유를 둡니다.
  • 반복이 잦은 작업(검색-요약-검증)이면 maxIterations를 늘리기보다 수문장 2, 3을 먼저 적용하는 편이 비용 대비 효과가 큽니다.

수문장 2: Tool 결과에 finality를 명시하는 스키마

무한루프의 절반은 “Tool이 뭘 반환해도 에이전트가 다음 행동을 결정하지 못하는” 문제입니다. 해결책은 Tool 출력에 완료 여부와 다음 행동 힌트를 구조적으로 포함하는 것입니다.

핵심은 ok, done, retryable, reason 같은 필드를 항상 반환하게 만드는 것입니다.

import { z } from "zod";
import { tool } from "langchain/tools";

const LookupResult = z.object({
  ok: z.boolean(),
  done: z.boolean(),
  retryable: z.boolean(),
  reason: z.string().optional(),
  data: z.any().optional(),
});

type LookupResult = z.infer<typeof LookupResult>;

export const userLookupTool = tool(
  async (input: { userId: string }): Promise<LookupResult> => {
    try {
      const data = await fetch(`https://api.example.com/users/${input.userId}`).then(r => r.json());
      return { ok: true, done: true, retryable: false, data };
    } catch (e: any) {
      // 재시도 가능한 실패와 아닌 실패를 분리
      const retryable = Boolean(e?.code === "ETIMEDOUT" || e?.status === 429);
      return { ok: false, done: !retryable, retryable, reason: String(e?.message ?? e) };
    }
  },
  {
    name: "user_lookup",
    description: "Fetch user profile by userId and return structured result.",
    schema: z.object({ userId: z.string() }),
  }
);

프롬프트에도 다음 규칙을 넣으면 효과가 커집니다.

  • Tool 결과가 done: true이면 더 이상 같은 Tool을 호출하지 말 것
  • retryable: false이면 다른 전략으로 전환하거나 사용자에게 실패를 설명하고 종료할 것

이 방식은 “에이전트의 추론”에만 의존하지 않고, **도구-정책 계약(contract)**으로 루프를 끊습니다.

수문장 3: 동일 Tool 반복 호출 감지(서킷 브레이커)

에이전트가 같은 Tool을 같은 입력으로 연속 호출하는 패턴은 대부분 버그입니다. 그러니 아예 실행기 레벨에서 반복 패턴을 감지해 차단합니다.

LangChain은 콜백을 통해 Tool 호출 이벤트를 관측할 수 있으므로, 호출 히스토리를 쌓아 “같은 호출 N회”면 중단시키는 서킷 브레이커를 둡니다.

import type {
  BaseCallbackHandler,
  CallbackManagerForToolRun,
} from "langchain/callbacks";

class ToolLoopBreaker extends (Object as { new (): BaseCallbackHandler }) {
  name = "tool_loop_breaker";
  private window: string[] = [];

  constructor(private readonly limit: number = 3) {
    super();
  }

  async handleToolStart(
    tool: { name: string },
    input: string,
    runId: string,
    parentRunId?: string,
    tags?: string[],
    metadata?: Record<string, unknown>,
    runManager?: CallbackManagerForToolRun
  ) {
    const key = `${tool.name}:${input}`;
    this.window.push(key);
    if (this.window.length > 10) this.window.shift();

    const recent = this.window.slice(-this.limit);
    const allSame = recent.length === this.limit && recent.every(v => v === recent[0]);

    if (allSame) {
      throw new Error(
        `Tool loop detected: repeated ${tool.name} with same input ${this.limit} times`
      );
    }
  }
}

// executor 생성 시 callbacks에 주입
const callbacks = [new ToolLoopBreaker(3)];

운영 팁:

  • “같은 Tool, 같은 입력”뿐 아니라 “같은 Tool, 입력이 유사”도 잡고 싶다면 입력을 정규화(공백 제거, 키 정렬)하거나 해시를 쓰세요.
  • 이 수문장은 비용 폭발을 막는 데 즉효가 있지만, 사용자 경험을 위해 수문장 7의 “대체 응답”과 세트로 적용하는 것을 권합니다.

수문장 4: 재시도 정책을 Tool 내부로 내리고, 에이전트 재시도는 금지

많은 루프는 에이전트가 “한 번 더 해보자”를 계속 선택해서 생깁니다. 재시도는 추론 문제가 아니라 네트워크/인프라 정책이므로, Tool 내부에서 지수 백오프와 최대 횟수를 명시하고 끝내야 합니다.

async function retry<T>(fn: () => Promise<T>, opts: { retries: number; baseMs: number }) {
  let lastErr: any;
  for (let i = 0; i <= opts.retries; i++) {
    try {
      return await fn();
    } catch (e) {
      lastErr = e;
      const wait = opts.baseMs * Math.pow(2, i);
      await new Promise(r => setTimeout(r, wait));
    }
  }
  throw lastErr;
}

export const searchTool = tool(
  async (input: { q: string }) => {
    try {
      const data = await retry(
        () => fetch(`https://search.example.com?q=${encodeURIComponent(input.q)}`).then(r => r.json()),
        { retries: 2, baseMs: 200 }
      );
      return { ok: true, done: true, retryable: false, data };
    } catch (e: any) {
      return { ok: false, done: true, retryable: false, reason: "Search failed after retries" };
    }
  },
  {
    name: "search",
    description: "Search the web and return results. Retries are handled internally.",
    schema: z.object({ q: z.string().min(1) }),
  }
);

핵심은 “에이전트가 재시도 결정을 하지 못하게” 만드는 것입니다. Tool이 실패하면 done: true로 종료시키고, 다음 단계는 “다른 Tool” 또는 “사용자에게 상황 설명”이어야 합니다.

수문장 5: 상태 오염 방지(메모리 최소화 + 요약 메모리)

무한루프는 종종 “이전의 잘못된 계획”이 메모리에 남아 계속 강화되는 형태로 나타납니다. 특히 대화형 에이전트에서 chat_history가 길어지면, 모델은 과거의 도구 호출 패턴을 그대로 따라 하기도 합니다.

대응은 두 가지입니다.

  • 메모리를 최소화: Tool 호출 로그 전체를 매 턴 넣지 않기
  • 요약 메모리: 핵심 결론만 남기고, 실패한 시도는 “다음에는 반복하지 말 것”으로 요약

프롬프트에 아래와 같은 규칙을 추가하면 실전에서 체감이 큽니다.

- If a tool call fails with retryable=false, do not call the same tool again.
- Do not repeat a tool call with the same arguments.
- When you have enough information to answer, stop using tools and provide the final answer.

이 문제는 프론트엔드의 useEffect 의존성 루프처럼 “상태가 원인이고, 상태가 결과를 다시 트리거”하는 구조로 이해하면 빠릅니다. 비슷한 디버깅 관점은 React 렌더 폭주? useEffect 의존성 루프 디버깅 글도 참고할 만합니다.

수문장 6: 실행 취소(AbortSignal)로 타임아웃 전파

Tool이 외부 API를 호출할 때, 타임아웃이 Tool 내부에만 있고 에이전트 레벨에는 없으면 “느린 반복”이 됩니다. 반대로 에이전트에서만 타임아웃을 걸면 Tool의 네트워크 요청이 계속 살아남아 리소스를 잡아먹습니다.

해결책은 AbortSignal을 Tool까지 전파하는 것입니다.

function withTimeout(ms: number) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(new Error("timeout")), ms);
  return {
    signal: controller.signal,
    clear: () => clearTimeout(timer),
  };
}

export const httpGetTool = tool(
  async (input: { url: string }) => {
    const { signal, clear } = withTimeout(4000);
    try {
      const res = await fetch(input.url, { signal });
      const text = await res.text();
      return { ok: true, done: true, retryable: false, data: text.slice(0, 2000) };
    } catch (e: any) {
      const isAbort = String(e?.name).includes("Abort") || String(e?.message).includes("timeout");
      return { ok: false, done: !isAbort, retryable: isAbort, reason: "HTTP request failed" };
    } finally {
      clear();
    }
  },
  {
    name: "http_get",
    description: "GET a URL with timeout and return truncated body.",
    schema: z.object({ url: z.string().url() }),
  }
);

이 수문장은 “무한루프”뿐 아니라 “무한 대기”도 함께 잡습니다.

수문장 7: 실패 시나리오용 graceful fallback 최종 응답

서킷 브레이커나 maxIterations로 끊기만 하면 사용자는 “에러”만 받습니다. 운영에서 중요한 것은 끊는 것 + 안전한 종료 메시지입니다.

패턴은 간단합니다.

  • 루프 감지 예외를 캐치한다
  • 지금까지 확보한 정보로 답할 수 있으면 답한다
  • 답이 불가능하면 사용자에게 필요한 추가 입력을 요청하고 종료한다
try {
  const result = await executor.invoke({ input: userInput }, { callbacks });
  return result;
} catch (e: any) {
  const msg = String(e?.message ?? e);
  if (msg.includes("Tool loop detected")) {
    return {
      output:
        "도구 호출이 반복되어 안전장치가 실행되었습니다. " +
        "현재 정보만으로는 확답이 어렵습니다. " +
        "다음 중 하나를 알려주시면 도구 호출 없이 정리해 드릴게요: (1) 정확한 에러 로그 (2) 기대 결과 (3) 마지막으로 성공했던 입력",
    };
  }
  throw e;
}

무한 리다이렉트나 렌더 루프처럼 “사용자에게는 끝이 없는 반복”으로 보이는 장애는 제품 신뢰도를 크게 깎습니다. 비슷한 사고 대응 관점은 Keycloak OAuth2 로그인 무한 리다이렉트 해결 가이드도 결이 같습니다.

7가지 수문장을 한 번에 적용하는 체크리스트

운영 배포 전에 아래를 점검하면 “루프성 장애”의 재발률이 확 떨어집니다.

  1. 실행기 상한: maxIterations 설정
  2. 시간 상한: Tool까지 타임아웃 전파(AbortSignal)
  3. Tool 출력 계약: ok/done/retryable 구조화
  4. 재시도 위치: Tool 내부에서만 제한적으로 수행
  5. 반복 감지: 동일 Tool 동일 입력 연속 호출 차단
  6. 메모리 관리: 실패한 시도는 요약하고 재반복 금지 규칙 추가
  7. 사용자 경험: 루프 차단 시 대체 응답 제공

마무리: “추론”이 아니라 “경계”를 세우면 루프가 끝난다

LangChain Tool 무한루프는 모델이 멍청해서가 아니라, 시스템이 “반복을 멈추는 경계 조건”을 명확히 제공하지 않았기 때문에 생깁니다.

  • Tool은 구조화된 결과로 완료 조건을 말해주고
  • 실행기는 반복을 감지해 차단하며
  • 프롬프트와 메모리는 같은 실패를 재현하지 않도록 정리하고
  • 최종적으로 사용자는 의미 있는 종료 메시지를 받게 해야 합니다.

이 7가지 수문장을 기본 템플릿으로 깔아두면, 비용 폭발과 응답 지연을 동시에 줄이면서도 에이전트의 자율성은 유지할 수 있습니다.