Published on

LangChain 에이전트 무한루프·비용폭탄 9가지 차단법

Authors

서버에 붙인 LangChain 에이전트가 갑자기 응답을 못 끝내고 같은 행동을 반복하거나, 툴 호출을 과도하게 반복해 비용이 폭증하는 경우가 있습니다. 특히 에이전트는 LLM 출력이 다음 스텝의 입력이 되기 때문에, 작은 프롬프트 실수나 툴 스키마 문제도 연쇄적으로 커집니다.

이 글은 “왜 무한루프가 생기는지”를 원인별로 쪼개고, “코드 레벨에서 어떻게 막는지”를 9가지 차단법으로 정리합니다. 예시는 LangChain 기반이지만, 에이전트 전반에 그대로 적용 가능합니다.

무한루프·비용폭탄이 생기는 전형적 패턴

에이전트 루프가 생기는 패턴은 크게 4가지로 압축됩니다.

  1. 종료 조건이 느슨함: 완료 기준이 모호하거나, 모델이 완료를 선언할 수 없는 형태의 지시.
  2. 툴 실패가 재시도로 증폭: 실패 원인이 고정인데도 “다시 시도”만 반복.
  3. 관측 불가: 어디서 비용이 새는지, 어떤 툴이 반복되는지 로그가 없음.
  4. 안전장치 부재: 스텝 수, 토큰, 비용, 시간, 호출 횟수 상한이 없음.

아래 9가지는 위 패턴을 각각 직접적으로 끊는 방법입니다.

1) 스텝 수 상한: max_iterations는 최저선

가장 먼저 넣어야 할 안전장치는 스텝 상한입니다. 스텝 상한이 없으면, 한 번의 요청이 영원히 돌아갈 수 있습니다.

from langchain.agents import AgentExecutor

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    max_iterations=8,  # 최저선
    early_stopping_method="force",  # 더 이상 진행 불가 시 강제 종료
    verbose=True,
)

운영 팁:

  • 단순 QnA는 5 내외, 검색·요약형은 8 내외부터 시작해 트래픽/비용을 보며 조정합니다.
  • early_stopping_method를 강제 종료로 두고, 종료 시 사용자에게 “부분 결과”와 “추가 실행 버튼”을 제공하면 UX를 유지하면서 비용을 제어할 수 있습니다.

2) 시간 제한: 요청 단위 timeout과 툴 단위 timeout을 분리

스텝 상한만으로는 부족합니다. 한 스텝이 외부 API 지연으로 30초씩 걸리면, 8스텝은 4분이 됩니다.

  • 요청 전체 타임아웃: 에이전트 실행 자체를 끊음
  • 툴 타임아웃: 특정 툴 호출을 끊고 에이전트에게 실패를 전달
import asyncio

async def run_agent_with_timeout(agent_executor, inputs, timeout_sec=20):
    return await asyncio.wait_for(
        agent_executor.ainvoke(inputs),
        timeout=timeout_sec,
    )

툴 쪽은 HTTP 클라이언트 레벨에서 타임아웃을 반드시 걸어야 합니다. 타임아웃이 없으면 에이전트는 “기다리기”를 반복하거나, 실패 후 재시도를 무한히 할 수 있습니다.

3) 토큰·비용 상한: 예산 기반으로 강제 중단

스텝 수가 적어도 한 번에 긴 컨텍스트를 넣으면 비용은 폭증합니다. 따라서 “토큰” 또는 “비용” 상한을 요청 단위로 두는 것이 가장 확실합니다.

LangChain 버전/모델에 따라 콜백 방식은 다를 수 있지만, 핵심은 아래 두 가지입니다.

  • 매 호출의 사용량을 수집
  • 누적이 임계치를 넘으면 예외로 중단
class BudgetExceeded(Exception):
    pass

class TokenBudget:
    def __init__(self, max_total_tokens: int):
        self.max_total_tokens = max_total_tokens
        self.total_tokens = 0

    def add(self, prompt_tokens: int, completion_tokens: int):
        self.total_tokens += (prompt_tokens + completion_tokens)
        if self.total_tokens > self.max_total_tokens:
            raise BudgetExceeded(f"token budget exceeded: {self.total_tokens}")

운영 팁:

  • “사용자 1회 요청” 예산과 “세션 1시간” 예산을 분리하면, 악성 반복 요청을 더 잘 막습니다.
  • 결제/크레딧 이슈로 API가 실패할 때는 재시도가 비용폭탄은 아니지만 장애가 길어질 수 있습니다. 결제 관련 오류는 별도로 분기 처리하는 것이 좋습니다. 필요하면 OpenAI Responses API 402 결제·크레딧 오류 해결도 함께 점검하세요.

