이종관
글 목록으로

Claude API Tool Use 실전 가이드

Tool Use, Agentic Loop, 병렬 호출, 스트리밍, 프롬프트 캐싱까지 — Claude API 실전 가이드

2026년 2월 21일·17 min read·
ai
claude-api
tool-use
function-calling
agentic-loop
streaming
python

Claude가 외부 도구를 호출하고, 결과를 해석하여 자연어로 응답하는 전체 흐름을 구현한다.

Tool Use란

Claude API의 Tool Use(Function Calling)는 모델이 외부 함수를 호출할 수 있게 하는 기능이다. Claude가 직접 API를 호출하는 것이 아니라, 어떤 도구를 어떤 인자로 호출해야 하는지 JSON으로 응답하면 서버가 이를 실행하고 결과를 다시 Claude에 전달하는 구조다.

전체 흐름

plaintext
User → "내 삼성전자 수익률 알려줘"

Claude API (tools 포함)

Response: stop_reason="tool_use"
  content: [text("잔고를 조회하겠습니다"), tool_use("get_balance", {...})]

서버: get_balance 실행 → KIS API 호출 → 결과 획득

Claude API (tool_result 포함)

Response: stop_reason="end_turn"
  content: [text("삼성전자 100주 보유, 수익률 +4.17%")]

도구 정의 (JSON Schema)

도구는 name, description, input_schema 세 필드로 정의한다.

python
tools = [
    {
        "name": "get_stock_price",
        "description": "특정 종목의 현재 시세를 조회합니다. "
                       "현재가, 전일대비, 등락률, 거래량을 반환합니다.",
        "input_schema": {
            "type": "object",
            "properties": {
                "symbol": {
                    "type": "string",
                    "description": "종목코드 (국내: 005930, 해외: AAPL)"
                },
                "market": {
                    "type": "string",
                    "enum": ["domestic", "overseas"],
                    "description": "시장 구분"
                }
            },
            "required": ["symbol", "market"]
        }
    },
    {
        "name": "get_balance",
        "description": "현재 계좌 잔고와 보유 종목을 조회합니다. "
                       "총 평가금액, 손익, 각 종목별 수량/평균가/현재가를 반환합니다.",
        "input_schema": {
            "type": "object",
            "properties": {
                "account_type": {
                    "type": "string",
                    "enum": ["live", "paper"],
                    "description": "조회할 계좌 유형. live=실전, paper=모의투자"
                }
            },
            "required": ["account_type"]
        }
    },
    {
        "name": "place_order",
        "description": "주식 주문을 실행합니다. "
                       "반드시 사용자 확인 후 실행해야 합니다.",
        "input_schema": {
            "type": "object",
            "properties": {
                "symbol": {"type": "string", "description": "종목코드"},
                "market": {"type": "string", "enum": ["domestic", "overseas"]},
                "side": {"type": "string", "enum": ["buy", "sell"]},
                "quantity": {"type": "integer", "description": "주문 수량"},
                "price": {"type": "number", "description": "주문 가격. 0이면 시장가"},
                "account_type": {"type": "string", "enum": ["live", "paper"]}
            },
            "required": ["symbol", "market", "side", "quantity", "price", "account_type"]
        }
    }
]

도구 설명 작성 베스트 프랙티스

  • 도구가 무엇을 하는지, 무엇을 반환하는지 명확히 기술한다
  • 각 파라미터의 description에 예시값을 포함한다 (e.g., "005930", "AAPL")
  • enum으로 가능한 값을 제한하여 Claude의 정확도를 높인다
  • 위험한 도구(주문 등)에는 제약 조건을 명시한다 ("반드시 사용자 확인 후 실행")

tool_choice 옵션

옵션설명용도
{"type": "auto"}Claude가 도구 사용 여부를 자율 판단 (기본값)일반 대화
{"type": "any"}반드시 하나 이상의 도구를 호출데이터 조회 강제
{"type": "tool", "name": "get_balance"}특정 도구를 반드시 호출특정 작업 강제
python
# 잔고 조회를 강제하는 예시
response = await client.messages.create(
    model="claude-sonnet-4-6",
    max_tokens=4096,
    tools=tools,
    tool_choice={"type": "tool", "name": "get_balance"},
    messages=messages,
)

Agentic Loop 구현

