liminfo

Claude API Tool Use 실전 가이드

Claude API의 Tool Use 기능을 활용하여 외부 함수를 호출하고 결과를 통합하는 AI 에이전트를 처음부터 끝까지 구축합니다. JSON Schema 기반 도구 정의, 메시지 루프, 병렬 호출, 에러 처리, 프로덕션 패턴을 실전 예제와 함께 다룹니다.

Claude API Tool UseClaude 함수 호출Anthropic APIAI 에이전트 구축tool_use tool_resultJSON Schema 도구 정의병렬 도구 호출Claude Opus Sonnet Haiku

문제 상황

AI 챗봇이 실시간 데이터(날씨, 주가, 일정 등)를 조회하거나 외부 시스템(DB, API, 파일 시스템)과 상호작용해야 하는데, LLM은 학습 데이터 이후의 정보를 알지 못하고 외부 API를 직접 호출할 수 없습니다. 프롬프트에 모든 데이터를 넣는 방식은 컨텍스트 제한과 비용 문제가 있고, 정형화된 출력을 파싱하는 방식은 불안정합니다. Claude API의 Tool Use 기능을 활용하면 모델이 구조화된 방식으로 함수 호출을 요청하고, 개발자가 실행 결과를 반환하는 안전한 에이전트 루프를 구축할 수 있습니다. 모델별 가격 (입력/출력 per MTok): Opus 4.6($5/$25), Sonnet 4.5($3/$15), Haiku 4.5($1/$5) - 에이전트 용도에서는 비용 효율을 고려한 모델 선택이 중요합니다.

필요한 도구

Anthropic Python SDK

Claude API 공식 Python 클라이언트. pip install anthropic으로 설치. Python 3.8+ 필요.

Anthropic TypeScript SDK

Claude API 공식 TypeScript/Node.js 클라이언트. npm install @anthropic-ai/sdk로 설치. Node.js 18+ 필요.

JSON Schema

도구의 입력 파라미터를 정의하는 표준 스키마. type, properties, required 필드로 Claude가 올바른 인자를 생성하도록 안내합니다.

Python / TypeScript

에이전트 루프 및 도구 실행 로직 구현 언어. 두 언어 모두 예제를 제공합니다.

해결 과정

1

시작 전 준비물 및 Tool Use 개념 이해

Tool Use는 Claude가 사용자의 질문에 답하기 위해 외부 도구(함수)를 호출할 수 있게 하는 기능입니다. 시작 전 준비물: 1. Anthropic API 키 - console.anthropic.com에서 발급 (사용량 기반 과금) 2. Python 3.8+ 또는 Node.js 18+ 설치 3. SDK 설치 (아래 코드 참조) Tool Use 전체 흐름 (4단계): 1. 개발자가 사용 가능한 도구 목록(이름, 설명, 파라미터 스키마)을 API 요청에 포함 2. Claude가 사용자 질문을 분석하고, 필요한 도구를 선택하여 tool_use 블록으로 호출 요청 3. 개발자가 해당 함수를 실제로 실행하고, 결과를 tool_result 메시지로 Claude에게 반환 4. Claude가 도구 실행 결과를 종합하여 최종 답변 생성 핵심 개념: - Claude는 도구를 "직접 실행하지 않습니다". 도구 호출을 "요청"할 뿐이며, 실제 실행과 보안 제어는 개발자의 코드에서 담당합니다. - stop_reason이 "tool_use"이면 아직 최종 답변이 아님 -> 도구 실행 후 결과를 다시 보내야 함 - stop_reason이 "end_turn"이면 최종 답변 완료 비용 고려 (에이전트는 여러 번 API를 호출하므로): - 간단한 도구 라우팅: Haiku 4.5 ($1/$5) - 가장 저렴 - 복잡한 추론 + 도구 사용: Sonnet 4.5 ($3/$15) - 권장 - 최고 정확도 필요: Opus 4.6 ($5/$25) - 고가 이렇게 보이면 성공입니다: SDK 설치 후 간단한 API 호출이 응답을 반환함 만약 "AuthenticationError"가 나오면: API 키가 올바른지 확인하세요. 환경변수 ANTHROPIC_API_KEY가 설정되어 있어야 합니다.

# ============================================
# Python SDK 설치 및 초기화
# ============================================
pip install anthropic

# 환경변수로 API 키 설정 (코드에 하드코딩 금지!)
# Linux/Mac:
export ANTHROPIC_API_KEY="sk-ant-api03-..."

# Windows PowerShell:
$env:ANTHROPIC_API_KEY="sk-ant-api03-..."

# Python 클라이언트 초기화 및 테스트
import anthropic

client = anthropic.Anthropic()  # ANTHROPIC_API_KEY 환경변수 자동 사용

# 연결 테스트
message = client.messages.create(
    model="claude-sonnet-4-5-20250514",
    max_tokens=100,
    messages=[{"role": "user", "content": "Hello!"}]
)
print(message.content[0].text)  # 응답이 나오면 설정 완료

