- Published on
LangChain 에이전트 무한루프·툴폭주 차단 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
LangChain 에이전트는 생각-행동-관찰 루프를 통해 문제를 해결합니다. 하지만 운영 환경에서는 이 루프가 끝나지 않거나, 툴 호출이 폭주하거나, 같은 입력으로 같은 툴을 반복 호출하는 형태로 장애를 만들기 쉽습니다. 비용(토큰/툴 과금)과 지연이 급격히 늘고, 외부 API 레이트리밋까지 연쇄적으로 터집니다.
이 글은 “에이전트가 왜 멈추지 않는가”를 원인별로 쪼개고, 무한루프·툴폭주를 차단하는 7가지 가드레일을 코드 중심으로 정리합니다. 예시는 Python + LangChain(LCEL/Agents) 관점으로 설명하지만, 핵심은 어디에든 적용되는 실행 제어(Execution control) 입니다.
참고로 스트리밍 환경에서 중복 토큰/메모리 누수로 증상이 확대되는 경우도 많습니다. 관련 이슈는 LangChain 스트리밍 중복토큰·메모리누수 9분 해결도 함께 보세요.
1) 하드 리밋: max_iterations + max_execution_time를 기본값으로
무한루프 방지의 1차 방어막은 “무조건 끝내기”입니다. 에이전트가 논리적으로 수렴하지 않더라도, 운영에서는 유한 시간 안에 종료되어야 합니다.
핵심 포인트:
- 반복 횟수 상한:
max_iterations - 실행 시간 상한:
max_execution_time - 종료 시 사용자에게 “부분 결과 + 다음 액션”을 반환하도록 설계
import time
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a careful agent. Stop when you have enough."),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),
])
agent = create_tool_calling_agent(llm, tools=[], prompt=prompt)
executor = AgentExecutor(
agent=agent,
tools=[],
verbose=True,
max_iterations=6, # 반복 상한
max_execution_time=12.0, # 초 단위 시간 상한
early_stopping_method="force", # 강제 종료
)
result = executor.invoke({"input": "Solve X"})
print(result["output"])
운영 팁:
max_iterations는 “정상 케이스 평균 반복 + 여유”로 잡고, 초과 시 정상적인 실패 응답(fallback)으로 전환합니다.- 시간 상한은 외부 API 지연까지 감안해 설정합니다.
2) 소프트 리밋: “진전(progress) 없음” 감지로 조기 중단
반복 상한만으로는 비용을 줄이기 어렵습니다. 에이전트가 같은 결론을 맴돌거나, 관찰(Observation)이 바뀌지 않는데도 계속 툴을 호출하는 경우가 많기 때문입니다.
해결책은 진전이 없는 루프를 감지하는 것입니다.
- 최근
N개의 툴 호출 시그니처가 동일하면 중단 - 최근
N개의 관찰이 동일하면 중단 - “목표에 대한 새로운 정보가 추가되었는가”를 점수화해서 임계치 이하이면 중단
아래는 간단한 “툴 호출 시그니처 반복 감지” 예시입니다.
import json
import hashlib
from collections import deque
class LoopGuard:
def __init__(self, window=5, repeat_threshold=3):
self.window = window
self.repeat_threshold = repeat_threshold
self.recent = deque(maxlen=window)
def signature(self, tool_name: str, tool_args: dict) -> str:
payload = json.dumps({"name": tool_name, "args": tool_args}, sort_keys=True)
return hashlib.sha256(payload.encode()).hexdigest()
def seen_too_much(self, tool_name: str, tool_args: dict) -> bool:
sig = self.signature(tool_name, tool_args)
self.recent.append(sig)
return self.recent.count(sig) >= self.repeat_threshold
loop_guard = LoopGuard(window=6, repeat_threshold=2)
# tool 호출 직전
# if loop_guard.seen_too_much(name, args):
# raise RuntimeError("Loop detected: repeated tool call")
실전에서는 이 로직을 CallbackHandler나 툴 래퍼(wrapper)에서 적용해 “호출 직전”에 차단하는 방식이 가장 효과적입니다.
3) 툴 게이팅: allowlist, 호출당 쿼터, 쿨다운(cooldown)
툴폭주의 대부분은 “툴을 호출해도 된다”는 전제가 너무 느슨해서 발생합니다. 특히 웹 검색/브라우징/DB 조회 같은 툴은 한 번 열리면 연쇄 호출이 일어나기 쉽습니다.
권장 방어막 3종 세트:
- Allowlist: 특정 태스크에서 허용된 툴만 노출
- Quota: 툴별 호출 횟수 상한
- Cooldown: 같은 툴을 연속 호출 금지(예: 2초)
import time
from dataclasses import dataclass
@dataclass
class ToolPolicy:
max_calls: int
cooldown_sec: float
class ToolGate:
def __init__(self, policies: dict[str, ToolPolicy]):
self.policies = policies
self.calls = {k: 0 for k in policies.keys()}
self.last_called_at = {k: 0.0 for k in policies.keys()}
def check(self, tool_name: str):
if tool_name not in self.policies:
raise PermissionError(f"Tool not allowed: {tool_name}")
p = self.policies[tool_name]
now = time.time()
if self.calls[tool_name] >= p.max_calls:
raise RuntimeError(f"Tool quota exceeded: {tool_name}")
if now - self.last_called_at[tool_name] < p.cooldown_sec:
raise RuntimeError(f"Tool cooldown active: {tool_name}")
self.calls[tool_name] += 1
self.last_called_at[tool_name] = now
# 예: 검색은 최대 3회, 1초 쿨다운
# DB는 최대 2회, 쿨다운 없음
gate = ToolGate({
"web_search": ToolPolicy(max_calls=3, cooldown_sec=1.0),
"db_query": ToolPolicy(max_calls=2, cooldown_sec=0.0),
})
# tool wrapper 내부에서
# gate.check(tool_name)
# return tool.invoke(args)
운영 팁:
- “태스크 타입별 allowlist”를 두면 효과가 큽니다. 예: 고객지원 FAQ는 검색 툴만, 결제 문의는 내부 DB 툴만.
- 쿼터 초과 시에는 “추가 툴 호출 없이 요약/가정/다음 질문”으로 전환하는 프롬프트를 준비합니다.
4) 상태 머신으로 전환: ReAct 루프를 “단계형 파이프라인”으로 쪼개기
에이전트가 무한루프에 빠지는 근본 이유 중 하나는, 목표가 모호할 때 계속 탐색만 하며 종료 조건을 못 찾는 것입니다.
이때는 자유도가 큰 에이전트 대신, 다음처럼 상태 머신(단계형) 으로 바꿔 루프 공간을 줄이는 게 효과적입니다.
PLAN단계: 필요한 정보/툴을 제한적으로 계획EXECUTE단계: 계획에 있는 툴만 실행SYNTHESIZE단계: 결과 요약 후 종료
LangGraph를 쓰면 더 깔끔하지만, 여기서는 최소 형태의 단계 분리를 보여줍니다.
from enum import Enum
class Stage(str, Enum):
PLAN = "plan"
EXECUTE = "execute"
SYNTHESIZE = "synthesize"
state = {"stage": Stage.PLAN, "plan": None, "observations": []}
def plan_step(user_input: str):
# LLM에게 "툴은 최대 2개만" 같은 제약을 강제
return {"tools": ["web_search"], "queries": [user_input]}
def execute_step(plan: dict):
obs = []
for q in plan["queries"]:
# web_search(q)
obs.append({"q": q, "result": "..."})
return obs
def synthesize_step(user_input: str, observations: list[dict]):
# 관찰 기반으로 답변 생성 후 종료
return "final answer"
user_input = "..."
state["plan"] = plan_step(user_input)
state["stage"] = Stage.EXECUTE
state["observations"] = execute_step(state["plan"])
state["stage"] = Stage.SYNTHESIZE
final = synthesize_step(user_input, state["observations"])
print(final)
핵심은 “에이전트가 마음대로 다음 툴을 고르는 구조”를 줄이고, 다음 단계로 넘어가기 위한 조건을 코드로 명시하는 것입니다.
5) 툴 입력 검증: 스키마 강제 + JSON 깨짐 대비
툴폭주는 종종 “툴 입력이 깨져서 실패 → 다시 시도 → 또 실패”의 재시도 루프에서 시작합니다. 특히 툴콜 JSON이 깨지면, 모델이 스스로 복구를 시도하다가 반복 호출로 이어질 수 있습니다.
대응:
- 툴 입력은 Pydantic/Zod 같은 스키마로 강제
- 파싱 실패 시 즉시 실패시키고, 재시도는 “한 번만”
- 실패 원인을 관찰에 명확히 남겨 다음 루프에서 같은 실수를 하지 않게 함
툴콜 JSON 깨짐 케이스는 LangChain에서 OpenAI 툴콜 JSON 깨짐 9가지도 참고하면 좋습니다.
from pydantic import BaseModel, Field, ValidationError
class SearchArgs(BaseModel):
query: str = Field(min_length=2, max_length=200)
top_k: int = Field(default=5, ge=1, le=10)
def safe_web_search(raw_args: dict):
try:
args = SearchArgs.model_validate(raw_args)
except ValidationError as e:
# 재시도 루프를 만들지 말고 즉시 관찰로 남기고 종료/대체
raise ValueError(f"Invalid tool args: {e}")
# 실제 검색 수행
return {"items": [], "query": args.query, "top_k": args.top_k}
운영 팁:
- “파싱 실패 시 LLM에게 다시 물어보기”는 재시도 폭주를 유발할 수 있습니다. 재시도를 하더라도
1회로 제한하고, 그 다음은 사용자에게 추가 정보를 요청하는 흐름이 안전합니다.
6) 취소 전파: 타임아웃과 사용자 취소가 툴까지 전달되게
서버에서는 사용자가 요청을 끊었는데도 백그라운드에서 에이전트가 계속 돌며 툴을 호출하는 경우가 흔합니다. 이건 비용 폭주로 직결됩니다.
반드시 필요한 것:
- 요청 컨텍스트 취소 신호를 에이전트 실행과 툴 호출에 전파
- HTTP 클라이언트 타임아웃, DB 쿼리 타임아웃 설정
- 스트리밍 중단 시에도 실행을 중단
Python에서는 asyncio.wait_for 또는 HTTP 클라이언트의 timeout을 강제합니다.
import asyncio
async def run_with_timeout(coro, timeout_sec: float):
return await asyncio.wait_for(coro, timeout=timeout_sec)
# 예: 에이전트 실행을 10초로 제한
# result = await run_with_timeout(executor.ainvoke({"input": "..."}), 10.0)
운영 팁:
- 취소/타임아웃은 “에이전트만” 끊어서는 부족합니다. 실제 비용은 툴(검색/크롤링/DB/외부 API)에서 나가므로, 툴 레이어까지 취소가 전파되어야 합니다.
- 클라이언트 취소로 인한 충돌/취소 오류를 다룰 때는 OpenAI Responses API 409 499 충돌 취소 오류 해결 같은 패턴도 함께 참고할 만합니다.
7) 관측성: “루프 징후”를 로그/메트릭으로 먼저 잡아라
가드레일을 넣어도, 어떤 요청에서 왜 폭주했는지 모르면 재발합니다. 에이전트는 일반 API보다 상태가 복잡하므로 관측성(Observability) 이 필수입니다.
추천 지표:
- 요청당 툴 호출 수(총합, 툴별)
- 요청당 반복 횟수(iteration)
- 동일 툴 동일 인자 반복률
- “관찰 길이” 증가 추이(컨텍스트 비대화 감지)
- 종료 사유(정상 종료, 반복 상한, 시간 초과, 정책 차단)
LangChain 콜백으로 최소한의 로깅을 추가할 수 있습니다.
from langchain_core.callbacks import BaseCallbackHandler
class GuardrailLogger(BaseCallbackHandler):
def __init__(self):
self.tool_calls = []
def on_tool_start(self, serialized, input_str, **kwargs):
name = serialized.get("name") if isinstance(serialized, dict) else str(serialized)
self.tool_calls.append(name)
print(f"tool_start name={name} total_calls={len(self.tool_calls)}")
def on_agent_finish(self, finish, **kwargs):
print(f"agent_finish tool_calls={self.tool_calls}")
handler = GuardrailLogger()
executor = AgentExecutor(
agent=agent,
tools=[],
callbacks=[handler],
max_iterations=6,
max_execution_time=12.0,
)
운영 팁:
- “차단 이벤트”는 에러로만 남기지 말고, 별도의 이벤트로 집계하세요. 예:
tool_quota_exceeded,loop_detected,timeout. - 루프가 발생하면 보통 특정 툴/특정 프롬프트/특정 사용자 입력 패턴에 몰립니다. 상위
N개를 뽑아 프롬프트와 정책을 조정하는 것이 가장 빠른 개선 루트입니다.
실전 체크리스트(요약)
max_iterations,max_execution_time는 기본값으로 켠다.- “진전 없음”을 감지해 조기 중단한다(반복 시그니처/관찰 동일성).
- 툴은 allowlist + quota + cooldown으로 게이팅한다.
- 자유 루프가 필요 없으면 상태 머신(단계형)으로 바꾼다.
- 툴 입력은 스키마로 검증하고, 파싱 실패 재시도는 1회 이하로 제한한다.
- 취소/타임아웃을 툴까지 전파한다.
- 툴 호출 수, 반복률, 종료 사유를 메트릭으로 남긴다.
마무리: “에이전트는 제품 기능”이 아니라 “제어 시스템”이다
LangChain 에이전트는 똑똑한 자동화처럼 보이지만, 운영 관점에서는 제어되지 않으면 언제든 발산하는 시스템입니다. 위 7가지는 모델 성능과 무관하게, 비용과 안정성을 지키는 최소한의 안전장치입니다.
다음 단계로는 (1) 태스크별 정책 템플릿화, (2) 실패 케이스 리플레이 테스트, (3) 프롬프트/툴 스키마의 계약 테스트를 붙이면 “가끔 폭주하는 에이전트”를 “예측 가능한 서비스”로 바꿀 수 있습니다.