Published on

LangChain Agent 무한루프·비용폭탄 차단 7가지

Authors

서버에 LangChain Agent를 붙이고 나면, 기능보다 먼저 운영 이슈가 터집니다. 대표적으로는 무한 루프비용 폭탄입니다. 에이전트가 같은 툴을 반복 호출하거나, 검색-요약-재검색을 끝없이 되풀이하고, 토큰이 폭증하면서 카드 명세서가 먼저 답을 내립니다.

이 글은 “왜 루프가 생기는지”를 이론적으로만 설명하지 않고, 실제로 비용을 막는 안전장치를 7가지로 분해해서 적용 방법(코드 포함)까지 정리합니다. LangChain 버전/모델이 바뀌어도 통하는 공통 원칙 위주로 구성했습니다.

참고로, 호출 폭증은 대개 레이트리밋(HTTP 429)과 함께 옵니다. 백오프 패턴은 아래 글도 같이 보면 좋습니다.

1) 스텝 상한: max_iterations와 하드 타임아웃을 동시에

가장 먼저 해야 할 것은 “언젠가 끝나게 만드는 것”입니다. LangChain 에이전트는 생각보다 쉽게 계속 더 잘할 수 있을 것 같은 상태에 빠집니다. 특히 검색 기반 툴이 있으면, 실패를 인정하지 않고 재검색으로 도망가며 루프를 만듭니다.

핵심은 두 겹입니다.

  • 논리적 상한: 최대 반복 횟수(max_iterations)
  • 물리적 상한: 시간 제한(서버 타임아웃)

아래는 LangChain의 에이전트 실행에 반복 상한을 걸고, asyncio.wait_for로 하드 타임아웃까지 추가하는 예시입니다.

import asyncio
from langchain.agents import AgentExecutor

# agent + tools + llm은 이미 구성되어 있다고 가정
executor = AgentExecutor(
    agent=agent,
    tools=tools,
    verbose=True,
    max_iterations=8,          # 논리적 상한
    return_intermediate_steps=True,
)

async def run_with_timeout(user_input: str):
    # 물리적 상한(예: 20초)
    return await asyncio.wait_for(
        executor.ainvoke({"input": user_input}),
        timeout=20,
    )

result = asyncio.run(run_with_timeout("지난달 매출 추이 분석해줘"))
print(result["output"])

운영 팁:

  • max_iterations는 6~12 정도가 현실적입니다. 그 이상은 “정답을 더 찾는 게 아니라” 비용을 더 태우는 경우가 많습니다.
  • 타임아웃은 API 게이트웨이, 로드밸런서, 워커(예: Celery) 타임아웃과 정합성을 맞추세요.

2) 예산 상한: 토큰·비용 “세션 예산”을 먼저 선언

무한 루프는 결국 토큰 폭증 문제입니다. 따라서 “이 요청은 최대 얼마까지 쓸 수 있다”를 코드 레벨에서 강제해야 합니다.

실무에서는 아래 두 가지 예산을 함께 둡니다.

  • 총 토큰 예산: 입력+출력+툴 호출 포함
  • 툴 호출 예산: 외부 검색/DB/API는 비용과 리스크가 더 큼

LangChain 내부 콜백을 활용해 토큰 사용량을 집계하고, 임계치를 넘으면 즉시 중단시키는 패턴이 효과적입니다. 아래 코드는 개념 예시로, 사용 중인 LLM/프로바이더에 맞게 토큰 메타데이터 접근 부분을 조정하면 됩니다.

from langchain.callbacks.base import BaseCallbackHandler

class BudgetGuard(BaseCallbackHandler):
    def __init__(self, max_total_tokens: int, max_tool_calls: int):
        self.max_total_tokens = max_total_tokens
        self.max_tool_calls = max_tool_calls
        self.total_tokens = 0
        self.tool_calls = 0

    def on_llm_end(self, response, **kwargs):
        # 프로바이더에 따라 token usage 접근 방식이 다릅니다.
        usage = getattr(response, "llm_output", {}) or {}
        token_usage = usage.get("token_usage") or {}
        self.total_tokens += int(token_usage.get("total_tokens") or 0)
        if self.total_tokens >= self.max_total_tokens:
            raise RuntimeError("Budget exceeded: total token limit")

    def on_tool_start(self, serialized, input_str, **kwargs):
        self.tool_calls += 1
        if self.tool_calls >= self.max_tool_calls:
            raise RuntimeError("Budget exceeded: tool call limit")

# executor 생성 시 callbacks에 주입
budget_guard = BudgetGuard(max_total_tokens=6000, max_tool_calls=6)
executor = AgentExecutor(
    agent=agent,
    tools=tools,
    callbacks=[budget_guard],
    max_iterations=10,
)

운영 팁:

  • “한 요청당 예산”을 먼저 강제하고, 그 다음에 유저/플랜별 예산을 붙이세요.
  • 예산 초과 시에는 부분 결과를 반환하는 UX가 중요합니다. 예: “현재까지 찾은 3개 근거로 요약합니다. 더 깊게 보려면 범위를 좁혀주세요.”

