본문 바로가기

AI Agent

Claude Code 디버깅 완전 가이드 — 에이전트가 실패할 때 추적하는 법

반응형

에이전트가 틀린 코드를 자신 있게 작성했습니다. 어디서 잘못됐는지 모릅니다. 에이전트한테 물어봐도 모릅니다. 이 상황을 체계적으로 추적하는 법을 정리했습니다.

[핵심 요약]
→ 문제: 에이전트 실패는 일반 버그보다 추적이 어려움
→ 원인: 비결정적, 멀티스텝, 컨텍스트 의존
→ 디버깅 레이어: 컨텍스트 → 툴 호출 → 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

 

 

반응형