Published on

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

Authors

에이전트 기반 시스템을 운영하다 보면 가장 자주 맞닥뜨리는 장애가 무한 루프툴 폭주입니다. 증상은 다양하지만 본질은 같습니다. 모델이 다음 행동(Action)을 선택하는 과정에서 종료 조건이 약하거나, 툴 호출이 너무 쉽게 허용되거나, 상태가 누적되며 스스로를 강화할 때 반복이 시작됩니다.

특히 LangChain의 에이전트는 “생각-행동-관찰” 루프를 전제로 설계되어 있어, 안전장치가 없으면 다음과 같은 운영 이슈로 바로 이어집니다.

  • 외부 API 비용 폭증(검색, 크롤링, 결제성 API)
  • DB/캐시/벡터DB에 과도한 트래픽(동일 쿼리 반복)
  • 요청 지연 증가로 인한 타임아웃과 재시도 폭풍
  • 컨테이너 메모리 증가로 OOMKilled까지 확산

운영 관점의 장애 전개는 K8s CrashLoopBackOff 진단 - OOMKilled·Probe에서 다룬 패턴과 유사합니다. 에이전트가 폭주하면 결국 리소스가 먼저 무너집니다. 필요하면 해당 글도 함께 참고하세요: K8s CrashLoopBackOff 진단 - OOMKilled·Probe

아래 8단계는 “프롬프트로 훈계” 같은 약한 처방이 아니라, 코드와 런타임 가드레일로 무한루프와 툴폭주를 구조적으로 차단하는 방법입니다.

1단계: 실행 스텝 상한을 강제한다

가장 기본이지만 가장 효과적인 차단책은 최대 스텝(max iterations)입니다. 에이전트가 몇 번의 툴 호출을 하든, 일정 횟수 이상이면 강제 종료합니다.

LangChain은 에이전트 실행기에서 반복 횟수 제한을 지원합니다. 버전에 따라 옵션명이 조금 다를 수 있으니, 핵심은 “루프 카운터를 프레임워크가 아니라 내가 통제한다”는 점입니다.

from langchain_openai import ChatOpenAI
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain.tools import tool

@tool
def search_api(q: str) -> str:
    return f"search result for: {q}"

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
agent = create_tool_calling_agent(llm, tools=[search_api])

executor = AgentExecutor(
    agent=agent,
    tools=[search_api],
    verbose=True,
    max_iterations=6,  # 핵심: 상한
)

result = executor.invoke({"input": "최신 논문을 찾아 요약해줘"})
print(result)

운영 팁:

  • 상한은 “정상 케이스에서 필요한 평균 스텝 + 여유분”으로 잡습니다.
  • 상한 초과 시 반환 메시지는 사용자 경험을 위해 부분 결과 + 다음 행동 제안 형태가 좋습니다.

2단계: 시간 제한(Timeout)으로 느린 루프를 끊는다

스텝 상한만으로는 부족합니다. 각 스텝이 느리면 전체 요청이 타임아웃까지 끌려가며, 상위 레이어(게이트웨이, 프론트, 워커)가 재시도를 걸어 중복 실행이 발생할 수 있습니다.

따라서 다음 두 가지를 같이 둡니다.

  • 전체 실행 시간 상한(예: 20초)
  • 툴별 타임아웃(예: 검색 2초, DB 1초)
import time

class Deadline:
    def __init__(self, seconds: float):
        self.end = time.time() + seconds

    def check(self):
        if time.time() > self.end:
            raise TimeoutError("agent deadline exceeded")


def run_with_deadline(executor, payload, seconds=20):
    dl = Deadline(seconds)
    # 간단 예시: 호출 전후로 체크
    dl.check()
    out = executor.invoke(payload)
    dl.check()
    return out

인프라까지 포함해 “타임아웃-재시도” 연쇄를 줄이는 방법은 네트워크 레벨 트러블슈팅과도 연결됩니다. 클러스터에서 지연이 커질 때는 EKS TLS handshake timeout 원인·해결 9가지도 함께 점검 포인트로 유용합니다.

3단계: 툴 호출 예산(Budget)을 별도로 둔다

무한루프는 스텝으로 막을 수 있지만, 툴폭주는 “스텝은 적어도 툴이 비싼 경우”에 터집니다. 예를 들어 3스텝인데 매번 웹 검색 10개를 팬아웃하거나, 결제성 API를 호출하면 비용이 급증합니다.

해결책은 툴 호출 예산을 별도로 두는 것입니다.

  • 전체 툴 호출 횟수 예산
  • 툴별 예산(예: search_api 최대 3회)
  • 툴별 비용 가중치(예: 크롤링 5포인트, DB 1포인트)
from collections import defaultdict