Tool Use의 핵심은 루프다. Claude가 stop_reason="tool_use"를 반환하면, 서버가 도구를 실행하고 결과를 다시 보내는 것을 반복한다. 최종적으로 stop_reason="end_turn"이 나올 때까지 이 루프를 돌린다.

Python 구현

python
import anthropic
from app.chat.tool_executor import execute_tool
 
client = anthropic.AsyncAnthropic()
 
SYSTEM_PROMPT = """당신은 PersonalTrader의 AI 어시스턴트입니다.
사용자의 투자 관련 질문에 답변하기 위해 제공된 도구를 활용하세요.
- 계좌/시세 데이터를 조회할 때는 반드시 도구를 사용하세요
- 주문 실행 시 반드시 사용자에게 확인을 구하세요
- 금액은 한국 원화 형식(₩1,234,567)으로 표시하세요
- 수익률은 소수점 2자리까지 표시하세요"""
 
 
async def chat(
    user_message: str,
    history: list[dict],
    tools: list[dict],
) -> tuple[str, list[dict]]:
    """Tool Use 루프를 포함한 채팅 함수."""
    messages = history + [{"role": "user", "content": user_message}]
 
    while True:
        response = await client.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=4096,
            system=SYSTEM_PROMPT,
            tools=tools,
            messages=messages,
        )
 
        # assistant 응답을 history에 추가
        messages.append({"role": "assistant", "content": response.content})
 
        # tool_use가 아니면 루프 종료
        if response.stop_reason != "tool_use":
            break
 
        # 도구 실행 및 결과 수집
        tool_results = []
        for block in response.content:
            if block.type == "tool_use":
                result = await execute_tool(block.name, block.input)
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result,
                })
 
        # tool_result를 user 메시지로 추가
        messages.append({"role": "user", "content": tool_results})
 
    # 최종 텍스트 추출
    final_text = "".join(
        block.text for block in response.content if block.type == "text"
    )
    return final_text, messages

도구 실행기 (Tool Executor)

python
import json
 
async def execute_tool(name: str, params: dict) -> str:
    """도구 이름과 파라미터로 실제 기능을 실행하고 JSON 문자열을 반환한다."""
 
    handlers = {
        "get_balance": _handle_get_balance,
        "get_stock_price": _handle_get_stock_price,
        "get_daily_prices": _handle_get_daily_prices,
        "get_agent_status": _handle_get_agent_status,
        "place_order": _handle_place_order,
    }
 
    handler = handlers.get(name)
    if not handler:
        return json.dumps({"error": f"Unknown tool: {name}"})
 
    try:
        result = await handler(params)
        return json.dumps(result, ensure_ascii=False, default=str)
    except Exception as e:
        return json.dumps({"error": str(e)})

messages 배열 구조

Claude API의 messagesuserassistant 역할이 교대하는 배열이다. Tool Use 과정에서 도구 결과는 user 역할로 추가된다.

python
messages = [
    # 1턴: 사용자 질문
    {"role": "user", "content": "내 잔고 알려줘"},
 
    # 2턴: Claude 응답 (도구 호출 포함)
    {"role": "assistant", "content": [
        {"type": "text", "text": "계좌 잔고를 조회하겠습니다."},
        {"type": "tool_use", "id": "toolu_abc", "name": "get_balance",
         "input": {"account_type": "live"}}
    ]},
 
    # 3턴: 도구 결과 (user 역할)
    {"role": "user", "content": [
        {"type": "tool_result", "tool_use_id": "toolu_abc",
         "content": '{"total_eval": 15000000, "holdings": [...]}'}
    ]},
 
    # 4턴: Claude 최종 응답
    {"role": "assistant", "content": [
        {"type": "text", "text": "현재 총 평가금액은 ₩15,000,000입니다..."}
    ]},
]

tool_result는 반드시 해당 tool_useid와 매칭되어야 한다. tool_use_id가 일치하지 않으면 API 에러가 발생한다.

병렬 도구 호출 (Parallel Tool Use)

Claude는 독립적인 도구를 한 번의 응답에서 여러 개 동시에 호출할 수 있다. 예를 들어 "삼성전자와 애플 현재가 비교해줘"라고 하면, 두 종목의 시세를 한 번에 요청한다.

python
# Claude 응답에 여러 tool_use 블록이 포함된 경우
# content: [
#   tool_use("get_stock_price", {"symbol": "005930", "market": "domestic"}),
#   tool_use("get_stock_price", {"symbol": "AAPL", "market": "overseas"})
# ]
 
