- Published on
LangChain 에이전트 무한루프·툴폭주 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 환경에서 LangChain 에이전트를 붙이면 가장 흔한 장애 패턴이 두 가지입니다. 첫째는 무한루프(같은 생각-행동을 반복하며 종료 조건에 도달하지 못함), 둘째는 툴폭주(의도치 않은 툴을 과도하게 호출해 비용·지연·외부 API 쿼터를 태움)입니다. 둘은 보통 같이 옵니다. 한 번 루프에 빠지면 에이전트는 “정보가 더 필요하다”는 이유로 툴을 계속 호출하고, 툴이 완벽히 실패하지 않는 한(예: 200이지만 빈 결과) 루프는 더 길어집니다.
이 글은 “왜 이런 현상이 생기는지”를 설명하는 데서 끝나지 않고, 재현 → 관측 → 차단(가드레일) → 근본 개선(툴/프롬프트/상태 설계) 순서로 정리합니다.
증상: 무한루프와 툴폭주의 전형적인 로그 패턴
다음과 같은 패턴이 보이면 거의 확실히 루프입니다.
- 동일한 툴이 짧은 간격으로 반복 호출됨(예:
search30회) - 툴 결과가 매번 유사하거나 빈 값인데도 “한 번만 더”를 반복
- 에이전트 최종 응답이 오지 않고 타임아웃으로 끝남
- 토큰 사용량/비용이 요청당 급증
- 외부 시스템(검색/DB/사내 API) 쿼터 소진, 레이트리밋 증가
운영 관점에서는 “LLM이 멈췄다”로 보이지만, 실제로는 멈춘 게 아니라 너무 열심히 일하고 있는 경우가 많습니다. 이때는 애플리케이션 타임아웃만 늘리면 더 큰 비용 폭탄이 됩니다. 타임아웃 장애 대응은 아래 글의 접근(재현/관측/단계적 타임아웃 조정)이 비슷한 결을 갖습니다: OpenAI Responses API 408 타임아웃 재현과 해결 실전 가이드
원인 분류: 왜 에이전트는 루프에 빠지나
1) 종료 조건이 모델에게 명확하지 않음
에이전트 프롬프트에 “언제 멈춰야 하는지”가 애매하면, 모델은 안전하게(?) 더 많은 정보를 수집하려고 합니다.
- “필요하면 검색해”만 있고 “검색은 최대 2회” 같은 상한이 없음
- “답을 확신할 때만 종료” 같은 표현은 오히려 종료를 어렵게 함
2) 툴이 비결정적이거나, 실패를 성공처럼 반환
툴이 다음처럼 동작하면 루프가 잘 생깁니다.
- 200 OK지만 본문이 비어 있음(검색 결과 0건)
- 오류를 잡아서
""같은 값으로 반환(에이전트는 실패로 인지 못함) - “부분 성공”인데 성공 플래그가 없음
3) 툴 출력이 너무 길거나, 핵심 신호가 없음
툴 결과가 장황하면 모델은 핵심을 못 잡고 “추가 확인”을 반복합니다.
4) 상태(메모리)가 누적되어 자기강화 루프가 됨
대화 히스토리나 scratchpad가 길어질수록 모델이 과거의 잘못된 가정을 강화합니다. AutoGPT류에서 흔한 메모리 폭주 패턴과 유사합니다: AutoGPT 메모리 폭주 해결 - 벡터DB TTL·요약
5) 멱등성 없는 툴 + 재시도 정책
네트워크 지연/타임아웃이 있을 때 재시도까지 붙으면 호출량이 기하급수로 늘 수 있습니다.
1단계: 재현 가능한 최소 케이스 만들기
디버깅의 첫걸음은 “운영에서 가끔 터진다”를 “로컬에서 100% 재현된다”로 바꾸는 것입니다.
- 툴 결과를 의도적으로 빈 값으로 만들기
- 툴이 애매한 결과를 반환하도록 스텁 서버 만들기
- 모델 온도를 높여(예:
temperature=0.7) 탐색적 행동을 유도
아래는 LangChain에서 툴이 항상 빈 결과를 반환하게 만들어 루프를 유도하는 예시입니다.
from langchain_openai import ChatOpenAI
from langchain_core.tools import tool
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain_core.prompts import ChatPromptTemplate
@tool
def search_docs(query: str) -> str:
# 의도적으로 애매한/무의미한 결과를 반환
return ""
llm = ChatOpenAI(model="gpt-4.1-mini", temperature=0.3)
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful agent. Use tools when needed."),
("human", "{input}"),
("placeholder", "{agent_scratchpad}"),
])
agent = create_tool_calling_agent(llm, tools=[search_docs], prompt=prompt)
executor = AgentExecutor(agent=agent, tools=[search_docs], verbose=True)
executor.invoke({"input": "사내 위키에서 '온콜 핸드오프 절차'를 찾아 요약해줘"})
이 예시는 “툴이 빈 값을 반환해도 모델이 계속 검색을 반복”하는 전형적인 루프를 보여줍니다.
2단계: 관측(Observability) — 루프를 숫자로 잡기
무한루프/툴폭주는 감으로 잡으면 안 됩니다. 호출 횟수, 반복 패턴, 원인 툴, 평균 지연이 보이면 해결이 빨라집니다.
(1) LangChain verbose 로그만으로는 부족한 이유
verbose=True는 “무슨 툴을 썼는지”는 보여주지만,
- 동일 툴 반복 호출의 원인(입력/출력 변화)
- 루프 탐지(유사도 기반)
- 요청 단위 비용/토큰
같은 핵심을 자동으로 요약해주진 않습니다.
(2) 콜백으로 툴 호출 횟수/입력/출력 길이 기록
아래는 간단한 콜백 핸들러로 툴 호출을 계측하는 예시입니다.
import time
from collections import defaultdict
from langchain_core.callbacks import BaseCallbackHandler
class ToolSpamMonitor(BaseCallbackHandler):
def __init__(self):
self.tool_calls = defaultdict(int)
self.events = []
def on_tool_start(self, serialized, input_str, **kwargs):
name = serialized.get("name", "unknown")
self.tool_calls[name] += 1
self.events.append({
"t": time.time(),
"type": "tool_start",
"tool": name,
"input_len": len(str(input_str)),
})
def on_tool_end(self, output, **kwargs):
self.events.append({
"t": time.time(),
"type": "tool_end",
"output_len": len(str(output)),
})
monitor = ToolSpamMonitor()
# AgentExecutor 생성 시 callbacks에 주입
# executor = AgentExecutor(..., callbacks=[monitor])
운영에서는 이 데이터를 구조화 로그(JSON)로 남겨서 요청 단위로 집계해야 합니다. “특정 요청에서 search_docs가 15회 이상 호출되면 경보” 같은 룰이 가능해집니다.
(3) 트레이싱 도구로 체인/툴 호출 그래프 보기
LangSmith나 OpenTelemetry를 붙이면 “어떤 단계에서 동일 행동을 반복하는지”가 시각적으로 보입니다. 특히 툴 결과가 길어 모델이 요지를 못 잡는 경우, 트레이스에서 툴 출력 크기가 비정상적으로 큰 것이 바로 드러납니다.
3단계: 즉시 효과 있는 차단책(가드레일)
근본 원인을 고치기 전에, 운영 장애를 막는 하드 리밋이 먼저입니다.
(1) 최대 반복 횟수 제한(max_iterations)
LangChain의 AgentExecutor는 반복 상한을 둘 수 있습니다.
executor = AgentExecutor(
agent=agent,
tools=[search_docs],
verbose=True,
max_iterations=6,
early_stopping_method="force", # 또는 "generate"
)
force: 상한 도달 시 강제 종료(부분 결과/중간 상태만 남을 수 있음)generate: 상한 도달 시 “현재까지로 답을 생성”을 시도
운영에서는 보통 generate가 사용자 경험이 낫습니다.
(2) 툴별 호출 쿼터(요청당 N회)
전체 반복 제한만으로는 부족합니다. 예를 들어 검색 5회는 괜찮지만 결제 API 2회는 위험할 수 있습니다. 툴 래퍼에서 카운트를 강제하세요.
from langchain_core.tools import tool
class QuotaExceeded(Exception):
pass
class ToolQuota:
def __init__(self, limit_by_tool):
self.limit_by_tool = dict(limit_by_tool)
self.used = {k: 0 for k in self.limit_by_tool}
def check_and_inc(self, tool_name: str):
if tool_name not in self.limit_by_tool:
return
self.used[tool_name] += 1
if self.used[tool_name] > self.limit_by_tool[tool_name]:
raise QuotaExceeded(f"tool quota exceeded: {tool_name}")
quota = ToolQuota({"search_docs": 2})
@tool
def guarded_search_docs(query: str) -> str:
quota.check_and_inc("search_docs")
return "" # 실제 구현에서는 검색 수행
핵심은 “모델이 아무리 우겨도 더 이상 호출되지 않게” 만드는 것입니다.
(3) 타임아웃과 회로 차단기(circuit breaker)
툴이 외부 API를 호출한다면 타임아웃은 필수입니다. 타임아웃이 없으면 루프가 아니라 “대기”로 시스템이 죽습니다.
- connect/read 타임아웃 분리
- 연속 실패 시 일정 시간 툴 차단
이때도 마찬가지로 -> 같은 기호를 로그에 그대로 남기기보다 구조화 필드로 남겨야 분석이 쉽습니다.
4단계: 근본 개선 1 — 툴 설계를 ‘에이전트 친화적’으로 바꾸기
(1) 성공/실패를 명시적으로 반환
에이전트가 가장 잘 판단하는 형태는 “상태 + 요약 + 다음 행동 힌트”입니다.
import json
from langchain_core.tools import tool
@tool
def search_docs_v2(query: str) -> str:
results = [] # 실제 검색 결과
payload = {
"ok": True,
"query": query,
"count": len(results),
"items": results[:3],
"hint": "If count is 0, do not retry with same query. Ask user for clarification.",
}
return json.dumps(payload, ensure_ascii=False)
포인트는 count=0을 “성공이지만 빈 결과”로 애매하게 두지 말고, 재시도 금지 힌트를 같이 주는 것입니다.
(2) 출력 길이 제한과 요약
툴이 원문을 통째로 반환하면 모델은 또 검색합니다. 툴이 먼저 요약해 주거나, 상위 3개만 반환하세요.
(3) 멱등 키(idempotency key)로 중복 실행 방지
결제/티켓 생성처럼 부작용이 있는 툴은 요청 단위 멱등 키를 반드시 두세요.
- 같은 입력이면 같은 결과를 반환
- 중복 호출은 “이미 처리됨”으로 응답
5단계: 근본 개선 2 — 프롬프트에 ‘정지 규칙’을 넣기
에이전트 프롬프트는 “잘하라”가 아니라 “그만해라”를 더 구체적으로 써야 합니다.
다음 규칙은 실제로 루프를 크게 줄입니다.
- 동일 툴을 동일/유사 쿼리로 2회 이상 호출 금지
- 툴 결과가
count=0이면 사용자에게 확인 질문으로 전환 - 확률적 탐색(temperature)을 낮추고, 계획을 먼저 작성 후 실행
예시:
You must follow these rules:
1) Do not call the same tool more than 2 times.
2) If a tool returns empty or count=0, do not retry with the same query.
3) If you cannot proceed, ask a single clarifying question and stop.
MDX 환경에서는 부등호가 있는 표현(예: count>0)을 본문에 그대로 쓰면 빌드 에러가 날 수 있으니, 반드시 count>0처럼 엔티티로 쓰거나 인라인 코드로 감싸는 습관을 들이세요.
6단계: 루프 탐지 로직 — “비슷한 행동”을 자동 차단
반복은 “완전히 동일”하지 않습니다. 쿼리의 조사만 바뀌거나 공백만 바뀌는 식으로 변형됩니다. 그래서 유사도 기반 루프 탐지가 효과적입니다.
간단한 방법은 “최근 N개의 툴 입력을 정규화(normalize)해서 동일하면 차단”입니다.
import re
def normalize_query(q: str) -> str:
q = q.lower().strip()
q = re.sub(r"\s+", " ", q)
q = re.sub(r"[\W_]+", " ", q)
return q.strip()
class LoopGuard:
def __init__(self, window=5):
self.window = window
self.recent = []
def seen(self, tool: str, query: str) -> bool:
key = f"{tool}:{normalize_query(query)}"
if key in self.recent:
return True
self.recent.append(key)
self.recent = self.recent[-self.window:]
return False
loop_guard = LoopGuard(window=6)
# 툴 내부에서 사용
# if loop_guard.seen("search_docs", query): raise QuotaExceeded("repeated query")
이 정도만 해도 “같은 검색어로 10번 때리는” 폭주는 대부분 사라집니다.
7단계: 운영에서의 장애 형태 — 재시작 루프와 헷갈리지 않기
에이전트 루프는 애플리케이션 레벨에서 발생하지만, 운영에서는 종종 “컨테이너가 계속 재시작된다”로 관측됩니다. 예를 들어 요청이 오래 걸려 워커가 죽고, 프로세스 매니저가 재시작하며 동일 요청이 재시도되면 툴 호출은 더 늘어납니다.
- 서비스 재시작 루프 점검: systemd 서비스가 자꾸 재시작될 때 진단 방법
또한 쿠버네티스 환경에서는 readiness/liveness 설정이 공격적으로 잡혀 있으면 “응답 지연 = 비정상”으로 판단해 재시작이 걸립니다. 이 경우 원인이 에이전트 루프인지, readiness 설계인지 분리해서 봐야 합니다.
체크리스트: 무한루프·툴폭주를 줄이는 12가지 규칙
max_iterations로 상한을 건다.- 툴별 쿼터를 둔다(요청당 N회).
- 툴에 타임아웃을 건다(connect/read).
- 툴 결과는
ok,count,hint를 포함한 구조화 형태로 반환한다. - 빈 결과는 “재시도 금지”로 명시한다.
- 툴 출력 길이를 제한하고 상위 N개만 반환한다.
- 부작용 툴은 멱등 키로 중복 실행을 막는다.
- 프롬프트에 “정지 규칙”을 구체적으로 쓴다.
- 동일/유사 쿼리 반복을 정규화 기반으로 탐지한다.
- 요청 단위로 툴 호출 횟수, 평균 지연, 출력 길이를 계측한다.
- 트레이싱으로 반복 지점을 시각화한다.
- 재시작/재시도로 인한 호출 증폭을 운영 레이어에서 차단한다.
마무리: 에이전트는 ‘똑똑한 프로그램’이 아니라 ‘확률적 런타임’이다
LangChain 에이전트의 무한루프는 “모델이 멍청해서”가 아니라, 종료 규칙과 툴 인터페이스가 확률적 런타임을 감당할 만큼 단단하지 않아서 발생합니다. 해결의 핵심은 모델을 더 좋은 것으로 바꾸는 게 아니라,
- 루프를 관측 가능하게 만들고
- 상한/쿼터/타임아웃으로 폭주를 막고
- 툴을 에이전트 친화적으로 설계하고
- 프롬프트에 정지 규칙을 넣는 것
입니다.
이 네 가지를 적용하면 “가끔 폭주하는 에이전트”가 “예측 가능한 비용과 지연을 가진 시스템”으로 바뀝니다.