- Published on
LangChain 에이전트 무한루프·툴폭주 9가지 차단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
에이전트를 붙이면 자동화가 빨라지지만, 동시에 가장 흔한 장애가 생깁니다. 바로 무한 루프(같은 생각을 반복)와 툴 폭주(불필요한 외부 호출 난사)입니다. 특히 LangChain 계열 에이전트는 LLM의 추론 결과를 그대로 다음 액션으로 연결하기 때문에, 작은 프롬프트 결함이나 툴 스키마 모호함이 곧바로 비용 폭발과 장애로 이어집니다.
이 글에서는 운영 환경에서 바로 적용 가능한 9가지 차단법을 정리합니다. 핵심은 단순히 max_iterations 같은 한 줄 옵션이 아니라, 종료 조건 설계, 예산(비용/시간/호출) 제약, 상태/메모리 관리, 관측성을 함께 묶어 방어선을 여러 겹으로 만드는 것입니다.
아울러 외부 API 호출이 많은 에이전트라면 레이트 리밋도 함께 고려해야 합니다. 대량 호출이 생겼을 때의 백오프·큐잉은 아래 글도 참고하세요.
- OpenAI 429·Rate Limit - 백오프·큐잉 실전
- 메모리 폭주를 같이 겪는다면: AutoGPT 메모리 폭주? 벡터DB TTL로 안정화
1) 하드 스톱: 반복 횟수·시간·토큰 예산을 동시에 걸기
무한 루프 차단의 1차 방어선은 하드 리밋입니다. 반복 횟수만 제한하면, 한 번의 스텝에서 긴 응답을 생성하거나 툴을 연쇄 호출해도 막지 못합니다. 따라서 최소 3가지를 같이 둡니다.
- 반복 횟수(
max_iterations) - 벽시계 시간(
timeout) - 비용/토큰 예산(대략치라도)
import time
from dataclasses import dataclass
@dataclass
class Budget:
max_steps: int = 8
max_seconds: int = 30
max_tool_calls: int = 12
class BudgetGuard:
def __init__(self, budget: Budget):
self.budget = budget
self.start = time.time()
self.steps = 0
self.tool_calls = 0
def on_step(self):
self.steps += 1
self._check()
def on_tool_call(self):
self.tool_calls += 1
self._check()
def _check(self):
if self.steps > self.budget.max_steps:
raise RuntimeError("budget exceeded: max_steps")
if self.tool_calls > self.budget.max_tool_calls:
raise RuntimeError("budget exceeded: max_tool_calls")
if time.time() - self.start > self.budget.max_seconds:
raise RuntimeError("budget exceeded: max_seconds")
운영에서는 여기서 한 단계 더 나아가 요청 단위 예산과 계정 단위 예산을 분리합니다. 요청이 정상이어도, 전체 트래픽이 몰리면 툴 호출이 폭주할 수 있기 때문입니다.
2) 소프트 스톱: 종료 조건을 “명시적으로” 모델에게 강제하기
하드 리밋은 최후의 안전장치입니다. 그 전에 정상 종료를 유도해야 비용과 지연이 줄어듭니다.
종료 조건은 추상적으로 쓰면 실패합니다.
- 나쁜 예: "충분하면 멈춰"
- 좋은 예: "아래 JSON의
final_answer를 채우면 종료" 또는 "DONE문자열을 출력하면 종료"
또한 종료 조건은 툴 결과를 기준으로 설계해야 합니다. 예를 들어 검색 툴을 쓰는 에이전트라면, n개의 출처를 확보하면 종료하도록 규칙을 둡니다.
규칙:
- 출처 URL 2개 이상 확보 시 추가 검색 금지
- 최종 출력은 JSON 한 번만
- JSON에 final_answer 가 채워지면 즉시 종료
LangChain을 쓸 때도 프롬프트/시스템 메시지에 이 규칙을 넣고, 파서가 깨지면 즉시 실패 처리하는 편이 루프보다 낫습니다.
3) 툴 스키마를 “결정 가능”하게 만들기: 모호함이 루프를 만든다
툴 폭주의 대표 원인은 툴 입력 스키마가 모호해서 LLM이 계속 시도하는 경우입니다.
- 필수 필드가 불명확
- 에러 메시지가 "invalid" 같이 뭉뚱그려짐
- 결과가 비결정적(매번 다른 포맷)
해결책은 간단합니다.
- 입력 스키마를 엄격히(필수/범위/패턴)
- 에러를 구조화(어떤 필드가 왜 틀렸는지)
- 결과를 고정 포맷(JSON)으로
from pydantic import BaseModel, Field, HttpUrl
class FetchArgs(BaseModel):
url: HttpUrl
timeout_seconds: int = Field(default=10, ge=1, le=30)
class FetchResult(BaseModel):
ok: bool
status_code: int
content: str | None = None
error_code: str | None = None
error_message: str | None = None
툴이 실패하면 LLM이 "다시 해볼게"를 반복하기 쉽습니다. 따라서 error_code 를 세분화해 재시도 가능한 실패인지(예: TEMPORARY_NETWORK) 즉시 판정하도록 만듭니다.
4) 재시도 정책을 툴 레벨로 내리기: 에이전트가 재시도하면 폭주한다
에이전트가 재시도를 판단하면 이런 패턴이 나옵니다.
- 같은 툴을 같은 입력으로 10번 호출
- 레이트 리밋에서 더 빠르게 죽음
- "조금만 바꿔서" 호출하며 외부 시스템을 흔듦
재시도는 툴 래퍼에서 통제하세요.
- 지수 백오프
- 최대 재시도 횟수
retryable에러만 재시도
import time
import random
RETRYABLE = {"TEMPORARY_NETWORK", "RATE_LIMIT"}
def call_with_retry(fn, *, max_retries=3, base_delay=0.5):
for attempt in range(max_retries + 1):
res = fn()
if getattr(res, "ok", False):
return res
if getattr(res, "error_code", None) not in RETRYABLE:
return res
if attempt == max_retries:
return res
delay = base_delay * (2 ** attempt) + random.random() * 0.2
time.sleep(delay)
이렇게 하면 에이전트는 "실패했다"는 사실만 받아들이고, 재시도 폭주를 스스로 만들지 않습니다.
5) 중복 호출 차단: 동일 입력의 툴 호출은 캐시·디듀프로 막기
루프 상황에서는 동일한 툴에 동일한 인자를 반복해서 넣는 경우가 많습니다. 이때는 캐시가 가장 즉효입니다.
- in-memory 캐시(요청 단위)
- Redis 같은 공유 캐시(프로세스 간)
- 디듀프 키:
tool_name + stable_json(args)
import json
import hashlib
class ToolDeduper:
def __init__(self):
self.cache = {}
def key(self, tool_name: str, args: dict) -> str:
payload = json.dumps(args, sort_keys=True, ensure_ascii=False)
return tool_name + ":" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
def call(self, tool_name: str, args: dict, fn):
k = self.key(tool_name, args)
if k in self.cache:
return self.cache[k]
out = fn(args)
self.cache[k] = out
return out
주의할 점은 캐시 TTL입니다. 검색/가격/재고처럼 변동되는 데이터는 TTL을 짧게 두고, 문서 파싱처럼 안정적인 데이터는 길게 둡니다.
6) “상태 머신”으로 에이전트를 쪼개기: 자유도는 루프 확률을 올린다
툴이 많아질수록 LLM이 선택할 수 있는 행동 공간이 커지고, 루프 확률도 올라갑니다. 이를 줄이는 가장 강력한 방법은 상태 머신(단계형 플로우)입니다.
예시:
- 요구사항 정규화
- 정보 수집(최대 2회)
- 검증
- 최종 답변
각 상태에서 허용되는 툴을 제한하면, "검증 단계에서 검색을 또 한다" 같은 폭주가 줄어듭니다.
from enum import Enum
class State(Enum):
NORMALIZE = "normalize"
GATHER = "gather"
VERIFY = "verify"
FINAL = "final"
ALLOWED_TOOLS = {
State.NORMALIZE: {"clarify"},
State.GATHER: {"search", "fetch"},
State.VERIFY: {"fact_check"},
State.FINAL: set(),
}
def assert_tool_allowed(state: State, tool_name: str):
if tool_name not in ALLOWED_TOOLS[state]:
raise RuntimeError(f"tool not allowed in state: {state.value}")
LangChain에서도 그래프 기반 오케스트레이션(예: LangGraph 스타일)로 전환하면 이런 제약을 자연스럽게 걸 수 있습니다.
7) 관측성: 스텝·툴·비용을 로그로 남기고 “루프 시그널”을 탐지하기
무한 루프는 재현이 어렵습니다. 따라서 관측성이 없으면 같은 장애를 반복합니다.
최소한 아래를 남기세요.
- step 번호
- 선택한 tool 이름
- tool 입력 요약(민감정보 마스킹)
- tool 결과의
ok와error_code - 누적 tool 호출 수, 누적 시간
그리고 루프 시그널을 정의합니다.
- 같은 tool이 연속 3회 호출
- 동일 디듀프 키가 2회 이상
- 에러 코드가 동일하게 반복
from collections import deque
class LoopDetector:
def __init__(self, window=6):
self.tools = deque(maxlen=window)
def push(self, tool_name: str):
self.tools.append(tool_name)
if len(self.tools) >= 3 and list(self.tools)[-3:] == [tool_name]*3:
raise RuntimeError("loop detected: same tool 3 times")
이런 시그널은 알람으로도 연결할 수 있고, 자동으로 에이전트를 중단하고 사람에게 넘기는 트리거로도 쓸 수 있습니다.
8) 툴 결과를 “최종 근거”로 고정하기: LLM이 결과를 무시하면 다시 호출한다
툴이 성공했는데도 에이전트가 다시 호출하는 경우가 있습니다.
- 툴 결과가 길어서 요약 중 핵심이 유실
- 결과 포맷이 들쭉날쭉해 파싱 실패
- LLM이 결과를 신뢰하지 못하고 재확인
해결책:
- 툴 결과를 구조화하고, 핵심 필드만 모델에 제공
- "이 결과가 최신이며 신뢰 가능한 단일 소스"라고 시스템 규칙으로 고정
- 필요하면 결과에
source_of_truth=true같은 플래그를 포함
{
"ok": true,
"source_of_truth": true,
"data": {
"price": 12000,
"currency": "KRW",
"as_of": "2026-02-26"
}
}
모델이 불확실성을 느끼는 순간 검색/조회 툴을 다시 부르는 경향이 강하므로, "무엇을 신뢰해야 하는지"를 결과 설계로 해결하는 편이 효과적입니다.
9) 멱등성·세이프가드: 외부 사이드 이펙트 툴은 반드시 보호한다
가장 위험한 폭주는 "조회"가 아니라 "변경" 툴에서 발생합니다.
- 결제 생성
- 티켓 발행
- DB 업데이트
- 이메일 발송
이런 툴은 에이전트가 루프에 빠지면 실제 피해로 이어집니다. 따라서 아래 3가지를 기본값으로 둡니다.
- 멱등 키(
idempotency_key) 필수 - 드라이런(
dry_run=true) 기본 - 사람 승인(고위험 액션)
from pydantic import BaseModel, Field
class SendEmailArgs(BaseModel):
idempotency_key: str = Field(min_length=16)
to: str
subject: str
body: str
dry_run: bool = True
def send_email(args: SendEmailArgs):
if args.dry_run:
return {"ok": True, "sent": False, "reason": "dry_run"}
# 실제 발송 로직은 idempotency_key 기준으로 중복 방지
return {"ok": True, "sent": True}
이 패턴은 분산 트랜잭션에서 보상 트랜잭션 중복 실행을 막는 것과 유사한 철학입니다. 에이전트도 결국 "불완전한 자동화"이므로, 중복 실행 방지를 설계의 중심에 두는 게 안전합니다.
실전 체크리스트: 배포 전 10분 점검
max_iterations뿐 아니라 시간/툴 호출 예산이 있는가- 종료 조건이 출력 포맷과 연결돼 있는가
- 툴 입력/출력이 JSON으로 안정적으로 고정돼 있는가
- 재시도는 에이전트가 아니라 툴 래퍼에서 통제하는가
- 동일 입력 반복 호출 디듀프가 있는가
- 단계별 허용 툴이 제한돼 있는가(상태 머신)
- 스텝/툴/에러코드가 로그로 남는가
- 루프 시그널 탐지(동일 툴 연속 호출 등)가 있는가
- 사이드 이펙트 툴에 멱등 키와 드라이런이 있는가
- 레이트 리밋 대응(백오프·큐잉)이 준비돼 있는가
마무리: “LLM의 자유도”를 “시스템의 제약”으로 감싸라
LangChain 에이전트의 무한 루프와 툴 폭주는 모델이 멍청해서가 아니라, 시스템이 안전한 제약을 제공하지 않았기 때문인 경우가 대부분입니다. 하드 리밋으로 바닥을 깔고, 종료 조건을 명확히 하고, 툴을 결정 가능하게 만들고, 관측성으로 루프를 조기에 탐지하면 운영 난이도가 급격히 내려갑니다.
특히 외부 API 호출이 많은 환경에서는 폭주가 곧바로 429와 비용 폭발로 이어지니, 레이트 리밋 대응까지 한 묶음으로 설계하세요.