Published on

LangChain 도구호출 무한루프 차단·비용 절감

Authors

서버에 LangChain 에이전트를 붙여 두면, 어느 순간 비용이 비정상적으로 치솟거나 응답이 끝나지 않는 사건을 겪기 쉽습니다. 대부분의 경우 원인은 단순합니다. 모델이 tool_call 을 반복하며 같은 입력으로 같은 도구를 계속 호출하거나, 실패한 도구를 재시도하면서 컨텍스트가 비대해지고, 그 비대해진 컨텍스트가 다시 실패 확률을 높여 악순환을 만듭니다.

이 글에서는 LangChain 기반 도구 호출에서 무한루프를 실전적으로 차단하는 방법과, 동시에 토큰·API 비용을 줄이는 설계를 다룹니다. 운영 관점에서는 CrashLoopBackOff 처럼 “원인 하나가 연쇄 장애로 번지는” 패턴과 유사하니, 장애 대응 관점이 익숙하다면 아래 글도 함께 참고하면 좋습니다.

1) 무한루프는 왜 생기나: 대표 패턴 5가지

1-1. “성공 조건”이 프롬프트에 없다

모델이 도구 호출을 멈춰야 하는 조건을 명확히 모르고, 추가 확인 같은 문구에 반응해 계속 조회를 반복합니다. 특히 검색/브라우징/DB 조회 도구에서 자주 발생합니다.

1-2. 도구가 실패하는데 모델이 같은 입력으로 재시도

timeout 이나 429 같은 오류가 났을 때 모델이 “다시 시도해보겠다”를 선택하고 동일 파라미터로 반복 호출합니다. 재시도는 애플리케이션 레이어에서 제어해야지, 모델의 추론에 맡기면 루프가 됩니다.

1-3. 도구 결과가 너무 길어 컨텍스트 오염

도구 출력이 장문이면 모델이 핵심을 못 잡고 “추가로 더 조회”를 반복합니다. 또는 컨텍스트가 길어져 비용이 커지고, 응답 품질이 떨어져 다시 도구 호출을 유도합니다.

1-4. 상태 저장이 잘못되어 “이미 한 일”을 잊음

에이전트 메모리 설계가 빈약하면, 동일 질문에 대해 동일 도구를 반복 호출합니다. RAG 설계에서 “무엇을 저장하고 무엇을 버릴지”가 비용과 루프 모두에 영향을 줍니다.

1-5. 도구 스키마가 애매해 모델이 파라미터를 못 맞춤

예를 들어 date 형식이 불명확하거나 필수 필드가 누락되면, 모델은 “다시 시도”를 반복합니다. 도구 스키마는 모델 친화적으로 강제해야 합니다.

2) 1차 방어선: 실행 상한과 중단 조건을 코드로 박기

무한루프 차단의 기본은 “모델이 멈추지 않으면 시스템이 멈춘다”입니다. LangChain 에이전트 실행에는 반드시 상한을 둡니다.

2-1. max_iterationsmax_execution_time

아래 예시는 LangChain 에이전트에 반복 횟수와 시간 상한을 걸어, 루프가 시작되면 강제 종료합니다.

import time
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are a careful assistant. Use tools only when necessary. Stop when you have enough information."),
    ("human", "{input}"),
    ("placeholder", "{agent_scratchpad}"),
])

# tools = [...]  # 실제 도구 리스트
agent = create_openai_tools_agent(llm, tools=[], prompt=prompt)

executor = AgentExecutor(
    agent=agent,
    tools=[],
    verbose=True,
    max_iterations=6,
    max_execution_time=15,  # seconds
    early_stopping_method="force",
)

result = executor.invoke({"input": "오늘 환율 알려줘"})
print(result["output"])
  • max_iterations 는 루프 차단에 가장 즉효입니다.
  • early_stopping_methodforce 로 두면, 상한 도달 시 “중간 결과라도” 반환하게 유도할 수 있습니다.

2-2. 도구 호출 “전” 훅에서 중단시키기

상한만으로는 비용을 충분히 못 줄일 수 있습니다. 이미 5번 도구를 호출하고 컨텍스트가 커졌다면, 6번째 호출을 막는 편이 낫습니다.

전략은 간단합니다.

  • 최근 N 개 도구 호출을 해시로 저장
  • 동일 도구명 + 동일 인자 조합이 K 번 반복되면 중단
import json
import hashlib
from collections import Counter, deque

