- Published on
LangChain 에이전트 루프 무한반복 감지·차단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
에이전트(Agent)는 도구 호출과 추론을 반복하며 목표를 달성합니다. 문제는 이 반복이 “유한한 탐색”이 아니라 “무한 루프”로 변질될 때입니다. 특히 LangChain 기반 서비스에서는 같은 툴을 계속 호출하거나, 관찰값이 조금씩만 바뀌며 종료 조건을 못 찾는 패턴이 자주 발생합니다. 비용(토큰·API 호출)과 지연시간이 폭발하고, 워커/큐가 막혀 연쇄 장애로 번질 수 있습니다.
이 글에서는 LangChain 에이전트 루프가 무한반복되는 대표 원인, 감지 신호, 그리고 코드 레벨에서 차단하는 방법을 정리합니다. 단순히 max_iterations 를 줄이는 수준을 넘어서, “반복을 의미적으로 감지”하고 “정책적으로 중단”하는 방법에 집중합니다.
또한 운영 관점에서 재시도(리트라이)와 결합되면 비용이 더 커질 수 있으니, 리트라이 중복 청구를 막는 패턴도 함께 참고하면 좋습니다: LangChain 리트라이 중복청구 막는 idempotency_key 실전
무한 루프가 생기는 전형적인 원인
1) 종료 조건이 프롬프트에 없다
에이전트가 “언제 멈춰야 하는지”를 모르거나, Final Answer 형식을 강제하지 않으면 도구 호출을 계속 시도합니다. 특히 “확신이 없으면 다시 검색해라” 같은 지시가 있으면 루프 확률이 올라갑니다.
2) 관찰(Observation)이 목표를 좁히지 못한다
검색 결과가 항상 비슷하거나, 툴이 에러를 반환하는데 에이전트가 이를 해결하지 못하면 같은 액션을 반복합니다.
예) HTTP 429 나 504 를 받았는데 “다시 호출”만 반복하는 패턴. 타임아웃 자체를 줄이거나 서버 측 원인을 먼저 잡는 것도 중요합니다: OpenAI Responses API 504 Timeout 재현·해결
3) 도구가 비결정적이거나 상태가 누적된다
같은 입력인데 출력이 조금씩 달라지면 “이번엔 될 것 같다”는 식으로 반복합니다. 또는 메모리/체크포인트가 누적되어 목표가 계속 바뀌는 경우도 있습니다.
4) 파서/라우터가 실패해 재시도 루프가 된다
도구 호출 JSON 파싱이 실패하거나, 라우팅이 애매해 동일 노드로 되돌아가는 그래프 구조(특히 LangGraph)에서 루프가 생깁니다.
감지 전략: “횟수 제한”만으로는 부족하다
무한 루프 차단은 보통 아래 3단계를 함께 둡니다.
- 하드 리미트: 반복 횟수, 시간, 토큰, 툴 호출 수
- 소프트 감지: 동일 액션 반복, 동일 입력 반복, 동일 관찰 반복, 에러 반복
- 정책적 중단: 사용자에게 질문으로 전환, 대체 플랜 실행, 안전 종료
하드 리미트만 두면, 루프를 “조금 덜 도는” 형태로 남겨 비용을 계속 발생시키거나, 중요한 정상 케이스까지 조기 중단시키는 문제가 생깁니다. 그래서 “의미 기반 반복 감지”가 핵심입니다.
차단법 1: max_iterations 와 max_execution_time 를 기본 안전장치로
LangChain 에이전트 실행 시 가장 먼저 적용해야 하는 것은 하드 리미트입니다. 아래 예시는 개념적으로 max_iterations 와 실행 시간 제한을 둡니다.
from langchain.agents import AgentExecutor
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
max_iterations=8,
max_execution_time=20, # seconds
early_stopping_method="force", # 또는 "generate"
)
result = agent_executor.invoke({"input": "요약해줘"})
early_stopping_method가force이면 강제 종료 메시지를 반환합니다.generate는 남은 컨텍스트로 “최종 답변을 만들어서” 종료하려고 시도합니다.
하지만 이것만으로는 “같은 툴을 8번 반복하고 끝” 같은 비용 낭비를 막기 어렵습니다. 다음 차단법이 필요합니다.
차단법 2: 동일 도구 호출 시그니처 반복 감지(가장 실전적)
에이전트 루프의 대부분은 “도구 이름 + 정규화된 입력” 조합이 반복됩니다. 따라서 툴 호출 이벤트를 가로채서 시그니처를 기록하고, 동일 시그니처가 일정 횟수 이상 반복되면 중단합니다.
핵심은 입력을 정규화하는 것입니다.
- 공백/줄바꿈 정리
- 키 순서 정렬(JSON)
- 불필요한 타임스탬프/nonce 제거
아래는 LangChain 콜백을 이용한 예시입니다.
import json
import time
from collections import defaultdict, deque
from langchain.callbacks.base import BaseCallbackHandler
def _normalize_tool_input(tool_input):
# 문자열/딕셔너리 모두 처리
if isinstance(tool_input, str):
s = " ".join(tool_input.split())
return s[:5000]
try:
return json.dumps(tool_input, ensure_ascii=False, sort_keys=True)[:5000]
except Exception:
return str(tool_input)[:5000]
class LoopGuardCallback(BaseCallbackHandler):
def __init__(self, *, max_same_call=3, window=10, max_errors=3, max_seconds=20):
self.max_same_call = max_same_call
self.window = window
self.max_errors = max_errors
self.max_seconds = max_seconds
self.start = time.time()
self.recent = deque(maxlen=window)
self.counts = defaultdict(int)
self.error_counts = defaultdict(int)
def on_tool_start(self, serialized, input_str, **kwargs):
if time.time() - self.start > self.max_seconds:
raise RuntimeError("LoopGuard: time budget exceeded")
tool_name = serialized.get("name", "unknown_tool")
sig = f"{tool_name}:{_normalize_tool_input(input_str)}"
self.recent.append(sig)
self.counts[sig] += 1
if self.counts[sig] >= self.max_same_call:
raise RuntimeError(f"LoopGuard: repeated tool call detected: {tool_name}")
def on_tool_error(self, error, **kwargs):
msg = str(error)
key = msg[:200]
self.error_counts[key] += 1
if self.error_counts[key] >= self.max_errors:
raise RuntimeError("LoopGuard: repeated tool error detected")
적용:
from langchain.agents import AgentExecutor
loop_guard = LoopGuardCallback(max_same_call=3, window=10, max_errors=2, max_seconds=25)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
callbacks=[loop_guard],
verbose=True,
max_iterations=20, # 하드 리미트는 넉넉히 두되
)
result = agent_executor.invoke({"input": "정책 문서에서 환불 규정 찾아줘"})
이 방식의 장점은 “정상적인 여러 단계 추론”은 허용하면서도, “같은 호출을 맹목적으로 반복”하는 경우를 빠르게 잘라낸다는 점입니다.
차단법 3: 관찰(Observation) 유사도 기반 반복 감지
도구 호출이 매번 조금씩 달라도, 관찰 결과가 사실상 동일하면 루프일 확률이 높습니다. 예를 들어 검색 결과가 매번 같은 상위 3개 문서로 돌아오는 경우가 대표적입니다.
간단한 방법은 관찰 텍스트를 해시로 요약해 최근 N개와 비교하는 것입니다.
import hashlib
from collections import deque
class ObservationStallGuard:
def __init__(self, max_same_obs=3, window=8):
self.max_same_obs = max_same_obs
self.window = window
self.obs_hashes = deque(maxlen=window)
def add_observation(self, text: str):
norm = " ".join((text or "").split())[:8000]
h = hashlib.sha256(norm.encode("utf-8")).hexdigest()
self.obs_hashes.append(h)
if self.obs_hashes.count(h) >= self.max_same_obs:
raise RuntimeError("LoopGuard: observation is not changing")
실제로는 콜백에서 on_tool_end 혹은 에이전트 스텝 종료 이벤트에서 관찰을 수집하면 됩니다. LangChain 버전별 이벤트 이름이 다를 수 있으니, 현재 사용 중인 실행기에서 제공하는 콜백 훅을 확인하세요.
차단법 4: “실패 유형”별 백오프·대체 플랜을 프롬프트에 내장
무한 루프는 “중단”만 하면 사용자 경험이 나빠집니다. 그래서 반복이 감지되면 다음 중 하나로 전환하는 정책이 필요합니다.
- 사용자에게 추가 정보 질문
- 다른 툴로 전환(예: 웹검색 실패 시 내부 DB 검색)
- 요약 수준을 낮추거나 범위를 좁힘
- 실패 사유와 함께 부분 결과를 반환
프롬프트에 아래처럼 명시하면 루프 확률이 눈에 띄게 줄어듭니다.
도구 호출이 2회 연속 같은 결과를 반환하거나, 같은 에러가 2회 반복되면
추가 도구 호출을 중단하고 사용자에게 필요한 추가 입력을 질문하라.
최종 응답은 반드시 "결론" 섹션을 포함해 종료하라.
주의할 점은, 이 문구만으로는 100퍼센트 보장되지 않습니다. 그래서 앞의 코드 레벨 가드와 함께 써야 합니다.
차단법 5: LangGraph 사용 시 “순환 간선”에 루프 브레이커 넣기
그래프 기반 에이전트는 노드 간 순환이 의도된 경우가 많습니다. 문제는 조건이 애매하면 같은 노드를 계속 왕복합니다.
권장 패턴:
- 상태(state)에
step_count와last_tool_signature를 저장 - 조건 분기에서
step_count가 임계치를 넘으면END또는human_input노드로 - 동일 시그니처 반복이면 다른 경로로 라우팅
의사코드 예시(개념):
# state 예시
state = {
"step_count": 0,
"last_sig": None,
"repeat_count": 0,
}
# 라우터
def route(state):
if state["step_count"] >= 10:
return "end"
if state["repeat_count"] >= 2:
return "ask_user"
return "continue"
이 구조는 “루프 자체는 허용하되, 위험한 루프만 끊는” 데 유리합니다.
운영 팁: 루프는 비용 폭탄이므로 관측 가능성부터 잡기
루프 차단은 디버깅이 아니라 운영 문제입니다. 아래를 로그/메트릭으로 반드시 남기세요.
- 요청 단위
trace_id - 에이전트 스텝 수, 툴 호출 수
- 툴 이름별 호출 횟수
- 반복 감지로 중단된 사유(같은 시그니처, 같은 에러, 시간 초과 등)
- 최종 종료 타입(정상 종료, 강제 종료, 사용자 질문 전환)
이런 “반복” 문제는 전통적인 동시성/데드락/리소스 누수 문제와 닮았습니다. 예를 들어 병렬 파이프라인이 특정 조건에서 진행을 못 하고 대기하는 상황처럼, 에이전트도 특정 상태에서 탈출을 못 합니다. 비슷한 진단 관점은 아래 글도 참고할 만합니다: Jenkins Declarative Pipeline 병렬 스테이지 데드락 해결
또한 루프가 길어질수록 워커가 오래 점유되어 큐가 막히고, 결국 프로세스가 재시작을 반복하는 형태로 관측될 수도 있습니다. 쿠버네티스 환경이라면 증상이 CrashLoopBackOff 로 보일 수 있으니, 인프라 쪽 신호도 함께 확인하세요: K8s CrashLoopBackOff 8가지 원인, 로그로 끝내기
실전 체크리스트
개발 단계
max_iterations,max_execution_time를 기본값으로 강제- 툴 입력/출력 정규화 후 시그니처 반복 감지
- 동일 에러 반복 감지 및 즉시 중단
- 프롬프트에 “반복 시 사용자 질문으로 전환” 규칙 포함
운영 단계
- 요청 단위 비용 상한(토큰/툴 호출) 설정
- 반복 중단 이벤트를 알람으로 연결(특정 비율 이상이면 회귀 버그)
- 리트라이 정책과 결합 시 중복 청구 방지(특히 결제형 API)
결론: 루프는 “정책 + 가드레일 + 관측”으로 끊는다
LangChain 에이전트의 무한반복은 모델이 멍청해서가 아니라, 종료 조건과 실패 처리 정책이 코드에 없기 때문에 발생하는 경우가 많습니다. 가장 효과적인 조합은 다음입니다.
- 하드 리미트로 최악을 막고
- 동일 도구 호출 시그니처 반복을 의미적으로 감지해 빠르게 차단하며
- 반복 시에는 사용자 질문/대체 플랜으로 전환하는 정책을 둔다
이 3가지를 넣으면 “가끔 루프 도는 에이전트”가 아니라 “루프를 스스로 멈추는 에이전트”에 가까워집니다.