Published on

LangChain 에이전트 무한루프·툴폭주 디버깅

Authors

서빙 환경에서 LangChain 에이전트를 붙이면 가장 흔한 장애 패턴이 두 가지입니다. 첫째는 무한루프(같은 생각-행동을 반복하며 종료 조건에 도달하지 못함), 둘째는 툴폭주(의도치 않은 툴을 과도하게 호출해 비용·지연·외부 API 쿼터를 태움)입니다. 둘은 보통 같이 옵니다. 한 번 루프에 빠지면 에이전트는 “정보가 더 필요하다”는 이유로 툴을 계속 호출하고, 툴이 완벽히 실패하지 않는 한(예: 200이지만 빈 결과) 루프는 더 길어집니다.

이 글은 “왜 이런 현상이 생기는지”를 설명하는 데서 끝나지 않고, 재현 → 관측 → 차단(가드레일) → 근본 개선(툴/프롬프트/상태 설계) 순서로 정리합니다.

증상: 무한루프와 툴폭주의 전형적인 로그 패턴

다음과 같은 패턴이 보이면 거의 확실히 루프입니다.

  • 동일한 툴이 짧은 간격으로 반복 호출됨(예: search 30회)
  • 툴 결과가 매번 유사하거나 빈 값인데도 “한 번만 더”를 반복
  • 에이전트 최종 응답이 오지 않고 타임아웃으로 끝남
  • 토큰 사용량/비용이 요청당 급증
  • 외부 시스템(검색/DB/사내 API) 쿼터 소진, 레이트리밋 증가

운영 관점에서는 “LLM이 멈췄다”로 보이지만, 실제로는 멈춘 게 아니라 너무 열심히 일하고 있는 경우가 많습니다. 이때는 애플리케이션 타임아웃만 늘리면 더 큰 비용 폭탄이 됩니다. 타임아웃 장애 대응은 아래 글의 접근(재현/관측/단계적 타임아웃 조정)이 비슷한 결을 갖습니다: OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드

원인 분류: 왜 에이전트는 루프에 빠지나

1) 종료 조건이 모델에게 명확하지 않음

에이전트 프롬프트에 “언제 멈춰야 하는지”가 애매하면, 모델은 안전하게(?) 더 많은 정보를 수집하려고 합니다.

  • “필요하면 검색해”만 있고 “검색은 최대 2회” 같은 상한이 없음
  • “답을 확신할 때만 종료” 같은 표현은 오히려 종료를 어렵게 함

2) 툴이 비결정적이거나, 실패를 성공처럼 반환

툴이 다음처럼 동작하면 루프가 잘 생깁니다.

  • 200 OK지만 본문이 비어 있음(검색 결과 0건)
  • 오류를 잡아서 "" 같은 값으로 반환(에이전트는 실패로 인지 못함)
  • “부분 성공”인데 성공 플래그가 없음

3) 툴 출력이 너무 길거나, 핵심 신호가 없음

툴 결과가 장황하면 모델은 핵심을 못 잡고 “추가 확인”을 반복합니다.

4) 상태(메모리)가 누적되어 자기강화 루프가 됨

대화 히스토리나 scratchpad가 길어질수록 모델이 과거의 잘못된 가정을 강화합니다. AutoGPT류에서 흔한 메모리 폭주 패턴과 유사합니다: AutoGPT 메모리 폭주 해결 - 벡터DB TTL·요약

5) 멱등성 없는 툴 + 재시도 정책

네트워크 지연/타임아웃이 있을 때 재시도까지 붙으면 호출량이 기하급수로 늘 수 있습니다.

1단계: 재현 가능한 최소 케이스 만들기

디버깅의 첫걸음은 “운영에서 가끔 터진다”를 “로컬에서 100% 재현된다”로 바꾸는 것입니다.

  • 툴 결과를 의도적으로 빈 값으로 만들기
  • 툴이 애매한 결과를 반환하도록 스텁 서버 만들기
  • 모델 온도를 높여(예: temperature=0.7) 탐색적 행동을 유도