# ============================================
# TypeScript SDK 설치 및 초기화
# ============================================
# npm install @anthropic-ai/sdk
#
# import Anthropic from "@anthropic-ai/sdk";
# const client = new Anthropic();  // ANTHROPIC_API_KEY 환경변수 자동 사용
2

도구 정의 - JSON Schema로 함수 명세 작성

Claude에게 사용할 수 있는 도구를 알려주려면 JSON Schema 형식으로 각 도구를 정의합니다. 도구 정의의 3가지 필수 요소: 1. name: 도구의 고유 이름 (영문, 밑줄 사용). 예: "get_weather", "search_database" 2. description: 도구가 하는 일의 상세 설명. Claude가 "언제 이 도구를 사용할지" 판단하는 핵심 정보 3. input_schema: JSON Schema 형식의 파라미터 정의 description 작성이 가장 중요합니다: - 나쁜 예: "날씨 조회" (너무 짧음, 어떤 입력이 필요한지 불명확) - 좋은 예: "지정된 도시의 현재 기온, 습도, 날씨 상태를 조회합니다. 도시 이름은 한국어('서울') 또는 영어('Seoul') 모두 가능합니다." input_schema 작성 팁: - type: "object"가 최상위 (필수) - properties: 각 파라미터의 타입과 설명 - required: 필수 파라미터 목록 (배열) - enum: 허용 값이 정해진 경우 명시 (예: ["celsius", "fahrenheit"]) - description: 각 파라미터에 대한 설명 (Claude가 올바른 값을 생성하도록 안내) 이렇게 보이면 성공입니다: API 호출 후 response.stop_reason이 "tool_use"이고, content에 ToolUseBlock이 포함됨 만약 Claude가 도구를 사용하지 않으면: description을 더 구체적으로 작성하세요. Claude는 description을 보고 도구 사용 여부를 판단합니다.

import anthropic

client = anthropic.Anthropic()

# ============================================
# 도구 정의 - JSON Schema로 파라미터 명세
# ============================================
tools = [
    {
        "name": "get_weather",
        "description": (
            "지정된 도시의 현재 날씨 정보(기온, 습도, 날씨 상태)를 조회합니다. "
            "도시 이름은 한국어('서울') 또는 영어('Seoul') 모두 가능합니다. "
            "사용자가 날씨, 기온, 온도, 기후 등을 질문할 때 이 도구를 사용합니다."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "city": {
                    "type": "string",
                    "description": "날씨를 조회할 도시 이름 (예: '서울', 'Tokyo', '뉴욕')"
                },
                "unit": {
                    "type": "string",
                    "enum": ["celsius", "fahrenheit"],
                    "description": "온도 단위. 기본값은 celsius"
                }
            },
            "required": ["city"]
        }
    },
    {
        "name": "manage_schedule",
        "description": (
            "사용자의 일정을 조회(list), 추가(add), 삭제(delete)합니다. "
            "특정 날짜의 일정 확인, 새 일정 등록, 기존 일정 삭제가 가능합니다. "
            "사용자가 일정, 약속, 스케줄, 미팅 등을 언급할 때 이 도구를 사용합니다."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["list", "add", "delete"],
                    "description": "수행할 작업: list(조회), add(추가), delete(삭제)"
                },
                "date": {
                    "type": "string",
                    "description": "대상 날짜 (YYYY-MM-DD 형식). 예: '2025-05-15'"
                },
                "title": {
                    "type": "string",
                    "description": "일정 제목 (action이 'add'일 때 필수)"
                },
                "time": {
                    "type": "string",
                    "description": "일정 시간 (HH:MM 형식, action이 'add'일 때 선택)"
                },
                "schedule_id": {
                    "type": "string",
                    "description": "삭제할 일정 ID (action이 'delete'일 때 필수)"
                }
            },
            "required": ["action", "date"]
        }
    }
]

# ============================================
# 도구를 포함한 API 호출 테스트
# ============================================
response = client.messages.create(
    model="claude-sonnet-4-5-20250514",
    max_tokens=1024,
    tools=tools,
    messages=[
        {"role": "user", "content": "서울 날씨 어때?"}
    ]
)

# 응답 분석
print(f"stop_reason: {response.stop_reason}")
# -> "tool_use" (Claude가 도구 호출을 요청)

for block in response.content:
    if hasattr(block, "text"):
        print(f"텍스트: {block.text}")
    if block.type == "tool_use":
        print(f"도구 호출: {block.name}")
        print(f"파라미터: {block.input}")
        print(f"tool_use_id: {block.id}")
# -> 도구 호출: get_weather
# -> 파라미터: {"city": "서울"}
# -> tool_use_id: "toolu_01Abc..."
3

에이전트 루프 구현 - 도구 호출과 결과 반환

