Skip to main content
Summary

이 장에서 배울 것

이번 장에서는 LLM이 외부 도구를 안전하게 사용하는 방법을 배운다. 여기서 도구란 검색 API, 계산기, 데이터베이스 조회 함수, 파일 처리 함수처럼 모델 바깥에서 실제 작업을 수행하는 기능을 말한다.

이 장의 목표는 단순히 "함수를 붙이는 법"이 아니다. 더 중요한 목표는 모델이 어떤 도구를 어떤 인자로 호출해야 하는지 제안하고, 애플리케이션이 그것을 검증한 뒤 실행하는 구조를 설계하는 것이다. OpenAI 문서에서도 function calling은 모델이 외부 시스템의 데이터와 동작에 접근할 수 있게 해 주는 방식이며, 도구는 JSON Schema로 정의된다1.

이 장을 끝내면 다음을 설명할 수 있어야 한다.

  • function calling에서 모델과 서버의 역할 차이

  • 도구 이름, 설명, JSON Schema, required 필드, 반환값 구조의 의미

  • 읽기 전용 도구와 상태 변경 도구의 차이

  • 잘못된 인자, 빈 결과, 타임아웃, API 실패를 처리하는 패턴

  • 작은 tool-use agent를 만들고 도구 호출 로그를 남기는 방법

Tool use와 function calling

왜 이 주제가 중요한가

앞 장에서는 agent pattern과 workflow pattern을 비교했다. 이번 장은 그 다음 단계다. 에이전트가 실제 업무를 하려면 말만 해서는 안 된다. 날씨를 알려면 날씨 API를 조회해야 하고, 주문 상태를 알려면 DB를 읽어야 하며, 문서를 정리하려면 파일을 열어야 한다.

하지만 도구를 많이 붙인다고 좋은 에이전트가 되지는 않는다. 오히려 위험해질 수 있다. 예를 들어 모델에게 send_email, delete_file, refund_payment 같은 도구를 아무 제한 없이 노출하면, 사용자의 애매한 말 한마디가 실제 발송, 삭제, 결제로 이어질 수 있다.

따라서 function calling의 핵심은 "모델에게 실행 권한을 넘기는 것"이 아니다. 핵심은 다음 경계를 명확히 세우는 것이다.

모델은 도구 사용을 제안한다.
서버는 제안을 검증한다.
실행은 서버가 통제한다.
결과는 다시 모델에게 관찰값으로 전달한다.

CLOVA Studio의 설명에서도 function calling은 모델이 직접 함수를 호출하는 것이 아니라, 사용할 함수와 필요한 매개변수를 생성하고 실제 실행은 개발자가 처리하는 흐름으로 설명된다2. 이 차이를 이해하지 못하면 초보자는 함수 실행 권한을 모델에게 "그냥 맡기는" 실수를 하기 쉽다.

핵심 개념 설명

Tool use와 function calling의 기본 흐름

가장 단순한 흐름은 다음과 같다.

  1. 사용자가 요청한다.
    예: "오늘 강남구 날씨 알려줘."

  2. 서버가 모델에게 사용 가능한 도구 목록을 함께 보낸다.

  3. 모델이 도구 호출을 제안한다.
    예: get_weather({"location":"강남구","date":"2026-05-18"})

  4. 서버가 함수명, 권한, 인자를 검증한다.

  5. 서버가 실제 날씨 API를 호출한다.

  6. 서버가 결과를 모델에게 tool result로 돌려준다.

  7. 모델이 사용자에게 최종 답변을 작성한다.

중요한 점은 4번이다. 모델이 만든 JSON은 신뢰할 수 있는 명령이 아니라 검증해야 할 입력값이다. 백엔드 API에서 사용자 입력을 검증하듯, tool call도 검증해야 한다.

도구 스키마의 다섯 요소

