Published on

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

Authors

서버에 올린 LangChain 에이전트가 갑자기 같은 툴을 수십 번 호출하거나, 답을 내지 못한 채 계속 생각만 하다가 토큰과 비용을 태워버리는 경우가 있습니다. 겉으로는 agent_executor.invoke() 한 번 호출했을 뿐인데, 내부에서는 tool 호출이 연쇄적으로 발생하면서 로그가 폭주하고, API 쿼터가 소진되고, 최악에는 장애로 이어집니다.

이 글은 그런 상황을 재현 가능한 실패 모드로 쪼개고, 무한루프와 툴폭주를 실제로 차단하는 7가지 방법을 정리합니다. 핵심은 한 가지입니다. 에이전트는 “똑똑한 함수”가 아니라 확률적 제어 루프이므로, 반드시 상한선, 가드레일, 상태, 관측성이 필요합니다.

0. 무한루프·툴폭주의 전형적인 원인

아래 패턴이 겹치면 거의 확정으로 폭주가 납니다.

  • 종료 조건이 모델의 자유 텍스트에만 의존함
  • 툴이 실패해도 동일 입력으로 재시도하고, 실패 원인이 모델에게 전달되지 않음
  • 툴 결과가 비결정적이거나, 동일 요청을 반복해도 매번 다른 결과가 나옴
  • “다음 행동을 결정”하는 프롬프트가 모호해서, 모델이 계속 더 확인하려 함
  • 루프 중간 상태가 저장되지 않아, 매 턴 같은 결론으로 회귀함

이제부터는 원인별로 “어디에 상한을 걸고, 어떤 신호로 중단할지”를 코드로 보여드립니다.

1) 반복 횟수 상한: max_iterations와 조기 중단

가장 먼저 해야 할 일은 반복 횟수에 하드 리밋을 거는 것입니다. LangChain의 에이전트 실행기는 반복 루프를 돌며 ThoughtAction을 생성하는데, 여기서 상한이 없으면 종료를 모델에게만 맡기는 셈이 됩니다.

from langchain.agents import AgentExecutor

agent_executor = AgentExecutor(
    agent=agent,
    tools=tools,
    max_iterations=8,  # 핵심: 루프 상한
    early_stopping_method="force",  # 더 이상 진행 불가 시 강제 종료
    verbose=True,
)

result = agent_executor.invoke({"input": "..."})
  • max_iterations는 “툴 호출 횟수”가 아니라 “에이전트 스텝” 상한에 가깝습니다.
  • early_stopping_method는 버전에 따라 동작이 다를 수 있지만, 요지는 “종료를 모델 텍스트에만 맡기지 말라”입니다.

운영 팁:

  • 고객 요청당 비용이 민감하면 max_iterations를 낮게, 대신 “사람에게 에스컬레이션” 경로를 마련하세요.
  • 상한에 걸려 종료되면 왜 종료됐는지를 결과에 포함시키는 것이 중요합니다. 그래야 재시도 정책을 세울 수 있습니다.

2) 시간 상한: 타임아웃과 취소 전파

반복 횟수만 제한하면, 한 번의 툴 호출이 오래 걸릴 때(예: 외부 검색, 느린 DB, LLM 스트리밍) 여전히 장애가 납니다. 따라서 벽시계 시간 기준 타임아웃이 필요합니다.

파이썬에서는 asyncio.wait_for로 가장 단순하게 구현할 수 있습니다.

import asyncio

async def run_with_timeout(executor, payload, timeout_sec=20):
    return await asyncio.wait_for(
        executor.ainvoke(payload),
        timeout=timeout_sec,
    )

# 사용 예
# result = asyncio.run(run_with_timeout(agent_executor, {"input": "..."}, 20))

중요 포인트:

  • 타임아웃이 발생하면 “중단”만으로 끝내지 말고 취소가 툴 레이어까지 전파되는지 확인해야 합니다.
  • 외부 HTTP 호출 툴이라면 클라이언트 타임아웃도 별도로 설정하세요. 에이전트 타임아웃만 걸면, 백그라운드에서 요청이 계속 살아있을 수 있습니다.

운영 관점에서는 애플리케이션 레벨 루프가 시스템 레벨 재시작 루프로 번지기도 합니다. 서비스가 재시작을 반복한다면 이 글도 함께 참고하세요: systemd 서비스 자동 재시작 무한루프 진단 가이드

