자율 에이전트 시스템의 디버깅과 관찰 가능성
조용히 실패하는 자율 에이전트는 에이전트가 없는 것보다 더 나쁩니다. 전통적인 함수가 예외를 던지면 스택 트레이스를 얻습니다. 에이전트가 스무 번의 도구 호출과 세 번의 모델 호출을 거쳐 잘못된 길로 가면 잘못된 답변을 얻습니다 — 명확한 설명 없이.
에이전트 디버깅에는 다른 정신적 모델이 필요합니다. 시스템은 결정론적 경로를 실행하는 것이 아니라 일련의 결정을 내리고 있습니다. 관찰 가능성이란 이러한 결정들을 포착하는 것을 의미합니다 — 입력과 출력뿐만 아니라 그것들을 연결하는 추론까지.
에이전트에 전통적인 디버깅이 실패하는 이유
표준 로깅은 무슨 일이 일어났는지를 포착합니다. 에이전트 관찰 가능성은 왜를 포착해야 합니다 — 모델이 무엇을 결론 내렸는지, 어떤 도구를 왜 선택했는지, 어떤 중간 상태에서 작업하고 있었는지.
실패 모드도 다릅니다:
- 조용한 환각: 에이전트가 불확실성을 신호하지 않고 자신 있게 잘못된 답변을 생성합니다.
- 결정 드리프트: 각 단계는 국소적으로 합리적으로 보이지만 시퀀스 전체가 목표에서 벗어납니다.
- 도구 오용: 에이전트가 올바른 도구를 미묘하게 잘못된 매개변수로 호출합니다.
- 무한 루프: 에이전트가 실패한 접근 방식을 계속 재시도하며 막힙니다.
- 컨텍스트 오염: 초기 단계의 잘못된 출력이 이후 모든 추론을 손상시킵니다.
이 중 어느 것도 예외를 생성하지 않습니다. 전체 실행 트레이스를 재구성할 때만 보이는 잘못된 동작을 만들어냅니다.
에이전트 결정을 위한 구조화된 로깅
첫 번째 단계는 모든 에이전트 상호작용을 구조화된 로그로 래핑하는 것입니다. 원시 API 응답을 기록하지 말고 의미적 이벤트를 기록하세요.
import jsonimport timeimport uuidfrom dataclasses import dataclass, asdictfrom typing import Anyimport anthropic
client = anthropic.Anthropic()
@dataclassclass AgentEvent: trace_id: str step: int event_type: str # "llm_call", "tool_call", "tool_result", "decision", "error" model: str | None input_tokens: int | None output_tokens: int | None latency_ms: float | None content: dict[str, Any] timestamp: float
def log_event(event: AgentEvent): print(json.dumps(asdict(event))) # 로그 싱크로 교체하세요
class TracedAgent: def __init__(self, trace_id: str | None = None): self.trace_id = trace_id or str(uuid.uuid4()) self.step = 0 self.tools = []
def add_tool(self, name: str, description: str, input_schema: dict): self.tools.append({ "name": name, "description": description, "input_schema": input_schema })
def call(self, messages: list[dict], system: str = "") -> str: self.step += 1 start = time.monotonic()
response = client.messages.create( model="claude-opus-4-6", max_tokens=4096, system=system, tools=self.tools, messages=messages )
latency_ms = (time.monotonic() - start) * 1000
log_event(AgentEvent( trace_id=self.trace_id, step=self.step, event_type="llm_call", model="claude-opus-4-6", input_tokens=response.usage.input_tokens, output_tokens=response.usage.output_tokens, latency_ms=latency_ms, content={ "stop_reason": response.stop_reason, "text_blocks": [b.text for b in response.content if b.type == "text"], "tool_calls": [ {"name": b.name, "input": b.input} for b in response.content if b.type == "tool_use" ] }, timestamp=time.time() ))
return response완전한 트레이스 구축
단일 로그 라인으로는 부족합니다 — 각 결정을 결과와 연결하는 전체 실행 트레이스가 필요합니다:
from typing import Callable
def run_traced_agent( task: str, tools: dict[str, Callable], tool_schemas: list[dict], system: str, max_steps: int = 20,) -> dict: agent = TracedAgent() for schema in tool_schemas: agent.add_tool(**schema)
messages = [{"role": "user", "content": task}] trace = {"trace_id": agent.trace_id, "task": task, "steps": []} step_count = 0
while step_count < max_steps: step_count += 1 response = agent.call(messages, system=system)
step_record = { "step": step_count, "stop_reason": response.stop_reason, "model_output": [], "tool_results": [] }
if response.stop_reason == "end_turn": for block in response.content: if block.type == "text": step_record["model_output"].append(block.text) trace["steps"].append(step_record) trace["final_answer"] = step_record["model_output"][-1] if step_record["model_output"] else "" break
tool_results = [] for block in response.content: if block.type == "tool_use": step_record["model_output"].append({ "tool": block.name, "input": block.input })
tool_fn = tools.get(block.name) if not tool_fn: result = f"오류: 알 수 없는 도구 '{block.name}'" else: try: result = tool_fn(**block.input) except Exception as e: result = f"도구 오류: {e}"
tool_results.append({ "type": "tool_result", "tool_use_id": block.id, "content": str(result) }) step_record["tool_results"].append({ "tool": block.name, "result_preview": str(result)[:200] })
trace["steps"].append(step_record) messages.append({"role": "assistant", "content": response.content}) messages.append({"role": "user", "content": tool_results})
else: trace["error"] = f"최대 단계 수 초과 ({max_steps})"
return trace루프 감지
무한 루프는 일반적인 실패 모드입니다. 각 LLM 호출의 도구 호출 패턴에 지문을 찍어 감지합니다:
def detect_loop(trace: dict, window: int = 4) -> bool: steps = trace["steps"] if len(steps) < window: return False
def step_signature(step: dict) -> str: tools_called = sorted( t["tool"] if isinstance(t, dict) else t for t in step.get("model_output", []) if isinstance(t, dict) and "tool" in t ) return "|".join(tools_called)
recent = [step_signature(s) for s in steps[-window:]] if len(set(recent)) == 1 and recent[0]: return True
if len(steps) >= 4: pattern = [step_signature(s) for s in steps[-4:]] if pattern[0] == pattern[2] and pattern[1] == pattern[3]: return True
return False프로덕션에서 추적할 메트릭
from collections import Counter
def compute_trace_metrics(trace: dict) -> dict: steps = trace["steps"] errors = [s for s in steps if "error" in s]
tool_calls_by_name: Counter = Counter() for step in steps: for output in step.get("model_output", []): if isinstance(output, dict) and "tool" in output: tool_calls_by_name[output["tool"]] += 1
return { "trace_id": trace["trace_id"], "total_steps": len(steps), "error_steps": len(errors), "tool_call_distribution": dict(tool_calls_by_name), "completed": "final_answer" in trace, "loop_detected": detect_loop(trace), }알림을 위한 핵심 신호:
- 루프 비율 > 5% — 에이전트가 막히고 있음
- 도구별 오류율 > 임계값 — 도구가 고장남
- 평균 단계 수 증가 추세 — 작업이 어려워지고 있음
- p99 지연 시간 급등 — 모델 엔드포인트가 느림
OpenTelemetry 통합
이미 OpenTelemetry를 사용하는 팀의 경우 에이전트 트레이스를 스팬으로 내보낼 수 있습니다:
from opentelemetry import trace as otel_trace
tracer = otel_trace.get_tracer("agent")
def run_with_otel(task: str, tools: dict, tool_schemas: list, system: str): with tracer.start_as_current_span("agent.run") as root_span: root_span.set_attribute("agent.task", task[:200])
agent = TracedAgent() for schema in tool_schemas: agent.add_tool(**schema)
messages = [{"role": "user", "content": task}]
for step in range(20): with tracer.start_as_current_span(f"agent.step.{step}") as step_span: response = agent.call(messages, system=system) step_span.set_attribute("llm.stop_reason", response.stop_reason) step_span.set_attribute("llm.input_tokens", response.usage.input_tokens)
if response.stop_reason == "end_turn": break로그에서 PII 삭제
에이전트 로그에는 종종 민감한 데이터가 포함됩니다. 외부 시스템으로 내보내기 전에 삭제합니다:
import re
PII_PATTERNS = [ (re.compile(r'\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b'), '[이메일]'), (re.compile(r'\b01[016789][-\s]?\d{3,4}[-\s]?\d{4}\b'), '[전화번호]'), (re.compile(r'\bsk-[a-zA-Z0-9]{20,}\b'), '[API키]'),]
def redact(text: str) -> str: for pattern, replacement in PII_PATTERNS: text = pattern.sub(replacement, text) return text가장 중요한 세 가지 메트릭
작업 완료율 — 몇 퍼센트의 실행이 final_answer에 도달하는지 대 max_steps나 오류로 끝나는지. 작업 유형별로 기준선을 설정합니다.
작업당 토큰 비용 — 모든 단계에 걸친 input_tokens + output_tokens의 합계. 완료율 변화 없이 비용이 20% 증가하면 대개 프롬프트 저하를 나타냅니다.
도구 오류율 — error_steps / total_steps. 이 메트릭의 급등은 고장난 도구나 API를 직접 가리킵니다.
에이전트 시스템에서 관찰 가능성은 선택 사항이 아닙니다 — 반복 개선할 수 있는 시스템과 고장나면 재시작만 할 수 있는 시스템의 차이입니다. 구조화된 이벤트와 트레이스 ID로 시작하세요. 루프 감지를 추가하세요. 메트릭을 푸시하세요. 프로덕션에서 첫 번째 장애가 발생하여 추측하는 대신 정확히 무슨 일이 일어났는지 재구성할 수 있을 때 이 투자가 보답합니다.
관련 기사
- 에이전트 오류 복구: 프로덕션 신뢰성을 위한 5가지 패턴
- 멀티에이전트 패턴: 오케스트레이터, 워커, 파이프라인
- 도구 사용 패턴: 신뢰할 수 있는 에이전트-도구 인터페이스 구축
- 상태 기계와 에이전트: LangGraph로 신뢰할 수 있는 워크플로우 구축하기