- Published on
LangChain 에이전트 무한루프·툴난사 차단법
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
운영 환경에서 LangChain 에이전트를 붙여두면 종종 두 가지 문제가 같이 터집니다. 첫째는 무한루프(같은 생각을 반복하며 종료 조건을 못 찾음), 둘째는 툴난사(웹검색, DB 조회, 파일 읽기 같은 툴을 과도하게 호출)입니다. 둘 다 비용과 지연을 폭발시키고, 최악의 경우 외부 시스템에 과부하를 유발합니다.
이 글은 “왜 루프가 생기는지”를 모델 탓으로만 돌리지 않고, 프레임워크 레벨에서 강제로 멈추게 만드는 안전장치를 설계하는 방법을 다룹니다. 특히 LangChain의 에이전트 실행기, 콜백, 메모리, 툴 래퍼를 이용해 반복 감지 + 예산(steps/time/cost) + 툴 게이팅 + 관측성을 조합합니다.
유사한 무한 반복 문제는 시스템 운영에서도 자주 보입니다. 예를 들어 systemd 서비스가 자동 재시작을 반복하는 루프를 추적하는 방식이 에이전트 루프 진단에도 그대로 통합니다. 필요하면 systemd 서비스 자동 재시작 무한루프 진단 가이드도 같이 참고하세요.
1) 에이전트 무한루프·툴난사의 전형적인 원인
1-1. 종료 조건이 프롬프트에만 의존
에이전트가 Final Answer를 내야 끝나는데, 프롬프트가 애매하면 모델이 “조금만 더 확인”을 무한 반복합니다. 특히 검색 툴이 있으면 “추가로 검색해보자”가 쉬운 탈출구가 됩니다.
1-2. 툴 출력이 다음 행동을 자극하는 형태
툴 결과가 길거나, 에러 메시지가 반복되거나, 모호한 값(예: None, 빈 결과)이 나오면 모델은 같은 툴을 다시 두드립니다.
1-3. 메모리/히스토리 누적으로 판단이 흐려짐
대화 히스토리가 커질수록 모델이 핵심 제약을 놓치고, 이미 했던 툴 호출을 “안 한 것처럼” 다시 하기도 합니다.
1-4. 재시도 정책이 에이전트 레벨에서 중복
HTTP 클라이언트 재시도, 툴 함수 내부 재시도, 에이전트의 재시도(혹은 반복 호출)가 겹치면 “실패 시 무한 재시도”가 됩니다.
2) 1차 방어선: step/time 예산으로 강제 종료
LangChain은 에이전트 실행 시 반복 횟수를 제한할 수 있습니다. 버전별 API가 조금씩 다르지만 핵심은 최대 반복 수와 최대 실행 시간을 둬서, 어떤 경우에도 빠져나오게 만드는 것입니다.
2-1. 최대 반복 수 제한
from langchain.agents import AgentExecutor, create_openai_tools_agent
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
agent = create_openai_tools_agent(
llm=llm,
tools=[],
prompt=None, # 예시 단순화
)
executor = AgentExecutor(
agent=agent,
tools=[],
verbose=True,
max_iterations=8,
handle_parsing_errors=True,
)
result = executor.invoke({"input": "요약해줘"})
print(result)
max_iterations는 “정답을 못 찾더라도” 일정 횟수 이후 중단시키는 안전벨트입니다. 운영에서는 보통 6~15 사이에서 시작해 트래픽/비용/정확도 균형을 맞춥니다.
2-2. 최대 실행 시간 제한(타임아웃)
반복 횟수 제한만으로는 “한 번의 툴 호출이 오래 걸리는” 상황을 막기 어렵습니다. 툴과 실행기 모두에 시간 예산을 걸어야 합니다.
import time
from langchain.agents import AgentExecutor
class TimeoutAgentExecutor(AgentExecutor):
def __init__(self, *args, max_wall_time_s=20, **kwargs):
super().__init__(*args, **kwargs)
self.max_wall_time_s = max_wall_time_s
def invoke(self, input, config=None, **kwargs):
start = time.time()
while True:
if time.time() - start > self.max_wall_time_s:
return {
"output": "시간 예산을 초과해 중단했습니다. 필요한 정보를 더 구체적으로 알려주세요.",
"stopped": True,
}
return super().invoke(input, config=config, **kwargs)
실무에서는 Python 레벨 루프보다, 툴 호출 자체에 HTTP 타임아웃을 반드시 걸고(예: requests timeout, httpx timeout), 실행기에도 벽시계 기준 타임아웃을 둡니다.
3) 2차 방어선: 반복 패턴 감지로 “같은 행동” 차단
무한루프의 본질은 “같은 입력으로 같은 툴을 같은 방식으로 계속 호출”입니다. 따라서 최근 N개의 툴 호출 시그니처를 저장하고 반복되면 차단합니다.
여기서 시그니처는 보통 다음을 해시한 값으로 만듭니다.
- 툴 이름
- 정규화된 인자(JSON 정렬)
- (선택) 툴 결과의 요약 또는 에러 코드
3-1. 툴 래퍼로 반복 호출 차단
import json
import hashlib
import time
from collections import deque
from langchain.tools import BaseTool
class AntiLoopTool(BaseTool):
def __init__(
self,
tool: BaseTool,
window=6,
max_same_calls=2,
cooldown_s=0,
):
super().__init__(name=tool.name, description=tool.description)
self._tool = tool
self._recent = deque(maxlen=window)
self._max_same_calls = max_same_calls
self._cooldown_s = cooldown_s
self._last_call_ts = 0.0
def _sig(self, tool_input) -> str:
payload = json.dumps(tool_input, sort_keys=True, ensure_ascii=False)
raw = f"{self.name}:{payload}".encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def _run(self, tool_input, **kwargs):
now = time.time()
if self._cooldown_s and (now - self._last_call_ts) < self._cooldown_s:
return "툴 호출 쿨다운 중입니다. 다른 접근을 시도하세요."
sig = self._sig(tool_input)
same = sum(1 for s in self._recent if s == sig)
if same >= self._max_same_calls:
return (
"동일한 툴 호출이 반복되어 차단했습니다. "
"입력을 바꾸거나, 다른 툴/추론으로 전환하세요."
)
self._recent.append(sig)
self._last_call_ts = now
return self._tool.run(tool_input, **kwargs)
이 방식의 장점은 단순합니다.
- 에이전트 종류가 바뀌어도 툴 레벨에서 동일하게 적용
- 반복 호출이 비용을 만들기 전에 차단
주의할 점은 “정상적인 재시도”까지 막지 않도록 max_same_calls와 cooldown_s를 보수적으로 잡는 것입니다. 예를 들어 네트워크가 불안정한 환경이면 같은 호출 1회 재시도는 허용하고, 2회 이상부터 차단하는 식이 안전합니다.
4) 3차 방어선: 툴 게이팅(권한·쿼터·조건부 허용)
툴난사는 보통 “모든 툴이 항상 열려있기 때문”에 발생합니다. 운영에서는 다음 정책을 조합합니다.
- 요청 단위 툴 호출 상한(총 호출 수)
- 툴별 호출 상한(검색은 2회, DB는 3회 등)
- 특정 조건에서만 툴 허용(예: 사용자가 최신 정보 요청 시에만 웹검색)
- 고비용 툴은 2단계 승인(에이전트가 계획을 먼저 제출)
4-1. 툴별 쿼터를 강제하는 게이트
from collections import defaultdict
from langchain.tools import BaseTool
class QuotaTool(BaseTool):
def __init__(self, tool: BaseTool, limits: dict, counter: dict):
super().__init__(name=tool.name, description=tool.description)
self._tool = tool
self._limits = limits
self._counter = counter
def _run(self, tool_input, **kwargs):
self._counter[self.name] += 1
limit = self._limits.get(self.name)
if limit is not None and self._counter[self.name] > limit:
return (
f"툴 `{self.name}` 호출 한도({limit})를 초과했습니다. "
"현재 정보로 결론을 내리거나 사용자에게 추가 질문을 하세요."
)
return self._tool.run(tool_input, **kwargs)
# 사용 예
limits = {"web_search": 2, "sql_query": 3}
counts = defaultdict(int)
# tools = [QuotaTool(t, limits, counts) for t in tools]
이렇게 하면 “검색만 10번 반복” 같은 상황을 구조적으로 차단할 수 있습니다.
5) 4차 방어선: 프롬프트에 ‘계획-실행-종료’ 규칙을 명문화
기술적으로 막아도, 모델이 계속 툴을 쓰려는 성향이 강하면 사용자 경험이 나빠집니다. 따라서 시스템/에이전트 프롬프트에 다음을 명시합니다.
- 툴 사용은 최대 몇 번
- 불확실하면 질문하고 종료
- 동일 툴을 같은 인자로 반복 호출 금지
- 툴 결과가 비었으면 다른 전략으로 전환
예시 문구(프롬프트에 그대로 포함 가능):
- You have a limited tool budget.
- Do not call the same tool with the same arguments more than once.
- If you cannot make progress after two tool calls, ask a clarifying question and stop.
- Prefer answering with available information; only use tools when necessary.
핵심은 “툴을 쓰지 말라”가 아니라, 툴 사용의 종료 조건을 문장으로 박아 넣는 것입니다.
6) 5차 방어선: 관측성(로그·트레이싱)으로 루프 원인 고립
무한루프는 재현이 어렵습니다. 운영에서 중요한 건 “왜 같은 툴을 반복했는지”를 남기는 것입니다.
권장 로그 필드:
request_id,user_id- step 번호, 누적 토큰/비용(가능하면)
- 툴 이름, 입력 인자(민감정보 마스킹), 결과 크기, 에러 코드
- 중단 사유(반복 감지, 쿼터 초과, 타임아웃, max iterations)
이 접근은 Kubernetes의 CrashLoopBackOff를 원인별로 쪼개듯이, 에이전트도 “반복의 종류”를 분류하게 해줍니다. 인프라 관점의 루프 진단 감각이 필요하면 K8s CrashLoopBackOff - OOMKilled·Probe 실패 진단도 도움이 됩니다.
6-1. LangChain 콜백으로 툴 호출 이벤트 수집
LangChain은 콜백 핸들러로 툴 시작/종료를 관찰할 수 있습니다.
from langchain.callbacks.base import BaseCallbackHandler
class ToolAuditCallback(BaseCallbackHandler):
def __init__(self):
self.events = []
def on_tool_start(self, serialized, input_str, **kwargs):
name = serialized.get("name") if isinstance(serialized, dict) else str(serialized)
self.events.append({"event": "tool_start", "tool": name, "input": input_str})
def on_tool_end(self, output, **kwargs):
self.events.append({"event": "tool_end", "output_preview": str(output)[:200]})
# executor.invoke(..., config={"callbacks": [ToolAuditCallback()]})
운영에서는 이 이벤트를 그대로 저장하기보다, 개인정보/토큰 폭발을 막기 위해 입력/출력을 요약하거나 길이 제한을 둡니다.
7) 실전 조합 레시피: “예산 + 반복감지 + 쿼터 + 타임아웃”
현장에서 가장 효과적인 조합은 다음 순서로 방어선을 쌓는 것입니다.
max_iterations로 1차 강제 종료- 툴에 HTTP 타임아웃 적용 + 실행기 벽시계 타임아웃
- 툴 래퍼로 반복 호출 차단(AntiLoop)
- 툴별 쿼터(Quota)로 툴난사 상한
- 콜백으로 원인 분류 가능한 로그 남기기
예시로 두 래퍼를 동시에 적용하면 다음처럼 구성할 수 있습니다.
from collections import defaultdict
counts = defaultdict(int)
limits = {"web_search": 2, "sql_query": 3}
wrapped_tools = []
for t in tools:
t1 = AntiLoopTool(t, window=6, max_same_calls=2, cooldown_s=0)
t2 = QuotaTool(t1, limits=limits, counter=counts)
wrapped_tools.append(t2)
executor = AgentExecutor(
agent=agent,
tools=wrapped_tools,
max_iterations=10,
verbose=True,
)
이렇게 하면 모델이 아무리 “검색을 더 하자”고 주장해도, 시스템은 2회까지만 허용하고 그 뒤에는 “현재 정보로 결론을 내거나 질문하라”로 유도합니다.
8) 자주 겪는 함정과 체크리스트
8-1. 툴 스키마/파라미터가 불안정하면 루프가 늘어난다
툴 입력 스키마가 애매하면 모델이 파라미터를 계속 바꿔가며 시도합니다. 특히 Tool Use 스키마가 엄격한 모델(Claude 계열 등)을 붙이면 스키마 오류로 재시도가 반복될 수 있습니다. 스키마 오류가 잦다면 Claude Tool Use 400 invalid_tool_schema 해결 가이드를 참고해 입력 스키마를 먼저 안정화하세요.
8-2. “재시도는 툴 내부에서만” 혹은 “에이전트에서만”
재시도 계층을 하나로 통일하세요. 예를 들어 네트워크 재시도는 HTTP 클라이언트에서 1회만, 에이전트는 반복 감지로 동일 호출 재시도를 막는 식이 깔끔합니다.
8-3. 툴 결과가 너무 길면 다음 스텝이 꼬인다
툴 결과는 요약해서 주거나, 상위 N개만 전달하세요. 길이가 길수록 모델이 핵심을 놓치고 “다시 호출”하는 경향이 커집니다.
8-4. 중단 메시지는 “다음 행동”을 안내해야 한다
차단했을 때 단순히 “안 됨”이라고 하면 사용자는 다시 같은 요청을 하고, 에이전트는 또 같은 루프에 들어갑니다. 차단 응답에는 다음 중 하나를 포함하세요.
- 추가 질문(무엇이 필요한지)
- 지금 가진 정보로 낸 임시 결론
- 허용된 대안(다른 툴, 다른 범위)
9) 결론: 에이전트는 ‘자율’이 아니라 ‘통제된 실행’이 핵심
LangChain 에이전트의 무한루프와 툴난사는 “모델이 멍청해서”가 아니라, 통제 장치가 없는 실행 환경에서 자연스럽게 발생하는 현상입니다. 운영 관점에서는 다음 4가지만 적용해도 대부분의 사고를 막을 수 있습니다.
- 반복 횟수와 시간 예산을 기본값으로 강제
- 동일 툴/동일 인자 반복 호출 차단
- 툴별 쿼터와 조건부 허용으로 비용 상한 설정
- 콜백 기반 관측성으로 원인 분류 및 재발 방지
이 조합을 먼저 넣고, 그 다음에 프롬프트 튜닝이나 모델 교체를 하세요. 그래야 “가끔 터지는” 루프가 아니라, 항상 예측 가능한 실패 모드로 관리할 수 있습니다.