- Published on
LangChain에서 OpenAI 툴콜 무한루프 끊기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LangChain 에이전트를 붙이다 보면, 모델이 툴을 한 번 호출하고 끝내지 못하고 같은 툴을 계속 부르는 문제가 종종 발생합니다. 로그를 보면 tool_calls 가 반복되고, 응답은 끝나지 않으며, 비용과 지연만 늘어납니다. 이 글은 “왜 무한루프가 생기는지”를 구조적으로 분해하고, LangChain에서 이를 끊는 실전 패턴을 코드로 정리합니다.
무한 툴콜 루프의 전형적인 증상
다음 중 하나라도 보이면 거의 확정입니다.
- 같은 툴이 동일하거나 유사한 인자로 연속 호출됨
- 툴 결과가 정상인데도 모델이 “추가 확인”을 하겠다며 재호출
- 툴이 에러를 반환하면 모델이 동일 호출을 재시도하며 폭주
- 스트리밍 중간에 툴콜이 끼어들고, 최종 답변이 생성되지 않음
- 에이전트가
Final Answer단계로 넘어가지 못하고 계속Action만 수행
운영 관점에서는 API 비용 폭증, 워커 점유, 타임아웃 증가로 이어집니다. 이런 종류의 문제는 앱 레벨에서 “루프 차단 장치”를 반드시 넣어야 합니다. (리소스 고갈을 막는다는 점에서 OOM 루프를 다루는 접근과도 결이 비슷합니다. 예: EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝)
원인 1: 툴 출력이 모델이 이해하기 어려운 형태
모델은 툴 결과를 보고 다음 계획을 세웁니다. 그런데 툴이 다음과 같은 출력을 주면, 모델은 “정보가 부족하다”라고 판단해 같은 툴을 다시 부릅니다.
- 빈 문자열,
null,{}같은 비어 있는 결과 - JSON인데 스키마가 매번 달라짐
- 성공인데도 성공 여부가 명확하지 않음
- 에러인데도 에러 타입이나 재시도 가능 여부가 불명확
해결: 툴 결과를 강제 스키마로 정규화
툴은 항상 ok, data, error, retryable 같은 필드를 가지도록 강제하는 편이 안정적입니다.
from typing import Any, Dict
def normalize_tool_result(data: Any = None, *, ok: bool = True,
error: str | None = None,
retryable: bool = False) -> Dict[str, Any]:
return {
"ok": ok,
"data": data,
"error": error,
"retryable": retryable,
}
def search_tool(query: str) -> Dict[str, Any]:
try:
# ... 실제 검색 로직
results = [
{"title": "...", "url": "...", "snippet": "..."}
]
if not results:
return normalize_tool_result(
ok=True,
data={"results": [], "note": "no_results"},
)
return normalize_tool_result(ok=True, data={"results": results})
except TimeoutError:
return normalize_tool_result(ok=False, error="timeout", retryable=True)
except Exception as e:
return normalize_tool_result(ok=False, error=str(e), retryable=False)
이렇게 하면 모델이 “성공했지만 결과가 없음”과 “실패했고 재시도 가능”을 구분할 수 있어 불필요한 재호출이 줄어듭니다.
원인 2: 툴이 비결정적이거나 부작용이 있는데 모델이 이를 모름
예를 들어 “주문 생성”, “결제 요청”, “이메일 발송” 같은 툴은 재호출되면 중복 실행이 됩니다. 모델은 보통 이를 인지하지 못합니다.
해결: 멱등성 키와 실행 캐시를 넣어 중복 실행을 차단
툴 실행을 “호출 ID” 기준으로 캐시하고, 같은 입력이 반복되면 즉시 차단하거나 이전 결과를 반환합니다.
import hashlib
import json
from typing import Any, Dict, Tuple
class ToolCallDeduper:
def __init__(self, max_calls: int = 8):
self.max_calls = max_calls
self.seen: dict[str, int] = {}
def _fingerprint(self, tool_name: str, tool_args: Dict[str, Any]) -> str:
payload = json.dumps(tool_args, sort_keys=True, ensure_ascii=False)
raw = f"{tool_name}:{payload}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def check(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[bool, str]:
fp = self._fingerprint(tool_name, tool_args)
self.seen[fp] = self.seen.get(fp, 0) + 1
# 총 호출 상한
total = sum(self.seen.values())
if total > self.max_calls:
return False, "tool_call_limit_exceeded"
# 동일 호출 반복 상한
if self.seen[fp] >= 3:
return False, "duplicate_tool_call_detected"
return True, "ok"
이제 에이전트 실행 루프에서 툴을 호출하기 전에 deduper.check(...) 를 통과시킵니다.
원인 3: 시스템 프롬프트가 “툴을 더 써라”로 과도하게 유도
다음 같은 지시가 있으면 모델은 답변을 끝내지 못하고 계속 툴을 찾습니다.
- “확실하지 않으면 반드시 툴을 사용”
- “항상 최신 정보를 위해 검색”
- “근거가 부족하면 한 번 더 조회”
해결: 종료 조건을 시스템 레벨에서 명시
프롬프트에 “언제 멈추는지”를 넣어야 합니다.
너는 필요한 경우에만 도구를 호출한다.
- 같은 도구를 같은 인자로 2회 이상 반복 호출하지 않는다.
- 도구 결과가 ok=true 이고 data.results 가 비어 있으면 추가 검색을 하지 말고
사용자가 선택할 수 있는 다음 액션을 제안하며 답변을 종료한다.
- 도구가 ok=false 이면 retryable=true 인 경우에만 1회 재시도하고,
그 외에는 실패 원인과 대안을 제시하며 답변을 종료한다.
프롬프트는 “툴 사용 정책”과 “종료 정책”을 함께 넣어야 합니다.
원인 4: LangChain 에이전트 루프에서 안전장치가 없음
LangChain은 에이전트 타입에 따라 내부에서 생각과 툴 호출을 반복합니다. 기본 설정만으로는 상황에 따라 충분히 멈추지 못할 수 있습니다.
해결 1: max_iterations 와 타임아웃을 강제
에이전트 실행에 반복 제한을 걸어 “비용 상한”을 설정합니다.
from langchain.agents import AgentExecutor
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
max_iterations=6,
# 일부 버전에서는 max_execution_time 지원
# max_execution_time=20,
handle_parsing_errors=True,
)
max_iterations 는 최후의 안전장치입니다. 다만 이것만으로는 “왜 반복되는지”를 해결하지 못하므로 아래의 차단 로직을 같이 넣는 것이 좋습니다.
해결 2: 툴 호출 전에 중복 감지 후 강제 종료 메시지 주입
아래 예시는 “중복 툴콜 감지” 또는 “호출 횟수 초과” 시, 모델에게 더 이상 툴을 쓰지 말고 요약 답변을 내도록 유도하는 패턴입니다.
from langchain_core.messages import ToolMessage
def tool_guarded_call(tool, tool_name: str, tool_args: dict, deduper: ToolCallDeduper):
allowed, reason = deduper.check(tool_name, tool_args)
if not allowed:
# 모델이 이해하기 쉬운 형태로 종료 힌트를 제공
return normalize_tool_result(
ok=False,
error=f"blocked:{reason}",
retryable=False,
)
return tool.invoke(tool_args)
핵심은 “툴 실행을 막았다”는 사실을 숨기지 말고, 모델이 다음 스텝에서 종료 답변을 만들 수 있도록 명확한 상태를 주는 것입니다.
원인 5: 에러 재시도 정책이 모델에게 위임되어 폭주
툴이 429, timeout 같은 일시 오류를 내면 모델은 “그럼 다시 해보자”를 반복합니다. 재시도는 모델이 아니라 애플리케이션이 통제해야 합니다. 이는 외부 모델 API를 호출할 때도 동일합니다. (비슷한 접근으로 스로틀링을 다루는 글: AWS Bedrock Claude InvokeModel 429·Throttling 해결)
해결: 재시도는 툴 내부에서 지수 백오프로 1회만
import time
def with_retry_once(fn, *, backoff_sec: float = 0.8):
try:
return fn()
except TimeoutError:
time.sleep(backoff_sec)
return fn()
def flaky_tool(x: str) -> dict:
def _do():
# 외부 API 호출
return {"value": x}
try:
data = with_retry_once(_do)
return normalize_tool_result(ok=True, data=data)
except Exception as e:
return normalize_tool_result(ok=False, error=str(e), retryable=False)
모델에게는 “이미 재시도 했고 실패했다”라는 최종 상태만 전달합니다.
LangChain에서 실무적으로 가장 잘 먹히는 3단 방어선
무한루프는 한 가지 장치로는 잘 안 잡힙니다. 아래 조합이 가장 현실적입니다.
- 하드 리밋:
max_iterations로 비용 상한 - 중복 차단: 동일 툴명과 동일 인자 반복 호출 차단
- 출력 정규화:
ok,retryable기반으로 다음 행동을 모델이 판단 가능하게
이를 하나로 묶은 예시입니다.
from langchain.agents import AgentExecutor
def run_agent_with_guards(agent, tools, user_input: str):
deduper = ToolCallDeduper(max_calls=8)
# 여기서는 개념 예시로 tools 를 래핑했다고 가정
guarded_tools = []
for t in tools:
tool_name = t.name
def _make_guarded(tool_obj, name):
def _invoke(args):
return tool_guarded_call(tool_obj, name, args, deduper)
tool_obj.invoke = _invoke
return tool_obj
guarded_tools.append(_make_guarded(t, tool_name))
executor = AgentExecutor(
agent=agent,
tools=guarded_tools,
verbose=True,
max_iterations=6,
handle_parsing_errors=True,
)
return executor.invoke({"input": user_input})
주의할 점은, 위처럼 invoke 를 직접 덮어쓰는 방식은 LangChain 버전과 Tool 구현체에 따라 부작용이 있을 수 있습니다. 프로덕션에서는 “툴 래퍼 클래스”를 만들어 명시적으로 위임하는 형태가 더 안전합니다.
디버깅 체크리스트: 루프가 생기면 여기부터 본다
1) 같은 툴명과 같은 인자가 반복되는가
- 반복된다면: 중복 차단 로직이 1순위
2) 툴 결과가 비어 있거나 애매한가
[],{},""만 던지고 끝내면 모델은 다시 호출합니다- 성공 여부를 명시하고, “결과 없음”도 정상 상태로 표현하세요
3) 툴 에러가 재시도 가능한지 불명확한가
retryable플래그를 분리- 재시도는 앱에서 1회만, 모델에게 위임하지 않기
4) 프롬프트가 과도하게 검색을 강제하는가
- “필요할 때만”을 명시
- “반복 호출 금지”와 “종료 조건”을 문장으로 박아두기
5) 관측 가능성이 있는가
- 툴 호출 로그에
tool_name, 인자 해시, 결과ok여부를 남기세요 - 루프는 재현이 어렵기 때문에 로그가 곧 해결의 절반입니다
마무리: 무한루프는 모델 문제가 아니라 시스템 설계 문제
OpenAI 툴콜 무한루프는 모델이 멍청해서가 아니라, 시스템이 “멈추는 규칙”과 “재시도 규칙”을 명시하지 않았기 때문에 발생합니다. LangChain을 쓸수록 에이전트가 똑똑하게 알아서 멈출 거라고 기대하기 쉬운데, 운영 환경에서는 반드시 다음을 넣어야 합니다.
- 반복 상한과 타임아웃
- 중복 호출 차단
- 툴 결과의 스키마 정규화
- 재시도 정책의 앱 레벨 통제
이 4가지만 갖추면, 툴콜 기반 에이전트가 실서비스에서 갑자기 비용 폭주하는 상황을 대부분 예방할 수 있습니다.