Published on

LangChain 에이전트 무한루프 7가지 원인과 차단법

Authors

LangChain 에이전트는 생각(Reasoning)행동(Action)을 반복하며 목표에 도달합니다. 문제는 이 반복이 종료 조건 없이 계속되거나, 같은 툴 호출/같은 검색/같은 플랜을 되풀이하면서 비용과 시간을 태워버리는 형태로 터진다는 점입니다. 특히 ReAct 스타일(툴 사용) 에이전트는 한 번 루프가 걸리면 토큰·API 호출·외부 시스템 부하가 동시에 증가합니다.

이 글은 LangChain 기반 에이전트에서 무한루프가 발생하는 대표 패턴 7가지를 원인별로 분해하고, 코드 레벨에서 차단하는 방법을 정리합니다. AutoGPT 계열에서의 종료조건/메모리 설계 관점도 유사하니 함께 참고하면 좋습니다: AutoGPT 에이전트 무한루프 방지 - 종료조건·메모리


무한루프를 “정의”부터 명확히 하기

현장에서 무한루프는 보통 아래 중 하나로 관측됩니다.

  • Step 루프: 동일한 Thought/Action 패턴이 N회 이상 반복
  • Tool 루프: 같은 입력으로 같은 툴을 계속 호출(예: 검색 쿼리 고정)
  • Planner 루프: 계획을 계속 수정만 하고 실행을 못 함
  • Recover 루프: 예외 처리/리트라이 경로에서 동일 실패를 반복

따라서 차단도 “한 방”이 아니라, (1) 상한선, (2) 반복 감지, (3) 실패 예산, (4) 종료 신호를 함께 둬야 합니다.


1) 종료 조건 부재: max_iterationsstop 신호가 없다

가장 흔한 원인은 단순합니다. 반복 상한이 없거나, 상한이 있어도 상한 도달 시 종료 전략이 부실합니다.

차단 전략

  • 에이전트 실행에 반드시 반복 상한을 둡니다.
  • 상한 도달 시 partial answer를 반환하거나, 사용자에게 추가 정보 요청으로 종료합니다.

코드 예시(파이썬, LangChain)

from langchain.agents import AgentExecutor, create_react_agent
from langchain_openai import ChatOpenAI
from langchain.tools import tool

@tool
def ping(x: str) -> str:
    return f"pong: {x}"

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
agent = create_react_agent(llm, tools=[ping])

executor = AgentExecutor(
    agent=agent,
    tools=[ping],
    verbose=True,
    max_iterations=8,
    early_stopping_method="generate",  # 상한 도달 시 요약/답 생성 시도
)

result = executor.invoke({"input": "ping을 한 번만 호출하고 끝내"})
print(result["output"])

max_iterations는 최소 안전장치입니다. 하지만 이것만으로는 “8번까지는 계속 돈다”이므로, 아래 원인들과 함께 막아야 합니다.


2) 툴 출력이 비결정적이거나 장황해서 “다시 확인” 루프가 생긴다

검색/브라우징/스크레이핑/DB 조회 같은 툴이 다음 조건을 만족하면 루프를 유발합니다.

  • 결과가 매번 다름(시간/랭킹/랜덤)
  • 결과가 너무 길어 핵심이 안 잡힘
  • 실패 시 에러 메시지가 애매해 재시도를 부름

에이전트는 “확신”을 얻기 위해 같은 툴을 반복 호출하는 경향이 있습니다.

차단 전략

  • 툴 출력은 요약된 고정 스키마로 반환
  • 타임아웃/실패 시 명시적 실패 타입 반환
  • 동일 입력 반복 호출을 감지해 차단

코드 예시: 툴 출력 스키마 고정 + 길이 제한

import json
from langchain.tools import tool

@tool
def web_search(query: str) -> str:
    # 실제로는 검색 API 호출
    docs = [
        {"title": "DocA", "url": "https://example.com/a", "snippet": "..."},
        {"title": "DocB", "url": "https://example.com/b", "snippet": "..."},
    ]

    payload = {
        "query": query,
        "top_k": 2,
        "results": docs,
        "note": "결과는 요약 스니펫만 포함하며 원문은 직접 열람 필요",
    }
    text = json.dumps(payload, ensure_ascii=False)
    return text[:2000]  # 과도한 길이 차단

툴이 “짧고, 구조화되고, 실패가 명확한” 출력을 주면 에이전트의 재호출 충동이 크게 줄어듭니다.


3) 메모리/상태 설계가 잘못되어 같은 결론을 반복한다

