- Published on
AutoGPT 툴콜 무한루프, OpenTelemetry로 추적하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에 올린 AutoGPT 계열 에이전트를 운영하다 보면, 어느 순간부터 토큰이 급격히 소모되고 외부 API 호출이 폭증하면서 작업이 끝나지 않는 현상을 만나게 됩니다. 로그를 보면 같은 툴을 계속 호출하거나, 관찰 결과가 같음에도 다음 스텝에서 또 같은 결정을 내립니다. 흔히 toolcall infinite loop 라고 부르는 이 문제는 단순히 프롬프트를 고치는 수준을 넘어, 에이전트 런타임의 상태 전이와 툴 결과의 안정성, 그리고 재시도 정책까지 함께 봐야 해결됩니다.
이 글에서는 OpenTelemetry로 에이전트 실행을 분산 추적으로 계측해, 무한루프의 원인을 정확히 좁히고 재발을 막는 방법을 다룹니다. 핵심은 “왜 같은 툴을 또 호출했는지”를 한 눈에 보이도록, step 단위 스팬과 tool 스팬을 설계하는 것입니다.
AutoGPT 툴콜 무한루프의 전형적인 패턴
무한루프는 대개 다음 중 하나로 시작됩니다.
1) 관찰값이 불안정하거나 비결정적임
- 같은 입력인데 툴 출력이 매번 조금씩 달라짐
- 시간, 정렬, 랜덤, 페이지네이션 등으로 결과가 흔들림
- LLM이 “아직 확신이 없다”라고 판단하며 재확인 루프에 들어감
예: 검색 API가 매 호출마다 상위 결과 순서가 바뀌어, 에이전트가 “다시 검색해서 확인”을 반복.
2) 툴 오류가 정상 결과처럼 취급됨
- HTTP
429나5xx를 툴이 문자열로 반환 - 에이전트는 이를 실패로 인식하지 못하고 “내용이 부족하다”로 해석
- 결국 같은 툴을 다시 호출
3) 상태 저장이 깨져 step 이 매번 초기화됨
- 메모리 저장소 연결 끊김
- 세션 키가 매 요청마다 달라짐
- 체크포인트가 저장되지 않아 같은 계획을 반복
4) 종료 조건이 프롬프트에만 의존함
- 코드 레벨의
max_steps,max_tool_calls같은 하드 가드가 없음 - “완료되면 종료” 같은 자연어 규칙만 존재
이런 문제는 일반 로그로는 원인 파악이 어렵습니다. 호출 순서, 재시도, 상위 의사결정 프롬프트, 툴 입력 파라미터가 서로 다른 파일과 시스템에 흩어져 있기 때문입니다. 이때 OpenTelemetry 트레이싱이 가장 빠른 진단 도구가 됩니다.
OpenTelemetry로 무엇을 추적해야 하나
무한루프 디버깅의 목표는 딱 3가지 질문에 답하는 것입니다.
- 어느
step에서 루프가 시작됐나 - 어떤
tool이 몇 번 호출됐나 - 각 호출의 입력과 결과가 왜 “다시 호출할 만큼” 만족스럽지 않았나
이를 위해 트레이스 구조를 다음처럼 잡는 것을 권장합니다.
agent.run스팬: 사용자 요청 단위의 루트 스팬agent.step스팬: 에이전트의 한 번의 사고-행동 루프tool.call스팬: 실제 툴 실행 단위llm.generate스팬: LLM 호출 단위
그리고 각 스팬에 “나중에 필터링 가능한” 속성을 붙입니다.
agent.session_id,agent.step_indextool.name,tool.attempt,tool.input_hash,tool.output_hashllm.model,llm.temperature,llm.prompt_hasherror.type,http.status_code,retry.count
중요한 포인트는, 무한루프는 대개 “같은 입력으로 같은 툴을 반복 호출”하거나 “같은 프롬프트로 같은 결정을 반복”하는 형태로 나타난다는 점입니다. 그래서 hash 류 속성이 특히 유용합니다.
Python 에이전트에 OpenTelemetry 트레이싱 붙이기
아래 예시는 AutoGPT 계열 런타임을 직접 구현했거나, 프레임워크 콜백 훅을 통해 툴 호출 지점을 감쌀 수 있다는 가정의 최소 예제입니다. OTLP Exporter 로 Jaeger, Tempo, Honeycomb, Grafana Cloud 등으로 보낼 수 있습니다.
1) 의존성 설치
pip install opentelemetry-api opentelemetry-sdk \
opentelemetry-exporter-otlp \
opentelemetry-instrumentation-requests
2) 트레이서 초기화
OTEL_EXPORTER_OTLP_ENDPOINT 는 환경에 맞게 설정하세요.
import os
from opentelemetry import trace
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
def setup_tracing():
resource = Resource.create({
"service.name": "autogpt-agent",
"service.version": "1.0.0",
"deployment.environment": os.getenv("ENV", "local"),
})
provider = TracerProvider(resource=resource)
exporter = OTLPSpanExporter(
endpoint=os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318/v1/traces"),
)
provider.add_span_processor(BatchSpanProcessor(exporter))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("autogpt")
3) 에이전트 step 과 tool 호출 계측
무한루프를 잡으려면 “반복”을 수치로 드러내야 합니다. 아래는 tool_input_hash 와 tool_output_hash 를 남기고, 같은 입력이 반복되면 loop.suspected 를 표시하는 예시입니다.
import hashlib
import json
from collections import defaultdict
from opentelemetry import trace
def stable_hash(obj) -> str:
raw = json.dumps(obj, ensure_ascii=False, sort_keys=True, default=str)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
class AgentRunner:
def __init__(self, tools, llm_client):
self.tools = tools
self.llm = llm_client
self.seen_tool_inputs = defaultdict(int)
def run(self, session_id: str, user_input: str, max_steps: int = 30, max_tool_calls: int = 80):
tracer = trace.get_tracer("autogpt")
tool_calls = 0
with tracer.start_as_current_span("agent.run") as run_span:
run_span.set_attribute("agent.session_id", session_id)
run_span.set_attribute("agent.max_steps", max_steps)
run_span.set_attribute("agent.max_tool_calls", max_tool_calls)
state = {"goal": user_input, "memory": []}
for step_idx in range(max_steps):
with tracer.start_as_current_span("agent.step") as step_span:
step_span.set_attribute("agent.step_index", step_idx)
# 1) LLM 계획 생성
with tracer.start_as_current_span("llm.generate") as llm_span:
llm_span.set_attribute("llm.model", self.llm.model)
plan = self.llm.plan(state) # 내부적으로 prompt 생성
llm_span.set_attribute("llm.plan_kind", plan.get("kind", "unknown"))
if plan.get("kind") == "final":
step_span.set_attribute("agent.finished", True)
return plan["output"]
tool_name = plan["tool"]["name"]
tool_input = plan["tool"]["input"]
tool_input_hash = stable_hash({"tool": tool_name, "input": tool_input})
self.seen_tool_inputs[tool_input_hash] += 1
repeated = self.seen_tool_inputs[tool_input_hash]
if repeated >= 3:
step_span.set_attribute("loop.suspected", True)
step_span.set_attribute("loop.tool_input_hash", tool_input_hash)
step_span.set_attribute("loop.repeated", repeated)
# 2) 툴 호출
with tracer.start_as_current_span("tool.call") as tool_span:
tool_span.set_attribute("tool.name", tool_name)
tool_span.set_attribute("tool.input_hash", tool_input_hash)
tool_span.set_attribute("tool.repeated", repeated)
result = self.tools[tool_name](tool_input)
tool_calls += 1
tool_output_hash = stable_hash({"result": result})
tool_span.set_attribute("tool.output_hash", tool_output_hash)
# 툴 결과를 state 로 반영
state["memory"].append({
"tool": tool_name,
"input": tool_input,
"result": result,
})
if tool_calls >= max_tool_calls:
run_span.set_attribute("agent.aborted", True)
run_span.set_attribute("agent.abort_reason", "max_tool_calls")
return "Aborted: max_tool_calls reached"
run_span.set_attribute("agent.aborted", True)
run_span.set_attribute("agent.abort_reason", "max_steps")
return "Aborted: max_steps reached"
이 정도만 해도 Jaeger 같은 UI 에서 tool.call 스팬이 동일한 tool.input_hash 로 반복되는지 바로 보입니다. 또한 loop.suspected 가 찍힌 agent.step 을 기준으로 앞뒤 스팬을 비교하면, “결정이 바뀌지 않는 이유”를 역추적할 수 있습니다.
무한루프의 원인을 트레이스로 읽는 방법
1) 같은 툴, 같은 입력이 반복된다
tool.name동일tool.input_hash동일tool.output_hash도 동일
이 경우는 종료 조건이 없거나, LLM 프롬프트가 툴 결과를 충분히 반영하지 못하는 경우가 많습니다.
대응:
- 코드 레벨 가드 추가:
max_tool_calls, 동일 입력 반복 시 강제 중단 - 프롬프트에 “같은 툴을 같은 파라미터로 2회 이상 호출하지 말 것” 같은 규칙 추가
- 결과 요약을 state 에 넣고, 다음 step 에서 반드시 참조하도록 강제
2) 같은 툴, 같은 입력인데 출력 해시가 매번 다르다
tool.input_hash동일tool.output_hash가 계속 변함
이 경우는 툴의 비결정성 또는 외부 시스템의 흔들림입니다.
대응:
- 툴 결과를 정규화: 정렬, 시간 필드 제거, 상위 N 개만 사용
- 캐시:
tool_input_hash로 결과 캐싱 - 외부 API 의 페이지네이션 토큰 고정,
seed지원 시 고정
3) 툴이 사실상 실패하는데 에이전트가 성공으로 인식한다
트레이스에서 tool.call 스팬의 속성으로 http.status_code 나 error.type 를 남기면, 실패가 반복되는지 바로 드러납니다.
대응:
- 툴 어댑터에서 실패를 예외로 던지고, 에이전트가 다른 전략을 선택하도록 설계
- 재시도 정책을 지수 백오프로 제한하고, 특정 코드에서는 즉시 중단
운영 환경에서 외부 호출이 폭주하는 상황은 결국 장애로 이어집니다. 인프라 관점의 장애 진단 흐름은 K8s CrashLoopBackOff 원인 12가지 10분 진단 같은 체크리스트와도 결이 같습니다. 원인을 “추측”하지 말고, 관측 가능한 신호로 좁혀야 합니다.
OpenTelemetry로 루프 차단까지 자동화하기
관측만으로 끝내면 같은 문제가 재발합니다. 트레이스를 기반으로 런타임에서 즉시 차단하는 방식을 함께 넣어두면 효과가 큽니다.
1) 동일 입력 반복 시 서킷 브레이커
class ToolLoopBreaker(Exception):
pass
def guard_repeated_calls(repeated: int, limit: int = 3):
if repeated >= limit:
raise ToolLoopBreaker(f"Repeated tool call detected: repeated={repeated}")
tool.call 직전에 guard_repeated_calls(repeated) 를 호출하고, 예외를 잡아 “다른 툴 선택” 또는 “사용자에게 확인 질문”으로 전환합니다. 이때 예외를 스팬에 기록해야 나중에 분석이 쉽습니다.
2) 트레이스 이벤트로 스냅샷 남기기
스팬 속성은 길이 제한이 있는 백엔드가 많습니다. 그래서 중요한 순간에만 이벤트로 상태 스냅샷을 남기는 전략이 좋습니다.
from opentelemetry import trace
span = trace.get_current_span()
span.add_event(
name="agent.loop_break",
attributes={
"tool.name": tool_name,
"tool.input_hash": tool_input_hash,
"agent.step_index": step_idx,
},
)
3) 비용 폭주 방지: 토큰, 툴 비용을 메트릭으로도 수집
트레이스는 원인 분석에 강하고, 메트릭은 이상 징후 감지에 강합니다.
tool_calls_total{tool}llm_tokens_total{model}agent_steps_totalloop_suspected_total{tool}
이렇게 두면 “루프가 시작되기 직전”을 알람으로 잡을 수 있습니다.
운영 환경에서 특히 조심할 포인트
1) 서버리스, 컨테이너에서 콜드스타트와 타임아웃
에이전트는 한 번의 실행이 길어지기 쉽습니다. 콜드스타트나 스케일링 지연이 겹치면 재시도 루프가 더 악화됩니다. Cloud Run 을 쓴다면 Cloud Run 503·콜드스타트 7분 지연 해결 가이드 처럼 타임아웃, 동시성, 최소 인스턴스를 함께 점검하세요.
2) 외부 스토리지 권한 문제는 루프를 유발한다
예를 들어 에이전트가 중간 결과를 S3 에 저장해야 하는데 403 이 나면, “저장에 실패했으니 다시 시도” 루프로 빠질 수 있습니다. 이때는 애플리케이션 레벨에서 실패를 명확히 분기하고, 인프라 레벨에서는 AWS S3 AccessDenied 403 - 정책·KMS·VPCE 점검 같은 체크리스트로 권한을 빠르게 정리하는 편이 낫습니다.
3) 샘플링 전략
무한루프는 “희귀하지만 치명적”이어서, 트레이스 샘플링에서 누락되면 가장 곤란합니다.
- 정상 트래픽은 낮은 비율로 샘플링
loop.suspected나agent.aborted같은 속성이 찍히면 강제 샘플링- 오류 코드, 재시도 급증 시 강제 샘플링
백엔드에 따라 tail sampling 을 쓰면 이런 정책을 더 깔끔하게 적용할 수 있습니다.
체크리스트: 재현부터 해결까지
-
agent.run/agent.step/tool.call/llm.generate스팬 구조가 있는가 -
tool.input_hash,tool.output_hash로 반복을 식별하는가 - 툴 실패가 예외 또는 명확한 실패 타입으로 모델에 전달되는가
-
max_steps,max_tool_calls, 동일 입력 반복 제한이 코드 레벨에 있는가 - 툴 출력이 비결정적이라면 정규화 또는 캐시가 있는가
- 무한루프 시나리오가 알람으로 감지되는가
마무리
AutoGPT 툴콜 무한루프는 “LLM이 멍청해서”가 아니라, 관측 불가능한 상태 전이와 불안정한 툴 경계에서 자주 발생합니다. OpenTelemetry로 step 과 tool 호출을 스팬으로 쪼개고, 입력과 출력의 해시를 남기면 루프는 더 이상 미스터리가 아닙니다. 원인을 찾는 속도가 빨라질 뿐 아니라, 반복 호출 차단 같은 런타임 가드까지 연결해 운영 안정성을 크게 올릴 수 있습니다.
다음 단계로는 트레이스와 메트릭을 함께 묶어, loop.suspected 발생 시 자동으로 세션을 격리하고 덤프를 남기는 운영 자동화를 붙여보는 것을 권합니다.