- Published on
LangChain 에이전트 루프 폭주? 토큰가드로 차단
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 LangChain 에이전트를 붙여두면, 어느 날 갑자기 비용이 튀거나 응답이 끝나지 않는 사건을 겪기 쉽습니다. 원인은 대개 단순합니다. 에이전트가 tool 호출과 자기 반성을 반복하면서 루프에 빠지고, 그 과정에서 토큰이 기하급수로 소비됩니다. 특히 ReAct 계열(생각-행동-관찰)을 쓰거나, 도구가 에러를 반환하는데도 에이전트가 같은 전략을 반복하면 폭주가 발생합니다.
이 글에서는 “에이전트가 루프에 빠졌을 때 어디를 막아야 하는가”를 토큰가드 관점으로 정리합니다. 핵심은 하나입니다. 모델을 똑똑하게 만드는 것보다, 시스템을 안전하게 만드는 게 먼저입니다.
관련해서 재시도/백오프 설계는 OpenAI 429·Rate Limit 에러 재시도 설계도 함께 참고하면, “무한 재시도”를 “제어된 재시도”로 바꾸는 감각을 잡는 데 도움이 됩니다.
왜 에이전트 루프가 생기나
에이전트 루프 폭주는 보통 아래 조합에서 발생합니다.
1) 도구가 애매한 실패를 반환
예: HTTP 500, 타임아웃, 빈 결과, 스키마 불일치. 에이전트는 이를 “다시 시도하면 될 것”으로 해석하고 같은 호출을 반복합니다.
2) 프롬프트가 목표를 과도하게 일반화
예: “완벽하게 조사해” 같은 지시가 있으면, 종료 조건 없이 계속 탐색합니다.
3) 관찰(Observation)이 축적되며 컨텍스트가 비대해짐
루프가 길어질수록 대화 컨텍스트가 커지고, 다음 스텝이 더 비싸집니다. 이때는 루프 자체가 비용을 가속합니다.
4) 재시도 정책이 에이전트 레벨과 도구 레벨에서 중첩
도구 클라이언트가 재시도하고, 에이전트도 같은 행동을 재시도하면 “재시도의 재시도”가 됩니다.
토큰가드(Token Guard)란 무엇인가
여기서 말하는 토큰가드는 특정 라이브러리 이름이 아니라, 에이전트 실행을 예산 기반으로 강제 제어하는 안전장치 묶음입니다. 보통 아래 4종을 같이 둡니다.
- 스텝 가드: 최대 행동 횟수 제한
- 토큰/비용 가드: 요청/세션 단위 토큰 예산 제한
- 시간 가드: 벽시계 기준 타임아웃
- 중복 가드: 동일 도구 호출 반복 차단(루프 감지)
이걸 “모델 앞단”이 아니라 에이전트 실행 루프 자체에 붙여야 효과가 큽니다.
1차 방어선: 스텝 가드로 무한 루프 끊기
LangChain 에이전트는 내부적으로 “생각하고 도구를 호출하고 관찰을 읽는” 루프를 돕니다. 가장 싸고 확실한 차단은 최대 스텝 제한입니다.
아래 예시는 LangChain의 개념을 따라가되, 실제 환경에서 가장 이식성이 좋은 형태(래퍼)로 보여줍니다.
import time
class StepLimitExceeded(Exception):
pass
class AgentRunner:
def __init__(self, agent, max_steps: int = 10):
self.agent = agent
self.max_steps = max_steps
def run(self, inputs: dict):
steps = 0
state = self.agent.init_state(inputs)
while True:
if steps >= self.max_steps:
raise StepLimitExceeded(f"max_steps={self.max_steps} exceeded")
action = self.agent.next_action(state)
state = self.agent.apply_action(state, action)
steps += 1
if self.agent.is_finished(state):
return self.agent.final_output(state)
실무 팁은 다음과 같습니다.
max_steps는 환경별로 다르게: UI 챗은6내외, 백오피스 배치성 분석은12내외부터 시작해 관측 후 조정- 스텝 초과 시 “부분 결과”를 반환하도록 설계: 완전 실패보다 낫습니다
2차 방어선: 토큰/비용 가드로 예산을 강제하기
스텝 제한만으로는 부족할 수 있습니다. 한 스텝이 매우 비싼 모델 호출일 수도 있고, 관찰이 커져 토큰이 급증할 수도 있습니다. 그래서 토큰 예산을 둡니다.
토큰 예산을 어디서 측정할까
- 가장 정확: 모델 응답의
usage(입력/출력 토큰)를 누적 - 보조: 사전 추정(대략적인 토큰 추정기 사용)
아래는 “모델 클라이언트 래퍼”로 usage를 누적하고, 초과 시 즉시 중단하는 패턴입니다.
class TokenBudgetExceeded(Exception):
pass
class BudgetedLLM:
def __init__(self, llm, max_total_tokens: int):
self.llm = llm
self.max_total_tokens = max_total_tokens
self.total_tokens = 0
def invoke(self, prompt: str, **kwargs):
res = self.llm.invoke(prompt, **kwargs)
# res.usage = {"input_tokens": x, "output_tokens": y, "total_tokens": z}
used = getattr(res, "usage", {}).get("total_tokens")
if used is None:
# usage 미제공이면 보수적으로 차단하거나, 추정치로 대체
used = len(prompt) // 4
self.total_tokens += used
if self.total_tokens > self.max_total_tokens:
raise TokenBudgetExceeded(
f"token budget exceeded: {self.total_tokens}/{self.max_total_tokens}"
)
return res
운영에서 중요한 건 “예산 초과 시 어떻게 사용자 경험을 마무리할지”입니다.
- 사용자에게는: “현재 예산 내에서 가능한 범위까지만 요약”
- 시스템에는: 루프 원인(도구 에러, 중복 호출, 프롬프트)을 로깅해 재발 방지
3차 방어선: 시간 가드로 벽시계 타임아웃 적용
토큰 예산이 있어도, 도구 호출이 느리거나 외부 API가 지연되면 워커가 묶입니다. 따라서 시간 제한을 둡니다.
class TimeoutExceeded(Exception):
pass
class TimeGuard:
def __init__(self, max_seconds: float):
self.max_seconds = max_seconds
self.started = None
def start(self):
self.started = time.time()
def check(self):
if time.time() - self.started > self.max_seconds:
raise TimeoutExceeded(f"timeout after {self.max_seconds}s")
이 가드는 에이전트 루프의 각 스텝 시작 시점, 그리고 도구 호출 전후에 check()를 넣는 방식이 좋습니다.
4차 방어선: 중복 가드로 “같은 도구 호출 반복” 차단
에이전트 폭주의 가장 흔한 형태는 아래입니다.
- 같은
tool을 - 거의 같은 인자(query, id, filters)로
- 연속 호출
이를 막으려면 “도구 호출 시그니처”를 만들고 최근 N개를 기억해 중복을 차단합니다.
import json
import hashlib
from collections import deque
class DuplicateToolCall(Exception):
pass
class ToolCallDeduper:
def __init__(self, window: int = 8, max_same: int = 2):
self.window = window
self.max_same = max_same
self.recent = deque(maxlen=window)
def _sig(self, tool_name: str, tool_args: dict) -> str:
payload = json.dumps({"tool": tool_name, "args": tool_args}, sort_keys=True)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
def check_and_record(self, tool_name: str, tool_args: dict):
sig = self._sig(tool_name, tool_args)
count = sum(1 for s in self.recent if s == sig)
if count >= self.max_same:
raise DuplicateToolCall(f"duplicate tool call detected: {tool_name}")
self.recent.append(sig)
이 방식의 장점은 “모델이 왜 반복하는지”를 몰라도, 반복 자체를 시스템적으로 끊을 수 있다는 점입니다.
실무에서는 중복 차단 시 다음 중 하나로 유도하면 좋습니다.
- 다른 도구를 사용하도록 프롬프트에 힌트 제공
- 사용자에게 추가 입력 요청(정확한 키, 기간, 조건)
- 캐시된 결과 반환(동일 쿼리라면)
토큰가드 통합: 에이전트 실행 파이프라인 예시
아래는 스텝/토큰/시간/중복 가드를 한 번에 묶는 실행기 예시입니다.
class GuardedAgentExecutor:
def __init__(
self,
agent,
llm,
tools,
*,
max_steps=8,
max_total_tokens=12000,
max_seconds=20,
):
self.agent = agent
self.llm = BudgetedLLM(llm, max_total_tokens=max_total_tokens)
self.tools = tools
self.step_runner = AgentRunner(agent, max_steps=max_steps)
self.time_guard = TimeGuard(max_seconds=max_seconds)
self.deduper = ToolCallDeduper(window=10, max_same=2)
def call_tool(self, name: str, args: dict):
self.time_guard.check()
self.deduper.check_and_record(name, args)
return self.tools[name](**args)
def run(self, inputs: dict):
self.time_guard.start()
# agent 내부에서 llm.invoke, tool 호출을 위 래퍼로 연결했다고 가정
return self.step_runner.run(inputs)
핵심은 “가드를 여기저기 흩뿌리는 것”이 아니라, 에이전트 실행의 단일 진입점에 묶는 것입니다. 그래야 예산 정책이 일관되고, 장애 분석도 쉬워집니다.
폭주를 줄이는 프롬프트/설계 체크리스트
가드만으로도 폭주는 막을 수 있지만, 애초에 폭주가 덜 나게 설계하면 성공률이 올라갑니다.
종료 조건을 프롬프트에 명시
- “최대 3번 도구 호출 후 결론을 제시”
- “정보가 부족하면 사용자에게 질문 1개만 하고 중단”
도구 에러를 모델이 이해 가능한 형태로 변환
- 원문 스택트레이스 그대로 넣지 말고
{"error": {"type": "timeout", "retryable": true, "hint": "..."}}처럼 구조화
재시도는 한 곳에서만
도구 호출 레벨에서 재시도하면, 에이전트 레벨에서는 재시도하지 않게 합니다(또는 반대로). 중첩 재시도는 폭주의 지름길입니다. 이 관점은 OpenAI 429·Rate Limit 에러 재시도 설계에서 다룬 “재시도 예산”과 동일한 철학입니다.
운영 관측: 루프 폭주를 빨리 찾는 지표
폭주는 “발생한 뒤”가 아니라 “조짐이 보일 때” 잡아야 합니다. 아래를 최소로 수집하세요.
- 요청 단위: 총 토큰, 총 비용 추정, 총 소요 시간, 총 스텝 수
- 스텝 단위: 도구명, 도구 인자 해시, 도구 응답 크기(바이트), 에러 타입
- 차단 이벤트: 어떤 가드가 발동했는지(
step_limit,token_budget,timeout,duplicate_tool_call)
추가로 에이전트가 메모리/벡터DB를 쓰는 구조라면, 장기적으로는 저장소가 비대해져 또 다른 비용 폭주를 만들 수 있습니다. 이 경우 TTL 전략은 AutoGPT 메모리 폭주? 벡터DB TTL로 안정화에서 다룬 방식이 그대로 적용됩니다.
마무리: “똑똑함”보다 “제어 가능함”
LangChain 에이전트의 루프 폭주는 모델 성능 문제가 아니라 제어 지점이 없을 때 생기는 시스템 문제인 경우가 많습니다. 토큰가드는 다음을 보장합니다.
- 스텝 가드로 무한 반복을 끊고
- 토큰/비용 가드로 예산을 강제하며
- 시간 가드로 워커 점유를 막고
- 중복 가드로 동일 도구 호출 루프를 차단
이 4가지를 단일 실행기에 통합하면, “가끔 폭주하는 데모”가 아니라 “운영 가능한 에이전트”로 바뀝니다. 다음 단계로는 차단 로그를 기반으로 프롬프트와 도구 스키마를 다듬어, 가드가 발동하는 빈도 자체를 줄이는 튜닝을 해보면 좋습니다.