k
korAI
고급 전체
🔥 고급2026-05-126~8분

프로덕션 Tool Use 스키마 버저닝: 하위 호환성 유지와 드리프트 탐지

LLM이 호출하는 Tool의 JSON 스키마가 변경될 때 하위 호환성을 깨지 않으면서 점진적으로 마이그레이션하는 전략과, 런타임에 스키마 드리프트를 자동 탐지하는 파이프라인을 설명한다.

tool-useschema-versioningreliability

스키마 변경이 프로덕션을 망가뜨리는 방식

Tool Use를 사용하는 에이전트는 LLM이 생성한 JSON을 실제 함수에 바인딩한다. 필드 이름 변경·타입 변경·필수 필드 추가 중 하나라도 조용히 배포되면 다음이 발생한다.

  1. 사일런트 실패: Pydantic 기본값이 채워져 오류 없이 잘못된 값으로 실행
  2. 모델 혼란: 이전 스키마로 파인튜닝된 모델이 새 필드를 무시
  3. 비용 폭발: 스키마 불일치로 재시도가 반복되어 토큰 비용 3~5배 증가

실제 사례: amount 필드를 quantity로 rename했을 때, 72시간 동안 에러 없이 0값이 삽입된 케이스가 보고된다.

버저닝 전략과 드리프트 탐지

핵심 원칙은 스키마를 코드가 아닌 레지스트리로 관리하는 것이다. 각 Tool 정의에 schema_version을 포함하고, LLM 응답 검증 시 버전을 명시적으로 체크한다.

import anthropic
from pydantic import BaseModel, ValidationError
from typing import Literal
import json

client = anthropic.Anthropic()

# 스키마 레지스트리
SCHEMA_REGISTRY = {
    "v1": {
        "name": "process_order",
        "description": "주문 처리 (v1)",
        "input_schema": {
            "type": "object",
            "properties": {
                "amount": {"type": "number"},
                "schema_version": {"type": "string", "const": "v1"}
            },
            "required": ["amount", "schema_version"]
        }
    },
    "v2": {
        "name": "process_order",
        "description": "주문 처리 (v2) — quantity로 필드명 변경",
        "input_schema": {
            "type": "object",
            "properties": {
                "quantity": {"type": "number"},
                "unit": {"type": "string", "enum": ["ea", "kg"]},
                "schema_version": {"type": "string", "const": "v2"}
            },
            "required": ["quantity", "unit", "schema_version"]
        }
    }
}

class DriftDetector:
    def __init__(self, active_version="v2", fallback_version="v1"):
        self.active = active_version
        self.fallback = fallback_version
        self.drift_count = 0

    def validate_and_migrate(self, tool_input: dict) -> dict:
        version = tool_input.get("schema_version", "v1")
        if version != self.active:
            self.drift_count += 1
            print(f"[DRIFT] 구버전 스키마 감지: {version} (누적 {self.drift_count}회)")
            # v1 → v2 마이그레이션
            if version == "v1" and "amount" in tool_input:
                tool_input["quantity"] = tool_input.pop("amount")
                tool_input["unit"] = "ea"
                tool_input["schema_version"] = "v2"
        return tool_input

detector = DriftDetector()

# 현재 활성 스키마로 호출
response = client.messages.create(
    model="claude-opus-4-5",
    max_tokens=256,
    tools=[SCHEMA_REGISTRY["v2"]],
    messages=[{"role": "user", "content": "사과 3kg 주문해줘"}]
)

for block in response.content:
    if block.type == "tool_use":
        safe_input = detector.validate_and_migrate(block.input)
        print(f"검증된 입력: {json.dumps(safe_input, ensure_ascii=False)}")

트레이드오프와 운영 체크리스트

트레이드오프

  • schema_version을 Tool 입력에 포함시키면 LLM이 이를 명시적으로 생성해야 해 프롬프트 토큰이 약 15~20% 증가한다. 이를 const 타입으로 고정하면 모델이 자동으로 채우므로 실질 오버헤드는 미미하다.
  • 마이그레이션 레이어를 영구적으로 유지하면 코드 부채가 된다. 6주 이후 드리프트 비율이 1% 미만이면 레거시 브랜치를 제거하는 sunset 정책을 권장한다.

수치 기준

  • 드리프트 비율 > 5%: 프롬프트에 새 스키마 예시 추가
  • 드리프트 비율 > 20%: 모델 버전 고정 또는 시스템 프롬프트에 명시적 마이그레이션 안내 삽입
  • 연속 3회 ValidationError: 해당 Tool 자동 비활성화 + 알림 발송

운영 체크리스트

  • [ ] 스키마 변경 시 CHANGELOG에 버전·변경 유형(breaking/non-breaking) 기록
  • [ ] drift_count를 Prometheus 메트릭으로 노출, 임계치 알림 설정
  • [ ] 새 스키마 배포 전 Canary 10%로 드리프트 베이스라인 측정
  • [ ] Tool 스키마를 OpenAPI 3.1 형식으로도 유지해 비-LLM 클라이언트와 공유
  • [ ] CI에서 이전 버전 스키마로 회귀 테스트 자동 실행