Published on

LangChain 에이전트 무한 루프·툴 반복 호출 차단법

Authors

서빙 환경에서 LangChain 에이전트를 붙이면, 기능 자체는 잘 동작하는데도 특정 프롬프트에서 툴을 계속 재호출하거나 같은 단계로 되돌아가며 무한 루프에 빠지는 문제가 종종 발생합니다. 토큰은 계속 소모되고, 외부 API 비용이 증가하며, 워커가 점유되어 전체 시스템 지연으로 번집니다.

이 글은 “왜 이런 루프가 생기는지”를 구조적으로 설명하고, LangChain 런타임에서 실제로 적용 가능한 차단법을 코드로 정리합니다. 핵심은 하나입니다.

  • 에이전트의 자율성은 유지하되, 반복 호출을 시스템적으로 관측하고 정책으로 중단해야 합니다.

아래 예시는 LangChain의 Runnable/콜백 기반(LCEL 계열)과 에이전트 실행기(AgentExecutor)에서 모두 응용할 수 있는 형태로 구성했습니다.

무한 루프·반복 호출이 생기는 대표 원인

1) 종료 조건이 모델에게만 맡겨진 경우

에이전트는 보통 “필요하면 툴 호출, 아니면 최종 답변” 패턴입니다. 그런데 프롬프트가 애매하거나, “확신이 없으면 다시 확인하라”류의 지시가 들어가면 모델은 스스로 안전하다고 믿는 행동인 재확인을 반복합니다.

  • 검색 툴을 계속 호출하며 근거를 더 찾으려 함
  • 같은 입력으로 재시도하며 결과가 달라지길 기대함
  • 외부 시스템이 항상 실패하는데도 복구 전략 없이 반복

2) 툴 결과가 비결정적이거나, 실패가 모호한 경우

툴이 timeout/429/5xx를 내거나, 결과 포맷이 깨져서 파싱에 실패하면 에이전트는 “다시 호출하면 되겠지”로 회귀합니다.

특히 다음 패턴이 위험합니다.

  • 툴이 에러를 문자열로만 반환하고, 실패 타입이 구조화되지 않음
  • 부분 성공인데 성공 여부 플래그가 없음
  • 응답이 길어 잘려서 “불완전”하게 보임

3) 동일 상태에서 동일 툴을 호출하는 사이클

에이전트가 내부 상태를 갱신하지 못하면 같은 입력, 같은 툴, 같은 결과를 반복합니다. 예를 들어 검색 쿼리를 생성하는 체인이 매번 동일한 쿼리를 만들고, 검색 결과도 동일하면 다음 스텝도 동일해집니다.

이건 DB 쿼리 튜닝에서 “같은 느린 쿼리를 계속 던지는” 문제와 유사합니다. 원인을 추적하려면 실행 로그가 필요합니다. 관측 관점은 PostgreSQL 쿼리 느림? auto_explain으로 추적 같은 접근과 비슷하게, 무엇이 반복되는지를 먼저 잡아야 합니다.

차단 전략 설계: 4단 가드레일

운영에서 효과가 큰 순서대로 정리하면 다음 4개를 함께 적용하는 것이 좋습니다.

  1. 하드 리밋: 최대 반복 횟수, 최대 툴 호출 횟수, 최대 실행 시간
  2. 중복 감지: 동일 툴·동일 입력(또는 유사 입력) 반복 시 차단
  3. 실패 정책: 같은 실패가 연속되면 백오프 후 중단, 또는 대체 경로
  4. 상태 기반 종료: “충분한 근거” “답변 가능” 같은 종료 플래그를 상태로 관리

아래부터는 구현 예시를 단계별로 제공합니다.

1) 하드 리밋: 반복 횟수·툴 호출 수·시간 제한

AgentExecutor 레벨의 반복 제한

