- Published on
LangChain Agent 무한루프·토큰폭탄 차단 5팁
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LangChain Agent를 돌리다 보면 가장 흔한 장애가 두 가지입니다. 첫째는 에이전트가 같은 생각을 반복하며 빠져나오지 못하는 무한 루프, 둘째는 대화/툴 결과를 계속 누적해 컨텍스트가 커지면서 비용과 지연이 폭증하는 토큰폭탄입니다. 둘 다 “모델이 똑똑하면 알아서 멈추겠지”로는 절대 해결되지 않습니다.
이 글은 LangChain 기반 에이전트에 가드레일(guardrails) 을 붙여, 루프와 토큰폭탄을 구조적으로 차단하는 5가지 팁을 정리합니다. 예시는 Python 기준이며, 개념은 JS/TS에도 그대로 적용됩니다.
운영 관점에서 보면 이 문제는 DB 커넥션 풀 고갈처럼 “언젠가 터지는 자원 고갈” 패턴과 유사합니다. 원인과 방어선을 미리 설계해야 합니다. 관련해서는 Spring Boot HikariCP 커넥션 고갈 원인과 해결 글의 사고 방식(상한선, 타임아웃, 격리)이 그대로 통합니다.
1) 반복 탐지: 동일 행동 패턴을 조기 종료
무한 루프는 대개 아래 형태로 나타납니다.
- 같은 툴을 같은 인자로 반복 호출
- 관찰 결과가 변하지 않는데도 같은 추론을 반복
- 실패 메시지를 읽고도 동일한 재시도
가장 효과적인 방법은 행동 시그니처(action signature) 를 만들고, 최근 N회 중 동일 시그니처가 K회 이상 나오면 강제 종료하는 것입니다.
import hashlib
import json
from collections import deque
class LoopDetector:
def __init__(self, window=8, threshold=3):
self.window = window
self.threshold = threshold
self.recent = deque(maxlen=window)
def _sig(self, tool_name: str, tool_input: dict) -> str:
payload = json.dumps({"tool": tool_name, "input": tool_input}, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def check_and_record(self, tool_name: str, tool_input: dict) -> None:
sig = self._sig(tool_name, tool_input)
self.recent.append(sig)
if self.recent.count(sig) >= self.threshold:
raise RuntimeError("Loop detected: repeated tool call pattern")
이 로직을 툴 실행 직전에 넣으면, 모델이 같은 요청을 반복하는 순간 빠르게 끊을 수 있습니다. 중요한 포인트는 “생각(Chain-of-Thought)”을 검사하는 게 아니라, 실제 외부 효과를 내는 행동(툴 호출)을 기준으로 잡는 것입니다.
운영 팁
- threshold는 2로 두면 정상 플로우도 끊길 수 있어 3부터 추천합니다.
- window는 6~10 사이가 무난합니다.
2) 스텝/툴 호출 상한선: 에이전트에 예산을 부여
루프를 탐지해 끊는 것만으로는 부족합니다. 루프가 아니더라도 “끝없이 탐색”하는 에이전트가 있습니다. 따라서 에이전트 실행에 명시적 예산을 부여해야 합니다.
- 최대 스텝 수
- 최대 툴 호출 수
- 최대 누적 토큰(대략치)
LangChain은 실행 옵션으로 반복 제한을 두는 패턴이 많습니다. 프레임워크 버전별로 API가 달라질 수 있으니, 핵심은 “에이전트 루프를 도는 while에 카운터를 둔다”입니다.
import time
class Budget:
def __init__(self, max_steps=12, max_tool_calls=8, max_seconds=25):
self.max_steps = max_steps
self.max_tool_calls = max_tool_calls
self.max_seconds = max_seconds
self.steps = 0
self.tool_calls = 0
self.started = time.time()
def step(self):
self.steps += 1
if self.steps > self.max_steps:
raise TimeoutError("Budget exceeded: max steps")
if time.time() - self.started > self.max_seconds:
raise TimeoutError("Budget exceeded: max seconds")
def tool_call(self):
self.tool_calls += 1
if self.tool_calls > self.max_tool_calls:
raise TimeoutError("Budget exceeded: max tool calls")
왜 상한선이 필수인가
- 비용은 “최악의 경우”를 기준으로 책정됩니다.
- 상한선이 없으면 악성 입력(프롬프트 인젝션 포함)이 곧바로 비용 공격 벡터가 됩니다.
레이트 리밋과 재시도 설계도 함께 고려해야 합니다. 재시도는 루프를 더 악화시키는 요인이기 때문입니다. 관련해서는 OpenAI API 429 Rate Limit 재시도·백오프 설계 의 백오프/상한 개념을 에이전트에도 동일하게 적용하세요.
3) 컨텍스트 다이어트: 메시지/관찰을 “요약 가능한 상태”로 축소
토큰폭탄의 핵심 원인은 단순합니다.
- 툴 결과(HTML, 로그, JSON)를 그대로 메시지에 붙임
- 매 스텝마다 이전 메시지를 전부 다시 넣음
- 에이전트가 생성한 장문의 중간 결과가 누적됨
해결책은 “대화를 기록하되, 모델 입력에는 최소 상태만 넣는” 것입니다. 즉, 메모리(저장) 와 컨텍스트(입력) 를 분리하세요.
패턴 A: 툴 결과를 원문이 아니라 요약/스키마로 저장
def truncate(text: str, limit: int = 2000) -> str:
if len(text) <= limit:
return text
return text[:limit] + "\n[TRUNCATED]"
def normalize_tool_output(output: str) -> dict:
# 운영에서는 JSON schema로 정규화하는 편이 더 안전합니다.
return {
"preview": truncate(output, 1200),
"length": len(output),
}
모델에는 preview만 주고, 원문은 스토리지(S3, DB, 캐시)에 별도로 저장한 뒤 링크나 키만 전달하는 방식이 안정적입니다.
패턴 B: 스텝마다 “상태 요약”을 갱신
에이전트가 문제를 푸는 데 필요한 것은 대부분 아래 두 가지입니다.
- 현재까지 확정된 사실
- 앞으로 해야 할 일 목록
이를 별도 상태로 유지하고, 매 스텝 입력에는 상태 요약만 포함합니다.
from dataclasses import dataclass, field
@dataclass
class AgentState:
facts: list[str] = field(default_factory=list)
todos: list[str] = field(default_factory=list)
last_tool_result_preview: str | None = None
def to_prompt_block(self) -> str:
facts = "\n".join(f"- {x}" for x in self.facts[-10:])
todos = "\n".join(f"- {x}" for x in self.todos[-10:])
return (
"[STATE]\n"
f"Facts:\n{facts}\n\n"
f"Todos:\n{todos}\n\n"
f"LastResultPreview:\n{self.last_tool_result_preview or ''}\n"
)
이 방식은 토큰을 줄일 뿐 아니라, 모델이 “이전 장문의 잡음”에 끌려 다니는 문제도 줄입니다.
4) 타임아웃과 격리: 툴이 느리면 에이전트도 망가진다
무한 루프처럼 보이지만 실제로는 “툴 지연” 때문에 같은 판단을 반복하는 경우가 많습니다.
- 검색 API가 느려서 결과가 늦게 오거나 실패
- 크롤러가 특정 페이지에서 멈춤
- DB/외부 서비스가 타임아웃 직전까지 지연
따라서 툴 호출에는 반드시 타임아웃, 서킷 브레이커, 동시성 상한을 둬야 합니다.
import asyncio
class ToolTimeout(Exception):
pass
async def run_tool_with_timeout(tool_coro, timeout_s: float = 8.0):
try:
return await asyncio.wait_for(tool_coro, timeout=timeout_s)
except asyncio.TimeoutError as e:
raise ToolTimeout("Tool call timed out") from e
격리 팁
- 툴별 타임아웃을 다르게: 검색 5초, DB 2초, 대용량 분석 15초 등
- 실패 시 에이전트에게 “재시도 말고 다른 전략”을 선택하게 유도: 예를 들어 캐시 사용, 더 좁은 쿼리, 사용자에게 추가 질문
프런트엔드 성능에서 Long Task가 전체 UX를 망치듯, 에이전트에서도 느린 툴 하나가 전체 실행을 망칩니다. 문제를 추적/분해하는 관점은 Chrome INP 200ms↓ - Long Task 추적·해결 의 접근(측정, 병목 분리, 상한선)이 참고됩니다.
5) 실패를 설계: 안전한 중단 메시지와 재시도 정책
무한 루프/토큰폭탄을 막는 마지막 퍼즐은 “중단했을 때 사용자 경험”입니다. 강제 종료는 필수지만, 그냥 에러를 던지면 제품이 불안정해 보입니다.
권장하는 실패 설계는 아래 순서입니다.
- 중단 사유를 내부적으로는 상세히 로깅
- 사용자에게는 짧고 실행 가능한 안내 제공
- 필요하면 “부분 결과”를 반환
- 재시도는 자동이 아니라 조건부
예시: 중단 시 반환 템플릿
class AgentAbort(Exception):
def __init__(self, user_message: str, debug_reason: str):
super().__init__(debug_reason)
self.user_message = user_message
self.debug_reason = debug_reason
def abort_with_partial(partial: str, reason: str) -> dict:
return {
"status": "aborted",
"message": "작업이 길어져 중단했습니다. 아래는 현재까지의 결과입니다.",
"partial_result": partial,
"reason": reason,
}
재시도 정책 체크리스트
- 429나 네트워크 오류는 제한된 횟수만 재시도
- 동일 입력으로 동일 툴을 반복 호출하는 재시도는 금지
- 재시도 시에는 컨텍스트를 줄이고(요약 상태만), 파라미터를 변경
실전 체크리스트: 5팁을 한 번에 묶기
아래는 운영에서 바로 적용 가능한 “최소 가드레일 세트”입니다.
- 반복 탐지: 최근 8회 중 동일 툴 호출 3회면 중단
- 예산: 최대 12스텝, 툴 8회, 25초
- 컨텍스트 다이어트: 툴 결과는 preview와 메타만, 원문은 외부 저장
- 타임아웃: 툴별 타임아웃 + 실패 시 대체 전략
- 실패 설계: 부분 결과 반환 + 조건부 재시도
async def agent_loop(agent, tools, state: AgentState):
budget = Budget(max_steps=12, max_tool_calls=8, max_seconds=25)
loop_detector = LoopDetector(window=8, threshold=3)
partial = ""
while True:
budget.step()
prompt = state.to_prompt_block() + "\n[USER]\n" + agent.user_input
action = await agent.plan(prompt) # tool_name, tool_input, or final
if action.get("final"):
return {"status": "ok", "result": action["final"]}
tool_name = action["tool"]
tool_input = action.get("input", {})
loop_detector.check_and_record(tool_name, tool_input)
budget.tool_call()
tool = tools[tool_name]
try:
raw = await run_tool_with_timeout(tool(**tool_input), timeout_s=8.0)
except Exception as e:
# 실패를 모델에게 장문으로 주지 말고, 짧은 상태로 반영
state.last_tool_result_preview = f"ToolError: {type(e).__name__}"
state.todos.append("다른 접근(쿼리 축소/캐시/추가 질문) 시도")
continue
normalized = normalize_tool_output(str(raw))
state.last_tool_result_preview = normalized["preview"]
partial = state.last_tool_result_preview
# 필요하면 facts/todos를 갱신하는 별도 로직을 둡니다.
state.facts.append(f"Used tool {tool_name}, got {normalized['length']} chars")
마무리
LangChain Agent의 무한 루프와 토큰폭탄은 “모델 품질” 문제가 아니라 “시스템 설계” 문제입니다. 반복 탐지, 예산 상한, 컨텍스트 축소, 타임아웃, 실패 설계를 기본값으로 깔아두면 운영 안정성이 급격히 올라갑니다.
다음 단계로는 (1) 툴별 성공률/지연/토큰 사용량을 메트릭으로 수집하고, (2) 자주 터지는 입력 패턴을 회귀 테스트로 고정하며, (3) 프롬프트 인젝션 대응(툴 권한 분리, allowlist)을 추가하면 실제 서비스 수준으로 올라갑니다.