import asyncio
 
async def execute_tools_parallel(tool_blocks: list) -> list[dict]:
    """여러 도구를 병렬로 실행한다."""
    tasks = [
        execute_tool(block.name, block.input)
        for block in tool_blocks
        if block.type == "tool_use"
    ]
    results = await asyncio.gather(*tasks, return_exceptions=True)
 
    tool_results = []
    for block, result in zip(
        [b for b in tool_blocks if b.type == "tool_use"], results
    ):
        if isinstance(result, Exception):
            content = json.dumps({"error": str(result)})
        else:
            content = result
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": content,
        })
    return tool_results

asyncio.gather를 사용하면 네트워크 호출이 동시에 진행되므로, 순차 실행 대비 응답 시간을 대폭 줄일 수 있다.

SSE 스트리밍

실시간 채팅 UI를 구현하려면 스트리밍이 필수다. Claude API는 Server-Sent Events(SSE) 방식으로 응답을 점진적으로 전달한다.

스트리밍 이벤트 흐름

plaintext
event: message_start
data: {"type":"message_start","message":{"id":"msg_...","role":"assistant",...}}
 
event: content_block_start
data: {"type":"content_block_start","index":0,
       "content_block":{"type":"text","text":""}}
 
event: content_block_delta
data: {"type":"content_block_delta","index":0,
       "delta":{"type":"text_delta","text":"잔고를"}}
 
event: content_block_delta
data: {"type":"content_block_delta","index":0,
       "delta":{"type":"text_delta","text":" 조회"}}
 
event: content_block_stop
data: {"type":"content_block_stop","index":0}
 
event: content_block_start
data: {"type":"content_block_start","index":1,
       "content_block":{"type":"tool_use","id":"toolu_...","name":"get_balance"}}
 
event: content_block_delta
data: {"type":"content_block_delta","index":1,
       "delta":{"type":"input_json_delta","partial_json":"{\"account_type\":"}}
 
event: content_block_delta
data: {"type":"content_block_delta","index":1,
       "delta":{"type":"input_json_delta","partial_json":" \"live\"}"}}
 
event: content_block_stop
data: {"type":"content_block_stop","index":1}
 
event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},
       "usage":{"output_tokens":89}}
 
event: message_stop
data: {"type":"message_stop"}

이벤트 타입 정리

이벤트설명주요 필드
message_start메시지 시작message.id, message.role
content_block_start콘텐츠 블록 시작content_block.type (text / tool_use)
content_block_delta증분 데이터 전송delta.type (text_delta / input_json_delta)
content_block_stop블록 완료index
message_delta메시지 메타 업데이트stop_reason, usage
message_stop스트림 종료

input_json_delta는 부분 JSON 문자열이다. content_block_stop 시점에 전체 JSON을 파싱해야 한다.

Python 스트리밍 구현

python
async def chat_stream(
    user_message: str,
    history: list[dict],
    tools: list[dict],
):
    """스트리밍으로 응답을 반환하는 제너레이터."""
    messages = history + [{"role": "user", "content": user_message}]
 
    async with client.messages.stream(
        model="claude-sonnet-4-6",
        max_tokens=4096,
        system=SYSTEM_PROMPT,
        tools=tools,
        messages=messages,
    ) as stream:
        async for event in stream:
            if event.type == "content_block_start":
                if event.content_block.type == "text":
                    yield {"type": "text_start"}
                elif event.content_block.type == "tool_use":
                    yield {
                        "type": "tool_start",
                        "name": event.content_block.name,
                        "id": event.content_block.id,
                    }
 
            elif event.type == "content_block_delta":
                if event.delta.type == "text_delta":
                    yield {"type": "text_delta", "text": event.delta.text}
                elif event.delta.type == "input_json_delta":
                    yield {
                        "type": "input_delta",
                        "partial_json": event.delta.partial_json,
                    }
 
            elif event.type == "content_block_stop":
                yield {"type": "block_stop", "index": event.index}
 
        # 스트림 완료 후 최종 메시지 획득
        final_message = await stream.get_final_message()
        yield {"type": "message_complete", "message": final_message}

FastAPI SSE 엔드포인트

python
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
 
router = APIRouter()
 
class ChatRequest(BaseModel):
    message: str
    session_id: str
 