Tool Use의 핵심은 "에이전트 루프"입니다. Claude가 도구 호출을 요청하면(stop_reason == "tool_use"), 개발자가 해당 함수를 실행하고 결과를 tool_result로 반환하는 과정을 반복합니다. 에이전트 루프의 메시지 흐름: 1. [user] "서울 날씨 알려줘" 2. [assistant] TextBlock("확인하겠습니다") + ToolUseBlock(get_weather, {city: "서울"}) 3. [user] ToolResultBlock(tool_use_id, "{"temp": 22, "condition": "맑음"}") 4. [assistant] "서울의 현재 기온은 22도이며 맑은 날씨입니다." (stop_reason: "end_turn") tool_result 작성 시 반드시 지켜야 할 규칙 3가지: 1. tool_use_id를 정확히 매칭해야 합니다 (block.id 값 사용) 2. 이전 assistant 응답을 그대로 messages에 포함해야 합니다 (빠뜨리면 API 에러) 3. content는 문자열(str)로 전달합니다 (JSON 객체가 아님) 에러 발생 시: - is_error: True로 설정하면 Claude가 에러를 인지하고 사용자에게 적절히 안내합니다 - 민감한 에러 정보(스택 트레이스, 내부 URL)는 포함하지 마세요 무한 루프 방지: - 반드시 max_turns를 설정하세요 (보통 5~15턴이면 충분) - 턴 수 초과 시 사용자에게 알림을 주고 루프를 종료 이렇게 보이면 성공입니다: "서울 날씨 알려줘"를 보내면 도구가 실행되고, 최종적으로 자연어 답변이 반환됨

import anthropic
import json

client = anthropic.Anthropic()

# ============================================
# 1. 실제 도구 실행 함수 정의
# ============================================
def get_weather(city: str, unit: str = "celsius") -> dict:
    """실제로는 OpenWeatherMap 등 기상 API를 호출합니다."""
    # 예시 데이터 (실제 구현 시 API 호출로 교체)
    weather_data = {
        "서울": {"temp": 22, "humidity": 55, "condition": "맑음", "wind": "3m/s"},
        "도쿄": {"temp": 26, "humidity": 70, "condition": "흐림", "wind": "5m/s"},
        "뉴욕": {"temp": 18, "humidity": 45, "condition": "구름 조금", "wind": "8m/s"},
    }
    data = weather_data.get(city, {"temp": 20, "humidity": 50, "condition": "정보 없음", "wind": "N/A"})
    if unit == "fahrenheit":
        data["temp"] = round(data["temp"] * 9 / 5 + 32, 1)
    data["city"] = city
    data["unit"] = unit
    return data

def manage_schedule(action: str, date: str, **kwargs) -> dict:
    """일정 관리 (실제로는 DB나 Google Calendar API 연동)"""
    if action == "list":
        return {
            "date": date,
            "schedules": [
                {"id": "1", "title": "팀 미팅", "time": "10:00"},
                {"id": "2", "title": "점심 약속", "time": "12:30"},
                {"id": "3", "title": "코드 리뷰", "time": "15:00"},
            ]
        }
    elif action == "add":
        return {
            "status": "success",
            "message": f"{date} {kwargs.get('time', '')} '{kwargs.get('title', '')}' 일정이 추가되었습니다.",
            "id": "4"
        }
    elif action == "delete":
        return {
            "status": "success",
            "message": f"일정 ID {kwargs.get('schedule_id', '')}이(가) 삭제되었습니다."
        }
    return {"status": "error", "message": "알 수 없는 작업입니다."}

# ============================================
# 2. 도구 이름 -> 함수 매핑 (디스패처)
# ============================================
TOOL_FUNCTIONS = {
    "get_weather": get_weather,
    "manage_schedule": manage_schedule,
}

# ============================================
# 3. 에이전트 루프 (핵심 코드)
# ============================================
def run_agent(user_message: str, tools: list, max_turns: int = 10) -> str:
    """Claude가 최종 답변을 줄 때까지 도구 호출을 반복하는 에이전트 루프"""
    messages = [{"role": "user", "content": user_message}]

    for turn in range(max_turns):
        # Claude API 호출
        response = client.messages.create(
            model="claude-sonnet-4-5-20250514",
            max_tokens=4096,
            system="당신은 날씨 확인과 일정 관리를 도와주는 비서입니다. 한국어로 답변합니다.",
            tools=tools,
            messages=messages,
        )

        # [중요] Claude의 응답을 메시지 히스토리에 추가
        messages.append({"role": "assistant", "content": response.content})

        # stop_reason이 "end_turn"이면 최종 답변 완료
        if response.stop_reason == "end_turn":
            return "\n".join(
                block.text for block in response.content
                if hasattr(block, "text")
            )

        # stop_reason이 "tool_use"이면 도구 실행
        if response.stop_reason == "tool_use":
            tool_results = []

            for block in response.content:
                if block.type != "tool_use":
                    continue

                func = TOOL_FUNCTIONS.get(block.name)

                if func is None:
                    # 알 수 없는 도구: is_error=True로 Claude에게 알림
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,  # 반드시 매칭!
                        "content": json.dumps({
                            "error": f"도구 '{block.name}'을(를) 찾을 수 없습니다."
                        }),
                        "is_error": True,
                    })
                    continue

                try:
                    # 도구 실행
                    result = func(**block.input)
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps(result, ensure_ascii=False),
                    })
                except Exception as e:
                    # 실행 에러: is_error=True + 에러 메시지
                    tool_results.append({
                        "type": "tool_result",
                        "tool_use_id": block.id,
                        "content": json.dumps({
                            "error": f"{type(e).__name__}: {str(e)}"
                        }),
                        "is_error": True,
                    })

            # [중요] tool_result를 user 메시지로 추가
            messages.append({"role": "user", "content": tool_results})

    return "최대 대화 턴 수를 초과했습니다. 질문을 더 구체적으로 해주세요."

