Published on

LangChain 도구호출 무한루프 끊는 6가지 패턴

Authors

LangChain의 tool calling(도구 호출)은 에이전트형 워크플로우를 빠르게 만들 수 있지만, 운영에서 가장 자주 터지는 장애 중 하나가 무한 루프입니다.

예를 들어 LLM이 search를 계속 호출하거나, get_user 같은 조회 도구를 같은 인자로 반복 호출하면서 토큰만 태우고 비용과 지연이 폭발합니다. 이 글은 “에이전트가 왜 같은 도구를 반복 호출하는지”를 구조적으로 분해하고, 무한루프를 끊는 6가지 패턴을 코드로 정리합니다.

또한 도구 스키마가 느슨해서 모델이 잘못된 인자를 내거나, 실패 응답을 성공으로 오해해 재시도 루프에 빠지는 경우도 많습니다. 이 부분은 Claude Tool Use 400 오류, JSON Schema로 끝내기에서 다룬 방식과 동일하게 “스키마를 강하게” 가져가는 것이 핵심입니다.

무한루프가 생기는 전형적 원인 5가지

  1. 종료 조건이 프롬프트/그래프에 없다: “필요하면 도구를 사용해”만 있고 “언제 멈출지”가 없음
  2. 도구 실패가 모델에게 성공처럼 보인다: HTTP 500, 빈 결과, 파싱 실패가 자연어로 뭉개져 전달
  3. 동일 호출의 중복 제거가 없다: 같은 querysearch를 반복
  4. 상태가 누락된다: 이전에 뭘 했는지 기억 못해 다시 시도
  5. 재시도 정책이 LLM 결정에 맡겨져 있다: 실패할수록 더 집요하게 호출

이제부터는 “프롬프트로 설득”이 아니라 시스템적으로 루프를 끊는 패턴을 소개합니다.


패턴 1) Step Budget(스텝 예산) + Hard Stop

가장 확실한 안전장치는 최대 스텝 수 제한입니다. LangChain은 에이전트 실행이 길어질 수 있으므로, 운영에서는 항상 상한을 둬야 합니다.

적용 포인트

  • max_iterations 또는 그래프 실행의 recursion_limit 같은 상한
  • 상한 도달 시 “부분 결과 + 다음 액션 제안”으로 종료

예시 (Python, AgentExecutor)

from langchain.agents import AgentExecutor

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=6,
    handle_parsing_errors=True,
)

result = agent_executor.invoke({"input": "경쟁사 가격을 조사해줘"})
print(result["output"])

운영 팁

  • 상한에 걸린 케이스는 로그/메트릭으로 분리해서 “왜 루프가 났는지”를 재현해야 합니다.
  • 상한을 너무 낮게 잡으면 품질이 급락하므로, 도구 호출 비용과 SLA를 기준으로 튜닝합니다.

패턴 2) 동일 Tool Call 디듀프(인자 해시)로 반복 호출 차단

무한루프의 절반은 “같은 입력으로 같은 도구를 재호출”입니다. 이때는 LLM의 의도를 추측하지 말고 동일 호출을 시스템이 차단하는 게 가장 싸고 강합니다.

핵심 아이디어

  • tool_name + normalized_args를 해시로 만들고
  • 최근 N개 내에 동일 해시가 있으면 실행하지 않고 “이미 수행됨”을 반환

예시 (간단한 미들웨어 스타일)

import json
import hashlib

class ToolCallDeduper:
    def __init__(self, window=20):
        self.window = window
        self.seen = []

    def _fingerprint(self, name: str, args: dict) -> str:
        normalized = json.dumps(args, sort_keys=True, ensure_ascii=False)
        raw = f"{name}:{normalized}"
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def should_run(self, name: str, args: dict) -> bool:
        fp = self._fingerprint(name, args)
        if fp in self.seen:
            return False
        self.seen.append(fp)
        self.seen = self.seen[-self.window:]
        return True

도구 실행 직전에 should_run을 검사하고, False면 도구 실행 대신 다음과 같이 “이미 수행됨”을 모델에게 명시적으로 전달합니다.

if not deduper.should_run(tool_name, tool_args):
    tool_result = {
        "status": "skipped",
        "reason": "duplicate_tool_call",
        "message": "동일한 인자로 이미 실행된 도구 호출입니다. 다른 전략을 선택하세요."
    }

왜 효과적인가

  • LLM은 종종 “결과가 마음에 안 듦”을 “다시 호출”로 표현합니다.
  • 하지만 동일 인자 재호출은 정보량이 늘지 않으므로, 시스템이 과감히 막아도 품질 손실이 적습니다.

패턴 3) 도구 결과를 구조화하고, 실패를 실패로 전달하기

루프는 보통 “도구가 실패했는데 모델이 성공했다고 착각”할 때 길어집니다. 자연어 문자열로 "에러가 발생했습니다"만 던지면 모델은 이를 다시 도구 호출로 덮어버립니다.

원칙

  • 모든 tool output은 항상 JSON
  • status"ok" | "error" | "skipped"처럼 제한
  • retryable을 명시해 LLM의 재시도를 통제

예시 (도구 출력 계약)

def tool_output_ok(data: dict):
    return {
        "status": "ok",
        "data": data,
        "error": None,
        "retryable": False,
    }

def tool_output_error(code: str, message: str, retryable: bool):
    return {
        "status": "error",
        "data": None,
        "error": {"code": code, "message": message},
        "retryable": retryable,
    }

프롬프트에 넣을 규칙(중요)

  • status"error"이고 retryableFalse다른 도구/다른 전략으로 전환
  • retryableTrue여도 최대 1회만 재시도