4) 반복 감지: 동일 툴·동일 입력이 연속되면 차단

무한루프의 70%는 “같은 툴을 같은 파라미터로 계속 호출”하는 형태입니다. 따라서 최근 N회 호출 히스토리를 보고 반복을 감지하면 효과가 큽니다.

import json
import hashlib
from collections import deque

class RepeatGuard:
    def __init__(self, window=6, max_same=3):
        self.window = window
        self.max_same = max_same
        self.recent = deque(maxlen=window)

    def _sig(self, tool_name: str, tool_input) -> str:
        raw = json.dumps({"tool": tool_name, "input": tool_input}, sort_keys=True, ensure_ascii=False)
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def check(self, tool_name: str, tool_input):
        sig = self._sig(tool_name, tool_input)
        self.recent.append(sig)
        if self.recent.count(sig) >= self.max_same:
            raise RuntimeError("repeat detected: same tool call repeated")

적용 포인트:

  • 툴 실행 직전에 check를 호출
  • 반복 감지 시 “다른 전략을 사용하라”는 시스템 메시지를 추가해 한 번만 더 기회를 주거나, 바로 중단

5) 툴 게이팅: 허용 목록·쿨다운·쿼터로 폭주를 막기

에이전트는 “할 수 있는 일”이 많을수록 폭주하기 쉽습니다. 특히 검색, 브라우징, 외부 API, DB 조회 같은 툴은 비용과 지연이 큽니다.

실무에서는 다음 3가지를 조합합니다.

  • Allowlist: 특정 요청 유형에서는 특정 툴만 허용
  • Cooldown: 동일 툴 연속 호출 최소 간격
  • Quota: 툴별 최대 호출 횟수
import time

class ToolGate:
    def __init__(self, per_tool_quota=None, cooldown_sec=0.0):
        self.per_tool_quota = per_tool_quota or {}
        self.cooldown_sec = cooldown_sec
        self.count = {}
        self.last_called_at = {}

    def allow(self, tool_name: str):
        now = time.time()
        if tool_name in self.last_called_at:
            if now - self.last_called_at[tool_name] < self.cooldown_sec:
                raise RuntimeError("tool cooldown")
        self.last_called_at[tool_name] = now

        self.count[tool_name] = self.count.get(tool_name, 0) + 1
        quota = self.per_tool_quota.get(tool_name)
        if quota is not None and self.count[tool_name] > quota:
            raise RuntimeError("tool quota exceeded")

이 방식은 “에이전트가 잘못 생각하는 것”을 고치는 게 아니라, “잘못 생각해도 폭발하지 않게” 만드는 안전벨트입니다.

6) 실패 분류: 재시도 가능한 실패와 불가능한 실패를 분리

에이전트가 툴을 반복 호출하는 이유는, 실패했을 때 “왜 실패했는지”를 모르기 때문입니다. 따라서 툴은 실패를 구조화된 에러로 반환해야 합니다.

권장 패턴:

  • retryable 여부를 포함
  • reason을 짧고 명확하게
  • hint로 다음 행동을 유도
def tool_error(retryable: bool, reason: str, hint: str = ""):
    return {
        "ok": False,
        "retryable": retryable,
        "reason": reason,
        "hint": hint,
    }

# 예: 인증 실패는 재시도 불가
return tool_error(False, "AUTH_FAILED", "API 키 또는 권한을 확인하세요")

# 예: 429는 재시도 가능
return tool_error(True, "RATE_LIMIT", "지수 백오프로 재시도하세요")

특히 429는 에이전트가 “조금만 더”를 반복하며 비용을 키우는 대표 케이스입니다. 재시도·백오프·큐로 흡수하는 설계는 OpenAI 429/RateLimitError 재시도·백오프·큐 설계에서 더 깊게 다룹니다.

7) 프롬프트에 종료 규약을 박아 넣기: 출력 계약을 강제

모델이 끝낼 수 있게 “종료 규약”을 명시해야 합니다. 예:

  • 완료 시 반드시 FINAL 섹션으로 답변
  • 정보가 부족하면 질문 1~3개만 하고 멈춤
  • 툴 호출은 최대 N회

중요한 점은 “권고”가 아니라 “계약”으로 쓰는 것입니다.