# ============================================
# 4. 실행 테스트
# ============================================
# 단일 도구 호출
result = run_agent("서울 날씨 알려줘", tools)
print(result)
# -> "서울의 현재 기온은 22도이며 맑은 날씨입니다. 습도는 55%이고..."

# 여러 도구 호출 (Claude가 자동으로 판단)
result = run_agent("서울 날씨 알려주고, 오늘 일정도 확인해줘", tools)
print(result)
# -> Claude가 get_weather + manage_schedule 두 도구를 호출
4

병렬 도구 호출 처리 및 에러 핸들링

Claude는 하나의 응답에서 여러 개의 tool_use 블록을 동시에 반환할 수 있습니다. 예를 들어 "서울과 도쿄 날씨 비교해줘"라고 하면 get_weather를 2번 병렬로 호출합니다. 병렬 호출 처리 규칙: 1. 모든 tool_use 블록에 대해 각각 tool_result를 반환해야 합니다 2. 각 tool_result의 tool_use_id가 해당 tool_use의 id와 정확히 대응해야 합니다 3. 하나의 도구가 실패해도 나머지 결과는 정상 반환해야 합니다 4. 모든 tool_result를 하나의 user 메시지에 배열로 담아 전송합니다 에러 처리 전략 (프로덕션 필수): 1. 도구 실행 에러: - is_error: true로 표시 - Claude가 사용자에게 "죄송합니다. 날씨 정보를 가져오는 데 실패했습니다"처럼 안내 2. API 레벨 에러: - 429 Rate Limit: 대기 후 재시도 (exponential backoff) - 500 Server Error: 재시도 (최대 3회) - 401 Auth Error: API 키 확인 필요 -> 재시도 불가 3. 보안 에러: - 민감한 에러 정보(스택 트레이스, 내부 DB 주소)를 Claude에게 전달하지 마세요 - 사용자에게 보여줄 메시지만 content에 포함 4. 타임아웃: - 각 도구에 실행 시간 제한을 설정 (보통 30초) - 타임아웃 발생 시 is_error: true + "시간 초과" 메시지 이렇게 보이면 성공입니다: "서울과 도쿄 날씨 비교해줘"를 보내면 두 도시의 날씨를 비교하는 답변이 반환됨

import anthropic
import json
import asyncio
from concurrent.futures import ThreadPoolExecutor

client = anthropic.Anthropic()

# ============================================
# 병렬 도구 실행 (asyncio + ThreadPoolExecutor)
# ============================================
async def execute_tools_parallel(
    tool_blocks: list,
    tool_functions: dict,
    timeout_seconds: float = 30.0,
) -> list:
    """여러 도구를 병렬로 실행하고 결과를 수집합니다."""
    loop = asyncio.get_event_loop()
    executor = ThreadPoolExecutor(max_workers=5)

    async def run_single_tool(block):
        func = tool_functions.get(block.name)

        # 1. 알 수 없는 도구
        if not func:
            return {
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps({
                    "error": f"도구 '{block.name}'을(를) 찾을 수 없습니다."
                }),
                "is_error": True,
            }

        try:
            # 2. 타임아웃 설정 + 스레드풀에서 실행
            result = await asyncio.wait_for(
                loop.run_in_executor(
                    executor, lambda: func(**block.input)
                ),
                timeout=timeout_seconds,
            )
            return {
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps(result, ensure_ascii=False),
            }

        # 3. 타임아웃
        except asyncio.TimeoutError:
            return {
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps({
                    "error": f"도구 실행 시간이 {timeout_seconds}초를 초과했습니다."
                }),
                "is_error": True,
            }

        # 4. 기타 에러 (민감 정보 필터링)
        except Exception as e:
            return {
                "type": "tool_result",
                "tool_use_id": block.id,
                "content": json.dumps({
                    "error": f"도구 실행 중 오류가 발생했습니다: {type(e).__name__}"
                    # str(e)에 민감 정보가 포함될 수 있으므로 타입만 전달
                }),
                "is_error": True,
            }

    # 모든 도구를 병렬로 실행
    tasks = [
        run_single_tool(b)
        for b in tool_blocks
        if b.type == "tool_use"
    ]
    results = await asyncio.gather(*tasks)
    return list(results)

# ============================================
# API 에러 처리를 포함한 프로덕션 에이전트
# ============================================
import time

