Published on

LangChain 에이전트 무한 루프 끊는 실전 디버깅

Authors

서빙 환경에서 LangChain 에이전트를 붙여두면, 가끔 “답을 못 내고 계속 툴만 두드리는” 무한 루프가 터집니다. 증상은 비슷합니다.

  • 같은 툴을 같은 인자로 반복 호출
  • 관찰값이 바뀌지 않는데도 다음 액션이 동일
  • Final 응답이 나오지 않고 Action만 이어짐
  • 토큰/비용이 기하급수로 증가하고, 결국 타임아웃 또는 쿼터 초과

이 글에서는 무한 루프를 재현 가능하게 만들고, 원인을 6가지 유형으로 분류한 뒤, 코드 레벨에서 루프를 끊는 가드레일을 넣는 실전 절차를 다룹니다.

또한, 루프가 길어지면 API 호출량이 폭증해 429나 네트워크 오류로 이어지기 쉬운데, 이런 2차 장애는 별도 대응도 함께 설계해야 합니다. 관련해서는 Gemini API 429 쿼터 초과 대응 - 재시도·백오프, Python httpx RemoteProtocolError 서버 끊김 원인과 해결도 같이 참고하면 좋습니다.

1) 무한 루프를 “증상”이 아니라 “원인”으로 분해하기

LangChain 에이전트 루프는 대개 아래 중 하나입니다.

유형 A. 툴 출력이 항상 동일하거나 정보가 부족함

  • 검색 툴이 빈 결과만 반환
  • DB 쿼리가 권한/필터로 항상 0건
  • 파서가 실패해서 항상 같은 에러 문자열

에이전트는 “관찰값이 개선되지 않았다”는 신호를 못 받으면 같은 전략을 반복합니다.

유형 B. 프롬프트가 종료 조건을 강제하지 못함

  • “정답을 못 찾으면 멈춰라” 규칙이 없음
  • “최대 N회까지만 시도” 같은 정책이 없음
  • 실패 시 대체 플랜이 없음

유형 C. 툴 스키마/응답 포맷 불일치

  • 툴이 JSON을 준다고 했지만 실제는 텍스트
  • 숫자/날짜 파싱 실패
  • 에이전트가 관찰값을 구조적으로 이해 못해 재시도

유형 D. 메모리/상태 오염으로 목표가 계속 바뀜

  • 대화 히스토리에 이전 실패가 누적되어 “다시 시도” 지시가 강화
  • 시스템 프롬프트와 사용자 프롬프트 충돌

유형 E. 함수 호출 실패를 “재시도해야 하는 상황”으로만 해석

  • 429나 일시 장애를 영구 문제로 오해하고 무한 재시도
  • 네트워크 오류가 관찰값에 그대로 노출되어 같은 액션 반복

유형 F. 에이전트 실행기 설정 부재

  • max_iterations 미설정
  • 타임아웃/캔슬 미설정
  • 스텝별 비용/토큰 제한 없음

이제부터는 이 원인들을 로그로 증명하고, 코드로 차단하는 흐름으로 접근합니다.

2) 1차 목표: “재현 가능한 로그”부터 만든다

무한 루프 디버깅은 감으로 하면 오래 걸립니다. 핵심은 한 번의 실행에서 어떤 액션이 몇 번 반복되었는지를 남기는 것입니다.

LangChain은 콜백을 통해 스텝별 이벤트를 잡을 수 있습니다. 아래 예시는 툴 호출, 관찰값, 최종 결과를 구조화해서 남기는 최소 형태입니다.

from __future__ import annotations

import json
import time
from typing import Any, Dict, List, Optional

from langchain_core.callbacks import BaseCallbackHandler


def _safe_preview(obj: Any, limit: int = 400) -> str:
    try:
        s = obj if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False)
    except Exception:
        s = repr(obj)
    return s[:limit]


class AgentTraceCallback(BaseCallbackHandler):
    def __init__(self) -> None:
        self.events: List[Dict[str, Any]] = []

    def on_tool_start(
        self,
        serialized: Dict[str, Any],
        input_str: str,
        **kwargs: Any,
    ) -> None:
        self.events.append({
            "ts": time.time(),
            "type": "tool_start",
            "tool": serialized.get("name"),
            "input": _safe_preview(input_str),
        })

    def on_tool_end(self, output: Any, **kwargs: Any) -> None:
        self.events.append({
            "ts": time.time(),
            "type": "tool_end",
            "output": _safe_preview(output),
        })

    def on_agent_finish(self, finish: Any, **kwargs: Any) -> None:
        self.events.append({
            "ts": time.time(),
            "type": "agent_finish",
            "output": _safe_preview(getattr(finish, "return_values", finish)),
        })

    def dump(self) -> str:
        return json.dumps(self.events, ensure_ascii=False, indent=2)

