- Published on
LangChain 에이전트 무한루프·비용폭탄 9가지 차단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 붙인 LangChain 에이전트가 갑자기 응답을 못 끝내고 같은 행동을 반복하거나, 툴 호출을 과도하게 반복해 비용이 폭증하는 경우가 있습니다. 특히 에이전트는 LLM 출력이 다음 스텝의 입력이 되기 때문에, 작은 프롬프트 실수나 툴 스키마 문제도 연쇄적으로 커집니다.
이 글은 “왜 무한루프가 생기는지”를 원인별로 쪼개고, “코드 레벨에서 어떻게 막는지”를 9가지 차단법으로 정리합니다. 예시는 LangChain 기반이지만, 에이전트 전반에 그대로 적용 가능합니다.
무한루프·비용폭탄이 생기는 전형적 패턴
에이전트 루프가 생기는 패턴은 크게 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 실전처럼 애플리케이션 레벨 레이트리밋을 함께 적용하는 것이 안전합니다.
실전 적용 순서 체크리스트
아래 순서로 적용하면, 큰 부작용 없이 빠르게 안정화됩니다.
max_iterations설정 (1번)- 요청 전체 타임아웃 + 툴 타임아웃 (2번)
- 토큰 또는 비용 상한 (3번)
- 반복 감지 가드 (4번)
- 툴별 쿼터/쿨다운 (5번)
- 툴 에러 구조화 + 재시도 정책 분리 (6번)
- 종료 규약을 시스템 프롬프트에 계약으로 명시 (7번)
- 메모리 슬라이딩/요약으로 컨텍스트 다이어트 (8번)
- 트레이싱으로 병목과 폭주 지점 가시화 (9번)
마무리: 에이전트는 “똑똑함”보다 “가드레일”이 먼저다
LangChain 에이전트의 무한루프와 비용폭탄은 대부분 모델 성능 문제가 아니라, 종료 조건·예산·재시도·관측성 같은 시스템 설계 부재에서 시작합니다. 위 9가지를 넣으면 에이전트가 실수하더라도 폭발 반경을 제한할 수 있고, 무엇보다 운영 중 튜닝이 가능한 상태가 됩니다.
다음 단계로는 “요청 유형별 정책 분리(툴 allowlist, 예산 차등)”와 “큐 기반 비동기 실행(긴 작업 분리)”까지 확장하면, 비용과 안정성이 한 단계 더 좋아집니다.