@router.post("/api/chat/stream")
async def chat_stream_endpoint(req: ChatRequest):
    """SSE 스트리밍 채팅 엔드포인트."""
    history = await load_session_history(req.session_id)
 
    async def event_generator():
        async for chunk in chat_stream_with_tool_loop(
            req.message, history, tools
        ):
            yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
        yield "data: [DONE]\n\n"
 
    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )

비용 최적화

Claude API는 토큰 단위로 과금된다. Tool Use를 사용하면 도구 정의, 시스템 프롬프트, 대화 이력이 매 요청마다 전송되므로 비용이 빠르게 증가할 수 있다. 이를 최적화하는 세 가지 핵심 전략이 있다.

프롬프트 캐싱 (Prompt Caching)

도구 정의와 시스템 프롬프트는 매 요청마다 동일하다. 이를 캐싱하면 읽기 비용이 90% 절감된다.

캐싱 비용 비교

항목기본 비용캐시 쓰기 (첫 요청)캐시 읽기 (이후 요청)
Sonnet 4.6$3/MTok$3.75/MTok (x1.25)$0.30/MTok (x0.1)
Haiku 4.5$1/MTok$1.25/MTok (x1.25)$0.10/MTok (x0.1)
  • 캐시 순서: tools -> system -> messages 순서로 prefix가 구성된다
  • 5분 TTL: 마지막 사용으로부터 5분 후 만료, 사용 시마다 갱신
  • 최소 토큰: Sonnet 4.6 기준 1,024 토큰 이상이어야 캐싱 가능

구현

python
async def create_cached_request(messages: list[dict]) -> dict:
    """도구 + 시스템 프롬프트를 캐싱하는 요청 구성."""
 
    # 도구 정의의 마지막 항목에 cache_control 추가
    cached_tools = list(tools)
    cached_tools[-1] = {
        **cached_tools[-1],
        "cache_control": {"type": "ephemeral"},
    }
 
    # 시스템 프롬프트에 cache_control 추가
    system = [
        {
            "type": "text",
            "text": SYSTEM_PROMPT,
            "cache_control": {"type": "ephemeral"},
        }
    ]
 
    # 대화 이력의 마지막 user 메시지에도 cache_control 추가
    cached_messages = list(messages)
    for i in range(len(cached_messages) - 1, -1, -1):
        if cached_messages[i]["role"] == "user":
            content = cached_messages[i]["content"]
            if isinstance(content, str):
                cached_messages[i]["content"] = [
                    {"type": "text", "text": content,
                     "cache_control": {"type": "ephemeral"}}
                ]
            elif isinstance(content, list):
                cached_messages[i]["content"][-1]["cache_control"] = {
                    "type": "ephemeral"
                }
            break
 
    response = await client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=4096,
        system=system,
        tools=cached_tools,
        messages=cached_messages,
    )
    return response

도구 정의 5개가 약 2,000 토큰이라면, 캐시 히트 시 매 요청마다 약 $0.006을 절약한다. 대화가 길어질수록 절감 효과가 커진다.

모델 티어링 (Model Tiering)

작업 복잡도에 따라 모델을 구분하여 비용을 절감한다.

모델InputOutput용도
Haiku 4.5$1/MTok$5/MTok단순 조회, 포맷팅
Sonnet 4.6$3/MTok$15/MTok분석, 대화, 도구 활용
Opus 4.6$5/MTok$25/MTok복잡한 추론, 보고서
python
def select_model(user_message: str, tool_results: list | None = None) -> str:
    """메시지 내용에 따라 적절한 모델을 선택한다."""
 
    # 단순 조회 → Haiku
    simple_patterns = ["시세", "현재가", "잔고", "보유", "몇 주"]
    if any(p in user_message for p in simple_patterns) and not tool_results:
        return "claude-haiku-4-5"
 
    # 분석/추천 → Sonnet
    analysis_patterns = ["분석", "추천", "전략", "비교", "왜", "이유"]
    if any(p in user_message for p in analysis_patterns):
        return "claude-sonnet-4-6"
 
    # 기본값
    return "claude-sonnet-4-6"

추가 최적화 기법

도구 결과 압축: 도구가 반환하는 전체 데이터 중 Claude가 필요한 필드만 추출한다.

python
def compress_tool_result(result: dict, tool_name: str) -> str:
    """도구 결과에서 Claude가 필요한 필드만 추출한다."""
    if tool_name == "get_balance":
        return json.dumps({
            "total": result["total_eval"],
            "profit_rate": result["profit_rate"],
            "holdings": [
                {"name": h["name"], "qty": h["quantity"],
                 "rate": h["profit_rate"]}
                for h in result["holdings"]
            ]
        }, ensure_ascii=False)
    return json.dumps(result, ensure_ascii=False)