class ToolLoopGuard:
    def __init__(self, window=10, max_repeat=2):
        self.window = window
        self.max_repeat = max_repeat
        self.recent = deque(maxlen=window)

    def fingerprint(self, tool_name: str, tool_args: dict) -> str:
        payload = json.dumps({"tool": tool_name, "args": tool_args}, sort_keys=True, ensure_ascii=False)
        return hashlib.sha256(payload.encode("utf-8")).hexdigest()

    def check(self, tool_name: str, tool_args: dict):
        fp = self.fingerprint(tool_name, tool_args)
        self.recent.append(fp)
        counts = Counter(self.recent)
        if counts[fp] > self.max_repeat:
            raise RuntimeError("Blocked: repeated identical tool call detected")

loop_guard = ToolLoopGuard(window=8, max_repeat=2)

# 도구 실행 래퍼 예시

def guarded_tool_call(tool, tool_name: str, tool_args: dict):
    loop_guard.check(tool_name, tool_args)
    return tool.invoke(tool_args)

핵심은 “모델의 의도”가 아니라 “실제 호출 패턴”을 기준으로 차단하는 것입니다.

3) 2차 방어선: 재시도는 모델이 아니라 애플리케이션이 한다

모델이 도구를 호출했는데 429 가 나면, 모델은 보통 “다시 시도”를 선택합니다. 이때 동일 호출이 반복되면 루프가 됩니다.

권장 패턴은 다음과 같습니다.

  • 도구 래퍼에서 retry 를 수행
  • 재시도 횟수 상한, 지수 백오프, 서킷 브레이커 적용
  • 실패 시 모델에게 “실패 사실 + 대안”을 짧게 전달
import time
import random

class ToolError(Exception):
    pass


def call_with_retry(fn, *, max_attempts=3, base_sleep=0.5):
    for attempt in range(1, max_attempts + 1):
        try:
            return fn()
        except ToolError as e:
            if attempt == max_attempts:
                raise
            sleep = base_sleep * (2 ** (attempt - 1)) + random.random() * 0.1
            time.sleep(sleep)

# 예: 실제 도구 호출을 fn 으로 감싼다
# result = call_with_retry(lambda: my_tool.invoke(args))

이렇게 하면 모델은 “재시도 전략”을 생각하지 않아도 되고, 실패 메시지는 짧아져 컨텍스트 팽창도 줄어듭니다.

4) 비용 절감의 핵심: “도구 결과 캐싱”과 “요약 저장”

무한루프가 아니더라도, 도구 호출은 비용을 키웁니다. 특히 검색/DB/HTTP 호출은 지연도 늘고, 결과를 그대로 컨텍스트에 넣으면 토큰 비용이 폭발합니다.

4-1. 동일 질의 캐싱

도구 호출은 대개 결정적입니다. tool_name + args 를 키로 캐시하면, 모델이 같은 도구를 다시 호출해도 즉시 반환할 수 있습니다.

import time

class SimpleTTLCache:
    def __init__(self, ttl_seconds=300):
        self.ttl = ttl_seconds
        self.store = {}

    def get(self, key):
        item = self.store.get(key)
        if not item:
            return None
        value, expires_at = item
        if time.time() > expires_at:
            self.store.pop(key, None)
            return None
        return value

    def set(self, key, value):
        self.store[key] = (value, time.time() + self.ttl)

cache = SimpleTTLCache(ttl_seconds=120)


def cached_tool_call(tool, tool_name: str, tool_args: dict, guard: ToolLoopGuard):
    guard.check(tool_name, tool_args)
    fp = guard.fingerprint(tool_name, tool_args)
    cached = cache.get(fp)
    if cached is not None:
        return cached
    out = tool.invoke(tool_args)
    cache.set(fp, out)
    return out

캐시는 비용뿐 아니라 루프 가능성도 낮춥니다. 모델이 같은 호출을 반복해도, 결과가 즉시 돌아오고 컨텍스트 변화가 줄어 “추가 호출”을 유도할 여지가 줄어듭니다.

4-2. 도구 결과를 그대로 넣지 말고 “정규화된 요약”만 넣기

검색 결과 HTML, DB 레코드 수백 줄, 로그 덩어리를 그대로 메시지로 넣는 순간 비용은 급증합니다. 도구 결과는 아래처럼 정리합니다.

  • 필요한 필드만 추출
  • 길이 상한 적용
  • 표준 스키마로 반환
MAX_CHARS = 1500