function calling 품질은 도구 스키마에서 크게 갈린다. 스키마는 모델에게 "이 도구는 이렇게 써라"라고 알려주는 계약서다. 보통 다음 요소로 구성된다.

요소의미좋은 설계 기준
도구 이름함수의 고유 이름동사+대상 형태로 명확하게 작성
설명언제 이 도구를 써야 하는지사용 조건과 사용하지 말아야 할 조건 포함
JSON Schema인자의 타입과 구조string, integer, enum, format 등을 명확히 지정
required 필드반드시 필요한 값없으면 실행할 수 없는 값만 필수로 지정
반환값 구조실행 결과의 형식성공/실패, 데이터, 근거, 오류 코드를 분리

예를 들어 FAQ 검색 도구는 이렇게 정의할 수 있다.

{
  "name": "search_faq",
  "description": "회사 FAQ 문서에서 사용자의 질문과 관련된 답변 후보를 검색한다. 사내 정책, 요금제, 환불, 계정 문제처럼 FAQ에 있을 가능성이 높은 질문에 사용한다. 실시간 주문 상태나 개인정보 조회에는 사용하지 않는다.",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "검색할 핵심 질문. 긴 문장보다 핵심 키워드 중심으로 작성한다."
      },
      "top_k": {
        "type": "integer",
        "minimum": 1,
        "maximum": 5,
        "default": 3,
        "description": "반환할 검색 결과 수"
      }
    },
    "required": ["query"],
    "additionalProperties": false
  }
}

여기서 additionalProperties: false는 정의되지 않은 인자를 막기 위한 장치다. JSON Schema 기반 도구 설계 자료에서도 enum, minimum, maximum, additionalProperties 같은 제약은 모델이 더 합리적인 인자를 생성하도록 돕는 중요한 수단으로 다뤄진다3.

잘못된 스키마가 도구 선택 오류를 만든다

나쁜 스키마는 모델을 헷갈리게 한다.

{
  "name": "process",
  "description": "사용자 요청을 처리한다",
  "parameters": {
    "type": "object",
    "properties": {
      "data": { "type": "string" }
    },
    "required": ["data"]
  }
}

이 스키마의 문제는 세 가지다.

첫째, 이름이 너무 넓다. process만 보고는 검색인지 계산인지 발송인지 알 수 없다.

둘째, 설명이 선택 기준을 주지 않는다. 모델은 어떤 상황에서 이 도구를 써야 하는지 판단하기 어렵다.

셋째, 인자 data가 모호하다. 날짜인지, 문서 내용인지, 검색어인지 알 수 없다.

이런 도구가 여러 개 있으면 모델은 비슷한 함수 사이에서 흔들린다. 좋은 도구 설계는 "모델이 똑똑하게 알아서 하겠지"가 아니라, 헷갈릴 여지를 줄이는 작업이다.

읽기 도구와 실행 도구를 구분하라

도구는 크게 두 종류로 나눌 수 있다.

Read-only 도구

읽기 전용 도구는 시스템 상태를 바꾸지 않는다.

  • 날씨 조회

  • FAQ 검색

  • 상품 재고 조회

  • 주문 상태 조회

  • 계산기

  • 문서 검색

읽기 도구도 검증은 필요하지만, 실패 비용은 비교적 낮다. 잘못 호출해도 보통 "틀린 답변"이나 "불필요한 API 비용" 정도에서 끝난다.

Side-effect 도구

side-effect 도구는 외부 상태를 바꾼다. 즉, 실행 후 현실의 무언가가 달라진다.

  • 이메일 전송

  • 파일 삭제

  • 결제 처리

  • 환불 실행

  • 주문 취소

  • 캘린더 일정 등록

  • DB 업데이트

  • 사내 게시판 업로드

이 도구들은 반드시 더 강한 통제가 필요하다. 안전한 function calling 구조를 설명하는 실무 자료들도 읽기와 쓰기 도구를 같은 수준에서 다루지 말고, 권한 검증·업무 규칙·사용자 확인·감사 로그를 별도 계층으로 두라고 강조한다4.