class ToolBudget:
    def __init__(self, total_points=10, per_tool=None, weights=None):
        self.total_points = total_points
        self.per_tool = per_tool or {}
        self.weights = weights or {}
        self.used_points = 0
        self.used_per_tool = defaultdict(int)

    def charge(self, tool_name: str):
        w = self.weights.get(tool_name, 1)
        if self.used_points + w > self.total_points:
            raise RuntimeError("tool budget exceeded")
        if tool_name in self.per_tool and self.used_per_tool[tool_name] + 1 > self.per_tool[tool_name]:
            raise RuntimeError(f"tool budget exceeded for {tool_name}")
        self.used_points += w
        self.used_per_tool[tool_name] += 1

budget = ToolBudget(
    total_points=6,
    per_tool={"search_api": 3},
    weights={"search_api": 2},
)

def guarded_tool_call(tool_name: str, fn, *args, **kwargs):
    budget.charge(tool_name)
    return fn(*args, **kwargs)

실전에서는 LangChain의 툴 실행 경로에 콜백이나 래퍼를 끼워 넣어 charge를 강제합니다. 중요한 건 “모델이 원하면 언제든 호출”이 아니라 “시스템이 허용할 때만 호출”로 권한을 뒤집는 것입니다.

4단계: 반복 패턴 감지(Loop signature)로 조기 종료한다

많은 무한루프는 다음 패턴을 보입니다.

  • 동일한 툴을 동일한 입력으로 반복 호출
  • 관찰 결과가 바뀌지 않는데도 재시도
  • final로 가지 않고 action만 반복

이를 막으려면 “최근 N개의 행동을 요약한 서명(signature)”을 만들고, 동일 서명이 일정 횟수 이상 반복되면 종료합니다.

import hashlib
from collections import deque, Counter

def signature(tool_name: str, tool_input: str) -> str:
    s = f"{tool_name}:{tool_input}".encode("utf-8")
    return hashlib.sha256(s).hexdigest()[:12]

class LoopGuard:
    def __init__(self, window=8, max_repeat=3):
        self.window = deque(maxlen=window)
        self.max_repeat = max_repeat

    def record(self, sig: str):
        self.window.append(sig)
        c = Counter(self.window)
        if c[sig] >= self.max_repeat:
            raise RuntimeError("detected repeated tool calls")

loop_guard = LoopGuard(window=10, max_repeat=3)

def guarded_tool(tool_name: str, tool_input: str, fn):
    loop_guard.record(signature(tool_name, tool_input))
    return fn(tool_input)

이 방식의 장점은 프롬프트와 무관하게 동작한다는 점입니다. 모델이 “다시 시도해”라고 주장해도 시스템이 반복을 끊습니다.

5단계: 상태를 상태머신으로 축소해 ‘되감기’를 막는다

에이전트가 폭주하는 또 다른 원인은 “대화 히스토리와 관찰이 계속 누적”되면서 모델이 과거의 실패를 반복 재현하는 것입니다. 특히 긴 히스토리는 토큰 비용 증가와 함께, 모델이 중요 신호를 놓치고 같은 결론으로 되돌아가는 현상을 키웁니다.

해결은 상태머신(state machine) 혹은 단일 소스 오브 트루스입니다.

  • 에이전트가 사용할 상태를 구조화된 객체로 정의
  • 매 스텝마다 상태를 업데이트하되, 히스토리는 요약본만 유지
  • “이미 시도한 전략” 플래그를 상태에 박아 재시도를 금지
from dataclasses import dataclass, field

@dataclass
class AgentState:
    question: str
    facts: list[str] = field(default_factory=list)
    tried_web_search: bool = False
    tried_db_lookup: bool = False
    answer: str | None = None

    def can_search(self) -> bool:
        return not self.tried_web_search

    def mark_search(self):
        self.tried_web_search = True

이렇게 하면 “검색이 안 되면 다시 검색” 같은 루프를 구조적으로 차단할 수 있습니다. 모델이 아니라 상태가 정책을 결정합니다.

6단계: 툴 라우팅을 화이트리스트 기반으로 좁힌다

툴이 많아질수록 에이전트는 선택지를 탐색하느라 흔들립니다. 특히 검색, 브라우징, 코드 실행, DB 쓰기 같은 툴이 한 에이전트에 섞이면, 실패 시 복구 전략이 아니라 “다른 툴을 더 호출”하는 방향으로 폭주하기 쉽습니다.

권장 패턴:

  • 읽기 전용(Read-only) 툴과 쓰기(Write) 툴을 분리
  • 요청 유형에 따라 허용 툴을 동적으로 제한
  • 고위험 툴(결제, 삭제, 배포)은 별도 승인 단계로 분리
