왜 스키마가 신뢰성을 좌우하는가

LLM의 함수 호출은 모델이 자유 텍스트 대신 정해진 인자 구조를 채우도록 강제하는 방식이다. 하지만 실무에서는 인자 누락, 타입 불일치, 열거값 오타, 존재하지 않는 필드 생성 같은 실패가 반복된다. 원인의 상당수는 모델이 아니라 스키마에 있다. 필드 의미가 모호하거나, 제약이 없거나, 이름이 애매하면 모델은 그 빈틈을 추측으로 메운다. 스키마는 단순한 검증 장치가 아니라 모델에게 전달되는 프롬프트의 일부라는 점을 전제로 설계해야 한다.

제약을 스키마에 명시하라

가장 흔한 실수는 필드를 `string`으로만 선언하고 나머지를 설명(description)에 위임하는 것이다. `enum`, `pattern`, `minimum/maximum`, `required`를 적극적으로 사용하면 유효 공간 자체가 좁아져 잘못된 출력 확률이 내려간다. 특히 상태값이나 카테고리는 반드시 `enum`으로 못 박는다.

{
  "name": "create_ticket",
  "description": "고객 지원 티켓을 생성한다. 이미 존재하는 티켓 수정에는 쓰지 않는다.",
  "input_schema": {
    "type": "object",
    "properties": {
      "priority": {
        "type": "string",
        "enum": ["low", "medium", "high", "urgent"],
        "description": "긴급도. 명시적 요청이 없으면 medium."
      },
      "due_date": {
        "type": "string",
        "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
        "description": "마감일(YYYY-MM-DD). 상대 표현은 절대날짜로 변환."
      },
      "title": { "type": "string", "minLength": 3, "maxLength": 120 }
    },
    "required": ["title", "priority"],
    "additionalProperties": false
  }
}

`additionalProperties: false`는 모델이 존재하지 않는 필드를 만들어내는 것을 막는 안전장치다.

이름과 설명은 곧 프롬프트다

필드명은 축약하지 말고 의도가 드러나게 쓴다. `dt`보다 `scheduled_at_utc`가 낫다. description에는 "무엇"뿐 아니라 "언제 쓰고 언제 쓰지 않는지", 기본값, 단위, 변환 규칙을 적는다. 여러 함수가 비슷할 때는 각 함수 설명에 경계 조건을 넣어 선택 실패를 줄인다.

나쁜 설계좋은 설계
amount: numberamount_cents: integer (원 단위 정수, 소수 금지)
flag: booleaninclude_archived: boolean (기본 false)
type: stringtype: enum[email, sms, push]

선택 인자와 기본값 처리

모든 필드를 `required`로 두면 모델은 값을 알 수 없을 때도 억지로 지어낸다(환각). 반대로 전부 선택이면 필수 정보가 빠진다. 기준은 "이 값이 없으면 함수가 실행 불가능한가"다. 실행에 필수인 것만 `required`로 두고, 나머지는 기본값을 description에 명시한 뒤 서버 측에서 채운다. 모델이 "모름"을 표현할 방법을 남겨두는 것이 억지 채움보다 안전하다.

서버 측 검증과 재시도 루프

스키마 검증은 모델 출력의 마지막 방어선이 아니라 첫 번째다. 검증 실패 시 에러를 그대로 모델에 되돌려 자가 교정을 유도하는 루프를 두면 일회성 실패의 상당수가 회복된다.

import jsonschema

def run_tool(model_call, schema, executor, max_retry=2):
    messages = [model_call]
    for _ in range(max_retry + 1):
        args = messages[-1]["input"]
        try:
            jsonschema.validate(args, schema)
        except jsonschema.ValidationError as e:
            # 에러 메시지를 그대로 tool_result로 반환해 재요청
            messages.append(ask_model_again(
                f"인자 검증 실패: {e.message}. 스키마에 맞게 다시 호출하라."))
            continue
        return executor(args)
    raise RuntimeError("검증 반복 실패, 사람에게 에스컬레이션")

에러 문구는 모델이 이해할 수 있게 구체적으로 반환한다. "invalid input"보다 "priority must be one of low/medium/high/urgent"가 교정 성공률을 크게 높인다.

함수는 작고 단일 책임으로 쪼갠다

인자가 15개인 만능 함수는 모델 입장에서 채우기 어렵고, 조건부 필드(“type이 A일 때만 필요”) 때문에 검증도 복잡해진다. `search_orders`, `refund_order`처럼 동사+명사 단위로 분리하면 각 스키마가 단순해지고 오호출이 줄어든다. 조건부 로직이 꼭 필요하면 `oneOf`로 분기를 명시해 애매함을 없앤다.

주의점

스키마를 지나치게 촘촘하게 만들면 정당한 입력까지 거부되어 재시도 비용이 늘어난다. 제약은 도메인상 반드시 참인 것에만 건다. 또한 검증 통과가 의미적 정확성을 보장하진 않는다. 형식은 맞지만 잘못된 값(존재하지 않는 order_id 등)은 실행 단계의 비즈니스 검증으로 걸러야 한다. 마지막으로 스키마 변경은 프롬프트 변경과 같으므로, 실패 케이스를 로깅해두고 회귀 평가셋으로 관리하는 것이 장기적으로 신뢰성을 유지하는 핵심이다.