실무에서는 보통 다음 정책을 둔다.

도구 유형실행 정책
읽기 전용스키마 검증 후 실행 가능
낮은 위험의 쓰기사용자 확인 후 실행
높은 위험의 쓰기사용자 확인 + 권한 검증 + 업무 규칙 검증
되돌리기 어려운 작업미리보기 도구와 실행 도구를 분리

예를 들어 이메일 도구는 하나로 만들지 말고 다음처럼 나누는 편이 안전하다.

  • draft_email: 이메일 초안 생성

  • preview_email: 수신자, 제목, 본문 확인

  • send_email: 승인 후 실제 발송

이렇게 하면 모델이 곧바로 발송하지 못하고, 사용자가 확인할 수 있는 단계가 생긴다.

실패를 정상 흐름으로 설계하기

도구 호출은 자주 실패한다. 실패를 예외 상황으로만 보면 에이전트는 쉽게 멈춘다. 처음부터 실패 경로를 설계해야 한다.

1. 잘못된 인자

모델이 다음처럼 잘못된 인자를 만들 수 있다.

{
  "location": ["서울"],
  "date": "내일쯤"
}

스키마가 location을 string으로 요구하고 date를 YYYY-MM-DD로 요구한다면 이 호출은 거부해야 한다. 가능한 처리는 세 가지다.

  • 안전하게 변환 가능하면 정규화한다. 예: "서울" → "Seoul"

  • 의미가 불명확하면 모델에게 오류를 돌려 재시도하게 한다.

  • 사용자 정보가 부족하면 사용자에게 다시 묻는다.

2. 필수 정보 누락

사용자가 "날씨 알려줘"라고만 말하면 위치가 없다. 이때 모델이 임의로 "서울"을 넣으면 안 된다. 더 좋은 응답은 다음이다.

어느 지역의 날씨를 확인할까요?

필수 정보가 없을 때는 추측보다 확인이 낫다. 한국어 function call 평가에서도 필요한 정보가 모두 없을 때 추가 정보를 요청하는지, 일부만 없을 때 부족한 정보만 묻는지가 중요한 평가 항목으로 다뤄진다5.

3. 빈 검색 결과

FAQ 검색 결과가 없을 수 있다. 이때 모델이 없는 내용을 지어내면 안 된다. 도구 결과를 다음처럼 구조화해 반환한다.

{
  "success": true,
  "data": [],
  "evidence": [],
  "message": "관련 FAQ를 찾지 못했습니다."
}

최종 답변은 이렇게 해야 한다.

현재 FAQ에서는 관련 답변을 찾지 못했습니다. 질문을 조금 더 구체적으로 바꾸거나 고객센터 연결이 필요합니다.

4. API 타임아웃

외부 API는 느리거나 실패할 수 있다. 재시도 정책을 둔다.

  • 짧은 네트워크 오류: 1~2회 재시도

  • 계속 실패: 사용자에게 지연 안내

  • 대체 도구 있음: 캐시 또는 다른 API 사용

  • side-effect 도구: 중복 실행 방지를 위해 멱등성 키 사용

멱등성 키는 같은 요청이 여러 번 들어와도 실제 실행은 한 번만 일어나게 하는 식별자다. 결제, 환불, 발송 같은 작업에는 필수에 가깝다.

쉬운 비유: 모델은 접수 직원, 서버는 담당자다

function calling을 회사 업무로 비유해 보자.

사용자가 민원을 말하면 접수 직원은 "이 건은 환불팀이 처리해야 하고, 주문번호와 사유가 필요합니다"라고 분류한다. 하지만 접수 직원이 직접 계좌에서 돈을 빼거나 주문을 취소하지는 않는다.