시스템 규약:
- 목표를 달성했거나 추가 정보가 필요하면 즉시 종료한다.
- 추가 정보가 필요할 때는 질문을 최대 3개만 하고, 그 외의 추측으로 진행하지 않는다.
- 툴 호출은 전체 실행 동안 최대 5회까지만 허용한다.
- 최종 답변은 반드시 FINAL 섹션에만 작성한다.

이 규약은 1) 스텝 상한, 3) 예산 상한과 함께 작동할 때 효과가 커집니다.

8) 컨텍스트 누수 차단: 메모리 요약·슬라이딩 윈도우로 토큰 폭증 방지

비용폭탄은 루프가 없어도 발생합니다. 대화 메모리를 무제한으로 쌓으면 프롬프트 토큰이 계속 증가합니다.

해결은 단순합니다.

  • 최근 N턴만 유지하는 슬라이딩 윈도우
  • 오래된 내용은 요약해서 유지
  • 툴 결과는 원문 전체를 넣지 말고 필요한 필드만 추려 넣기
# 개념 예시: 최근 N개 메시지만 유지
class SlidingWindowMemory:
    def __init__(self, max_messages=12):
        self.max_messages = max_messages
        self.messages = []

    def add(self, role, content):
        self.messages.append({"role": role, "content": content})
        if len(self.messages) > self.max_messages:
            self.messages = self.messages[-self.max_messages:]

실무 팁:

  • 검색 결과를 그대로 붙이지 말고 title, url, 핵심 bullet 3개 정도로 정규화하세요.
  • RAG를 쓰더라도 “한 번에 너무 많은 문서”를 넣으면 모델이 결론을 못 내리고 추가 검색을 반복할 수 있습니다.

9) 관측성: 스텝/툴/토큰/비용을 한 요청 단위로 트레이싱

막는 것만큼 중요한 게 “어디서 새는지”를 보는 것입니다. 다음 4가지는 최소로 남겨야 합니다.

  • 스텝 번호, 선택된 행동
  • 툴 이름, 입력 크기, 결과 크기, 성공 여부
  • 모델 호출별 토큰(프롬프트/완성)
  • 총 소요 시간과 총 비용(추정치라도)
import time

class Trace:
    def __init__(self):
        self.events = []
        self.started = time.time()

    def log(self, kind: str, data: dict):
        self.events.append({
            "t": time.time(),
            "kind": kind,
            "data": data,
        })

    def summary(self):
        return {
            "elapsed_sec": round(time.time() - self.started, 3),
            "events": self.events,
        }

이벤트를 쌓아두면, 반복 감지(4번)와 툴 게이팅(5번)을 “감”이 아니라 “근거”로 튜닝할 수 있습니다.

또한 운영 환경에서는 레이트리밋과 결합해야 합니다. 에이전트는 사용자 1명이 아니라 “에이전트가 외부를 두드리는 빈도”가 문제이므로, 서버 단에서 제한을 걸어야 합니다. Spring 기반이라면 Spring Boot 3에서 429 폭증 - RateLimiter 실전처럼 애플리케이션 레벨 레이트리밋을 함께 적용하는 것이 안전합니다.

실전 적용 순서 체크리스트

아래 순서로 적용하면, 큰 부작용 없이 빠르게 안정화됩니다.

  1. max_iterations 설정 (1번)
  2. 요청 전체 타임아웃 + 툴 타임아웃 (2번)
  3. 토큰 또는 비용 상한 (3번)
  4. 반복 감지 가드 (4번)
  5. 툴별 쿼터/쿨다운 (5번)
  6. 툴 에러 구조화 + 재시도 정책 분리 (6번)
  7. 종료 규약을 시스템 프롬프트에 계약으로 명시 (7번)
  8. 메모리 슬라이딩/요약으로 컨텍스트 다이어트 (8번)
  9. 트레이싱으로 병목과 폭주 지점 가시화 (9번)

마무리: 에이전트는 “똑똑함”보다 “가드레일”이 먼저다

LangChain 에이전트의 무한루프와 비용폭탄은 대부분 모델 성능 문제가 아니라, 종료 조건·예산·재시도·관측성 같은 시스템 설계 부재에서 시작합니다. 위 9가지를 넣으면 에이전트가 실수하더라도 폭발 반경을 제한할 수 있고, 무엇보다 운영 중 튜닝이 가능한 상태가 됩니다.

다음 단계로는 “요청 유형별 정책 분리(툴 allowlist, 예산 차등)”와 “큐 기반 비동기 실행(긴 작업 분리)”까지 확장하면, 비용과 안정성이 한 단계 더 좋아집니다.