무한루프는 종종 모델이 멍청해서가 아니라, 상태가 누락되어서 발생합니다.

  • 이미 시도한 액션을 기억하지 못함
  • “무엇을 했고 무엇이 실패했는지”가 프롬프트에 남지 않음
  • 반대로 메모리가 너무 길어 핵심이 묻혀 동일 패턴으로 회귀

차단 전략

  • 시도 목록(Attempt log)을 짧게 유지하며 시스템 메시지에 포함
  • 실패 원인과 다음 액션 금지 규칙을 명시
  • 일정 길이 이상 대화는 요약 메모리로 축약

코드 예시: 시도 로그를 별도 상태로 유지

from dataclasses import dataclass, field

@dataclass
class AttemptState:
    attempts: list[str] = field(default_factory=list)

    def add(self, key: str) -> None:
        self.attempts.append(key)
        self.attempts = self.attempts[-10:]  # 최근 10개만 유지

    def seen(self, key: str) -> bool:
        return key in self.attempts

이 상태를 툴 래퍼나 실행 루프에서 사용하면, 같은 입력으로 같은 툴을 반복 호출하는 것을 손쉽게 막을 수 있습니다.


4) 관찰(Observation) 해석 실패: 툴 결과를 이해 못해 “다시 해보기”로 도망

툴이 반환하는 데이터가 애매하거나, 에이전트 프롬프트가 결과 해석을 강제하지 않으면 다음이 발생합니다.

  • Observation을 읽고도 다음 결정을 못 내림
  • “확인 필요”라는 말로 같은 툴을 재호출

차단 전략

  • 프롬프트에 관찰 결과를 반드시 구조화해 요약하도록 강제
  • “재호출 조건”을 명확히 제한

프롬프트 패턴(핵심만)

  • 관찰을 받으면 아래를 반드시 출력
    • 핵심 사실 3개
    • 결론
    • 추가 호출이 필요하면 이유 1줄과 필요한 입력
  • 동일 툴을 같은 입력으로 2회 이상 호출 금지

LangChain에서는 시스템 메시지/에이전트 프롬프트 템플릿에 이 규칙을 넣고, 아래 5번의 반복 감지와 결합하는 방식이 효과적입니다.


5) 반복 감지(Loop detection)가 없다: 같은 액션 시그니처를 계속 탄다

실전에서 가장 즉효가 큰 차단은 반복 감지입니다.

  • tool_name + normalized_input을 시그니처로 만들고
  • 동일 시그니처가 N회 이상이면 종료 또는 다른 전략으로 전환

코드 예시: 툴 호출 반복 차단 래퍼

import re
from collections import Counter

class ToolCallGuard:
    def __init__(self, max_same_call: int = 2):
        self.max_same_call = max_same_call
        self.counter = Counter()

    def _norm(self, s: str) -> str:
        s = s.strip().lower()
        s = re.sub(r"\s+", " ", s)
        return s[:500]

    def check_and_count(self, tool_name: str, tool_input: str) -> None:
        sig = f"{tool_name}:{self._norm(tool_input)}"
        self.counter[sig] += 1
        if self.counter[sig] > self.max_same_call:
            raise RuntimeError(f"loop_detected: repeated tool call: {sig}")

이 가드를 각 툴 실행 직전에 넣으면, “검색을 같은 쿼리로 계속” 같은 전형적 루프를 즉시 차단할 수 있습니다.


6) 예외 처리/리트라이 정책이 공격적이다: 실패가 루프를 만든다

에이전트 시스템은 보통 외부 API를 많이 호출합니다. 네트워크 오류, 429, 5xx, 타임아웃이 생기면 리트라이가 걸리는데, 아래 조합이 위험합니다.

  • 무제한 리트라이
  • 짧은 고정 딜레이
  • 실패 원인을 프롬프트에 전달하지 않음

이러면 “같은 실패를 같은 방식으로 반복”하는 복구 루프가 됩니다.

차단 전략

  • 리트라이는 지수 백오프 + 최대 횟수
  • 실패 유형을 fatalretryable로 구분
  • 실패가 누적되면 에이전트 종료 또는 대체 경로로 전환

코드 예시: 단순 백오프 리트라이(의사 코드)

import time

def call_with_retry(fn, max_retry: int = 3, base_sleep: float = 0.5):
    last_err = None
    for i in range(max_retry + 1):
        try:
            return fn()
        except Exception as e:
            last_err = e
            if i == max_retry:
                break
            time.sleep(base_sleep * (2 ** i))
    raise RuntimeError(f"tool_failed_after_retries: {last_err}")