def run_agent_production(
    user_message: str,
    tools: list,
    max_turns: int = 10,
    max_api_retries: int = 3,
) -> str:
    """프로덕션 수준의 에이전트 (에러 처리 + 병렬 실행 + 재시도)"""
    messages = [{"role": "user", "content": user_message}]

    for turn in range(max_turns):
        # API 호출 (재시도 로직 포함)
        response = None
        for retry in range(max_api_retries):
            try:
                response = client.messages.create(
                    model="claude-sonnet-4-5-20250514",
                    max_tokens=4096,
                    system="날씨 확인과 일정 관리를 도와주는 비서입니다. 한국어로 답변합니다.",
                    tools=tools,
                    messages=messages,
                )
                break  # 성공 시 루프 탈출

            except anthropic.RateLimitError:
                # 429: 대기 후 재시도
                wait_time = 2 ** retry  # 1초, 2초, 4초 (exponential backoff)
                print(f"Rate limit 초과. {wait_time}초 대기 후 재시도...")
                time.sleep(wait_time)

            except anthropic.APIStatusError as e:
                if e.status_code >= 500:
                    # 5xx: 서버 에러, 재시도
                    print(f"서버 에러({e.status_code}). 재시도 {retry + 1}/{max_api_retries}")
                    time.sleep(1)
                else:
                    # 4xx (401, 403 등): 재시도 불가
                    return f"API 에러: {e.status_code}. API 키와 설정을 확인하세요."

            except anthropic.APIConnectionError:
                print(f"네트워크 연결 실패. 재시도 {retry + 1}/{max_api_retries}")
                time.sleep(2)

        if response is None:
            return "API 호출에 실패했습니다. 네트워크 연결과 API 키를 확인하세요."

        # 응답 처리
        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            return "\n".join(
                block.text for block in response.content
                if hasattr(block, "text")
            )

        if response.stop_reason == "tool_use":
            tool_blocks = [b for b in response.content if b.type == "tool_use"]
            # 병렬 실행
            tool_results = asyncio.run(
                execute_tools_parallel(tool_blocks, TOOL_FUNCTIONS)
            )
            messages.append({"role": "user", "content": tool_results})

    return "최대 대화 턴 수를 초과했습니다."

# ============================================
# 실행 테스트: 병렬 호출이 발생하는 케이스
# ============================================
result = run_agent_production(
    "서울과 도쿄 날씨를 비교해주고, 내일 일정도 확인해줘",
    tools,
)
print(result)
# Claude가 get_weather("서울"), get_weather("도쿄"),
# manage_schedule("list", "2025-05-16") 세 개를 병렬로 호출
5

TypeScript 완전한 에이전트 예제

지금까지 배운 내용을 TypeScript로 통합한 완전한 에이전트 예제입니다. Anthropic TypeScript SDK를 사용하여 날씨 조회와 일정 관리를 동시에 처리하는 에이전트를 구현합니다. Python과 TypeScript 차이점: - 타입 안전성: TypeScript의 타입 시스템으로 도구 입출력을 더 안전하게 처리 - Anthropic.Tool[]: 도구 정의에 타입 적용 - Anthropic.TextBlock, Anthropic.ToolUseBlock: 응답 블록 타입으로 필터링 - async/await: TypeScript에서 비동기 처리가 자연스러움 실전 고려사항: - 도구 실행 결과가 너무 크면 요약하여 전달 (토큰 비용 절약) - 예: DB 쿼리 결과 1000행 -> 상위 10행만 전달 - 시스템 프롬프트로 에이전트의 역할과 제약 조건을 명확히 정의 - 사용자 입력 검증 및 도구 입력 새니타이징 (SQL 인젝션 등 방지) 이렇게 보이면 성공입니다: TypeScript 에이전트가 여러 도구를 호출하고 최종 답변을 반환함

import Anthropic from "@anthropic-ai/sdk";

const client = new Anthropic();

// ============================================
// 1. 도구 정의 (TypeScript 타입 적용)
// ============================================
const tools: Anthropic.Tool[] = [
  {
    name: "get_weather",
    description:
      "지정된 도시의 현재 날씨(기온, 습도, 상태)를 조회합니다. " +
      "도시 이름은 한국어 또는 영어 모두 가능합니다.",
    input_schema: {
      type: "object" as const,
      properties: {
        city: {
          type: "string",
          description: "도시 이름 (예: '서울', 'Tokyo')",
        },
        unit: {
          type: "string",
          enum: ["celsius", "fahrenheit"],
          description: "온도 단위 (기본값: celsius)",
        },
      },
      required: ["city"],
    },
  },
  {
    name: "manage_schedule",
    description:
      "사용자의 일정을 조회(list), 추가(add), 삭제(delete)합니다.",
    input_schema: {
      type: "object" as const,
      properties: {
        action: {
          type: "string",
          enum: ["list", "add", "delete"],
          description: "수행할 작업",
        },
        date: {
          type: "string",
          description: "날짜 (YYYY-MM-DD)",
        },
        title: {
          type: "string",
          description: "일정 제목 (add 시)",
        },
        time: {
          type: "string",
          description: "시간 (HH:MM, add 시)",
        },
        schedule_id: {
          type: "string",
          description: "일정 ID (delete 시)",
        },
      },
      required: ["action", "date"],
    },
  },
];