이 콜백을 에이전트 실행에 붙여 무한 루프가 난 요청의 실행 트레이스를 확보하세요. “어떤 툴이 어떤 입력으로 반복되는지”가 보이면 원인의 절반은 해결됩니다.

3) 2차 목표: 실행기 레벨에서 “무조건 멈추는” 안전장치

무한 루프는 완벽히 예방하기 어렵습니다. 대신 반드시 멈추게 만들어야 합니다.

(1) 반복 횟수 제한

LangChain 에이전트 실행기에서 max_iterations 또는 유사 옵션을 반드시 설정하세요. 프로젝트/버전에 따라 클래스명이 다를 수 있지만, 원칙은 같습니다.

# 예시: 에이전트 실행기 생성 시 반복 제한과 early stopping 설정
agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    max_iterations=8,
    early_stopping_method="generate",
    verbose=False,
)

early_stopping_method는 반복 제한에 걸렸을 때 “그 시점까지의 정보로 답을 생성”하도록 유도합니다. 이게 없으면 “중간에 끊긴 상태”로 끝나 UX가 나빠질 수 있습니다.

(2) 시간 제한과 요청 취소

반복 제한만으로는 부족합니다. 툴 하나가 느리면 스텝 수는 적어도 전체 요청은 오래 걸립니다. 서버에서는 전체 타임아웃과 취소(캔슬)까지 고려하세요.

import asyncio

async def run_with_timeout(coro, timeout_s: float):
    return await asyncio.wait_for(coro, timeout=timeout_s)

# 사용 예
# result = await run_with_timeout(agent_executor.ainvoke({"input": q}), 20.0)

4) “같은 액션 반복”을 감지해서 끊는 커스텀 가드레일

max_iterations는 최후의 보루입니다. 더 좋은 방법은 동일 액션 반복을 조기에 감지하는 것입니다.

아래는 “최근 K스텝 동안 같은 툴명과 같은 입력이 반복되면 종료”하는 간단한 패턴입니다. LangChain 내부 스텝을 직접 훅킹하기 어렵다면, 콜백 이벤트를 기반으로 사후 분석해도 되고, 툴 래퍼에서 차단해도 됩니다.

툴 래퍼로 반복 호출 차단하기

from collections import deque
from dataclasses import dataclass
from typing import Callable, Deque, Tuple


@dataclass
class LoopGuard:
    window: int = 4
    max_same: int = 3

    def __post_init__(self):
        self.recent: Deque[Tuple[str, str]] = deque(maxlen=self.window)

    def check(self, tool_name: str, tool_input: str) -> None:
        key = (tool_name, tool_input)
        self.recent.append(key)
        same = sum(1 for x in self.recent if x == key)
        if same >= self.max_same:
            raise RuntimeError(
                "Detected repeated tool call loop: "
                f"tool={tool_name}, count_in_window={same}"
            )


def wrap_tool(tool_name: str, tool_fn: Callable[[str], str], guard: LoopGuard):
    def _wrapped(inp: str) -> str:
        guard.check(tool_name, inp)
        return tool_fn(inp)
    return _wrapped
  • 장점: 에이전트 프레임워크 내부를 건드리지 않고도 루프를 빠르게 끊음
  • 단점: “정상적인 재시도”도 막을 수 있으니, window, max_same 튜닝이 필요

운영에서는 이 예외를 잡아 사용자에게 “추가 정보 요청” 또는 “다른 경로 제안”으로 전환하는 게 좋습니다.

5) 툴 출력 품질을 올려서 루프를 원천 감소시키기

루프의 대부분은 “관찰값이 다음 행동을 바꾸기에 충분하지 않다”에서 시작합니다. 툴을 다음 관점으로 점검하세요.

(1) 빈 결과를 구조화해서 반환

검색 결과가 없을 때 단순히 빈 문자열을 반환하면, 모델은 “내가 잘못 검색했나”로 해석하고 재검색을 반복합니다.

def search_tool(query: str) -> str:
    docs = do_search(query)
    if not docs:
        return json.dumps({
            "ok": False,
            "reason": "no_results",
            "hint": "Try broader keywords or ask user for constraints",
        }, ensure_ascii=False)

    return json.dumps({
        "ok": True,
        "count": len(docs),
        "top": docs[:3],
    }, ensure_ascii=False)

여기서 포인트는 ok, reason, hint처럼 에이전트가 전략을 바꾸는 트리거를 명시하는 것입니다.

(2) 파싱 실패를 “재시도”가 아니라 “프롬프트 수정”으로 유도

모델이 JSON을 만들어야 하는데 계속 실패한다면, 툴에서 “파싱 실패”를 반환할 때 재시도 대신 출력 형식을 강제하는 힌트를 주는 편이 낫습니다.

6) 프롬프트 레벨에서 종료 조건을 명시한다