실제 처리는 담당 부서가 한다. 담당 부서는 권한을 확인하고, 주문 상태를 보고, 환불 가능 기간을 확인한 뒤 실행한다. 처리 결과는 다시 접수 직원에게 전달되고, 접수 직원은 고객이 이해하기 쉬운 말로 설명한다.

LLM은 접수 직원에 가깝다. 자연어를 이해하고 적절한 도구와 인자를 제안한다. 서버는 담당자다. 검증하고 실행하고 기록한다.

직접 해보는 실습: 작은 tool-use agent 만들기

이번 실습에서는 세 가지 도구를 가진 작은 에이전트를 만든다.

  • get_weather: 날씨 조회

  • calculate: 안전한 계산기

  • search_faq: FAQ 검색

목표는 세 가지다.

  1. 모델이 적절한 도구를 선택하게 한다.

  2. 서버가 인자를 검증한 뒤 실행한다.

  3. 도구 호출 로그와 최종 답변의 근거를 함께 저장한다.

1단계: 도구 명세 작성

tools = [
    {
        "name": "get_weather",
        "description": "특정 지역의 현재 날씨를 조회한다. 사용자가 현재 날씨, 기온, 비 여부를 물을 때 사용한다. 과거 날씨나 장기 예보에는 사용하지 않는다.",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {"type": "string", "description": "날씨를 조회할 지역명"},
                "unit": {"type": "string", "enum": ["celsius", "fahrenheit"], "default": "celsius"}
            },
            "required": ["location"],
            "additionalProperties": False
        }
    },
    {
        "name": "calculate",
        "description": "사칙연산과 간단한 수치 계산을 수행한다. 실시간 정보 조회나 단위 변환 정책 판단에는 사용하지 않는다.",
        "parameters": {
            "type": "object",
            "properties": {
                "expression": {"type": "string", "description": "계산할 수식. 예: 123 * 456"}
            },
            "required": ["expression"],
            "additionalProperties": False
        }
    },
    {
        "name": "search_faq",
        "description": "FAQ 문서에서 답변 후보를 검색한다. 환불 정책, 계정 문제, 요금제 안내처럼 문서에 근거한 답변이 필요한 경우 사용한다.",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {"type": "string"},
                "top_k": {"type": "integer", "minimum": 1, "maximum": 5, "default": 3}
            },
            "required": ["query"],
            "additionalProperties": False
        }
    }
]

2단계: 실행 함수 만들기

import ast
import operator


def get_weather(location: str, unit: str = "celsius"):
    return {
        "success": True,
        "location": location,
        "temperature": 22,
        "unit": unit,
        "condition": "흐림",
        "evidence": ["mock_weather_api"]
    }


_ALLOWED_OPERATORS = {
    ast.Add: operator.add,
    ast.Sub: operator.sub,
    ast.Mult: operator.mul,
    ast.Div: operator.truediv,
    ast.USub: operator.neg,
}


def _eval_number_expr(node):
    if isinstance(node, ast.Expression):
        return _eval_number_expr(node.body)
    if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
        return node.value
    if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPERATORS:
        left = _eval_number_expr(node.left)
        right = _eval_number_expr(node.right)
        return _ALLOWED_OPERATORS[type(node.op)](left, right)
    if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPERATORS:
        value = _eval_number_expr(node.operand)
        return _ALLOWED_OPERATORS[type(node.op)](value)
    raise ValueError("허용되지 않은 계산식입니다.")


def calculate(expression: str):
    # 실서비스에서는 문자열을 eval로 실행하지 않는다.
    # 여기서는 사칙연산 노드만 허용하는 작은 계산기로 제한한다.
    try:
        tree = ast.parse(expression, mode="eval")
        result = _eval_number_expr(tree)
        return {"success": True, "result": result, "evidence": ["safe_calculator"]}
    except Exception as e:
        return {"success": False, "error": str(e), "evidence": []}