3) 토큰·비용 상한: max_tokens와 “예산 기반 종료”

무한루프의 비용은 대부분 토큰에서 발생합니다. 반복 횟수와 시간 제한이 있어도, 한 번의 응답이 길어지면 비용이 급증합니다. 따라서 응답 토큰 상한누적 토큰 예산을 같이 적용하는 것이 안전합니다.

모델 레벨 상한 예시:

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="gpt-4o-mini",
    temperature=0,
    max_tokens=512,  # 한 번의 출력 상한
)

누적 예산 기반 종료는 콜백으로 구현하는 편이 실용적입니다. 버전별 콜백 API가 다르므로, 아이디어만 간단히 제시하면 아래와 같습니다.

  • 각 스텝마다 입력 토큰과 출력 토큰을 누적
  • 누적 토큰이 임계치를 넘으면 즉시 종료
  • 종료 사유를 “budget_exceeded” 같은 코드로 남김

이때 중요한 점은 “예산 초과”를 실패로만 취급하지 말고, 부분 결과와 함께 반환하는 전략입니다. 예를 들어 “현재까지 확인한 사실”과 “추가 조사가 필요한 항목”을 반환하면 UX가 크게 좋아집니다.

4) 툴 호출 폭주 차단: 툴별 쿨다운·레이트리밋·동시성 제한

에이전트가 폭주할 때 가장 흔한 모습은 특정 툴을 연속 호출하는 것입니다. 예를 들어 검색 툴이나 DB 조회 툴을 같은 쿼리로 반복합니다.

대응은 3단계로 나누는 것이 좋습니다.

4-1. 툴별 호출 횟수 제한

from collections import defaultdict

class ToolQuota:
    def __init__(self, limits):
        self.limits = limits
        self.counts = defaultdict(int)

    def check(self, tool_name: str):
        self.counts[tool_name] += 1
        if self.counts[tool_name] > self.limits.get(tool_name, 999999):
            raise RuntimeError(f"tool_quota_exceeded: {tool_name}")

quota = ToolQuota(limits={
    "web_search": 3,
    "db_query": 5,
})

def guarded_tool_call(tool, *args, **kwargs):
    quota.check(tool.name)
    return tool.invoke(*args, **kwargs)

4-2. 쿨다운과 레이트리밋

  • 동일 툴 연속 호출 사이에 최소 간격을 둠
  • 사용자별, 세션별로 초당 호출 수 제한

이는 인프라 레벨에서도 반드시 필요합니다. 애플리케이션에서 막아도, 동시 요청이 몰리면 결국 외부 API가 터집니다.

4-3. 동시성 제한

에이전트가 병렬로 툴을 호출하는 구조라면, 세마포어로 동시성을 제한하세요.

import asyncio

sem = asyncio.Semaphore(4)

async def limited_tool_call(tool, *args, **kwargs):
    async with sem:
        return await tool.ainvoke(*args, **kwargs)

5) 멱등성으로 “같은 요청 재실행” 막기

툴폭주가 위험한 이유는 “같은 작업을 반복”하기 때문입니다. 특히 결제, 예약, 티켓 생성, 이메일 발송 같은 툴은 반복 호출 자체가 사고입니다.

해결책은 멱등 키입니다.

  • 에이전트 스텝마다 “의도된 작업”을 키로 정규화
  • 동일 키가 이미 성공 처리되었으면 툴 호출을 스킵하고 결과를 재사용

간단한 예시:

import hashlib
import json

class IdempotencyStore:
    def __init__(self):
        self.data = {}

    def key(self, tool_name, payload):
        raw = json.dumps({"tool": tool_name, "payload": payload}, sort_keys=True)
        return hashlib.sha256(raw.encode("utf-8")).hexdigest()

    def get(self, key):
        return self.data.get(key)

    def set(self, key, value):
        self.data[key] = value

store = IdempotencyStore()

def idempotent_invoke(tool, payload):
    k = store.key(tool.name, payload)
    cached = store.get(k)
    if cached is not None:
        return cached
    out = tool.invoke(payload)
    store.set(k, out)
    return out