LangChain 에이전트 실행기에는 보통 반복에 대한 제한 옵션이 있습니다. 버전에 따라 필드명이 다를 수 있지만, 핵심은 “최대 반복”과 “최대 실행 시간”을 반드시 켜는 것입니다.

from langchain.agents import AgentExecutor

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=8,          # 무한 루프 방지 1차 방어선
    max_execution_time=20,     # 초 단위 타임아웃
    early_stopping_method="force",  # 강제 종료 시 반환 방식
)

result = agent_executor.invoke({"input": "..."})

이 설정만으로도 “끝없이 돈다”는 최악의 상황은 줄어듭니다. 다만 현실에서는 8번 안에 같은 툴을 8번 호출해 비용을 태우는 문제가 남습니다. 그래서 다음 단계가 필요합니다.

2) 중복 감지: 동일 툴·동일 입력 반복 차단

중복 감지는 보통 아래 정보를 키로 삼습니다.

  • tool_name
  • tool_input (문자열 또는 JSON)
  • (선택) 직전 N개 히스토리

콜백으로 툴 호출을 관측하고 차단하기

LangChain은 콜백 핸들러로 툴 호출 이벤트를 관측할 수 있습니다. 버전에 따라 이벤트 이름이 다를 수 있으니, 본인의 런타임에서 “tool 시작/끝” 이벤트를 확인해 맞춰 적용하세요.

아래는 개념적으로 “같은 툴을 같은 입력으로 3회 이상 호출하면 예외”를 던지는 예시입니다.

import json
import time
from collections import defaultdict, deque

from langchain_core.callbacks import BaseCallbackHandler

class ToolLoopGuardCallback(BaseCallbackHandler):
    def __init__(self, max_same_call=2, window=10):
        self.max_same_call = max_same_call
        self.window = window
        self.recent = deque(maxlen=window)  # (tool_name, tool_input_hash)
        self.counts = defaultdict(int)

    def _hash_input(self, tool_input):
        if isinstance(tool_input, (dict, list)):
            s = json.dumps(tool_input, sort_keys=True, ensure_ascii=False)
        else:
            s = str(tool_input)
        return hash(s)

    def on_tool_start(self, serialized, input_str, **kwargs):
        tool_name = serialized.get("name", "unknown_tool")
        key = (tool_name, self._hash_input(input_str))

        self.recent.append(key)
        self.counts[key] += 1

        if self.counts[key] > self.max_same_call:
            raise RuntimeError(
                f"Tool loop detected: {tool_name} called repeatedly with same input"
            )

    def on_tool_end(self, output, **kwargs):
        # 필요 시 output 기반으로도 반복/유사도 체크 가능
        pass

적용은 실행 시 콜백을 주입하면 됩니다.

callbacks = [ToolLoopGuardCallback(max_same_call=2)]

result = agent_executor.invoke(
    {"input": "..."},
    config={"callbacks": callbacks},
)

이 방식의 장점은 모델이 어떤 추론을 하든 “툴 호출”이라는 비용 지점에서 강제로 막을 수 있다는 점입니다.

유사 입력 반복도 막아야 하는 이유

동일 문자열만 막으면 “공백만 바꿔서” 같은 검색을 반복하는 경우를 놓칩니다. 운영에서는 아래 중 하나를 추가로 권장합니다.

  • 정규화: 공백/대소문자/구두점 제거
  • 토큰 셋 유사도(간단한 자카드 유사도)
  • 임베딩 유사도(비용이 들지만 효과적)

RAG 검색 품질이 흔들릴 때 정규화나 유사도 기준이 중요해지는 것처럼, 반복 호출 방지에서도 “같은 의미의 입력”을 잡는 것이 핵심입니다. 관련해서는 PostgreSQL pgvector RAG 검색 품질 급락 원인과 해결 체크리스트에서 다루는 정규화 관점이 참고됩니다.

3) 실패 정책: 재시도 예산·백오프·대체 경로