max_tokens 제한: 응답 길이를 작업 유형에 맞게 제한한다. 단순 조회는 1,024, 분석은 4,096, 보고서는 8,192.

Batch API: 비실시간 작업(보고서 생성 등)은 Batch API를 사용하면 50% 할인이 적용된다.

대화 이력 관리

컨텍스트 윈도우 관리

긴 대화에서 토큰 한도를 초과하지 않도록 오래된 메시지부터 제거한다.

python
MAX_CONTEXT_TOKENS = 150_000  # Claude Sonnet 4.6 기준 여유 포함
 
async def trim_history(
    messages: list[dict],
    max_tokens: int = MAX_CONTEXT_TOKENS,
) -> list[dict]:
    """토큰 한도를 초과하면 오래된 메시지부터 제거한다."""
    total_tokens = estimate_tokens(messages)
 
    if total_tokens <= max_tokens:
        return messages
 
    # 가장 오래된 user/assistant 쌍부터 제거
    # tool_use/tool_result 쌍을 함께 제거하여 일관성을 유지한다
    trimmed = list(messages)
    while estimate_tokens(trimmed) > max_tokens and len(trimmed) > 2:
        trimmed.pop(0)
 
    return trimmed

tool_use와 tool_result는 반드시 쌍으로 존재해야 한다. 하나만 제거하면 API 에러가 발생하므로, 이력 정리 시 항상 쌍을 함께 제거해야 한다.

에러 처리

도구 실행 에러

도구 실행이 실패하면 is_error: True로 Claude에 알린다. Claude는 이를 받아 사용자에게 적절한 에러 메시지를 생성한다.

python
tool_results.append({
    "type": "tool_result",
    "tool_use_id": block.id,
    "content": json.dumps({"error": "KIS API 연결 실패: 토큰 만료"}),
    "is_error": True,
})

API 에러 핸들링

python
import anthropic
 
try:
    response = await client.messages.create(...)
except anthropic.RateLimitError:
    # 429: 레이트 리밋 → 지수 백오프 재시도
    await asyncio.sleep(retry_delay)
except anthropic.APIStatusError as e:
    if e.status_code == 529:
        # 과부하 → 잠시 대기 후 재시도
        await asyncio.sleep(30)
    else:
        raise

서비스 아키텍처

전체 시스템을 하나의 다이어그램으로 정리하면 다음과 같다.

plaintext
┌─────────────────────────────────────────────────┐
│  Frontend (Next.js)                             │
│  useChat() ──SSE──→ /api/chat/stream           │
└────────────────────────┬────────────────────────┘

┌────────────────────────▼────────────────────────┐
│  FastAPI Backend                                │
│  ┌──────────────────────────────────────────┐   │
│  │  ChatService                             │   │
│  │  ├─ model_selector → Haiku/Sonnet/Opus   │   │
│  │  ├─ tool_executor → KIS API / Agents     │   │
│  │  ├─ session_manager → PostgreSQL         │   │
│  │  └─ prompt_cache → tools + system 캐싱    │   │
│  └──────────────────────────────────────────┘   │
│                    │                             │
│  ┌─────────────────▼──────────────────────┐     │
│  │  Claude API (Anthropic)                │     │
│  │  messages.stream() + tools             │     │
│  └────────────────────────────────────────┘     │
│                    │                             │
│  ┌─────────────────▼──────────────────────┐     │
│  │  Tool Handlers                         │     │
│  │  ├─ get_balance → KISClient.account    │     │
│  │  ├─ get_stock_price → KISClient.dom/ov │     │
│  │  ├─ get_daily_prices → KISClient.dom   │     │
│  │  ├─ get_agent_status → Orchestrator    │     │
│  │  └─ place_order → KISClient.order      │     │
│  └────────────────────────────────────────┘     │
└─────────────────────────────────────────────────┘

Tool Use를 활용하면 Claude가 단순한 챗봇을 넘어 실시간 데이터를 조회하고, 외부 시스템과 상호작용하는 에이전트로 동작할 수 있다. 핵심은 Agentic Loop를 안정적으로 구현하고, 프롬프트 캐싱과 모델 티어링으로 비용을 관리하는 것이다.