def allowed_tools(intent: str) -> list[str]:
    if intent == "faq":
        return ["search_api"]
    if intent == "db_read":
        return ["sql_read"]
    if intent == "db_write":
        # 쓰기는 기본적으로 막고, 별도 워크플로로 넘긴다
        return []
    return ["search_api"]

이 단계는 “에이전트가 똑똑해지면 해결”이 아니라, “툴 표면적을 줄이면 사고가 줄어든다”는 보안/운영의 기본 원칙입니다.

7단계: 회로차단기(Circuit Breaker)로 외부 장애 시 폭주를 막는다

외부 검색 API나 벡터DB가 느려지면 에이전트는 “결과가 이상하니 다시 호출”을 반복합니다. 이때가 툴폭주가 가장 쉽게 터지는 순간입니다.

따라서 툴 호출에 회로차단기를 붙입니다.

  • 실패율이 일정 수준 이상이면 open
  • open 상태에서는 즉시 실패시키고 대체 경로로 유도
  • 일정 시간이 지나면 half-open으로 1회만 시험
import time

class CircuitBreaker:
    def __init__(self, fail_threshold=3, reset_seconds=30):
        self.fail_threshold = fail_threshold
        self.reset_seconds = reset_seconds
        self.fail_count = 0
        self.open_until = 0.0

    def allow(self) -> bool:
        if time.time() < self.open_until:
            return False
        return True

    def success(self):
        self.fail_count = 0

    def fail(self):
        self.fail_count += 1
        if self.fail_count >= self.fail_threshold:
            self.open_until = time.time() + self.reset_seconds

cb = CircuitBreaker(fail_threshold=2, reset_seconds=20)

def call_tool_with_cb(fn, *args, **kwargs):
    if not cb.allow():
        raise RuntimeError("tool temporarily disabled by circuit breaker")
    try:
        out = fn(*args, **kwargs)
        cb.success()
        return out
    except Exception:
        cb.fail()
        raise

운영에서 이 패턴은 LLM 서빙에도 그대로 적용됩니다. 서빙 레이어가 불안정할 때 재시도 폭풍을 줄이는 관점은 KServe LLM 서빙 503·스케일0 지연 해결법과도 연결됩니다.

8단계: “종료 가능한 프롬프트”와 “부분 결과 반환”을 설계한다

마지막은 프롬프트입니다. 다만 여기서 말하는 프롬프트는 “하지 마”가 아니라, 종료 조건을 명시하고 실패 시 반환 형식을 강제하는 설계입니다.

핵심 요소:

  • 목표를 달성하지 못하면 추가 정보 요청으로 종료
  • 툴 호출 전에 “왜 필요한지”를 한 줄로 정당화
  • 동일 전략 재시도 금지(예: 같은 쿼리로 검색 반복 금지)
  • 결과가 불충분하면 “현재까지의 근거 + 다음 질문 2개”로 마무리

예시 시스템 지침(인라인 코드로 안전하게 표기):

  • 툴 호출은 최대 3회까지만 허용된다
  • 같은 툴과 같은 입력을 반복 호출하지 않는다
  • 정보가 부족하면 추정하지 말고 사용자에게 확인 질문을 하고 종료한다

프롬프트만으로 모든 루프를 막을 수는 없지만, 1~7단계의 가드레일과 결합하면 “종료를 자연스러운 선택지”로 만들어 전체 실패율이 크게 줄어듭니다.

실전 체크리스트: 운영에서 바로 쓰는 8가지

아래 항목을 배포 전 점검표로 삼으면 좋습니다.

  1. max_iterations가 설정되어 있는가
  2. 전체 실행 시간 제한과 툴별 타임아웃이 있는가
  3. 툴 호출 예산(횟수 또는 포인트)이 있는가
  4. 반복 시그니처 감지로 조기 종료하는가
  5. 상태가 구조화되어 있고 재시도 플래그가 있는가
  6. 요청 유형별 허용 툴 화이트리스트가 있는가
  7. 외부 장애 시 회로차단기로 폭주를 막는가
  8. 실패 시 부분 결과와 확인 질문으로 종료하는 프롬프트인가

마무리: “에이전트의 자율성”은 통제 위에서만 성립한다

LangChain 에이전트의 무한루프와 툴폭주는 모델 성능 문제가 아니라 시스템 설계 문제인 경우가 대부분입니다. 스텝 제한만 걸어도 급한 불은 끌 수 있지만, 비용과 안정성까지 고려하면 예산, 반복 감지, 라우팅, 회로차단기, 상태머신이 함께 필요합니다.

위 8단계를 적용하면 에이전트는 덜 자유로워 보일 수 있습니다. 하지만 운영 환경에서는 그게 장점입니다. “필요할 때만 툴을 쓰고, 실패하면 빨리 멈추고, 부분 결과를 남기는” 에이전트가 결국 사용자 경험과 비용, 장애 대응까지 모두 개선합니다.