def normalize_search_result(raw: dict) -> dict:
    # raw 가 어떤 형태든, 모델에게는 일정한 형태로 준다
    items = raw.get("items", [])
    normalized = []
    for it in items[:5]:
        normalized.append({
            "title": (it.get("title") or "")[:120],
            "url": (it.get("url") or "")[:300],
            "snippet": (it.get("snippet") or "")[:300],
        })

    payload = {"items": normalized}
    text = json.dumps(payload, ensure_ascii=False)
    if len(text) > MAX_CHARS:
        payload = {"items": normalized[:3]}
    return payload

이 방식은 AutoGPT 메모리 누적·환각 줄이는 RAG 설계에서 말하는 “메모리의 질 관리”와 같은 맥락입니다. 저장해야 할 것은 원문이 아니라, 다음 추론에 유효한 구조화된 사실입니다.

5) 프롬프트 레벨 가드레일: 도구 사용 정책을 명문화

코드 가드만으로도 충분히 막을 수 있지만, 비용을 더 줄이려면 모델이 애초에 도구를 덜 쓰게 만들어야 합니다.

시스템 프롬프트에 아래를 명시합니다.

  • 도구는 “필요할 때만”
  • 도구 호출 후에는 “추가 호출 없이” 결론을 내리기
  • 동일 도구를 같은 인자로 재호출 금지
  • 불확실하면 사용자에게 질문하고 종료

예시:

Tool policy:
1) Call at most 2 tools per user request.
2) Never call the same tool with identical arguments more than once.
3) If a tool fails, do not retry via the model. Explain the failure and ask a clarification or suggest alternatives.
4) After receiving sufficient info, respond to the user without further tool calls.

중요한 점은 “정책을 썼으니 안전하다”가 아니라, 정책 위반을 코드로 감지하고 중단하는 것입니다. 프롬프트는 비용 최적화, 코드는 안전장치입니다.

6) 관측 가능성: 루프는 로그로 잡아야 비용이 멈춘다

운영에서 가장 무서운 건 “조용히 돈이 새는” 상태입니다. 아래 지표를 반드시 남기세요.

  • 요청당 도구 호출 횟수
  • 동일 도구/인자 반복 횟수
  • 에이전트 총 토큰(입력/출력)과 비용 추정
  • 강제 종료 사유: max_iterations 인지, 중복 호출 차단인지

LangChain 콜백을 써서 도구 호출 이벤트를 수집하면, 특정 사용자/프롬프트/도구에서 루프가 집중되는지 쉽게 찾을 수 있습니다.

또한 장애가 나면 애플리케이션이 계속 재시작하며 비용이 더 커질 수 있습니다. 쿠버네티스 환경이라면 재시작 루프를 빨리 끊는 것도 중요합니다. 이 관점에서는 아래 글의 진단 흐름이 도움이 됩니다.

7) 실전 체크리스트: 무한루프 차단과 비용 절감 동시 달성

  • 실행 상한
    • max_iterations 설정
    • max_execution_time 설정
    • 상한 도달 시 사용자에게 “지금까지 확인한 내용”을 요약 반환
  • 중복 호출 차단
    • 최근 도구 호출 핑거프린트 저장
    • 동일 호출 K 회 반복 시 중단
  • 재시도 정책 분리
    • 재시도는 도구 래퍼에서만 수행
    • 429timeout 은 지수 백오프 + 최대 횟수 제한
  • 결과 최소화
    • 도구 결과는 정규화된 스키마로 축약
    • 길이 상한, 상위 N 개만
  • 캐싱
    • tool_name + args 기반 TTL 캐시
    • 캐시 적중률 모니터링
  • 프롬프트 정책
    • 요청당 도구 호출 예산(예: 2회)
    • 동일 호출 재사용 금지
  • 관측
    • 요청당 도구 호출 수, 반복률, 강제 종료 사유 로그화

8) 마무리: “에이전트는 똑똑하지만, 안전은 코드의 책임”

LangChain 도구 호출 무한루프는 모델이 멍청해서가 아니라, 시스템이 멈추는 규칙을 제공하지 않았기 때문에 발생합니다. 가장 효과적인 해법은

  • 상한으로 강제 종료하고
  • 중복 호출을 감지해 차단하며
  • 도구 결과를 줄이고 캐싱해 비용을 낮추는 것

입니다.

이 3가지만 제대로 적용해도, 응답 지연과 토큰 비용이 눈에 띄게 안정화됩니다. 이후에는 관측 지표를 기반으로 “어떤 도구가 비용을 만드는지”를 찾아 정책을 더 정교화하면 됩니다.