// ============================================
// 2. 도구 실행 함수 (타입 안전)
// ============================================
type ToolInput = Record<string, string>;

function executeTool(name: string, input: ToolInput): string {
  switch (name) {
    case "get_weather":
      // 실제 구현에서는 기상 API 호출
      return JSON.stringify({
        city: input.city,
        temp: 23,
        humidity: 60,
        condition: "맑음",
        wind: "4m/s",
        unit: input.unit || "celsius",
      });

    case "manage_schedule":
      if (input.action === "list") {
        return JSON.stringify({
          date: input.date,
          schedules: [
            { id: "1", title: "팀 스탠드업", time: "09:30" },
            { id: "2", title: "코드 리뷰", time: "14:00" },
            { id: "3", title: "1:1 미팅", time: "16:00" },
          ],
        });
      }
      if (input.action === "add") {
        return JSON.stringify({
          status: "success",
          message: `${input.date} ${input.time || ""} '${input.title}' 추가 완료`,
          id: "4",
        });
      }
      if (input.action === "delete") {
        return JSON.stringify({
          status: "success",
          message: `일정 ${input.schedule_id} 삭제 완료`,
        });
      }
      return JSON.stringify({ error: "알 수 없는 작업" });

    default:
      return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
  }
}

// ============================================
// 3. 에이전트 메인 루프
// ============================================
async function runAgent(userMessage: string): Promise<string> {
  const messages: Anthropic.MessageParam[] = [
    { role: "user", content: userMessage },
  ];

  const MAX_TURNS = 10;

  for (let turn = 0; turn < MAX_TURNS; turn++) {
    const response = await client.messages.create({
      model: "claude-sonnet-4-5-20250514",
      max_tokens: 4096,
      system:
        "당신은 날씨 확인과 일정 관리를 도와주는 비서입니다. " +
        "항상 한국어로 자연스럽게 답변하세요. " +
        "도구 결과를 그대로 나열하지 말고 자연스러운 문장으로 요약해 주세요.",
      tools,
      messages,
    });

    // assistant 응답을 히스토리에 추가
    messages.push({ role: "assistant", content: response.content });

    // 최종 답변이면 텍스트 추출 후 반환
    if (response.stop_reason === "end_turn") {
      return response.content
        .filter((b): b is Anthropic.TextBlock => b.type === "text")
        .map((b) => b.text)
        .join("\n");
    }

    // 도구 호출 처리
    if (response.stop_reason === "tool_use") {
      const toolResults: Anthropic.ToolResultBlockParam[] =
        response.content
          .filter(
            (b): b is Anthropic.ToolUseBlock => b.type === "tool_use"
          )
          .map((block) => {
            try {
              const result = executeTool(
                block.name,
                block.input as ToolInput
              );
              return {
                type: "tool_result" as const,
                tool_use_id: block.id,
                content: result,
              };
            } catch (e) {
              return {
                type: "tool_result" as const,
                tool_use_id: block.id,
                content: JSON.stringify({
                  error:
                    e instanceof Error
                      ? e.message
                      : "알 수 없는 실행 오류",
                }),
                is_error: true,
              };
            }
          });

      messages.push({ role: "user", content: toolResults });
    }
  }

  return "최대 대화 턴을 초과했습니다.";
}

// ============================================
// 4. 실행 (다단계 도구 호출 시나리오)
// ============================================
(async () => {
  const answer = await runAgent(
    "서울 날씨 어떤지 알려주고, 오늘 일정도 확인해줘. " +
    "날씨가 좋으면 오후 3시에 산책 일정도 추가해줘."
  );
  console.log(answer);
  // Claude가 3단계로 처리:
  // 1. get_weather("서울") + manage_schedule("list", "오늘")
  // 2. 날씨 확인 -> 좋으면 manage_schedule("add", "오늘", "산책", "15:00")
  // 3. 최종 요약 답변
})();
6

운영/확장 팁: 프로덕션 패턴 및 고급 기법