def search_faq(query: str, top_k: int = 3):
    docs = [
        {"id": "faq_1", "title": "환불 정책", "text": "구매 후 7일 이내 미사용 상품은 환불 가능합니다."},
        {"id": "faq_2", "title": "비밀번호 재설정", "text": "로그인 화면에서 비밀번호 찾기를 선택하세요."}
    ]
    hits = [d for d in docs if query in d["text"] or query in d["title"]]
    return {"success": True, "data": hits[:top_k], "evidence": [h["id"] for h in hits[:top_k]]}

실습 코드도 운영 코드처럼 생각하는 습관이 중요하다. 특히 문자열을 코드로 실행하는 방식은 작은 예제에서는 편해 보이지만, 실제 서비스에서는 보안 사고로 이어질 수 있다. 계산기처럼 단순한 도구도 허용할 연산과 입력 범위를 명확히 제한해야 한다.

3단계: 도구 호출 로그 저장

tool_logs = []


def log_tool_call(session_id, tool_name, args, result, latency_ms):
    tool_logs.append({
        "session_id": session_id,
        "tool_name": tool_name,
        "args": args,
        "result_summary": str(result)[:300],
        "latency_ms": latency_ms,
        "ok": bool(result.get("success", False)) if isinstance(result, dict) else False,
        "evidence": result.get("evidence", []) if isinstance(result, dict) else []
    })

운영 환경에서는 여기에 user_id, request_id, validation_error, retry_count, model_name, token_usage도 함께 저장한다. 다음 장들에서 다룰 tracing과 evals의 기초가 바로 이 로그다.

4단계: 최종 답변에 근거 포함

최종 답변은 단순히 자연어만 만들지 말고, 내부적으로 근거를 연결해 저장한다.

{
  "answer": "구매 후 7일 이내 미사용 상품은 환불 가능합니다.",
  "used_tools": ["search_faq"],
  "evidence": ["faq_1"]
}

사용자에게는 자연스럽게 답하되, 시스템에는 어떤 도구와 문서를 근거로 답했는지 남긴다. 그래야 나중에 답변을 검증할 수 있다.

흔한 실수와 오류 분석

실수 1. 함수 이름을 추상적으로 짓는다

handle_request, process_data, do_action 같은 이름은 피한다. 모델도 사람도 헷갈린다. get_order_status, search_faq, cancel_order처럼 동작과 대상을 드러내야 한다.

실수 2. 모든 필드를 required로 만든다

필수 필드가 너무 많으면 모델이 사용자에게 묻지 않고 값을 지어낼 수 있다. 도구 실행에 꼭 필요한 값만 required로 둔다. 나머지는 기본값을 두거나 선택 필드로 둔다.

실수 3. 자유 문자열을 너무 많이 허용한다

상태, 우선순위, 정렬 기준, 카테고리는 가능하면 enum으로 제한한다.

"priority": {
  "type": "string",
  "enum": ["low", "medium", "high", "critical"]
}

이렇게 하면 urgent!!!, 매우 급함, 상처럼 제각각 들어오는 값을 줄일 수 있다.

실수 4. 읽기와 쓰기를 같은 권한으로 둔다

조회 도구와 실행 도구는 위험도가 다르다. 특히 이메일 전송, 파일 삭제, 결제, 환불, 주문 취소는 사용자 승인과 서버 권한 검증을 반드시 붙인다.

실수 5. 도구 결과를 원본 그대로 모델에 넘긴다

외부 API 응답에는 민감정보, 내부 필드, 불필요한 메타데이터가 섞일 수 있다. 모델에는 필요한 필드만 정제해서 넘긴다.

실수 6. 실패 로그를 남기지 않는다

도구 호출이 실패했을 때 로그가 없으면 원인을 찾기 어렵다. 최소한 다음은 남긴다.

  • 어떤 사용자 요청이었는가

  • 어떤 도구가 선택되었는가

  • 어떤 인자가 생성되었는가

  • 검증에서 막혔는가, 실행에서 실패했는가

  • 재시도했는가

  • 최종 답변은 무엇이었는가

