- Published on
LangChain 도구호출 무한루프 차단 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LangChain 에이전트를 붙이다 보면 가장 골치 아픈 장애 중 하나가 tool calling 무한루프입니다. 모델이 같은 툴을 계속 호출하거나, 실패한 호출을 재시도하다가 토큰과 비용을 태우고, 결국 타임아웃으로 끝납니다. 특히 관측 없이 “왜 답이 안 오지?” 상태로 남는 게 치명적입니다.
이 글은 “무한루프를 유발하는 구조”를 먼저 정리하고, 그 다음에 차단 장치를 7가지로 나눠 제시합니다. 각 항목은 단독으로도 효과가 있지만, 실제로는 2~4개를 조합했을 때 안정성이 급격히 올라갑니다.
참고로 무한 반복은 인증/필터 체인에서 401이 반복되는 문제와 디버깅 감각이 비슷합니다. 반복되는 증상 자체를 “원인”으로 착각하지 말고, 반복을 유발하는 상태 전이를 끊어야 합니다. 유사한 접근은 Spring Boot 3 JWT 인증 401 반복? 필터체인 점검도 참고할 만합니다.
무한루프가 생기는 대표 패턴
LangChain의 에이전트/그래프에서 무한루프는 보통 아래 중 하나로 시작합니다.
- 툴 결과가 불충분하거나 모호해서 모델이 “다시 조회”를 선택
- **툴 호출 실패(429, 5xx, 타임아웃)**를 모델이 “다시 시도”로 해석
- 상태가 업데이트되지 않음: 메모리/스테이트에 성공 플래그가 기록되지 않아 동일 조건이 유지
- 종료 조건 부재: “충분하면 종료” 같은 자연어 규칙만 있고, 하드 가드레일이 없음
- 툴 스키마/설명 부정확: 입력이 계속 틀려 재시도 루프
이제부터는 이런 루프를 “설계적으로 불가능하게” 만들거나 “일찍 끊어내는” 7가지 방법을 소개합니다.
1) 최대 스텝/최대 툴 호출 수 하드 제한
가장 기본이자 가장 강력한 안전장치입니다. 에이전트가 몇 번의 사고-행동 루프를 돌 수 있는지 상한을 둡니다. 이 제한은 모델이 아무리 고집을 부려도 실행기가 멈추게 해줍니다.
적용 포인트
- 에이전트 실행 시
max_iterations또는 유사 옵션 사용 - 그래프 기반이면 “루프 엣지”에 카운터를 둬서 차단
- 제한 도달 시 사용자에게 “현재까지의 결과 + 다음 액션 제안”으로 graceful degrade
예시 코드 (Python, LangChain 스타일)
from langchain.agents import AgentExecutor, create_tool_calling_agent
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0)
agent = create_tool_calling_agent(llm, tools=[], prompt=...)
executor = AgentExecutor(
agent=agent,
tools=[],
max_iterations=8, # 핵심: 무한 루프 상한
early_stopping_method="force",
verbose=True,
)
result = executor.invoke({"input": "..."})
운영에서는 보통 8~15 정도가 실용 범위입니다. “한 번에 끝내는” 과제가 아니라면, 루프 상한에 걸렸을 때 다음 요청으로 이어갈 수 있도록 응답 포맷을 설계하세요.
2) 동일 툴/동일 인자 반복 호출 감지(디듀프 가드)
무한루프의 상당수는 “같은 툴을 같은 파라미터로 또 호출”입니다. 이건 모델 품질 문제가 아니라, 실행기가 막아야 하는 클래스입니다.
전략
- 최근
N개 툴 호출을(tool_name, normalized_args)로 해시 - 동일 해시가
K회 이상 반복되면 중단 - 중단 시 모델에게 “이미 동일 호출을 수행했고 결과가 변하지 않는다”는 시스템 메시지를 주고 종료/대안 선택
예시 코드 (간단 가드)
import json
import hashlib
class ToolCallLoopGuard:
def __init__(self, window=6, repeat_threshold=2):
self.window = window
self.repeat_threshold = repeat_threshold
self.history = []
def _fingerprint(self, tool_name: str, args: dict) -> str:
normalized = json.dumps(args, sort_keys=True, ensure_ascii=False)
raw = f"{tool_name}:{normalized}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def check(self, tool_name: str, args: dict):
fp = self._fingerprint(tool_name, args)
self.history.append(fp)
self.history = self.history[-self.window:]
if self.history.count(fp) >= self.repeat_threshold:
raise RuntimeError("Detected repeated identical tool calls")
이 가드는 관측에도 도움이 됩니다. “동일 호출 반복”은 보통 툴 결과가 비어 있거나, 프롬프트가 “확실할 때까지 조회”를 강요할 때 발생합니다.
3) 툴 결과에 종료 가능한 상태 플래그를 포함
모델은 텍스트를 보고 다음 행동을 결정합니다. 툴 결과에 done, confidence, next_action 같은 필드를 넣어주면 루프가 급감합니다.
나쁜 툴 결과
"검색 결과: 없음""에러"
이런 값은 모델에게 “그럼 다시 해볼까?”를 유도합니다.
좋은 툴 결과(JSON)
{"ok": true, "done": true, "data": ..., "reason": "no_more_results"}{"ok": false, "retryable": false, "error": {"code": "NOT_FOUND"}}
예시 코드 (툴 반환 스키마)
def search_docs(query: str) -> dict:
hits = ...
if not hits:
return {
"ok": True,
"done": True,
"data": [],
"reason": "NO_HITS",
"suggestion": "Ask user for more context",
}
return {
"ok": True,
"done": True,
"data": hits,
"reason": "FOUND",
}
핵심은 done과 retryable입니다. 실패여도 “재시도 가치가 있는 실패인지”를 실행기/모델이 구분할 수 있어야 합니다.
4) 재시도 정책을 모델이 아니라 실행기에 둔다
429나 네트워크 타임아웃 같은 케이스에서 모델이 “다시 호출”을 결정하게 두면, 루프가 매우 쉽게 발생합니다. 재시도는 결정 로직이 아니라 인프라 정책입니다.
- 재시도 횟수, 백오프, 지터, 서킷 브레이커는 실행기에서 처리
- 모델에는 “현재 호출은 실패했고, 시스템이 이미 2회 재시도했으며 더 이상 불가” 같은 사실만 전달
재시도/백오프 운영 패턴은 OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉와 같은 형태로 정리해두면 에이전트에도 그대로 적용됩니다.
예시 코드 (간단 백오프)
import time
import random
def call_tool_with_retry(fn, *args, max_retries=2, base_delay=0.5, **kwargs):
last_err = None
for attempt in range(max_retries + 1):
try:
return fn(*args, **kwargs)
except Exception as e:
last_err = e
if attempt == max_retries:
break
delay = base_delay * (2 ** attempt) + random.random() * 0.2
time.sleep(delay)
raise last_err
이렇게 하면 모델이 실패를 “추론”할 필요가 없습니다. 시스템은 일관된 방식으로 실패를 확정하고, 모델은 그 실패를 바탕으로 대안을 제시하게 됩니다.
5) 프롬프트에 “툴 호출 금지 조건”과 “종료 조건”을 명문화
많은 팀이 “필요하면 툴을 사용해” 정도만 넣고 끝냅니다. 그러면 모델은 필요/불필요의 경계를 모호하게 해석합니다.
프롬프트에 넣을 규칙 예시
- 같은 툴을 같은 인자로 두 번 호출하지 말 것
- 툴 결과가
done: true면 추가 호출 없이 답변 생성 - 툴 실패가
retryable: false면 사용자에게 추가 정보 요청 - 불확실할 때 무조건 재조회하지 말고, 질문으로 전환
예시 (시스템 메시지 일부)
You are an agent that can call tools.
Rules:
1) Never call the same tool with the same arguments more than once.
2) If a tool returns {"done": true}, stop calling tools and write the final answer.
3) If a tool returns {"ok": false, "retryable": false}, do not retry. Ask the user for missing info.
4) If you cannot make progress after 2 tool calls, stop and propose next steps.
여기서 중요한 건 “자연어 규칙 + 런타임 가드”를 같이 두는 것입니다. 프롬프트만으로는 100% 막히지 않습니다.
6) 상태 머신(그래프)로 루프 엣지를 명시하고, 가드 조건을 코드로 건다
에이전트를 자유형 루프로 두면, 종료 조건이 흐려집니다. 반대로 LangGraph 같은 그래프 접근(또는 자체 상태 머신)을 쓰면 “어떤 조건에서 어디로 이동하는지”가 명시됩니다.
권장 구조
PLAN노드: 필요한 툴 1~2개만 선택ACT노드: 툴 실행EVALUATE노드:done/retryable/need_user_input판정FINAL노드: 답변
예시 (의사 코드)
state = {
"tool_calls": 0,
"last_tool_fingerprint": None,
"done": False,
}
def evaluate(tool_result):
if tool_result.get("done") is True:
state["done"] = True
return "FINAL"
if tool_result.get("ok") is False and tool_result.get("retryable") is False:
return "FINAL" # 사용자에게 질문으로 마무리
if state["tool_calls"] >= 8:
return "FINAL"
return "PLAN"
무한루프는 결국 “그래프에서 같은 사이클을 계속 돈다”는 뜻입니다. 사이클은 허용하되, 사이클을 도는 조건을 점점 좁히거나(예: 호출마다 요구되는 신규 정보 증가), 카운터로 끊어야 합니다.
7) 관측(로그/트레이스)과 자동 차단: 루프를 ‘장애’로 취급
운영에서 무한루프는 기능 버그이면서 비용 사고입니다. 따라서 탐지와 차단을 자동화해야 합니다.
최소 관측 항목
- 세션/요청 ID
- 스텝 번호
- 툴 이름, 인자 요약(민감정보 마스킹)
- 툴 응답의
ok,done,retryable - 종료 사유:
MAX_STEPS,DEDUP_BLOCKED,NON_RETRYABLE_ERROR,TIMEOUT
예시 로그 레코드(JSON)
{
"request_id": "req_123",
"step": 5,
"tool": "search_docs",
"args": {"query": "langchain tool loop"},
"result": {"ok": true, "done": true, "reason": "NO_HITS"},
"termination": null
}
또한 인프라 타임아웃/재시작이 루프를 더 악화시키는 경우가 있습니다. 예를 들어 프로세스가 재시작되며 메모리가 초기화되고, 사용자는 같은 요청을 다시 보내고, 다시 같은 루프가 재현됩니다. 이런 “재시작 루프” 관점은 systemd 서비스가 자꾸 재시작될 때 7단계 진단처럼 운영 체크리스트로 굳혀두면 좋습니다.
실전 조합 레시피
7가지를 전부 한 번에 도입하기 어렵다면, 아래 조합부터 추천합니다.
- 1 + 2 + 4: 즉시 비용 폭발 방지(상한, 디듀프, 재시도 분리)
- 3 + 5: 모델이 “멈출 근거”를 명확히 받도록(결과 스키마, 종료 규칙)
- 6 + 7: 장기적으로 장애를 줄이는 구조화(상태 머신, 관측/차단)
체크리스트: 루프가 발생했을 때 빠른 원인 분류
- 같은 툴/같은 인자 반복인가? 그렇다면
2부터 - 툴 결과가
done없이 텍스트만 반환하는가? 그렇다면3 429나 타임아웃이 섞이는가? 그렇다면4와 레이트리밋 정책- “확실할 때까지 검색” 같은 프롬프트 문장이 있는가? 그렇다면
5 - 그래프가 사실상
PLAN과ACT만 있고EVALUATE가 없는가? 그렇다면6
마무리
LangChain의 도구호출 무한루프는 “모델이 멍청해서”가 아니라, 대부분 종료 조건과 실패 처리의 책임이 애매하게 섞여서 생깁니다. 실행기는 하드 가드레일(max steps, 디듀프, 재시도 정책)을 제공하고, 툴은 종료 가능한 신호(done, retryable)를 반환하며, 프롬프트는 행동 규칙을 명시해야 합니다. 마지막으로, 관측과 자동 차단을 붙여 루프를 기능 버그가 아니라 운영 장애로 다루면 재발률이 크게 떨어집니다.