Published on

LangChain 에이전트 무한루프·툴폭주 차단법

Authors

프로덕션에서 LangChain 에이전트를 붙이면 가장 먼저 만나는 장애가 무한루프툴 폭주(tool spam)입니다. 에이전트가 같은 검색 API를 수십 번 두드리거나, 에러를 만났는데도 같은 입력으로 재시도만 반복하거나, “다음 단계”라는 말만 남긴 채 종료를 못 하는 형태로 나타납니다.

이 문제는 단순히 max_iterations 같은 옵션 하나로 끝나지 않습니다. 왜냐하면 루프는 여러 레이어에서 발생하기 때문입니다.

  • LLM이 잘못된 계획을 세움(추론 레벨)
  • 툴이 비결정적/불안정해서 결과가 흔들림(도구 레벨)
  • 에러 처리/재시도 정책이 과격함(복구 레벨)
  • 상태 저장이 부정확해 같은 일을 “처음 하는 것처럼” 반복함(메모리/상태 레벨)

아래는 “루프가 발생할 수밖에 없는 구조”를 줄이고, 발생하더라도 “작게 터지고 빨리 멈추게” 만드는 실전 차단법입니다.

1) 루프·툴폭주의 전형적인 패턴부터 분류하기

패턴 A: 동일 툴 동일 입력 반복

  • 검색 툴에 같은 쿼리를 반복
  • DB 조회에 같은 SQL 반복
  • 웹 요청에 같은 URL 반복

원인

  • 툴 결과를 요약해 상태에 저장하지 않음
  • 툴 결과가 너무 길어 LLM이 핵심을 못 잡고 다시 요청
  • “이전 시도와 동일”을 인지하지 못하는 프롬프트

패턴 B: 실패 재시도 폭주

  • 429, 5xx, 타임아웃에서 즉시 재시도
  • 재시도마다 입력/옵션이 동일

원인

  • 백오프/지터가 없음
  • 툴 레벨 예외를 에이전트가 “다른 시도”로 오해

재시도·백오프는 LLM 앱에서 특히 중요합니다. API 호출이 많은 구조라서 작은 재시도 폭주가 비용과 장애로 직결됩니다. 관련해서는 OpenAI 429 RateLimitError 재시도·백오프 실전도 함께 읽어두면 좋습니다.

패턴 C: 계획 재수립 루프(Thinking loop)

  • “더 조사하겠습니다”만 반복
  • 툴 호출 없이 메시지만 길어짐

원인

  • 종료 조건(acceptance criteria)이 없음
  • “충분한 정보”의 정의가 없음

2) 1차 방어선: 스텝 예산, 시간 예산, 비용 예산

LangChain 에이전트는 기본적으로 반복 실행 구조이므로 “예산(budget)”을 먼저 잡아야 합니다.

  • 스텝 예산: 최대 툴 호출/반복 횟수
  • 시간 예산: wall-clock 제한(예: 20초)
  • 비용/토큰 예산: 모델 호출 누적 토큰 제한

예시(개념 코드, LangChain 버전에 따라 API는 다를 수 있음)

import time

MAX_STEPS = 8
MAX_SECONDS = 20

start = time.time()
steps = 0

while True:
    if steps >= MAX_STEPS:
        raise RuntimeError("Agent budget exceeded: max steps")
    if time.time() - start >= MAX_SECONDS:
        raise TimeoutError("Agent budget exceeded: time limit")

    # agent_step() 내부에서 LLM 호출/툴 호출 수행
    result = agent_step()
    steps += 1

    if result.get("final") is True:
        break

핵심은 “어떤 이유로든 종료가 안 되면 강제 종료”가 기본값이 되어야 한다는 점입니다. 그리고 강제 종료는 실패가 아니라 “안전장치 발동”으로 관측되어야 합니다.

3) 2차 방어선: 툴별 쿼터와 쿨다운(Throttle)

툴 폭주는 대부분 특정 툴에서 시작합니다. 따라서 전역 스텝 제한과 별개로 툴별 쿼터를 둡니다.

  • 툴별 최대 호출 횟수(예: search는 3회, db_query는 5회)
  • 동일 입력 반복 시 즉시 차단
  • 쿨다운(예: 1초)으로 순간 폭주 완화
import time
from collections import defaultdict

TOOL_QUOTA = {
    "search": 3,
    "http_get": 4,
    "db_query": 5,
}