3) 반복 감지: 같은 행동을 N번 하면 강제 종료

에이전트 루프는 보통 패턴이 있습니다.

  • 같은 쿼리로 검색을 3~5번 반복
  • 같은 툴을 번갈아 호출(검색 A 후 요약 B 후 다시 A)
  • “추가 정보가 필요합니다”를 형태만 바꿔 무한 반복

따라서 “행동 시퀀스”를 해시로 기록하고, 동일한 시퀀스가 반복되면 중단하는 것이 실전에서 매우 잘 먹힙니다.

import hashlib
from langchain.callbacks.base import BaseCallbackHandler

class LoopDetector(BaseCallbackHandler):
    def __init__(self, max_same_action: int = 3):
        self.max_same_action = max_same_action
        self.counts = {}

    def on_tool_start(self, serialized, input_str, **kwargs):
        tool_name = serialized.get("name")
        key_raw = f"{tool_name}:{input_str.strip()}"
        key = hashlib.sha256(key_raw.encode("utf-8")).hexdigest()
        self.counts[key] = self.counts.get(key, 0) + 1
        if self.counts[key] >= self.max_same_action:
            raise RuntimeError("Loop detected: repeated tool call")

loop_detector = LoopDetector(max_same_action=3)
executor = AgentExecutor(
    agent=agent,
    tools=tools,
    callbacks=[loop_detector],
    max_iterations=12,
)

운영 팁:

  • 반복 감지는 “정답률”보다 “안전”에 최적화합니다. 오탐이 조금 있어도 비용 폭탄을 막는 편이 낫습니다.
  • 검색 툴은 input_str에 타임스탬프나 랜덤 값이 섞이면 해시가 달라져 감지가 어려우니, 입력을 정규화하세요.

4) 툴 설계: 에이전트가 탈선하지 않게 “입력/출력 계약”을 좁히기

많은 비용 폭탄은 LLM이 아니라 이 만듭니다.

  • 검색 툴이 결과를 너무 길게 반환해서 컨텍스트를 폭발시킴
  • DB 툴이 무제한 레코드를 반환
  • HTTP 툴이 리다이렉트/재시도를 무한히 수행

툴은 반드시 계약을 좁혀야 합니다.

  • 입력 스키마를 강제(필수 필드, 길이 제한)
  • 출력 길이를 제한(상위 N개, 요약만 반환)
  • 페이지네이션을 명시적으로 설계(자동 “다음 페이지” 금지)

Pydantic 스키마로 입력을 제한하는 예시입니다.

from pydantic import BaseModel, Field
from langchain.tools import tool

class SearchInput(BaseModel):
    query: str = Field(..., min_length=2, max_length=120)
    top_k: int = Field(5, ge=1, le=5)

@tool(args_schema=SearchInput)
def web_search(query: str, top_k: int = 5) -> str:
    # 반드시 상위 top_k만, 그리고 각 결과는 짧게
    results = my_search_impl(query, top_k=top_k)
    compact = []
    for r in results:
        compact.append(f"- {r['title']} | {r['url']} | {r['snippet'][:160]}")
    return "\n".join(compact)

운영 팁:

  • 툴 출력은 “원문”이 아니라 “에이전트가 다음 결정을 내리기에 충분한 최소 정보”만 반환하세요.
  • 툴이 긴 텍스트를 반환해야 한다면, 툴 내부에서 먼저 요약하고 길이를 고정하세요.

5) 프롬프트 가드레일: “종료 조건”을 시스템 레벨로 못 박기

에이전트가 루프에 빠지는 이유 중 하나는, 종료 조건이 프롬프트에 명확히 없기 때문입니다. “모르면 모른다고 말해라”는 문장 하나로는 부족합니다.

다음 요소를 시스템 프롬프트에 명시적으로 넣으면 루프가 줄어듭니다.

  • 최대 툴 호출 횟수
  • 같은 툴을 반복 호출하지 말 것
  • 정보가 부족하면 “추가 질문 1개만” 하고 종료
  • 근거가 없으면 가정하지 말고 “불확실”로 표시

예시(개념):

규칙:
1) 툴 호출은 최대 4회까지만 한다.
2) 동일한 툴을 동일 목적의 입력으로 2회 이상 반복 호출하지 않는다.
3) 4회 안에 답이 불가능하면, 필요한 추가 정보 질문 1개만 하고 종료한다.
4) 최종 답변에는 (a) 결론 (b) 근거 (c) 불확실한 점 을 포함한다.

운영 팁:

  • 프롬프트는 “예산/반복 제한이 존재한다”는 사실을 에이전트가 인지하게 만드는 용도입니다.
  • 하지만 프롬프트만으로는 강제력이 없습니다. 반드시 1~3번의 코드 레벨 제한과 같이 쓰세요.