실서비스에서는 메모리 대신 Redis 같은 외부 저장소를 쓰는 편이 안전합니다. 분산 환경에서 “중복 실행” 자체를 막는 패턴은 아래 글과도 결이 같습니다: Spring Boot에서 Redis 분산락으로 중복 실행 막기

또한 이벤트 기반으로 후속 처리를 한다면 멱등키와 아웃박스 패턴이 강력합니다: Kafka Exactly-Once 깨질 때 멱등키·Outbox 패턴

6) 상태 머신화: “계획-실행-검증-종료”를 강제하기

무한루프는 대개 “다음에 무엇을 해야 하는지”가 프롬프트에 명확히 정의되지 않아 생깁니다. 해결은 에이전트에게 자유도를 주는 대신, 명시적 상태 머신을 부여하는 것입니다.

권장 상태 예시:

  • PLAN: 필요한 툴과 질의를 짧게 계획
  • ACT: 계획된 툴만 실행
  • VERIFY: 목표를 달성했는지 체크
  • FINAL: 사용자에게 응답하고 종료

핵심은 VERIFY에서 “종료 조건”을 구조화하는 것입니다. 예를 들어 체크리스트를 만들고, 모두 충족되면 종료하도록 합니다.

프롬프트에도 다음과 같은 제약을 넣습니다.

  • PLAN 단계에서는 툴 호출 금지
  • ACT 단계에서는 계획에 없는 툴 호출 금지
  • VERIFY 단계에서만 추가 탐색 여부 결정

이렇게 하면 모델이 “혹시 모르니 한 번 더”를 반복하기 어려워집니다.

7) 관측성: 루프를 ‘보이게’ 만들면 절반은 해결된다

무한루프는 디버깅이 어렵습니다. 왜냐하면 에이전트의 내부 상태가 로그에 남지 않거나, 남더라도 구조화되어 있지 않기 때문입니다. 관측성은 단순 로깅이 아니라, 다음을 메트릭과 트레이스로 남기는 것입니다.

필수 관측 항목:

  • 요청 단위: iterations, tool_calls_total, tool_calls_by_name, total_tokens, latency_ms
  • 에러 단위: tool_error_rate, timeout_count, budget_exceeded_count
  • 안전 단위: early_stop_reason (예: max_iterations, timeout, budget_exceeded, tool_quota_exceeded)

콜백으로 스텝별 이벤트를 수집하고, OpenTelemetry 같은 트레이싱으로 툴 호출을 스팬으로 묶으면 “어떤 툴이 어디서 반복되었는지”가 한눈에 보입니다.

운영에서 자주 겪는 부작용은 로그 폭주입니다. 에이전트가 루프를 돌면 로그도 같이 폭주해 디스크를 채울 수 있습니다. 시스템 로그가 커지는 문제가 있다면 이 글의 접근이 그대로 적용됩니다: 리눅스 journald 로그 폭주로 디스크 꽉 찰 때 해결

실전 체크리스트: 7가지 차단법을 한 번에 적용하기

아래는 운영 환경에서 최소한으로 권장하는 조합입니다.

  1. 반복 상한: max_iterations 설정
  2. 시간 상한: 요청 타임아웃과 툴 타임아웃 분리
  3. 비용 상한: max_tokens 및 누적 토큰 예산
  4. 툴 폭주 방지: 툴별 quota, 레이트리밋, 동시성 제한
  5. 멱등성: 위험 툴에 멱등키 필수
  6. 상태 머신: PLANACTVERIFY 를 분리
  7. 관측성: 종료 사유와 툴 호출 분포를 메트릭으로 수집

마무리

LangChain 에이전트의 무한루프와 툴폭주는 “모델이 멍청해서”가 아니라, 제어 루프에 안전장치가 없어서 생기는 전형적인 엔지니어링 문제입니다. 위 7가지를 적용하면, 에이전트는 여전히 유연하게 문제를 풀되, 운영 환경에서는 예측 가능한 비용과 지연, 그리고 안전한 실패 모드를 갖게 됩니다.

다음 단계로는, 실제 트래픽에서 수집한 tool_calls_by_nameearly_stop_reason 분포를 기반으로 프롬프트와 툴 스펙을 조정해 “루프가 덜 생기는” 방향으로 개선하는 것이 가장 효과적입니다.