- Published on
LangChain 에이전트 툴콜링 무한루프 7원인
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LangChain 에이전트를 붙이다 보면, 모델이 답을 내지 못하고 툴만 계속 호출하는 상황을 자주 만납니다. 겉으로는 tool_calls 가 반복되고 토큰만 소모되며, 더 심하면 백엔드도 같은 API를 반복 호출하다가 레이트리밋이나 장애로 이어집니다.
이 글에서는 “왜 무한루프가 생기는지”를 원인 중심으로 7가지로 분해하고, 각 원인별로 재현 패턴과 끊는 방법(프롬프트, 스키마, 상태관리, 실행 가드레일)을 제시합니다. 예시는 LangChain의 에이전트 실행 흐름을 기준으로 설명하지만, 개념은 대부분의 툴콜링 기반 에이전트에 그대로 적용됩니다.
무한루프를 먼저 정의하기
툴콜링 무한루프는 보통 다음 형태로 관측됩니다.
- 모델이
final answer로 종료하지 않고 동일 또는 유사한 툴을 반복 호출 - 툴 결과가 충분히 제공되었는데도 “확인하기 위해 다시 호출” 같은 자기합리화가 반복
- 툴 호출 인자만 조금씩 바꾸며 재시도하지만 성공 조건을 충족하지 못함
운영 관점에서는 “종료 조건이 없거나, 종료 조건을 모델이 인지하지 못하거나, 종료 조건을 충족할 수 없는 상태”로 요약할 수 있습니다.
아래 원인 7가지는 대부분 이 세 가지 중 하나로 귀결됩니다.
원인 1) 툴 결과가 에이전트 상태에 누적되지 않음
가장 흔한 케이스입니다. 툴은 정상 호출되지만, 그 결과가 다음 턴의 컨텍스트에 들어가지 않거나(메모리 미적용), 들어가더라도 너무 축약되어 모델이 “결과를 못 받았다”고 판단합니다.
전형적 증상
- 로그에는 툴 응답이 존재하는데, 다음 모델 입력 메시지에는 툴 메시지가 없음
- 매 턴마다 같은 툴을 같은 인자로 호출
해결 체크리스트
- 툴 응답을
tool역할 메시지로 대화 히스토리에 반드시 append 하는지 확인 - 스트리밍 중간에 예외가 나서 툴 메시지 커밋이 누락되지 않는지 확인
- 요약 메모리를 쓰는 경우, 요약이 툴 결과의 핵심을 제거하지 않는지 확인
Python 예시: 툴 결과 누적 로깅
from langchain_core.callbacks import BaseCallbackHandler
class TraceHandler(BaseCallbackHandler):
def on_tool_start(self, serialized, input_str, **kwargs):
print("TOOL_START", serialized.get("name"), input_str)
def on_tool_end(self, output, **kwargs):
print("TOOL_END", output)
def on_llm_end(self, response, **kwargs):
# 모델이 final로 끝내는지, tool_calls를 내는지 확인
print("LLM_END", response)
핵심은 “툴이 끝난 뒤 그 결과가 다음 턴 입력에 포함되는지”를 메시지 단위로 검증하는 것입니다.
원인 2) 툴 스키마가 모호하거나 과도하게 자유로움
툴 입력 스키마가 느슨하면 모델이 인자를 계속 바꿔가며 호출합니다. 특히 query: string 하나만 있는 검색 툴은 모델이 “더 좋은 쿼리”를 찾겠다며 무한 개선 루프를 돌기 쉽습니다.
전형적 증상
- 검색 툴을
"좀 더 구체적으로"같은 이유로 반복 호출 - 같은 목표를 향해 쿼리만 계속 변형
해결 방법
- 스키마에 선택지를 주고, 종료 조건을 스키마로 강제
- “필요한 정보가 이미 있으면 툴 호출 금지” 같은 규칙을 시스템 프롬프트에 명시
- 검색/조회 툴에는
max_results,freshness,must_include같은 제약 필드를 추가
예시: 제약 필드를 추가한 툴 입력
from pydantic import BaseModel, Field
class SearchInput(BaseModel):
query: str = Field(..., description="사용자 질문을 해결하기 위한 단일 검색 쿼리")
max_results: int = Field(5, ge=1, le=10)
stop_after: bool = Field(
False,
description="이 호출 결과로 답변이 가능하면 true로 설정하고 더 이상 검색하지 않는다"
)
모델이 stop_after 를 켜는 습관을 갖도록 프롬프트와 함께 운용하면, “검색을 위한 검색”이 줄어듭니다.
원인 3) 툴 실패가 ‘성공처럼’ 보이는 응답 포맷
툴이 실패했는데도 HTTP 200으로 내려오거나, 에러가 문자열로만 들어오면 모델은 실패를 인지하지 못합니다. 그러면 “결과가 이상하니 다시 호출”을 반복합니다.
전형적 증상
- 툴 응답에
"error"문자열이 있는데도 모델이 계속 재호출 - 같은 파라미터로 재시도(백오프 없이)
해결 방법
- 툴 응답을 구조화하고, 성공/실패를 명시적으로 분리
- 실패 시에는 에이전트가 즉시 종료하거나, 다른 전략으로 전환하도록 규칙화
예시: 구조화된 툴 응답
from pydantic import BaseModel
from typing import Optional
class ToolResult(BaseModel):
ok: bool
data: Optional[dict] = None
error_code: Optional[str] = None
error_message: Optional[str] = None
모델에게는 ok=false 인 경우 “같은 입력으로 재시도하지 말고 사용자에게 제한을 설명” 같은 정책을 주는 편이 효과적입니다.
레이트리밋이나 쿼터 문제로 재시도가 루프를 만드는 경우도 많습니다. 이때는 원인 분석을 위해 429 패턴을 먼저 정리해두면 좋습니다. 관련해서는 OpenAI Responses API 429인데 TPM만 넘는 6가지 원인도 함께 참고하면 운영 진단이 빨라집니다.
원인 4) “검증 루프”가 프롬프트에 내재됨
프롬프트에 “답하기 전에 반드시 근거를 확인하라”, “확실하지 않으면 다시 검색하라” 같은 문장이 강하게 들어가면, 모델은 안전하게 행동하려다 무한 검증에 빠질 수 있습니다.
전형적 증상
- 모델이 매번 “추가 확인이 필요”라고 말하며 툴을 호출
- 사용자가 이미 충분한 정보를 줬는데도 계속 외부 조회
해결 방법
- 검증을 “최대 N회”로 제한
- “불확실성은 불확실하다고 말하고 종료”를 허용
- 툴 호출이 아닌 내부 추론으로도 답할 수 있는 범위를 명시
프롬프트 패턴 예시
규칙:
1) 툴 호출은 최대 2회까지만 허용한다.
2) 2회 내에 충분한 근거를 얻지 못하면, 현재까지의 정보로 가정과 한계를 명시하고 최종 답변을 작성한다.
3) 동일 툴을 동일 목적의 인자로 반복 호출하지 않는다.
이 규칙만으로도 “완벽주의 루프”가 크게 줄어듭니다.
원인 5) 종료 조건이 코드 레벨에서 보장되지 않음
모델에게 “그만해”라고 말하는 것만으로는 부족합니다. 운영에서는 반드시 코드 레벨 가드레일이 필요합니다.
전형적 증상
- 특정 입력에서만 간헐적으로 무한루프
- 모델이 프롬프트를 무시하거나, 툴 결과가 애매하면 쉽게 루프
해결 방법
max_iterations또는 스텝 제한을 강제- 동일 툴과 동일 인자 반복 시 강제 종료
- “툴 호출 비용”을 스코어링해서 임계값 초과 시 중단
예시: 반복 호출 감지 가드
import json
class LoopGuard:
def __init__(self, max_steps=8):
self.max_steps = max_steps
self.step = 0
self.seen = set()
def check(self, tool_name: str, tool_args: dict):
self.step += 1
if self.step > self.max_steps:
raise RuntimeError("max_steps exceeded")
key = (tool_name, json.dumps(tool_args, sort_keys=True))
if key in self.seen:
raise RuntimeError("repeated tool call detected")
self.seen.add(key)
에이전트 실행 루프 안에서 툴 호출 직전에 check 를 걸면, 모델이 어떻게 행동하든 서버는 안전합니다.
원인 6) 툴이 비결정적이거나, 시간에 따라 결과가 흔들림
검색, 추천, 랭킹, “최신 데이터” 조회처럼 결과가 매번 달라지는 툴은 모델이 결과를 수렴시키기 어렵습니다. 특히 모델이 “결과가 바뀌었다”를 이상 징후로 보고 다시 호출하면 루프가 생깁니다.
전형적 증상
- 같은 쿼리인데 결과가 매번 조금씩 달라짐
- 모델이 “다시 확인”을 반복
해결 방법
- 툴에
snapshot_id또는as_of같은 시점을 고정하는 파라미터 제공 - 캐시를 도입해 동일 입력에 동일 출력을 보장(최소 TTL)
- 모델에게 “결과가 약간 달라도 첫 결과를 기준으로 답하라”고 지시
예시: 캐시로 결정성 부여
from functools import lru_cache
@lru_cache(maxsize=256)
def cached_search(query: str, max_results: int):
# 실제 검색 호출
return real_search(query=query, max_results=max_results)
운영에서는 캐시가 단순 성능 최적화가 아니라 “에이전트 수렴을 돕는 안정장치”가 되기도 합니다.
원인 7) 인증·권한 실패가 툴 레벨에서 반복 재시도를 유발
툴이 내부 서비스(API 게이트웨이, 사내 백엔드)를 호출할 때, 401이나 403이 나면 모델은 원인을 이해하지 못하고 같은 호출을 반복할 수 있습니다. 특히 “로그인 필요” 같은 메시지가 자연어로만 내려오면 더 그렇습니다.
전형적 증상
- 툴 응답이 401인데 모델이 파라미터를 바꿔가며 계속 호출
- 특정 사용자/세션에서만 재현
해결 방법
- 인증 실패는
ok=false와 함께error_code="UNAUTHORIZED"같이 구조화 - 에이전트 정책에 “UNAUTHORIZED면 즉시 중단하고 사용자에게 조치 안내”를 추가
- 백엔드에서는 간헐적 인증 컨텍스트 누락을 먼저 제거
인증 이슈는 에이전트의 문제가 아니라 인프라/보안 설정 문제인 경우가 많습니다. Nginx를 앞단에 두고 JWT 검증을 한다면 Nginx JWT 검증 401? auth_jwt 설정과 디버깅과 Nginx에서 JWT 검증 실패 401 원인 7가지를 같이 점검하는 것이 좋습니다. Spring Security 기반이라면 Spring Boot 3에서 가끔 401? SecurityContext 누락 해결처럼 “간헐적 401” 패턴이 루프를 만들기도 합니다.
운영에서 바로 쓰는 진단 순서
무한루프를 재현했을 때, 다음 순서로 보면 원인 분류가 빨라집니다.
- 스텝별 메시지 덤프: 각 턴에 모델 입력에 툴 결과가 포함되는지 확인
- 툴 응답 구조: 성공과 실패가 기계적으로 구분되는지 확인(
ok,error_code) - 반복 키 탐지: 동일 툴명과 동일 인자 반복 여부 확인
- 종료 가드레일:
max_steps와 반복 감지로 서버가 무한히 돌지 않게 차단 - 비결정성/인증/레이트리밋: 외부 요인(429, 401, 흔들리는 검색 결과) 여부 확인
결론: 프롬프트보다 “상태·스키마·가드레일”이 먼저다
툴콜링 무한루프는 모델의 “의지” 문제로 보이지만, 실제로는 대부분 시스템 설계 문제입니다.
- 상태가 누적되지 않으면 모델은 영원히 같은 질문을 다시 합니다.
- 스키마가 모호하면 모델은 영원히 더 좋은 호출을 찾습니다.
- 실패가 성공처럼 보이면 모델은 영원히 재시도합니다.
- 코드 가드레일이 없으면 운영에서 언젠가 반드시 장애가 납니다.
따라서 해결 우선순위는 보통 툴 응답 구조화 상태 누적 보장 반복 감지 및 max steps 프롬프트 규칙 보강 순이 가장 안전합니다.
원하면, 사용 중인 LangChain 버전과 에이전트 타입(예: ReAct, tool-calling, LangGraph) 그리고 현재 툴 정의/응답 샘플을 기준으로 “당신 코드에 맞춘 루프 차단 템플릿”도 함께 정리해드릴 수 있습니다.