운영 환경에서는 관측성(로그/메트릭)과 결합해 “특정 툴의 실패율 급등”을 감지해야 합니다. 무한루프는 애플리케이션 레벨 장애로 확대되기 쉽습니다.


7) 목표/성공 기준이 모호하다: 완료 판정이 없어 계속 ‘개선’한다

에이전트에게 “최대한 좋은 답”을 요구하면, 모델은 끝없이 개선하려고 합니다. 특히 아래 형태가 위험합니다.

  • “완벽하게”, “가능한 많이”, “모든 경우를” 같은 지시
  • 성공 조건이 수치나 체크리스트로 표현되지 않음
  • 출력 형식이 불명확

차단 전략

  • 성공 기준을 체크리스트로 명시
  • “충족 시 즉시 종료” 규칙을 시스템 메시지에 넣기
  • 출력 형식을 JSON 스키마 등으로 제한(가능하면)

예시: 성공 기준 체크리스트

  • 필수 항목 5개를 채우면 종료
  • 불확실한 항목은 unknown으로 표기하고 추가 조사 없이 종료

이 패턴은 RAG에서도 동일하게 중요합니다. 검색을 무한히 늘리는 대신, “충분 조건”을 정의해야 정확도와 비용이 안정됩니다. 관련 튜닝 관점은 RAG 정확도 폭락? Milvus HNSW 튜닝 7가지도 함께 보면 좋습니다.


실전용: 무한루프 차단을 한 번에 넣는 실행 래퍼

아래는 “상한선 + 반복 감지 + 실패 예산”을 묶은 최소 실행 래퍼 예시입니다. LangChain 내부 콜백을 깊게 파지 않더라도, 바깥에서 실행을 감싸는 것만으로도 사고를 크게 줄일 수 있습니다.

from time import monotonic

class AgentRunLimiter:
    def __init__(
        self,
        max_seconds: float = 30.0,
        max_tool_errors: int = 3,
        max_same_tool_call: int = 2,
    ):
        self.max_seconds = max_seconds
        self.max_tool_errors = max_tool_errors
        self.guard = ToolCallGuard(max_same_call=max_same_tool_call)
        self.tool_errors = 0
        self.started = None

    def start(self):
        self.started = monotonic()

    def check_time(self):
        if self.started is None:
            return
        if monotonic() - self.started > self.max_seconds:
            raise TimeoutError("agent_timeout")

    def on_tool_call(self, tool_name: str, tool_input: str):
        self.check_time()
        self.guard.check_and_count(tool_name, tool_input)

    def on_tool_error(self, err: Exception):
        self.tool_errors += 1
        if self.tool_errors > self.max_tool_errors:
            raise RuntimeError(f"tool_error_budget_exceeded: {err}")

핵심은 “에이전트가 똑똑해지길 기다리는 것”이 아니라, 시스템이 안전하게 멈추게 만드는 것입니다.


디버깅 체크리스트: 어디서 루프가 도는지 빠르게 찾기

운영에서 루프를 잡을 때는 원인 규명이 먼저입니다.

  • 동일 tool_name이 연속 호출되는가
  • 동일 입력(정규화 기준)이 반복되는가
  • Observation이 너무 길거나, 에러가 애매한가
  • max_iterations에 도달해도 왜 답이 생성되지 않는가
  • 실패가 특정 외부 시스템(검색, DB, HTTP)에서 집중되는가

에이전트 시스템도 결국 분산 시스템처럼 장애가 전파됩니다. 특히 외부 인증/권한 오류가 반복되면 “복구 루프”로 보이기 쉬운데, 이런 류의 문제는 원인을 정확히 깎아내야 합니다. 예를 들어 클라우드 권한/토큰 만료는 무한 재시도를 부르기 쉬우니, 유사한 트러블슈팅 글인 EKS IRSA 웹아이덴티티 토큰 만료·403 해결 같은 접근(원인 분류, 재현, 차단)을 참고하면 도움이 됩니다.


결론: 무한루프는 모델 문제가 아니라 “가드레일 부재” 문제다

LangChain 에이전트 무한루프는 대부분 아래 4가지 가드레일이 빠져서 발생합니다.

  • 반복 상한(max_iterations, timeout)
  • 반복 감지(같은 툴/같은 입력 시그니처)
  • 실패 예산(리트라이/에러 누적 제한)
  • 명확한 성공 기준(체크리스트, 출력 스키마)

위 7가지 원인을 기준으로 자신의 에이전트를 점검하고, 최소한 상한선 + 반복 감지부터 적용해도 비용 폭주와 장애 전파를 크게 줄일 수 있습니다.