6) 레이트리밋·재시도 설계: 재시도가 루프를 증폭시키지 않게

비용 폭탄은 종종 장애 상황에서 더 심해집니다.

  • 모델 호출이 429를 받음
  • SDK가 재시도
  • 에이전트는 실패를 “추론 실패”로 보고 다시 시도
  • 결과적으로 재시도 곱하기 에이전트 반복으로 호출이 기하급수로 증가

따라서 재시도는 “전역에서 단 한 번”만 책임지게 하고, 에이전트 레벨에서는 실패를 즉시 종료 또는 축약 응답으로 처리하는 게 안전합니다.

  • 네트워크/429 재시도는 HTTP 클라이언트 또는 LLM 래퍼에서 담당
  • 에이전트는 Retry-After 같은 신호를 받으면 즉시 중단하고 사용자에게 재시도 안내

백오프 패턴은 아래 글을 함께 참고하세요.

간단한 예시(개념):

import time
import random

def backoff_sleep(attempt: int):
    base = min(2 ** attempt, 30)
    jitter = random.uniform(0, 1)
    time.sleep(base + jitter)

def call_llm_with_retry(prompt: str, max_retry: int = 3):
    for attempt in range(max_retry + 1):
        try:
            return llm.invoke(prompt)
        except Exception as e:
            # 여기서 429/timeout만 선별하는 것이 중요
            if attempt == max_retry:
                raise
            backoff_sleep(attempt)

운영 팁:

  • “툴 내부 재시도”와 “에이전트 재시도”가 중첩되지 않게 계층을 정리하세요.
  • 대량 트래픽 환경에서는 동시성 제한(세마포어)도 함께 걸어야 합니다.

7) 관측성: 중간 스텝, 툴 호출, 비용을 로그로 남기고 알람 걸기

무한 루프는 사전에 완벽히 막기 어렵습니다. 결국 “빨리 발견하고, 자동으로 차단하고, 원인을 재현”할 수 있어야 합니다.

최소로 남겨야 할 데이터:

  • 요청 ID(트레이스 ID)
  • 에이전트 반복 횟수
  • 툴 호출 목록(툴 이름, 입력 길이, 응답 길이, 소요 시간)
  • 토큰 사용량(가능하면 모델/프롬프트별)
  • 종료 사유(정상 종료, 예산 초과, 반복 감지, 타임아웃)

LangChain은 return_intermediate_steps로 중간 과정을 받을 수 있으니, 이를 구조화 로그로 저장하세요.

import json
import time

start = time.time()
res = executor.invoke({"input": "경쟁사 가격 정책 요약"})

duration_ms = int((time.time() - start) * 1000)
log = {
    "trace_id": "req_123",
    "duration_ms": duration_ms,
    "output": res.get("output"),
    "intermediate_steps": [
        {
            "action": str(step[0]),
            "observation_len": len(str(step[1])) if step[1] is not None else 0,
        }
        for step in (res.get("intermediate_steps") or [])
    ],
}
print(json.dumps(log, ensure_ascii=False))

운영 팁:

  • 알람은 “에러율”보다 “비용 시그널”에 걸어야 합니다. 예: 분당 툴 호출 수, 요청당 평균 토큰.

  • 로그 파이프라인이 불안정하면 원인 분석이 더 어려워집니다. 쿠버네티스 환경이라면 수집 지연/누락도 함께 점검하세요.

  • EKS에서 fluent-bit 로그 누락·지연 원인 9가지

체크리스트: 7가지 안전장치 요약

  • 스텝 상한: max_iterations + 하드 타임아웃
  • 예산 상한: 총 토큰/툴 호출 예산을 세션 단위로 강제
  • 반복 감지: 동일 툴+입력 반복 시 즉시 중단
  • 툴 계약 축소: 입력 스키마/출력 길이/페이지네이션 강제
  • 프롬프트 가드레일: 종료 조건과 “추가 질문 1개만” 규칙
  • 레이트리밋/재시도 계층화: 재시도 중첩 제거, 백오프 표준화
  • 관측성: 중간 스텝/비용/종료 사유 로그 + 비용 알람

마무리: 에이전트는 “똑똑함”보다 “제어”가 먼저

LangChain Agent는 도구를 쥐여주면 강력해지지만, 동시에 통제하지 않으면 비용과 장애를 증폭시키는 자동화 장치가 됩니다. 위 7가지는 서로 대체재가 아니라 조합재입니다.

권장 적용 순서는 1) 스텝/타임아웃2) 예산3) 반복 감지까지를 먼저 넣고, 그 다음에 툴 계약 축소관측성을 강화하는 방식이 가장 빠르게 효과를 봅니다.

이미 운영 중이라면, 오늘은 “요청당 툴 호출 수”와 “요청당 토큰” 두 개만이라도 대시보드에 올려보세요. 무한 루프는 대부분 그 그래프에서 먼저 모습을 드러냅니다.