에이전트가 같은 툴을 반복 호출하는 이유의 상당수는 “실패했는데 어떻게 실패했는지 몰라서”입니다. 따라서 툴은 실패를 구조화하고, 에이전트 실행기는 실패 예산을 가져야 합니다.

툴 래퍼로 재시도 예산을 강제하기

아래는 어떤 툴이든 감싸서

  • 동일한 에러가 연속 N회면 중단
  • 지수 백오프
  • 최종적으로는 예외를 던져 상위에서 다른 전략을 선택

하도록 만드는 예시입니다.

import random
import time

class RetryBudgetExceeded(Exception):
    pass

class ToolWrapperWithRetryBudget:
    def __init__(self, tool, max_retries=2, base_sleep=0.5):
        self.tool = tool
        self.max_retries = max_retries
        self.base_sleep = base_sleep

    def invoke(self, tool_input):
        last_err = None
        for i in range(self.max_retries + 1):
            try:
                return self.tool.invoke(tool_input)
            except Exception as e:
                last_err = e
                if i == self.max_retries:
                    raise RetryBudgetExceeded(str(e))

                sleep = self.base_sleep * (2 ** i) + random.random() * 0.1
                time.sleep(sleep)

        raise RetryBudgetExceeded(str(last_err))

중요한 점은 “재시도는 툴 내부에서 끝낸다”입니다. 에이전트가 재시도를 주도하면 루프가 더 커집니다.

멱등성과 중복 방지까지 함께 고려

툴이 결제, 예약, 티켓 발권처럼 부작용이 있는 작업이면 “반복 호출 차단”은 비용 문제가 아니라 사고 방지입니다. 이때는 에이전트 레벨 가드레일과 별개로, 서버 측에서 멱등키로 중복 실행을 막아야 합니다. 이 관점은 Kubernetes MSA에서 멱등키로 중복결제 막기와 동일합니다.

에이전트는 실수합니다. 멱등성은 최후의 안전장치입니다.

4) 상태 기반 종료: “충분 조건”을 코드로 만들기

무한 루프를 줄이는 가장 근본적인 방법은 “언제 멈춰야 하는지”를 모델의 감에 맡기지 않고, 상태로 명시하는 것입니다.

예를 들어 RAG 에이전트라면 아래 같은 상태를 둡니다.

  • evidence_count: 근거 문서 수
  • coverage_score: 질문의 핵심 키워드가 근거에 포함되는 비율
  • answerable: 답변 가능 여부

그리고 answerable=True면 더 이상 검색 툴을 호출하지 못하게 합니다.

간단한 정책 예시

def should_allow_search(state):
    # 이미 근거가 충분하면 검색 금지
    if state.get("answerable") is True:
        return False

    # 검색 호출 예산
    if state.get("search_calls", 0) >= 3:
        return False

    return True

def update_state_after_search(state, docs):
    state["search_calls"] = state.get("search_calls", 0) + 1
    state["evidence_count"] = state.get("evidence_count", 0) + len(docs)

    # 예시: 근거가 4개 이상이면 답변 가능으로 간주
    if state["evidence_count"] >= 4:
        state["answerable"] = True

    return state

이 패턴은 LangGraph 같은 상태 머신 기반 구성에서 특히 강력하지만, 일반 에이전트에서도 “툴 호출 전 체크” 형태로 적용할 수 있습니다.

운영에서 바로 쓰는 체크리스트

A. 관측

  • 요청 단위로 trace_id를 찍고, 툴 호출 로그에 함께 남긴다
  • tool_name, tool_input, latency_ms, success, error_type을 구조화 로그로 저장
  • 동일 trace_id에서 툴 호출 횟수 분포를 대시보드로 본다

B. 정책

  • max_iterationsmax_execution_time은 기본값으로 강제
  • 툴별로 max_calls_per_request를 둔다
  • 동일 입력 반복은 2회까지만 허용하고 이후 차단
  • 실패는 툴 내부에서만 제한된 재시도 후, 구조화된 예외로 올린다

