함수 호출 스키마 설계: LLM이 올바르게 툴을 사용하게 만드는 방법

AI 기술

함수 호출스키마 설계Tool UseJSON SchemaLLM 엔지니어링

이 글은 누구를 위한 것인가

  • LLM이 툴을 잘못 호출하거나 파라미터를 채우지 못하는 팀
  • tool description을 어떻게 써야 할지 모르는 개발자
  • OpenAPI 스펙을 LLM 툴로 자동 변환하고 싶은 엔지니어

들어가며

함수 호출 실패의 80%는 스키마 설계 문제다. LLM은 description을 읽고 언제, 어떻게 함수를 호출할지 결정한다. description이 모호하면 LLM도 모호하게 행동한다.

이 글은 bluefoxdev.kr의 LLM 함수 호출 최적화 를 참고하여 작성했습니다.


1. 좋은 스키마 설계 원칙

[나쁜 vs 좋은 description]

나쁜 예:
  name: "search"
  description: "검색합니다"
  → LLM이 언제 써야 하는지 모름

좋은 예:
  name: "search_products"
  description: "상품 카탈로그에서 사용자의 검색어로 관련 상품을 찾습니다.
                구매 의도가 있거나 특정 상품을 찾을 때 호출합니다.
                일반 대화나 추천 요청 시에는 호출하지 마세요."
  → 명확한 사용 시점과 제외 조건

[파라미터 설계 원칙]
  필수 vs 선택 명시: required 배열 활용
  열거형 사용: enum으로 가능한 값 제한
  기본값 설명: description에 default 명시
  단위 명시: "가격 (원화, 정수)" 처럼
  예시 포함: "예: '나이키 운동화'"

[에러 복구 설계]
  툴 실패 시 LLM이 다른 방법 시도 가능하게
  에러 메시지를 구체적으로: "상품 ID가 없음" not "에러"
  부분 성공 표현: 10개 중 7개 처리 완료

2. 스키마 설계 및 검증

import anthropic
import json
from typing import Any

client = anthropic.Anthropic()

# 잘 설계된 툴 스키마 예시
WELL_DESIGNED_TOOLS = [
    {
        "name": "search_products",
        "description": """상품 카탈로그에서 사용자의 검색어로 관련 상품을 조회합니다.
사용자가 특정 상품을 찾거나 구매 의도를 보일 때 호출하세요.
카테고리 필터와 가격 범위로 결과를 좁힐 수 있습니다.
최대 20개 결과를 반환합니다.""",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "검색어. 예: '방수 등산화 남성용'",
                },
                "category": {
                    "type": "string",
                    "description": "상품 카테고리 필터 (선택사항)",
                    "enum": ["전자제품", "의류", "식품", "스포츠", "뷰티", "도서"],
                },
                "min_price": {
                    "type": "integer",
                    "description": "최소 가격 (원화, 정수). 예: 10000",
                },
                "max_price": {
                    "type": "integer",
                    "description": "최대 가격 (원화, 정수). 지정 안 하면 제한 없음",
                },
                "sort_by": {
                    "type": "string",
                    "enum": ["relevance", "price_asc", "price_desc", "rating", "newest"],
                    "description": "정렬 기준. 기본값: relevance",
                },
                "limit": {
                    "type": "integer",
                    "description": "반환할 최대 결과 수 (1-20). 기본값: 10",
                    "minimum": 1,
                    "maximum": 20,
                },
            },
            "required": ["query"],
        },
    },
    {
        "name": "get_product_details",
        "description": "상품 ID로 상품 상세 정보를 조회합니다. search_products로 찾은 상품의 ID를 사용하세요.",
        "input_schema": {
            "type": "object",
            "properties": {
                "product_id": {
                    "type": "string",
                    "description": "상품 고유 ID. 형식: 'PROD-XXXXXX'",
                    "pattern": "^PROD-[A-Z0-9]{6}$",
                },
                "include_reviews": {
                    "type": "boolean",
                    "description": "리뷰 포함 여부. 기본값: false",
                },
            },
            "required": ["product_id"],
        },
    },
]

def validate_tool_input(tool_name: str, tool_input: dict, schema: dict) -> tuple[bool, str]:
    """툴 입력 검증"""
    from jsonschema import validate, ValidationError
    
    try:
        validate(instance=tool_input, schema=schema["input_schema"])
        return True, ""
    except ValidationError as e:
        return False, f"입력 검증 실패: {e.message}"

def openapi_to_claude_tool(openapi_spec: dict, operation_id: str) -> dict:
    """OpenAPI 스펙을 Claude 툴 스키마로 변환"""
    
    for path, path_item in openapi_spec.get("paths", {}).items():
        for method, operation in path_item.items():
            if operation.get("operationId") == operation_id:
                parameters = operation.get("parameters", [])
                request_body = operation.get("requestBody", {})
                
                properties = {}
                required = []
                
                for param in parameters:
                    name = param["name"]
                    schema = param.get("schema", {})
                    properties[name] = {
                        "type": schema.get("type", "string"),
                        "description": param.get("description", ""),
                    }
                    if "enum" in schema:
                        properties[name]["enum"] = schema["enum"]
                    if param.get("required"):
                        required.append(name)
                
                return {
                    "name": operation_id,
                    "description": operation.get("summary", ""),
                    "input_schema": {
                        "type": "object",
                        "properties": properties,
                        "required": required,
                    },
                }
    
    raise ValueError(f"Operation {operation_id} not found")

async def test_tool_schema(tool: dict, test_queries: list[str]) -> dict:
    """툴 스키마 품질 테스트"""
    
    results = {"successful_calls": 0, "failed_calls": 0, "errors": []}
    
    for query in test_queries:
        response = client.messages.create(
            model="claude-haiku-4-5-20251001",
            max_tokens=500,
            tools=[tool],
            messages=[{"role": "user", "content": query}],
        )
        
        tool_uses = [b for b in response.content if b.type == "tool_use"]
        
        if tool_uses:
            is_valid, error = validate_tool_input(
                tool["name"], tool_uses[0].input, tool
            )
            if is_valid:
                results["successful_calls"] += 1
            else:
                results["failed_calls"] += 1
                results["errors"].append({"query": query, "error": error})
        else:
            results["failed_calls"] += 1
            results["errors"].append({"query": query, "error": "툴 미호출"})
    
    results["success_rate"] = results["successful_calls"] / len(test_queries)
    return results

마무리

좋은 툴 description은 세 가지를 담아야 한다: 언제 호출하는가, 무엇을 하는가, 언제 호출하지 않는가. 파라미터에 enum을 적극 활용하면 LLM의 자유도를 줄여 에러를 방지한다. 스키마 변경 시 20개 테스트 쿼리로 success rate를 측정하면 퇴행을 조기에 발견할 수 있다.