Tool Use를 프로덕션 환경에서 운영할 때의 핵심 패턴과 확장 전략입니다. 1. 도구 결과 크기 관리: 도구가 대량의 데이터를 반환하면 토큰 비용이 급증합니다. - DB 쿼리 결과: 상위 N개만 반환 + 총 개수 표시 - API 응답: 필요한 필드만 추출하여 전달 - 파일 내용: 요약 또는 일부만 전달 2. 도구 입력 검증 (보안): Claude가 생성한 입력을 신뢰하지 마세요. - SQL 인젝션 방지: 파라미터 바인딩 사용 - 경로 탐색 방지: 파일 경로 화이트리스트 - 권한 확인: 사용자별 접근 권한 검증 3. 도구 선택 강제: - tool_choice: "auto" (기본값, Claude가 판단) - tool_choice: {"type": "tool", "name": "get_weather"} (특정 도구 강제 호출) - tool_choice: "any" (반드시 하나 이상의 도구 사용) 4. 스트리밍과 Tool Use: 스트리밍 모드에서도 Tool Use를 사용할 수 있습니다. content_block_start 이벤트에서 tool_use 타입을 감지하고, input_json_delta로 파라미터를 점진적으로 수집합니다. 5. 비용 최적화: - 도구 정의 자체도 입력 토큰으로 계산됨 -> 사용하지 않는 도구는 제외 - 간단한 도구 라우팅은 Haiku로, 복잡한 추론은 Sonnet으로 - 캐싱 가능한 도구 결과는 캐싱하여 중복 호출 방지 6. 참고 문서: - 공식 문서: https://docs.anthropic.com/en/docs/build-with-claude/tool-use/overview - SDK 레퍼런스: https://github.com/anthropics/anthropic-sdk-python

import anthropic
import json

client = anthropic.Anthropic()

# ============================================
# [패턴 1] 도구 결과 크기 관리
# ============================================
def search_database(query: str, limit: int = 10) -> dict:
    """DB 검색 결과가 크면 요약하여 반환"""
    # 실제 DB 쿼리 실행
    all_results = [{"id": i, "name": f"항목 {i}"} for i in range(100)]

    return {
        "total_count": len(all_results),
        "returned_count": min(limit, len(all_results)),
        "results": all_results[:limit],
        "message": f"총 {len(all_results)}개 중 상위 {limit}개를 반환합니다."
    }

# ============================================
# [패턴 2] 도구 입력 검증 (보안)
# ============================================
import re

def safe_file_read(filepath: str) -> dict:
    """파일 경로 검증 후 읽기"""
    # 허용 경로 목록
    ALLOWED_DIRS = ["/data/reports/", "/data/exports/"]

    # 경로 탐색 공격 방지
    normalized = filepath.replace("\\", "/")
    if ".." in normalized:
        return {"error": "잘못된 파일 경로입니다."}

    if not any(normalized.startswith(d) for d in ALLOWED_DIRS):
        return {"error": "접근이 허용되지 않은 경로입니다."}

    # 실제 파일 읽기 (생략)
    return {"content": "파일 내용...", "size": "1.2KB"}

# ============================================
# [패턴 3] tool_choice로 도구 선택 제어
# ============================================

# 기본: Claude가 도구 사용 여부를 자동 판단
response_auto = client.messages.create(
    model="claude-sonnet-4-5-20250514",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "auto"},  # 기본값
    messages=[{"role": "user", "content": "안녕하세요!"}],
)
# -> Claude가 도구 없이 직접 답변 (날씨/일정 관련 아니므로)

# 특정 도구 강제 호출
response_forced = client.messages.create(
    model="claude-sonnet-4-5-20250514",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "tool", "name": "get_weather"},
    messages=[{"role": "user", "content": "좋은 아침이에요"}],
)
# -> 반드시 get_weather를 호출 (질문과 무관하게)

# 반드시 하나 이상의 도구 사용
response_any = client.messages.create(
    model="claude-sonnet-4-5-20250514",
    max_tokens=1024,
    tools=tools,
    tool_choice={"type": "any"},
    messages=[{"role": "user", "content": "오늘 뭐 하면 좋을까?"}],
)
# -> 날씨 또는 일정 중 하나를 반드시 호출

# ============================================
# [패턴 4] 도구 결과 캐싱 (비용 절감)
# ============================================
from functools import lru_cache
from datetime import datetime

@lru_cache(maxsize=100)
def get_weather_cached(city: str, date: str) -> str:
    """같은 도시+날짜 조합은 캐싱하여 중복 API 호출 방지"""
    # 실제 기상 API 호출 (비용 발생)
    result = {"city": city, "temp": 22, "condition": "맑음"}
    return json.dumps(result, ensure_ascii=False)

# 사용: 같은 날 같은 도시를 여러 번 조회해도 API는 1번만 호출
today = datetime.now().strftime("%Y-%m-%d")
get_weather_cached("서울", today)  # API 호출
get_weather_cached("서울", today)  # 캐시에서 반환 (API 호출 없음)

# ============================================
# [패턴 5] 로깅 및 모니터링
# ============================================
import logging

logger = logging.getLogger("agent")