요약

function calling은 LLM을 외부 시스템과 연결하는 핵심 패턴이다. 하지만 모델이 함수를 직접 실행하는 구조로 이해하면 위험하다. 모델은 도구와 인자를 제안하고, 서버가 검증과 실행을 책임져야 한다.

좋은 tool-use agent를 만들려면 도구 스키마를 명확히 설계해야 한다. 도구 이름은 구체적이어야 하고, 설명에는 사용 조건과 제한 조건이 들어가야 한다. JSON Schema는 타입, enum, required, additionalProperties를 활용해 입력 공간을 좁혀야 한다.

읽기 전용 도구와 side-effect 도구는 반드시 구분한다. 상태를 바꾸는 도구에는 사용자 승인, 권한 검증, 업무 규칙 검증, 멱등성 키, 감사 로그가 필요하다.

마지막으로 실패를 정상 흐름으로 설계해야 한다. 잘못된 인자, 필수 정보 누락, 빈 검색 결과, API 타임아웃은 모두 실제 서비스에서 자주 발생한다. 재시도, 사용자 확인, 대체 경로, 안전한 실패 응답을 준비해야 한다.

퀴즈

  1. function calling에서 모델이 담당하는 역할과 서버가 담당하는 역할을 구분해 설명하라.

  2. 도구 스키마에서 description이 중요한 이유는 무엇인가?

  3. required 필드를 너무 많이 지정하면 어떤 문제가 생길 수 있는가?

  4. read-only 도구와 side-effect 도구의 예를 각각 3개씩 들어보라.

  5. 이메일 전송 도구를 안전하게 설계하려면 어떤 승인 단계를 넣어야 하는가?

  6. API 타임아웃이 발생했을 때 가능한 처리 패턴 세 가지를 말해보라.

  7. 도구 호출 로그에 반드시 남겨야 할 항목은 무엇인가?

체크리스트

  • 도구 이름이 구체적인 동사+대상 형태인가?

  • 도구 설명에 사용 조건과 제한 조건이 들어 있는가?

  • JSON Schema에 타입, required, enum, 범위 제한이 명확한가?

  • additionalProperties: false를 적용할 수 있는가?

  • 읽기 도구와 쓰기 도구를 구분했는가?

  • 위험한 도구에 사용자 승인 단계를 넣었는가?

  • 서버에서 권한과 업무 규칙을 강제하는가?

  • 도구 결과에서 민감정보를 제거한 뒤 모델에 전달하는가?

  • 실패 유형별 재시도, 확인 질문, 대체 경로가 정의되어 있는가?

  • 도구 호출 로그와 최종 답변의 근거를 저장하는가?

다음 장 연결

이번 장에서는 개별 도구를 안전하게 정의하고 호출하는 방법을 배웠다. 다음 장에서는 이 도구들이 많아질 때 필요한 표준 연결 방식인 MCP를 다룬다.

function calling이 개별 함수 호출의 계약이라면, MCP는 여러 도구와 리소스를 일관된 방식으로 연결하는 상위 인터페이스에 가깝다. 다음 장에서는 외부 도구를 어떻게 등록하고, 어떤 경계로 노출하며, 에이전트가 도구 생태계를 안전하게 사용하도록 만들지 살펴본다.

참고

1OpenAI Function calling 가이드

2Function calling 활용법: LLM으로 실시간 데이터 호출과 액션 수행까지

3Designing Tool Schemas for AI Agents: JSON Schema Best Practices

4Function Calling의 정석: 외부 API와 LLM을 안전하게 연결하는 구조

5한국어 LLM Function Call 평가

책 목록

AI 교육 6권 : 에이전트 설계·배포: 도구를 쓰는 LLM을 운영 가능한 시스템으로 만드는 법

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기