반응형
에이전트가 틀린 코드를 자신 있게 작성했습니다. 어디서 잘못됐는지 모릅니다. 에이전트한테 물어봐도 모릅니다. 이 상황을 체계적으로 추적하는 법을 정리했습니다.
[핵심 요약]
→ 문제: 에이전트 실패는 일반 버그보다 추적이 어려움
→ 원인: 비결정적, 멀티스텝, 컨텍스트 의존
→ 디버깅 레이어: 컨텍스트 → 툴 호출 → LLM 추론 → 출력
→ 도구: Claude Code /debug, LangSmith 트레이싱, 로그 분석
→ 패턴: 격리 → 재현 → 원인 파악 → 수정 → 검증
→ 핵심: "에이전트가 뭘 보고 있었는가"가 디버깅의 출발점
에이전트 디버깅이 왜 어려운가
일반 코드 디버깅:
→ 스택 트레이스 → 라인 번호 → 원인 명확
→ 같은 입력 → 같은 오류 (재현 가능)
→ 로컬에서 중단점 설정 가능
에이전트 디버깅:
→ "왜 이런 코드를 썼지?" → 알 수 없음
→ 어제는 됐는데 오늘은 안 됨 (비결정적)
→ 10단계 중 7단계에서 잘못됨 → 어디서부터?
→ 컨텍스트가 틀렸나? 프롬프트가 틀렸나? 툴이 틀렸나?
[에이전트 실패의 5가지 원인]
1. 컨텍스트 오염
→ 잘못된 파일이 컨텍스트에 들어감
→ 오래된 코드를 최신으로 착각
2. 프롬프트 모호성
→ 지시가 불명확해서 다르게 해석
→ CLAUDE.md/.cursorrules 충돌
3. 툴 실패
→ MCP 서버 응답 오류
→ 권한 없는 파일 접근 시도
4. 컨텍스트 한도 초과
→ 긴 세션에서 초반 지시 망각
→ 핵심 정보가 컨텍스트 밖으로 밀려남
5. 추론 오류
→ LLM이 잘못된 가정으로 시작
→ 이전 스텝의 오류가 다음 스텝에 전파
실전 1 — Claude Code 내장 디버깅 도구
# ===== /debug 모드 =====
# Claude Code 세션에서 실행
/debug
# → 현재 세션 상태 출력:
# - 컨텍스트 사용량 (토큰)
# - 로드된 파일 목록
# - 활성 MCP 서버 상태
# - 마지막 툴 호출 결과
# ===== 컨텍스트 확인 =====
/context
# → 현재 컨텍스트에 뭐가 들어있는지 확인
# → 불필요한 파일 제거 가능
# ===== 툴 호출 로그 =====
/tools
# → 이 세션에서 호출된 툴 전체 목록
# → 성공/실패 여부
# ===== 세션 리셋 =====
/clear
# → 컨텍스트 완전 초기화
# → 오염된 컨텍스트 리셋
# Claude Code 상세 로그 활성화
CLAUDE_LOG_LEVEL=debug claude
# 로그 파일로 저장
claude 2>&1 | tee claude_debug.log
# 특정 세션 로그 확인
cat ~/.claude/logs/session_*.jsonl | jq '.'
실전 2 — 에이전트 실패 체계적 추적
import anthropic
import json
import logging
from datetime import datetime
from typing import Any
# 구조화된 로거 설정
logging.basicConfig(
level=logging.DEBUG,
format='%(asctime)s [%(levelname)s] %(message)s',
handlers=[
logging.FileHandler(f'agent_debug_{datetime.now():%Y%m%d_%H%M%S}.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class DebuggableAgent:
"""디버깅 추적이 내장된 에이전트"""
def __init__(self, verbose: bool = False):
self.client = anthropic.Anthropic()
self.verbose = verbose
self.trace = [] # 전체 실행 추적
async def run(self, task: str) -> str:
session_id = f"session_{datetime.now():%Y%m%d_%H%M%S}"
logger.info(f"[{session_id}] 시작: {task[:100]}")
self.trace = [{
"type": "start",
"task": task,
"timestamp": datetime.now().isoformat()
}]
try:
result = await self._execute(task, session_id)
self._save_trace(session_id, success=True)
return result
except Exception as e:
logger.error(f"[{session_id}] 실패: {e}")
self._save_trace(session_id, success=False, error=str(e))
self._print_debug_report(session_id)
raise
async def _execute(self, task: str, session_id: str) -> str:
messages = []
step = 0
while True:
step += 1
logger.debug(f"[{session_id}] 스텝 {step} 시작")
# LLM 호출 추적
request_data = {
"model": "claude-sonnet-4-6",
"max_tokens": 4096,
"system": self._get_system_prompt(),
"messages": messages + [
{"role": "user", "content": task if step == 1
else "계속 진행해주세요."}
]
}
self.trace.append({
"type": "llm_request",
"step": step,
"token_count": self._estimate_tokens(request_data),
"timestamp": datetime.now().isoformat()
})
response = self.client.messages.create(**request_data)
# 응답 추적
self.trace.append({
"type": "llm_response",
"step": step,
"stop_reason": response.stop_reason,
"usage": {
"input": response.usage.input_tokens,
"output": response.usage.output_tokens
},
"timestamp": datetime.now().isoformat()
})
if self.verbose:
logger.debug(
f"[{session_id}] 스텝 {step} 응답: "
f"stop_reason={response.stop_reason}, "
f"tokens={response.usage.output_tokens}"
)
# 완료 확인
if response.stop_reason == "end_turn":
return response.content[0].text
# 툴 호출 처리
for block in response.content:
if block.type == "tool_use":
tool_result = await self._execute_tool_with_trace(
block, session_id, step
)
messages.append({
"role": "assistant",
"content": response.content
})
messages.append({
"role": "user",
"content": [{
"type": "tool_result",
"tool_use_id": block.id,
"content": str(tool_result)
}]
})
# 무한 루프 방지
if step >= 20:
raise RuntimeError(
f"최대 스텝 초과: {step}스텝\n"
f"마지막 응답: {response.content}"
)
async def _execute_tool_with_trace(
self, tool_call, session_id: str, step: int
) -> Any:
"""툴 실행 + 상세 추적"""
tool_name = tool_call.name
tool_args = tool_call.input
logger.info(
f"[{session_id}] 스텝 {step} 툴 호출: "
f"{tool_name}({json.dumps(tool_args, ensure_ascii=False)[:200]})"
)
start_time = datetime.now()
try:
result = await self._call_tool(tool_name, tool_args)
elapsed = (datetime.now() - start_time).total_seconds()
self.trace.append({
"type": "tool_call",
"step": step,
"tool": tool_name,
"args": tool_args,
"success": True,
"elapsed_s": elapsed,
"result_preview": str(result)[:200],
"timestamp": datetime.now().isoformat()
})
logger.info(
f"[{session_id}] 툴 성공: {tool_name} "
f"({elapsed:.2f}s)"
)
return result
except Exception as e:
self.trace.append({
"type": "tool_call",
"step": step,
"tool": tool_name,
"args": tool_args,
"success": False,
"error": str(e),
"timestamp": datetime.now().isoformat()
})
logger.error(
f"[{session_id}] 툴 실패: {tool_name} — {e}"
)
raise
def _save_trace(
self, session_id: str,
success: bool, error: str = None
):
"""실행 추적을 파일로 저장"""
trace_file = f"traces/{session_id}.json"
import os
os.makedirs("traces", exist_ok=True)
with open(trace_file, "w", encoding="utf-8") as f:
json.dump({
"session_id": session_id,
"success": success,
"error": error,
"trace": self.trace
}, f, ensure_ascii=False, indent=2)
logger.info(f"추적 저장됨: {trace_file}")
def _print_debug_report(self, session_id: str):
"""실패 시 디버그 리포트 출력"""
print("\n" + "="*60)
print(f"🔴 에이전트 실패 디버그 리포트: {session_id}")
print("="*60)
tool_calls = [t for t in self.trace if t["type"] == "tool_call"]
failed = [t for t in tool_calls if not t["success"]]
llm_calls = [t for t in self.trace if t["type"] == "llm_request"]
total_input = sum(
t.get("usage", {}).get("input", 0)
for t in self.trace if t["type"] == "llm_response"
)
total_output = sum(
t.get("usage", {}).get("output", 0)
for t in self.trace if t["type"] == "llm_response"
)
print(f"\n📊 실행 통계:")
print(f" LLM 호출: {len(llm_calls)}회")
print(f" 툴 호출: {len(tool_calls)}회 ({len(failed)}회 실패)")
print(f" 총 토큰: 입력 {total_input:,} / 출력 {total_output:,}")
if failed:
print(f"\n❌ 실패한 툴 호출:")
for f in failed:
print(f" - {f['tool']}: {f['error']}")
print(f"\n📋 전체 추적: traces/{session_id}.json")
print("="*60)
def _estimate_tokens(self, data: dict) -> int:
"""토큰 수 추정 (실제 계산 아님)"""
text = json.dumps(data, ensure_ascii=False)
return len(text) // 4
실전 3 — 컨텍스트 오염 진단
class ContextInspector:
"""컨텍스트 상태 진단 도구"""
def __init__(self, client: anthropic.Anthropic):
self.client = client
def diagnose(self, messages: list[dict]) -> dict:
"""현재 컨텍스트 상태 분석"""
total_chars = sum(
len(str(m.get("content", "")))
for m in messages
)
estimated_tokens = total_chars // 4
# 역할별 분포
role_counts = {}
for m in messages:
role = m.get("role", "unknown")
role_counts[role] = role_counts.get(role, 0) + 1
# 툴 결과 수
tool_results = sum(
1 for m in messages
if m.get("role") == "user"
and isinstance(m.get("content"), list)
and any(
c.get("type") == "tool_result"
for c in m["content"]
if isinstance(c, dict)
)
)
# 컨텍스트 경고
warnings = []
if estimated_tokens > 150000:
warnings.append(
f"⚠️ 컨텍스트 한도 근접: ~{estimated_tokens:,} 토큰"
)
if len(messages) > 50:
warnings.append(
f"⚠️ 메시지 수 많음: {len(messages)}개 — 초반 컨텍스트 망각 가능"
)
if tool_results > 20:
warnings.append(
f"⚠️ 툴 결과 누적: {tool_results}개 — 관련 없는 결과 정리 권장"
)
return {
"estimated_tokens": estimated_tokens,
"message_count": len(messages),
"role_counts": role_counts,
"tool_results": tool_results,
"warnings": warnings,
"health": "🟢 정상" if not warnings else "🟡 주의"
}
def trim_context(
self,
messages: list[dict],
keep_recent: int = 20
) -> list[dict]:
"""
컨텍스트 정리 — 최근 N개 메시지만 유지
첫 번째 사용자 메시지(원래 작업)는 보존
"""
if len(messages) <= keep_recent:
return messages
# 첫 메시지 (원래 작업) 보존
first_message = messages[0]
# 최근 메시지 유지
recent = messages[-keep_recent:]
# 중간 요약 삽입
trimmed_count = len(messages) - keep_recent - 1
summary = {
"role": "user",
"content": f"[컨텍스트 요약: 이전 {trimmed_count}개 메시지 생략됨]"
}
return [first_message, summary] + recent
# 사용
inspector = ContextInspector(client)
# 컨텍스트 진단
diagnosis = inspector.diagnose(messages)
print(f"상태: {diagnosis['health']}")
for warning in diagnosis['warnings']:
print(warning)
# 컨텍스트가 너무 길면 정리
if diagnosis['estimated_tokens'] > 150000:
messages = inspector.trim_context(messages, keep_recent=15)
print("컨텍스트 정리 완료")
실전 4 — LangSmith 트레이싱 통합
from langsmith import traceable, Client as LangSmithClient
ls_client = LangSmithClient()
class TracedAgent:
"""LangSmith 트레이싱이 통합된 에이전트"""
@traceable(name="agent_run")
async def run(self, task: str) -> str:
"""
@traceable 데코레이터로 자동 추적
LangSmith 대시보드에서 전체 실행 흐름 확인 가능
"""
return await self._execute(task)
@traceable(name="llm_call")
async def _call_llm(self, messages: list) -> str:
"""LLM 호출 추적"""
response = self.client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=messages
)
return response.content[0].text
@traceable(name="tool_execution")
async def _execute_tool(self, name: str, args: dict) -> Any:
"""툴 실행 추적"""
return await self._call_tool(name, args)
# 실패한 실행 분석
def analyze_failed_runs(project: str = "my-agent"):
"""LangSmith에서 실패한 실행 조회"""
runs = ls_client.list_runs(
project_name=project,
error=True, # 실패한 것만
limit=10
)
for run in runs:
print(f"\n실패 실행: {run.id}")
print(f" 오류: {run.error}")
print(f" 시작: {run.start_time}")
print(f" 입력: {str(run.inputs)[:200]}")
print(f" LangSmith: https://smith.langchain.com/...")
실전 5 — 일반적인 실패 패턴과 해결법
[패턴 1: 에이전트가 무한 루프에 빠짐]
증상: 같은 툴을 반복 호출, 진행 없음
원인: 툴 결과를 잘못 해석 → 같은 액션 반복
진단:
→ trace에서 같은 tool_name이 연속으로 나오는지 확인
→ 툴 결과 내용 확인 (에러 메시지가 모호한지)
해결:
→ max_iterations 설정으로 강제 종료
→ 툴 에러 메시지 더 명확하게 개선
→ "이 툴을 n번 이상 연속 호출하면 다른 방법을 시도하라" 지시 추가
# 무한 루프 감지 및 중단
from collections import Counter
def detect_loop(trace: list, threshold: int = 3) -> bool:
"""같은 툴을 n번 이상 연속 호출하면 루프로 판단"""
tool_calls = [
t["tool"] for t in trace
if t["type"] == "tool_call" and t["success"]
]
# 최근 10번 툴 호출에서 같은 툴이 threshold 이상
recent = tool_calls[-10:] if len(tool_calls) >= 10 else tool_calls
counts = Counter(recent)
for tool, count in counts.items():
if count >= threshold:
return True, tool, count
return False, None, 0
[패턴 2: 에이전트가 잘못된 파일을 수정]
증상: 엉뚱한 파일 변경, 관련 없는 코드 수정
원인: 컨텍스트에 잘못된 파일 로드됨
진단:
→ /context로 현재 로드된 파일 목록 확인
→ 에이전트가 처음 열어본 파일 목록 추적
해결:
→ 작업 시작 시 관련 파일만 명시적으로 지정
→ CLAUDE.md에 수정 금지 파일 목록 명시
→ git diff로 변경된 파일 즉시 확인
# 작업 시작 전 체크포인트 생성
git stash # 또는
git add -A && git commit -m "WIP: 에이전트 작업 전 체크포인트"
# 에이전트 실행 후 변경사항 확인
git diff --stat # 어떤 파일이 바뀌었는지
git diff # 실제 변경 내용
# 문제 발견 시 롤백
git checkout . # 모든 변경 취소
git stash pop # 스태시에서 복원
[패턴 3: 컨텍스트 한도 초과로 초반 지시 망각]
증상: 긴 세션 후 초기 요구사항 무시, 스타일 일관성 깨짐
원인: 컨텍스트 한도에서 초반 내용이 밀려남
진단:
→ /debug에서 컨텍스트 토큰 수 확인
→ 100K+ 토큰이면 망각 위험
해결:
→ CLAUDE.md에 핵심 요구사항 반드시 포함
(CLAUDE.md는 매 턴 자동 로드됨)
→ 세션 중간에 /clear + 핵심 상태 요약으로 재시작
→ 긴 작업은 여러 세션으로 분리
[패턴 4: 툴 호출 결과를 잘못 해석]
증상: 툴이 성공했는데 실패로 판단, 또는 반대
원인: 툴 결과 포맷이 모호함
진단:
→ tool_call trace의 result_preview 확인
→ 에이전트의 다음 행동이 결과와 일치하는지 확인
해결:
→ 툴 반환값을 명확하게 구조화
→ 성공/실패를 명시적인 필드로 반환
# ❌ 모호한 툴 반환값
def bad_tool(query: str) -> str:
results = db.query(query)
return str(results) # 빈 결과인지 에러인지 불명확
# ✅ 명확한 툴 반환값
def good_tool(query: str) -> dict:
try:
results = db.query(query)
return {
"success": True,
"count": len(results),
"data": results,
"message": f"{len(results)}개 결과 조회됨"
}
except Exception as e:
return {
"success": False,
"error": str(e),
"message": f"쿼리 실패: {e}"
}
실전 6 — 디버깅 체크리스트
[에이전트 실패 시 체크 순서]
Step 1: 컨텍스트 확인 (30초)
→ /debug 또는 /context 실행
→ 예상 파일이 로드됐는지 확인
→ 컨텍스트 토큰 수 확인 (100K+ 주의)
Step 2: 툴 호출 로그 확인 (1분)
→ /tools로 이 세션 툴 호출 목록 확인
→ 실패한 툴 호출 있는지 확인
→ 같은 툴 반복 호출 패턴 확인
Step 3: 재현 시도 (2분)
→ 새 세션에서 같은 작업 다시 실행
→ 매번 같은 방식으로 실패하는지 확인
→ 다른 입력으로 실패하는지 확인
Step 4: 격리 테스트 (5분)
→ 실패한 툴만 독립적으로 테스트
→ 시스템 프롬프트 없이 순수 LLM 테스트
→ 컨텍스트 줄여서 재테스트
Step 5: 원인 파악 후 수정
→ 컨텍스트 문제 → 파일 정리 또는 재시작
→ 툴 문제 → 툴 반환값 개선
→ 프롬프트 문제 → CLAUDE.md/.cursorrules 수정
→ 추론 문제 → 작업을 더 작게 분해
마무리
✅ 이 가이드로 해결되는 문제
→ 에이전트가 왜 실패했는지 추적 불가
→ 무한 루프 감지 및 중단
→ 컨텍스트 오염으로 인한 엉뚱한 동작
→ 툴 호출 실패 원인 파악
→ 프로덕션 에이전트 품질 모니터링
[가장 자주 쓰는 디버깅 명령 3개]
1. /debug — 현재 세션 상태 전체 확인
2. git diff — 에이전트가 실제로 바꾼 것 확인
3. /clear — 오염된 컨텍스트 리셋 후 재시작
관련 글:
https://cell-devlog.tistory.com/43
AI 에이전트 모니터링 완전 가이드 — LangSmith vs Langfuse 실전 비교
프로덕션에서 AI 에이전트가 이상한 답을 내놨어요.고객이 계좌 잔액을 물었는데 에이전트가 숫자를 지어냈어요. 4번의 툴 호출, 2개의 서브 에이전트. 어디서 망가졌는지 로그엔 최종 출력만 있
cell-devlog.tistory.com
https://cell-devlog.tistory.com/150
AI 에이전트 테스트 전략 완전 가이드 — 단위 테스트부터 통합 테스트, E2E까지
일반 소프트웨어는 같은 입력에 항상 같은 출력이 나옵니다. AI 에이전트는 그렇지 않습니다. 테스트 전략 자체가 달라야 합니다.[핵심 요약]→ 문제: AI 에이전트는 비결정적 → 기존 단위 테스
cell-devlog.tistory.com
https://cell-devlog.tistory.com/152
AI 에이전트 롤백 전략 완전 가이드 — 에이전트가 망쳤을 때 복구하는 법
에이전트가 프로덕션 DB를 잘못 수정했습니다. 파일 200개를 잘못 덮어썼습니다. 되돌릴 방법이 없습니다. 이 상황을 구조적으로 막는 법을 정리했습니다.[핵심 요약]→ 문제: AI 에이전트는 실수
cell-devlog.tistory.com
반응형
'AI Agent' 카테고리의 다른 글
| 5개국 "에이전트 AI 보안 가이드" 완전 분석 — 정부가 경고한 AI 에이전트 5가지 위험과 개발자 체크리스트 (0) | 2026.05.06 |
|---|---|
| 임베딩 모델 완전 가이드 — text-embedding 선택과 RAG 적용 (0) | 2026.05.04 |
| LLM-as-Judge 완전 가이드 — AI로 AI 출력을 자동 평가하는 법 (0) | 2026.04.30 |
| AI 에이전트 롤백 전략 완전 가이드 — 에이전트가 망쳤을 때 복구하는 법 (0) | 2026.04.28 |
| AI 에이전트 상태 관리 완전 가이드 — 장기 실행 에이전트에서 상태를 잃지 않는 법 (0) | 2026.04.28 |