문제: 도구가 늘수록 정확도가 떨어진다

에이전트에 도구를 5개 붙일 때는 잘 동작하다가 30개를 넘어서면 갑자기 엉뚱한 도구를 호출하기 시작한다. 원인은 대부분 모델이 아니라 도구 정의(description·파라미터 스키마)에 있다. 모델은 사용자 발화와 각 도구 설명의 의미적 근접도로 선택한다. 설명이 모호하거나 기능이 겹치면 근접도 차이가 사라지고, 선택은 사실상 무작위에 가까워진다. 특히 search, query, lookup처럼 이름이 비슷한 도구가 공존하면 오호출률이 급격히 오른다.

왜 설명이 정확도를 좌우하나

도구 선택은 "이 발화에 어떤 도구가 가장 적합한가"라는 분류 문제다. 라벨(도구)의 경계가 겹치면 분류기 성능이 떨어지는 것과 같다. 좋은 설명은 세 가지를 담는다. (1) 무엇을 하는가, (2) 언제 써야 하는가, (3) 언제 쓰면 안 되는가. 특히 (3)의 부정 조건이 겹치는 도구 간 경계를 만들어 준다.

설명 작성 원칙

  • 동사로 시작하고 대상 도메인을 명시한다: "고객의 결제 내역을 조회한다".
  • 다른 도구와 헷갈릴 지점을 명시적으로 배제한다: "환불에는 사용하지 말 것. 환불은 refund_payment."
  • 파라미터마다 예시값과 단위를 넣는다. 모델은 스키마 설명도 함께 읽는다.
  • 도구 이름 자체를 구체화한다. get보다 get_invoice_by_id.
from anthropic import Anthropic

# 나쁜 예: 경계가 모호하다
BAD = {
    "name": "search",
    "description": "데이터를 검색합니다",
    "input_schema": {"type": "object",
        "properties": {"q": {"type": "string"}}, "required": ["q"]},
}

# 좋은 예: 용도·배제·예시를 명시
GOOD = {
    "name": "search_orders",
    "description": (
        "고객의 '주문' 내역을 키워드로 검색한다. "
        "주문 상태·배송 조회에 사용. "
        "결제/환불 조회에는 쓰지 말 것(-> search_payments)."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "keyword": {"type": "string",
                "description": "상품명 또는 주문번호. 예: 'ORD-20260915-001'"},
            "days": {"type": "integer",
                "description": "최근 며칠 이내. 기본 30, 최대 365"},
        },
        "required": ["keyword"],
    },
}

도구를 줄이는 것도 최적화다

설명을 아무리 다듬어도 겹치는 도구 자체가 많으면 한계가 있다. 유사 도구는 파라미터로 분기되는 하나의 도구로 합치는 편이 정확도에 유리하다. 아래는 세 개의 조회 도구를 한 개로 통합하고 resource enum으로 분기한 예다.

name: lookup_record
description: >
  단일 리소스를 ID로 조회한다. resource로 대상을 지정.
  목록/키워드 검색이 아니라 '정확한 ID'가 있을 때만 사용.
input_schema:
  type: object
  properties:
    resource:
      type: string
      enum: [order, payment, customer]   # 후보를 강제로 좁힘
      description: 조회 대상 종류
    id:
      type: string
      description: "리소스 ID. 예: CUST-8842"
  required: [resource, id]

선택 정확도 측정과 회귀 방지

설명을 바꿀 때마다 감으로 판단하면 안 된다. "발화 → 기대 도구" 쌍으로 평가셋을 만들고 정확도를 수치로 추적한다. 설명 한 줄을 고쳤을 때 다른 도구 선택이 깨지는 회귀를 잡으려면 이 셋을 CI에 넣어야 한다.

CASES = [
    ("주문번호 ORD-1 배송 어디쯤이야?", "search_orders"),
    ("결제 취소하고 싶어요",            "refund_payment"),
    ("고객 CUST-8842 정보 줘",         "lookup_record"),
]

def eval_tools(client, tools):
    ok = 0
    for utter, expected in CASES:
        msg = client.messages.create(
            model="claude-opus-4-8",
            max_tokens=256, tools=tools,
            messages=[{"role": "user", "content": utter}])
        picked = next((b.name for b in msg.content
                       if b.type == "tool_use"), None)
        ok += (picked == expected)
        if picked != expected:
            print(f"MISS: {utter!r} -> {picked} (기대 {expected})")
    return ok / len(CASES)

설명 스타일 비교

항목모호한 설명최적화된 설명
동사·도메인"검색합니다""주문 내역을 검색한다"
배제 조건없음"환불 조회엔 쓰지 말 것"
파라미터타입만예시값·단위·기본값 명시
결과유사 도구와 혼동경계 뚜렷, 오호출 감소

주의점

설명을 길게 쓸수록 좋은 것은 아니다. 도구가 많으면 전체 정의가 매 요청 토큰을 차지하고, 프롬프트 캐시를 쓰더라도 장황함은 오히려 신호를 희석한다. 핵심 용도 한 문장 + 배제 한 문장 + 파라미터 예시가 가성비 좋은 기준선이다. 또한 enum으로 후보를 좁히면 정확도는 오르지만, 실제 값 분포가 바뀌면 스키마도 함께 갱신해야 한다. 마지막으로, 평가셋 없이 설명을 손보는 것은 회귀를 눈감는 것과 같다. 정확도 수치가 없으면 "좋아졌다"는 말은 근거가 없다.