def run_agent_with_logging(user_message: str, tools: list) -> str:
    """모든 도구 호출을 로깅하는 에이전트"""
    messages = [{"role": "user", "content": user_message}]
    total_input_tokens = 0
    total_output_tokens = 0

    for turn in range(10):
        response = client.messages.create(
            model="claude-sonnet-4-5-20250514",
            max_tokens=4096,
            tools=tools,
            messages=messages,
        )

        # 토큰 사용량 추적
        total_input_tokens += response.usage.input_tokens
        total_output_tokens += response.usage.output_tokens

        messages.append({"role": "assistant", "content": response.content})

        if response.stop_reason == "end_turn":
            logger.info(
                f"완료 | 턴: {turn + 1} | "
                f"입력 토큰: {total_input_tokens} | "
                f"출력 토큰: {total_output_tokens} | "
                f"예상 비용: $" + f"{(total_input_tokens * 3 + total_output_tokens * 15) / 1_000_000:.4f}"
            )
            return "\n".join(
                b.text for b in response.content if hasattr(b, "text")
            )

        if response.stop_reason == "tool_use":
            for b in response.content:
                if b.type == "tool_use":
                    logger.info(f"도구 호출: {b.name}({json.dumps(b.input)})")

            # 도구 실행 및 결과 반환 (생략)
            tool_results = []
            for b in response.content:
                if b.type == "tool_use":
                    func = TOOL_FUNCTIONS.get(b.name)
                    if func:
                        result = func(**b.input)
                        tool_results.append({
                            "type": "tool_result",
                            "tool_use_id": b.id,
                            "content": json.dumps(result, ensure_ascii=False),
                        })

            messages.append({"role": "user", "content": tool_results})

    return "최대 턴 초과"

핵심 코드

Claude Tool Use 에이전트 루프의 핵심 패턴: (1) 도구를 JSON Schema로 정의, (2) API 호출 시 tools 파라미터에 포함, (3) stop_reason이 "tool_use"이면 함수 실행, (4) tool_result를 tool_use_id와 정확히 매칭하여 반환, (5) "end_turn"이 될 때까지 반복.

import anthropic, json

client = anthropic.Anthropic()

# 1. 도구 정의 (JSON Schema)
tools = [{
    "name": "get_weather",
    "description": "도시의 현재 날씨를 조회합니다",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "도시 이름"}
        },
        "required": ["city"]
    }
}]

# 2. 에이전트 루프 (핵심 패턴)
messages = [{"role": "user", "content": "서울 날씨 알려줘"}]

while True:
    response = client.messages.create(
        model="claude-sonnet-4-5-20250514",
        max_tokens=4096,
        tools=tools,
        messages=messages,
    )

    # [필수] assistant 응답을 히스토리에 추가
    messages.append({"role": "assistant", "content": response.content})

    # 최종 답변이면 종료
    if response.stop_reason == "end_turn":
        print([b.text for b in response.content if hasattr(b, "text")])
        break

    # tool_use -> 함수 실행 -> tool_result 반환
    if response.stop_reason == "tool_use":
        results = []
        for block in response.content:
            if block.type == "tool_use":
                output = execute_tool(block.name, block.input)
                results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,  # 반드시 매칭!
                    "content": json.dumps(output, ensure_ascii=False),
                })
        messages.append({"role": "user", "content": results})

자주 하는 실수

tool_result의 tool_use_id를 잘못 매칭하거나 누락하여 API 에러 발생

Claude가 반환한 tool_use 블록의 block.id 값을 tool_result의 tool_use_id에 정확히 대응시켜야 합니다. 병렬 호출 시 여러 개의 tool_use가 있으면, 각각에 대해 올바른 id로 tool_result를 반환해야 합니다. for 루프에서 block.id를 바로 사용하세요.

assistant 응답을 messages에 포함하지 않고 tool_result만 전송하여 컨텍스트 에러

Claude의 tool_use 응답(response.content 전체)을 반드시 messages.append({"role": "assistant", "content": response.content})로 추가한 뒤, 그 다음에 tool_result를 user 메시지로 추가해야 합니다. 이 순서가 바뀌거나 빠지면 400 에러가 발생합니다.

에이전트 루프에 최대 턴 수 제한이 없어 무한 루프 및 비용 폭증

반드시 max_turns 제한을 설정하세요 (보통 5~15턴). while True 대신 for turn in range(max_turns)를 사용하고, 턴 수를 초과하면 사용자에게 안내 메시지를 반환하세요. 프로덕션에서는 토큰 사용량 제한도 함께 설정하세요.

도구의 description을 너무 짧거나 모호하게 작성하여 Claude가 도구를 잘못 선택

"날씨 조회"처럼 짧은 description은 Claude가 언제 이 도구를 사용할지 판단하기 어렵습니다. 어떤 입력을 받고, 어떤 결과를 반환하며, 어떤 상황에서 사용해야 하는지 2~3문장으로 구체적으로 기술하세요. 각 파라미터의 description도 빠짐없이 작성하세요.

도구 실행 결과를 검증/필터링 없이 그대로 Claude에게 전달하여 비용 급증 또는 보안 문제

대량의 데이터(DB 쿼리 결과 수천 행 등)는 필요한 부분만 추출하여 전달하세요. 또한 민감한 정보(비밀번호, API 키, 내부 에러 스택 트레이스)가 결과에 포함되지 않도록 필터링하세요. 결과 크기가 일정 한도를 넘으면 요약하여 전달하는 로직을 추가하세요.

관련 liminfo 서비스