아래는 LangChain에서 툴이 항상 빈 결과를 반환하게 만들어 루프를 유도하는 예시입니다.

from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate

@tool
def search_docs(query: str) -> str:
    # 의도적으로 애매한/무의미한 결과를 반환
    return ""

llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0.3)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a helpful agent. Use tools when needed."),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

agent = create_tool_calling_agent(llm, tools=[search_docs], prompt=prompt)
executor = AgentExecutor(agent=agent, tools=[search_docs], verbose=True)

executor.invoke({"input": "사내 위키에서 '온콜 핸드오프 절차'를 찾아 요약해줘"})

이 예시는 “툴이 빈 값을 반환해도 모델이 계속 검색을 반복”하는 전형적인 루프를 보여줍니다.

2단계: 관측(Observability) — 루프를 숫자로 잡기

무한루프/툴폭주는 감으로 잡으면 안 됩니다. 호출 횟수, 반복 패턴, 원인 툴, 평균 지연이 보이면 해결이 빨라집니다.

(1) LangChain verbose 로그만으로는 부족한 이유

verbose=True는 “무슨 툴을 썼는지”는 보여주지만,

  • 동일 툴 반복 호출의 원인(입력/출력 변화)
  • 루프 탐지(유사도 기반)
  • 요청 단위 비용/토큰

같은 핵심을 자동으로 요약해주진 않습니다.

(2) 콜백으로 툴 호출 횟수/입력/출력 길이 기록

아래는 간단한 콜백 핸들러로 툴 호출을 계측하는 예시입니다.

import time
from collections import defaultdict
from langchain_core.callbacks import BaseCallbackHandler

class ToolSpamMonitor(BaseCallbackHandler):
    def __init__(self):
        self.tool_calls = defaultdict(int)
        self.events = []

    def on_tool_start(self, serialized, input_str, **kwargs):
        name = serialized.get("name", "unknown")
        self.tool_calls[name] += 1
        self.events.append({
            "t": time.time(),
            "type": "tool_start",
            "tool": name,
            "input_len": len(str(input_str)),
        })

    def on_tool_end(self, output, **kwargs):
        self.events.append({
            "t": time.time(),
            "type": "tool_end",
            "output_len": len(str(output)),
        })

monitor = ToolSpamMonitor()

# AgentExecutor 생성 시 callbacks에 주입
# executor = AgentExecutor(..., callbacks=[monitor])

운영에서는 이 데이터를 구조화 로그(JSON)로 남겨서 요청 단위로 집계해야 합니다. “특정 요청에서 search_docs가 15회 이상 호출되면 경보” 같은 룰이 가능해집니다.

(3) 트레이싱 도구로 체인/툴 호출 그래프 보기

LangSmith나 OpenTelemetry를 붙이면 “어떤 단계에서 동일 행동을 반복하는지”가 시각적으로 보입니다. 특히 툴 결과가 길어 모델이 요지를 못 잡는 경우, 트레이스에서 툴 출력 크기가 비정상적으로 큰 것이 바로 드러납니다.

3단계: 즉시 효과 있는 차단책(가드레일)

근본 원인을 고치기 전에, 운영 장애를 막는 하드 리밋이 먼저입니다.

(1) 최대 반복 횟수 제한(max_iterations)

LangChain의 AgentExecutor는 반복 상한을 둘 수 있습니다.

executor = AgentExecutor(
    agent=agent,
    tools=[search_docs],
    verbose=True,
    max_iterations=6,
    early_stopping_method="force",  # 또는 "generate"
)
  • force: 상한 도달 시 강제 종료(부분 결과/중간 상태만 남을 수 있음)
  • generate: 상한 도달 시 “현재까지로 답을 생성”을 시도

운영에서는 보통 generate가 사용자 경험이 낫습니다.