C. 프롬프트

  • “확신이 없으면 계속 확인” 같은 지시를 제거하거나 상한을 명시한다
  • “툴은 최대 N회까지만 호출”을 시스템 지침으로 넣되, 코드 가드레일을 반드시 병행한다

예시: 검색 툴 반복 호출을 실전적으로 막는 구성

아래는 전체 흐름을 한 번에 묶은 예시입니다.

  • 실행기: 반복/시간 하드 리밋
  • 콜백: 동일 툴·동일 입력 반복 차단
  • 툴 래퍼: 재시도 예산
from langchain.agents import AgentExecutor

# 1) 툴을 재시도 예산 래퍼로 감싼다
safe_tools = []
for t in tools:
    wrapped = ToolWrapperWithRetryBudget(t, max_retries=1, base_sleep=0.4)
    # LangChain Tool 인터페이스에 맞추려면 invoke 시그니처를 맞추는 어댑터가 필요할 수 있음
    # 여기서는 개념 예시로 유지
    safe_tools.append(wrapped)

# 2) 에이전트 실행기 하드 리밋
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,  # 실제 적용 시 safe_tools를 Tool로 어댑팅해 주입
    verbose=True,
    max_iterations=6,
    max_execution_time=15,
    early_stopping_method="force",
)

# 3) 반복 호출 가드 콜백
callbacks = [ToolLoopGuardCallback(max_same_call=2, window=12)]

result = agent_executor.invoke(
    {"input": "사용자 질문"},
    config={"callbacks": callbacks},
)

print(result)

현실적으로는 safe_tools를 LangChain의 Tool 타입으로 감싸는 어댑터를 추가하고, 툴별로 max_same_call을 다르게 설정하는 식으로 발전시킵니다. 예를 들어 웹 검색은 2회, DB 조회는 3회, 결제 실행은 1회 같은 식입니다.

자주 겪는 함정과 해결 팁

함정 1) “모델이 알아서 멈추겠지”

운영에서는 멈추지 않습니다. 특히 장애 상황에서 더 멈추지 않습니다. 하드 리밋은 필수입니다.

함정 2) 툴 실패를 자연어로만 반환

예를 들어 "에러가 발생했습니다" 같은 문자열은 에이전트가 실패 원인을 구분하지 못합니다. 최소한 아래처럼 구조화하세요.

{"ok": false, "error_type": "timeout", "message": "upstream timeout"}

이렇게 하면 에이전트가 “다시 시도할지, 다른 툴로 갈지, 사용자에게 확인할지”를 더 잘 결정합니다.

함정 3) 반복 차단이 사용자 경험을 깨뜨림

반복 차단은 강제 종료를 만들 수 있습니다. 따라서 종료 시에는 다음을 함께 반환하는 것이 좋습니다.

  • 어디까지 시도했는지 요약
  • 왜 중단했는지(툴 반복, 시간 초과, 재시도 예산 초과)
  • 사용자가 추가로 제공하면 좋은 정보

마무리

LangChain 에이전트의 무한 루프와 툴 반복 호출은 “모델 성능 문제”로만 보면 해결이 어렵습니다. 런타임 가드레일로 비용 지점을 통제하고, 실패를 구조화하며, 상태 기반 종료 조건을 코드로 명시해야 안정화됩니다.

정리하면 다음 3가지만 먼저 적용해도 체감 효과가 큽니다.

  • max_iterationsmax_execution_time 강제
  • 동일 툴·동일 입력 반복 호출 감지 후 차단
  • 툴 내부 재시도 예산과 실패 타입 구조화

이 3가지를 기본값으로 깔고, 서비스 특성에 맞게 상태 머신과 멱등성까지 확장하면 “에이전트가 가끔 미쳐 날뛰는” 운영 리스크를 현실적으로 관리할 수 있습니다.