이 방식은 도구 호출 스키마를 엄격히 가져가는 접근과 맞물립니다. 스키마를 느슨하게 두면 모델이 retryable을 무시하거나, 엉뚱한 키를 만들어냅니다. 관련해서는 Claude Tool Use 400 오류, JSON Schema로 끝내기처럼 JSON Schema를 강하게 적용하는 것이 안전합니다.


패턴 4) “종료 조건”을 시스템 프롬프트가 아니라 상태 머신으로 고정

프롬프트에 “충분하면 종료해”라고 써도, 모델은 충분함을 판단하지 못하거나 더 좋은 답을 찾겠다며 계속 도구를 부릅니다. 그래서 종료 조건은 모델의 판단이 아니라 그래프/상태 머신이 가져가야 합니다.

실전 종료 조건 예시

  • 필수 필드가 채워지면 종료: price, source_url, timestamp
  • 목표 달성 점수(coverage)가 0.8 이상이면 종료
  • 동일 주제 검색 결과가 k개 이상이면 종료

LangGraph 스타일 의사 코드

def should_continue(state):
    if state["steps"] >= 6:
        return "end"

    if state.get("coverage", 0) >= 0.8:
        return "end"

    if len(state.get("sources", [])) >= 5:
        return "end"

    return "continue"

핵심은 should_continueLLM 출력과 독립적으로 동작해야 한다는 점입니다.


패턴 5) 재시도는 LLM이 아니라 “정책 레이어”가 한다

장애 상황에서 LLM에게 “다시 해봐”를 맡기면 보통 다음이 발생합니다.

  • 같은 인자 재호출
  • 실패 원인과 무관한 도구 변경
  • 타임아웃이 계속 누적

따라서 재시도는 애플리케이션 레벨에서 지수 백오프 + 상한 + 오류 분류로 처리하고, LLM에게는 “재시도 결과”만 전달하는 게 좋습니다. 이 설계는 API 과부하/레이트리밋에서도 동일하게 유효합니다. 재시도 설계 자체는 Claude API 529 과부하·429 제한 재시도 설계와 같은 원칙으로 접근하면 됩니다.

예시 (재시도 래퍼)

import time

def run_with_retry(tool_fn, args, max_attempts=2, base_sleep=0.5):
    last_err = None
    for attempt in range(1, max_attempts + 1):
        try:
            return tool_fn(**args)
        except Exception as e:
            last_err = e
            if attempt == max_attempts:
                raise
            time.sleep(base_sleep * (2 ** (attempt - 1)))
    raise last_err

LLM에 전달할 메시지 형태

  • “도구를 재호출하라”가 아니라
  • “이미 2회 재시도했고 실패했다. 다른 전략을 선택하라”를 상태로 제공

패턴 6) 관측 가능성(Observability)로 루프를 조기 탐지하고 자동 차단

무한루프는 코드로 막는 것도 중요하지만, 운영에서는 빨리 발견해서 자동 차단하는 것이 더 중요합니다. 특히 Next.js나 서버리스 환경에서 에이전트가 길게 돌면 메모리/동시성에 영향을 줍니다. 앱 라우터 기반 서버에서 리소스가 누수되면 증상이 더 커질 수 있는데, 이 관점은 Next.js 14 App Router 메모리 누수 9가지처럼 “요청 단위 리소스 관리”와도 연결됩니다.

반드시 남길 메트릭/로그

  • agent_run_id, conversation_id
  • tool_name, tool_args_hash
  • tool_latency_ms, tool_status
  • step_index, total_tokens, total_cost
  • loop_suspected 플래그

루프 의심 규칙(간단하지만 강력)

  • 같은 tool_args_hash가 3회 이상 등장
  • tool_status=error가 연속 2회
  • step_index가 5를 넘었는데 coverage 증가 없음

예시 (루프 감지)

def detect_loop(tool_calls, max_same_call=3):
    counts = {}
    for call in tool_calls:
        key = (call["tool"], call["args_hash"])
        counts[key] = counts.get(key, 0) + 1
        if counts[key] >= max_same_call:
            return True
    return False

루프가 감지되면:

  • 즉시 종료하고
  • “현재까지의 근거/결과”를 요약해서 반환하며
  • 다음 액션(사용자 질문, 파라미터 변경, 권한 확인)을 제안합니다.

한 번에 적용하는 체크리스트

아래 6개를 모두 적용하면, 대부분의 LangChain 도구호출 무한루프는 운영에서 사라집니다.

  1. 스텝 예산: max_iterations 같은 하드 스탑
  2. 중복 호출 차단: tool_name + args 해시 디듀프
  3. 구조화된 tool output: status, retryable 강제
  4. 종료 조건을 상태 머신으로: LLM 판단에서 분리
  5. 재시도는 정책 레이어가: 백오프/상한/오류 분류
  6. 관측 가능성 + 자동 차단: 루프 탐지 룰과 강제 종료

마무리: “LLM을 믿지 말고 시스템을 믿어라”

무한루프는 모델의 “실수”라기보다, 종료/실패/중복에 대한 시스템 계약이 약할 때 자연스럽게 발생하는 현상입니다.

프롬프트를 더 정교하게 쓰는 것보다, 위 6가지 패턴처럼 가드레일을 코드로 고정하는 편이 비용, 지연, 장애 대응 측면에서 훨씬 유리합니다. 특히 JSON Schema로 도구 입력/출력을 강제하고, 실패를 실패로 전달하는 것만 적용해도 루프 발생률이 눈에 띄게 줄어듭니다.