에이전트 프롬프트에는 아래 3가지를 반드시 넣는 것을 권장합니다.

  1. 목표 달성 조건(무엇이 최종 산출물인지)
  2. 실패 조건(어떤 경우에 멈추고 사용자에게 질문할지)
  3. 반복 제한 정책(최대 시도 횟수, 동일 툴 반복 금지 등)

예시 문구:

- You must not call the same tool with the same input more than 2 times.
- If observations do not change after retries, stop and ask the user for missing info.
- If you cannot complete within 6 tool calls, provide the best partial answer and list next questions.

이런 정책은 모델이 “멈추는 선택지”를 갖게 만들어 루프를 줄입니다.

7) 네트워크/쿼터 오류가 루프를 증폭시키는 패턴 차단

무한 루프가 무서운 이유는 비용도 비용이지만, 2차 장애를 연쇄적으로 유발하기 때문입니다.

  • 반복 호출로 API 쿼터를 소진해서 429 발생
  • 재시도 로직이 에이전트 루프와 결합해 재시도가 재시도를 부름
  • 네트워크가 끊기면 관찰값이 에러 문자열로 고정되어 동일 액션 반복

(1) 재시도는 “툴 내부”에서, 에이전트 바깥으로 새지 않게

에이전트는 “관찰값을 기반으로 다음 행동을 선택”합니다. 따라서 일시 오류는 툴 내부에서 짧게 흡수하고, 실패 시에는 구조화된 실패를 반환해야 합니다.

import random
import time


def call_api_with_backoff(fn, max_tries: int = 4, base: float = 0.5):
    for i in range(max_tries):
        try:
            return fn()
        except Exception as e:
            # 마지막이면 구조화된 실패를 던지거나 반환
            if i == max_tries - 1:
                raise
            sleep_s = base * (2 ** i) + random.random() * 0.2
            time.sleep(sleep_s)

429 대응은 백오프 설계가 핵심이라, 자세한 패턴은 Gemini API 429 쿼터 초과 대응 - 재시도·백오프에 정리된 방식과 동일한 원칙을 적용하면 됩니다.

(2) 네트워크 오류는 “고정 에러 문자열”로 남기지 말 것

예를 들어 RemoteProtocolError 같은 예외를 그대로 문자열로 반환하면, 모델은 이를 해결하려고 같은 요청을 반복할 수 있습니다. 아래처럼 분류된 에러로 반환하고, 다음 행동을 바꾸도록 힌트를 주는 게 안전합니다.

def safe_http_tool(url: str) -> str:
    try:
        return fetch(url)
    except Exception as e:
        return json.dumps({
            "ok": False,
            "reason": "network_error",
            "error": type(e).__name__,
            "hint": "Do not retry immediately. Ask user to confirm URL or try later.",
        }, ensure_ascii=False)

네트워크 예외의 원인 분석은 Python httpx RemoteProtocolError 서버 끊김 원인과 해결에서 다룬 체크리스트가 그대로 적용됩니다.

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

무한 루프는 “한 번 고치면 끝”이 아니라, 새 툴/새 프롬프트/새 모델로 언제든 재발합니다. 아래 체크리스트를 배포 파이프라인에 포함시키는 것을 권합니다.

실행기 설정

  • max_iterations 설정
  • 전체 타임아웃 설정
  • early stopping 설정

툴 설계

  • 빈 결과, 권한 문제, 파싱 실패를 구조화된 JSON으로 반환
  • 일시 오류는 툴 내부에서 백오프 후 실패를 반환
  • 동일 입력 반복 호출 감지 가드레일 적용

관측/로그

  • 툴명, 입력, 출력 프리뷰, 스텝 수를 요청 단위로 저장
  • “같은 툴 반복”을 메트릭으로 뽑아 알람

프롬프트

  • 종료 조건과 실패 시 사용자 질문 정책 명시
  • 동일 툴 반복 금지 규칙 명시

9) 마무리: 루프를 “끊는” 것과 “덜 돌게 하는” 것을 분리하라

정리하면, LangChain 에이전트 무한 루프 대응은 두 겹으로 설계해야 합니다.

  • 끊는 장치: max_iterations, 타임아웃, 반복 호출 감지(강제 종료)
  • 덜 돌게 하는 장치: 툴 출력 구조화, 실패 힌트, 프롬프트 종료 조건, 재시도 정책 정교화

여기에 429나 네트워크 오류 같은 운영 이슈까지 결합되면 루프가 더 잘 발생하므로, 백오프/에러 분류를 툴 계층에서 흡수하는 전략이 특히 중요합니다.

다음 단계로는, 실제 서비스 트래픽에서 “루프가 난 트레이스”를 수집해 유형 A부터 F 중 어디에 해당하는지 라벨링하고, 라벨별로 프롬프트/툴/가드레일을 개선하는 식으로 품질을 올리면 됩니다. 이 방식이 가장 빠르고, 비용을 가장 확실하게 줄입니다.