calls = defaultdict(int)
last_call_at = defaultdict(lambda: 0.0)
seen_inputs = set()

def guarded_tool_call(tool_name: str, tool_input: str, fn):
    # 툴별 쿼터
    calls[tool_name] += 1
    if calls[tool_name] > TOOL_QUOTA.get(tool_name, 3):
        raise RuntimeError(f"Tool quota exceeded: {tool_name}")

    # 동일 입력 반복 차단
    sig = (tool_name, tool_input.strip())
    if sig in seen_inputs:
        raise RuntimeError(f"Repeated tool call blocked: {tool_name}")
    seen_inputs.add(sig)

    # 쿨다운(간단 스로틀)
    now = time.time()
    if now - last_call_at[tool_name] < 0.8:
        time.sleep(0.8 - (now - last_call_at[tool_name]))
    last_call_at[tool_name] = time.time()

    return fn(tool_input)

이 방식의 장점은 LLM의 “의도”와 무관하게 시스템이 안전해진다는 점입니다. 에이전트가 잘못된 결정을 내려도 폭주가 물리적으로 불가능해집니다.

4) 루프 감지: 상태 기반(trajectory) 중복 탐지

단순히 “동일 툴 동일 입력”만 막으면, 에이전트는 입력을 살짝 바꾸며 우회할 수 있습니다(예: 공백 추가, 표현만 변경). 그래서 더 강력한 방식이 필요합니다.

방법: 최근 N스텝의 행동 시퀀스를 해시로 기록

  • tool_name + 정규화된 입력 + 결과 요약(또는 에러 타입)
  • 최근 5~10개를 링버퍼로 저장
  • 동일 패턴이 반복되면 루프로 간주
import hashlib
from collections import deque

history = deque(maxlen=10)

def normalize(s: str) -> str:
    return " ".join(s.strip().lower().split())

def fingerprint(tool_name: str, tool_input: str, outcome: str) -> str:
    raw = f"{tool_name}|{normalize(tool_input)}|{normalize(outcome)}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]

def record_and_check(fp: str):
    history.append(fp)
    # 최근 6개 중 같은 fp가 3번 이상이면 루프 가능성
    if list(history).count(fp) >= 3:
        raise RuntimeError("Loop detected by fingerprint repetition")

outcome에는 결과 전문을 넣기보다 “요약/상태 코드”를 넣는 편이 안정적입니다.

  • HTTP라면 status_code + 핵심 메시지
  • DB라면 row_count + 에러 코드
  • 검색이라면 “상위 3개 타이틀” 정도

5) 실패 재시도 정책: 백오프·지터·상한, 그리고 “재시도 가능한 에러”만

툴 폭주의 절반은 재시도 정책 문제입니다. LLM 에이전트는 “실패하면 다시”를 너무 쉽게 선택합니다.

권장 정책

  • 재시도는 툴 레벨에서 통제(에이전트에게 맡기지 않기)
  • 재시도 가능한 에러만 허용(예: 429, 일시적 5xx, 타임아웃)
  • 지수 백오프 + 지터
  • 최대 재시도 횟수와 최대 대기 시간 상한
import random
import time

RETRYABLE = {"429", "500", "502", "503", "504", "timeout"}

def call_with_retry(fn, *, max_tries=4, base=0.6, cap=6.0):
    last_err = None
    for i in range(max_tries):
        try:
            return fn()
        except Exception as e:
            last_err = e
            code = getattr(e, "code", None) or getattr(e, "status", None) or "unknown"
            code = str(code)
            if code not in RETRYABLE:
                raise
            sleep = min(cap, base * (2 ** i))
            sleep = sleep * (0.7 + random.random() * 0.6)  # jitter
            time.sleep(sleep)
    raise RuntimeError(f"Retry exhausted: {last_err}")

이렇게 해두면 에이전트는 “재시도”라는 선택지를 직접 실행하기 어렵고, 툴 호출은 항상 제한된 비용으로 수렴합니다.

6) 프롬프트/출력 스키마로 종료 조건을 강제하기

기술적 가드레일이 있어도, LLM이 종료를 못 하면 스텝 예산까지 계속 달립니다. 따라서 “언제 끝내야 하는지”를 모델에게 명확히 줘야 합니다.

체크리스트 기반 종료 조건

  • 답변에 반드시 포함해야 하는 항목 N개를 명시
  • 항목을 채웠으면 final로 종료