(2) 툴별 호출 쿼터(요청당 N회)

전체 반복 제한만으로는 부족합니다. 예를 들어 검색 5회는 괜찮지만 결제 API 2회는 위험할 수 있습니다. 툴 래퍼에서 카운트를 강제하세요.

from langchain_core.tools import tool

class QuotaExceeded(Exception):
    pass

class ToolQuota:
    def __init__(self, limit_by_tool):
        self.limit_by_tool = dict(limit_by_tool)
        self.used = {k: 0 for k in self.limit_by_tool}

    def check_and_inc(self, tool_name: str):
        if tool_name not in self.limit_by_tool:
            return
        self.used[tool_name] += 1
        if self.used[tool_name] > self.limit_by_tool[tool_name]:
            raise QuotaExceeded(f"tool quota exceeded: {tool_name}")

quota = ToolQuota({"search_docs": 2})

@tool
def guarded_search_docs(query: str) -> str:
    quota.check_and_inc("search_docs")
    return ""  # 실제 구현에서는 검색 수행

핵심은 “모델이 아무리 우겨도 더 이상 호출되지 않게” 만드는 것입니다.

(3) 타임아웃과 회로 차단기(circuit breaker)

툴이 외부 API를 호출한다면 타임아웃은 필수입니다. 타임아웃이 없으면 루프가 아니라 “대기”로 시스템이 죽습니다.

  • connect/read 타임아웃 분리
  • 연속 실패 시 일정 시간 툴 차단

이때도 마찬가지로 -> 같은 기호를 로그에 그대로 남기기보다 구조화 필드로 남겨야 분석이 쉽습니다.

4단계: 근본 개선 1 — 툴 설계를 ‘에이전트 친화적’으로 바꾸기

(1) 성공/실패를 명시적으로 반환

에이전트가 가장 잘 판단하는 형태는 “상태 + 요약 + 다음 행동 힌트”입니다.

import json
from langchain_core.tools import tool

@tool
def search_docs_v2(query: str) -> str:
    results = []  # 실제 검색 결과

    payload = {
        "ok": True,
        "query": query,
        "count": len(results),
        "items": results[:3],
        "hint": "If count is 0, do not retry with same query. Ask user for clarification.",
    }
    return json.dumps(payload, ensure_ascii=False)

포인트는 count=0을 “성공이지만 빈 결과”로 애매하게 두지 말고, 재시도 금지 힌트를 같이 주는 것입니다.

(2) 출력 길이 제한과 요약

툴이 원문을 통째로 반환하면 모델은 또 검색합니다. 툴이 먼저 요약해 주거나, 상위 3개만 반환하세요.

(3) 멱등 키(idempotency key)로 중복 실행 방지

결제/티켓 생성처럼 부작용이 있는 툴은 요청 단위 멱등 키를 반드시 두세요.

  • 같은 입력이면 같은 결과를 반환
  • 중복 호출은 “이미 처리됨”으로 응답

5단계: 근본 개선 2 — 프롬프트에 ‘정지 규칙’을 넣기

에이전트 프롬프트는 “잘하라”가 아니라 “그만해라”를 더 구체적으로 써야 합니다.

다음 규칙은 실제로 루프를 크게 줄입니다.

  • 동일 툴을 동일/유사 쿼리로 2회 이상 호출 금지
  • 툴 결과가 count=0이면 사용자에게 확인 질문으로 전환
  • 확률적 탐색(temperature)을 낮추고, 계획을 먼저 작성 후 실행

예시:

You must follow these rules:
1) Do not call the same tool more than 2 times.
2) If a tool returns empty or count=0, do not retry with the same query.
3) If you cannot proceed, ask a single clarifying question and stop.

MDX 환경에서는 부등호가 있는 표현(예: count>0)을 본문에 그대로 쓰면 빌드 에러가 날 수 있으니, 반드시 count>0처럼 엔티티로 쓰거나 인라인 코드로 감싸는 습관을 들이세요.