예: 시스템/지시문에 아래를 추가(인라인 코드로 안전 처리)

  • 반드시 (1) 결론 (2) 근거 3개 (3) 남은 불확실성 (4) 다음 액션을 포함
  • 위 항목이 모두 채워졌다면 더 이상 툴을 호출하지 말고 종료

구조화 출력(JSON) 강제

에이전트가 다음 중 하나만 내도록 제한합니다.

  • {"action":"tool","tool_name":"...","tool_input":"..."}
  • {"action":"final","answer":"..."}

이 패턴은 “툴 호출 없이 말만 늘어놓는 루프”를 줄이고, 런타임에서 검증/차단하기 쉬워집니다.

7) 툴 설계 자체를 “에이전트 친화적으로” 바꾸기

에이전트가 툴을 반복 호출하는 이유는 대개 툴이 다음 특성을 갖기 때문입니다.

  • 출력이 너무 길다(LLM이 핵심을 못 잡음)
  • 결정성이 없다(같은 입력에 다른 출력)
  • 에러가 불명확하다(무엇을 바꿔야 하는지 모름)

개선 팁

  • 검색 툴은 원문 대신 “상위 5개 요약 + URL + 신뢰도”처럼 제한된 스키마로 반환
  • DB 툴은 결과를 전부 주지 말고 row_count와 샘플 몇 줄만 제공
  • HTTP 툴은 본문 전체 대신 status, content_type, excerpt만 제공

이렇게 하면 에이전트가 “정보가 부족해서 다시 호출”할 가능성이 줄어듭니다.

8) 관측성(Observability): 폭주를 빨리 발견하고 재현 가능하게 만들기

무한루프는 “가끔만” 터지기 때문에 로그가 부실하면 재현이 어렵습니다. 최소한 아래는 남겨야 합니다.

  • 요청 단위 trace_id
  • 스텝 번호
  • 툴 이름, 입력(민감정보 마스킹), 결과 요약
  • 종료 사유(정상 종료, 스텝 예산 초과, 시간 초과, 루프 감지 등)

운영에서 이런 “루프 차단 이벤트”는 일종의 안전장치 발동이므로 알림 기준을 잡아야 합니다. 예를 들어 5분 동안 동일한 tool quota exceeded가 10회 이상이면 프롬프트/툴 장애로 볼 수 있습니다.

인프라 관점에서 “폭주 이벤트를 자동 복구”시키는 패턴은 다른 영역에서도 유효합니다. 프로세스가 죽거나 비정상 상태가 반복될 때 자동 재시작과 하드닝을 적용하는 방법은 systemd 서비스 자동 재시작 - 죽었다 깨도 복구에서 개념을 빌려올 수 있습니다.

9) 실전 조합: 권장 가드레일 세트

프로덕션에서 무난한 기본값 조합은 다음입니다.

  • 전역: max_steps 610, max_seconds 1530
  • 툴별: 쿼터 search 23, http_get 35, db_query 3~5
  • 동일 입력 반복 차단: 활성화
  • 재시도: 툴 내부에서만 수행, max_tries 3~4, 지수 백오프 + 지터
  • 루프 감지: 최근 10스텝 fingerprint 중복 감지
  • 출력 스키마: tool 또는 final만 허용
  • 관측성: 스텝/툴/종료 사유 필수 로깅

그리고 운영 중에 “가드레일이 자주 발동하는데 정답률이 낮다”면, 예산을 늘리기보다 먼저 아래를 점검하는 편이 비용 대비 효과가 좋습니다.

  • 툴 출력이 너무 길거나 불안정하지 않은가
  • 종료 조건이 모호하지 않은가
  • 에러 메시지가 다음 액션을 안내하고 있는가
  • 동일 작업을 상태에 저장해 재사용하고 있는가

무한루프는 에이전트의 문제가 아니라 시스템 설계 문제인 경우가 많습니다. 예산과 쿼터로 “폭발 반경”을 줄이고, 루프 감지로 “조기 종료”를 만들고, 툴과 프롬프트를 에이전트 친화적으로 다듬으면 대부분의 툴폭주는 통제 가능한 수준으로 내려옵니다.

마지막으로, CI나 배포 파이프라인에서도 비슷한 “무한 루프” 문제가 자주 나옵니다. 반복 트리거를 끊는 방식의 사고법은 GitHub Actions Docker CI/CD 무한 재빌드 루프 끊기와도 통하는 부분이 있으니, 운영 관점에서 함께 참고해보면 좋습니다.