6단계: 루프 탐지 로직 — “비슷한 행동”을 자동 차단

반복은 “완전히 동일”하지 않습니다. 쿼리의 조사만 바뀌거나 공백만 바뀌는 식으로 변형됩니다. 그래서 유사도 기반 루프 탐지가 효과적입니다.

간단한 방법은 “최근 N개의 툴 입력을 정규화(normalize)해서 동일하면 차단”입니다.

import re

def normalize_query(q: str) -> str:
    q = q.lower().strip()
    q = re.sub(r"\s+", " ", q)
    q = re.sub(r"[\W_]+", " ", q)
    return q.strip()

class LoopGuard:
    def __init__(self, window=5):
        self.window = window
        self.recent = []

    def seen(self, tool: str, query: str) -> bool:
        key = f"{tool}:{normalize_query(query)}"
        if key in self.recent:
            return True
        self.recent.append(key)
        self.recent = self.recent[-self.window:]
        return False

loop_guard = LoopGuard(window=6)

# 툴 내부에서 사용
# if loop_guard.seen("search_docs", query): raise QuotaExceeded("repeated query")

이 정도만 해도 “같은 검색어로 10번 때리는” 폭주는 대부분 사라집니다.

7단계: 운영에서의 장애 형태 — 재시작 루프와 헷갈리지 않기

에이전트 루프는 애플리케이션 레벨에서 발생하지만, 운영에서는 종종 “컨테이너가 계속 재시작된다”로 관측됩니다. 예를 들어 요청이 오래 걸려 워커가 죽고, 프로세스 매니저가 재시작하며 동일 요청이 재시도되면 툴 호출은 더 늘어납니다.

또한 쿠버네티스 환경에서는 readiness/liveness 설정이 공격적으로 잡혀 있으면 “응답 지연 = 비정상”으로 판단해 재시작이 걸립니다. 이 경우 원인이 에이전트 루프인지, readiness 설계인지 분리해서 봐야 합니다.

체크리스트: 무한루프·툴폭주를 줄이는 12가지 규칙

  1. max_iterations로 상한을 건다.
  2. 툴별 쿼터를 둔다(요청당 N회).
  3. 툴에 타임아웃을 건다(connect/read).
  4. 툴 결과는 ok, count, hint를 포함한 구조화 형태로 반환한다.
  5. 빈 결과는 “재시도 금지”로 명시한다.
  6. 툴 출력 길이를 제한하고 상위 N개만 반환한다.
  7. 부작용 툴은 멱등 키로 중복 실행을 막는다.
  8. 프롬프트에 “정지 규칙”을 구체적으로 쓴다.
  9. 동일/유사 쿼리 반복을 정규화 기반으로 탐지한다.
  10. 요청 단위로 툴 호출 횟수, 평균 지연, 출력 길이를 계측한다.
  11. 트레이싱으로 반복 지점을 시각화한다.
  12. 재시작/재시도로 인한 호출 증폭을 운영 레이어에서 차단한다.

마무리: 에이전트는 ‘똑똑한 프로그램’이 아니라 ‘확률적 런타임’이다

LangChain 에이전트의 무한루프는 “모델이 멍청해서”가 아니라, 종료 규칙과 툴 인터페이스가 확률적 런타임을 감당할 만큼 단단하지 않아서 발생합니다. 해결의 핵심은 모델을 더 좋은 것으로 바꾸는 게 아니라,

  • 루프를 관측 가능하게 만들고
  • 상한/쿼터/타임아웃으로 폭주를 막고
  • 툴을 에이전트 친화적으로 설계하고
  • 프롬프트에 정지 규칙을 넣는 것

입니다.

이 네 가지를 적용하면 “가끔 폭주하는 에이전트”가 “예측 가능한 비용과 지연을 가진 시스템”으로 바뀝니다.