Tool Calling에서 함수는 누가 실행할까?

ChatGPT나 Claude에 오늘 날씨를 물으면 검색해서 답하고 Claude Code 같은 코딩 Agent는 파일을 열고 테스트를 돌립니다. 그런데 이런 일을 하는 Agent 속의 LLM(Large Language Model)은 받은 글 뒤에 올 말을 이어 쓰는 프로그램입니다. 스스로 할 수 있는 일은 글을 쓰는 것뿐입니다. 그럼 검색이나 티켓 조회는 누가 하는 걸까요?

답은 Agent 프로그램입니다. 개발자가 짠 코드이고 사용자와 모델 사이에서 돌아갑니다. 이 프로그램이 모델을 운영하는 회사의 API(프로그램끼리 일을 주고받는 창구)로 모델을 부르면서 모델이 쓸 수 있는 함수 목록을 함께 보냅니다. 이 함수를 도구(tool)라고 합니다. 모델은 "이 도구를 이 값으로 실행해 달라"는 요청서만 쓰고 실행은 모델을 부른 쪽의 코드가 합니다. 이 방식을 Tool Calling이라고 부릅니다. Function Calling이라고도 합니다.

그림에서 직원이 묻는 티켓의 실제 상태는 부품 대기입니다. 도구 목록을 보내기 전과 후에 모델의 답이 어떻게 달라지는지 견주어 보세요. 이 글은 그 요청서가 실제로 어떤 모양인지, Agent 프로그램과 모델 사이에 어떤 메시지가 오가는지 Python 코드까지 열어 봅니다.

모델이 쓸 수 있는 함수 목록(도구 목록)을 함께 보내면 답이 어떻게 달라질까요?

모델에게 도구 목록을
"보냄"을 누르면 빈 칸에 1~4 단계가 채워집니다
모델 · 글만 씀
글만 오감
Agent 프로그램 · 실제로 실행
직원T-1042 티켓 어떻게 됐어요?
티켓 시스템
T-1041완료
T-1042부품 대기
T-1043처리 중
모델
받은 도구 목록 없음 받은 도구 목록: get_ticket
도구가 있는 줄 모르니 요청서를 쓰지 않습니다
1요청서 (모델이 씀)
get_ticket
{"ticket_id": "T-1042"}
조회 요청이 오지 않았습니다
2코드가 get_ticket("T-1042")를 실행해 티켓 시스템을 조회합니다
모델은 한 번만 불리고 바로 답을 씁니다
모델이 결과 글을 읽습니다
돌려줄 결과도 없습니다
3결과 (코드가 씀)
{"status": "부품 대기", …}
모델의 답T-1042 티켓은 현재 처리 중입니다. 곧 담당자가 연락드릴 예정입니다.
실제 상태와 다름: 지어낸 답
4모델을 한 번 더 불러 받은 답T-1042 티켓은 지금 부품 대기 중입니다.
티켓 시스템에서 확인한 답
모델 호출 1번 · 티켓 시스템 조회 0번
모델 호출 2번 · 티켓 시스템 조회 1번

설명을 위해 만든 예시입니다. 도구 목록이 없을 때 모델이 늘 상태를 지어내지는 않습니다. "확인할 수 없다"고 답할 때도 많습니다. 다만 어느 쪽이든 실제 티켓 시스템에는 닿지 않습니다.

모델은 주문서를 쓰고 요리는 주방이 합니다

Tool Calling은 식당과 비슷합니다. 웨이터는 손님 말을 듣고 메뉴판에 있는 요리를 주문서에 적어 주방에 넘깁니다. 요리는 주방이 합니다. 요리가 나오면 다시 웨이터 차례입니다. 웨이터가 요리를 받아 손님에게 설명하며 가져다줍니다.

주방 손님 웨이터 메뉴판 주문서 볶음밥 1 주방 직원 주문 요리는 웨이터를 거쳐 손님에게 설명하며 전달 보고 고름 사용자 모델 tool call Agent 프로그램 메뉴판 = 도구 목록 손님= 사용자 주문 웨이터= 모델 메뉴판= 도구 목록전달 주문서= tool call ↓ 주방 주방= Agent 프로그램 요리는 웨이터를 거쳐손님에게 갑니다

점선이 주방 벽입니다. 웨이터는 벽을 넘어가 요리하지 않고 주문서만 넘깁니다. 그림은 단순하게 그렸습니다. 실제로는 손님과 웨이터 사이에서도 Agent 프로그램이 말을 전합니다.

여기서 판단하는 쪽은 웨이터이고 주방은 시킨 대로 만들기만 합니다. 웨이터가 모델, 주문서가 요청서(tool call), 주방이 Agent 프로그램입니다. 메뉴판은 개발자가 보내는 도구 목록입니다. 웨이터가 메뉴판을 보고 고르듯 모델도 도구 목록 안에서 고릅니다. 주문서에는 도구 이름과 그 도구에 넘길 값이 적힙니다. 도구에 넘기는 값을 인자(argument)라고 합니다.

식당과 다른 점도 있습니다. 웨이터는 메뉴에 없는 요리를 적지 않지만 모델은 없는 도구 이름이나 엉뚱한 값을 적을 수 있습니다. 그래서 주문서를 받은 코드가 내용을 확인한 다음에 실행합니다.

모델이 쓰는 요청서도 결국 글입니다

모델이 글만 쓴다면 요청서는 어떻게 응답의 tool_calls 칸에 담겨 올까요? 요청서도 모델이 쓴 글입니다. system 글에 "이 표시 안에 적어라"라는 안내가 함께 들어가고 모델은 많은 예시로 그 표시를 쓰도록 학습되어(길러져) 있습니다.

모델을 돌리는 회사 쪽 컴퓨터를 API 서버라고 합니다. 모델이 닫는 표시(</tool_call>)까지 쓰면 API 서버는 거기서 쓰기를 멈추게 합니다. 그리고 표시 안의 JSON을 떼어 내 tool_calls 칸에 담아 돌려줍니다. 그림의 "정리"가 이 일입니다.

질문을 바꿔 보세요

1모델이 실제로 읽는 글

<|im_start|>system 너는 IT 헬프데스크 상담원이다. <tools> {"name": "get_ticket", "description": "티켓 번호로 …조회한다.", "parameters": {…}} </tools> 도구를 쓰려면 <tool_call> 안에 JSON으로 적어라. <|im_end|> <|im_start|>user 와이파이 껐다 켜는 법 알려 주세요T-1042 티켓 어떻게 됐어요? <|im_end|> <|im_start|>assistant

회색 표시는 누가 하는 말인지(role) 가르는 칸막이입니다. 모델은 이 칸막이로 말한 사람을 구분합니다. system은 개발자의 지시, user는 사람의 말, assistant는 모델의 말입니다. 주황 바탕이 메뉴판이 글로 들어간 곳입니다

2모델이 이어 쓰는 글

설정에서 Wi-Fi를 껐다가 다시 켜 보세요.<tool_call> {"name": "get_ticket", "arguments": {"ticket_id": "T-1042"}} </tool_call>

표시가 없는 보통 글입니다1번 칸 마지막 두 줄의 안내대로 표시 안에 요청서를 적었습니다

3Agent 프로그램이 받는 응답

finish_reason: "stop" message: content: "설정에서 Wi-Fi를 껐다가 다시 켜 보세요." tool_calls: 없음finish_reason: "tool_calls" message: content: null (비어 있음) tool_calls[0]: id: "call_Qd7w" name: "get_ticket" arguments: '{"ticket_id": "T-1042"}'

finish_reason은 모델이 쓰기를 멈춘 이유, content는 답 글이 들어가는 칸입니다. 글이 content 칸에 그대로 담겼습니다finish_reason은 모델이 쓰기를 멈춘 이유, content는 답 글이 들어가는 칸입니다. 표시를 떼어 내 tool_calls 칸에 담았고 번호(id)도 이때 붙습니다

1·2번 칸은 공개 모델 Qwen2.5의 채팅 템플릿을 본떴습니다. 채팅 템플릿은 대화를 긴 글 하나로 이어 붙이는 규칙입니다. 길이는 줄이고 지시 문장은 우리말로 옮겼습니다. 이 글의 코드가 API로 부르는 GPT 모델이 안에서 쓰는 형식은 공개되지 않았습니다. 다만 도구 정의가 system 메시지 안에 글로 들어간다는 점은 OpenAI 문서에도 적혀 있습니다.

메뉴판도 토큰을 씁니다

메뉴판이 글로 들어가니 공짜가 아닙니다. 모델이 한 번에 읽을 수 있는 글에는 한도가 있는데, 이 한도를 context window라고 합니다. 도구 정의도 이 한도를 함께 쓰고 모델이 글을 세는 단위인 토큰 수만큼 요금도 매겨집니다.

도구 하나는 작아도 여럿이 모이면 커집니다. 그래서 OpenAI 문서는 한 번에 열어 두는 도구를 20개보다 적게 두라고 권합니다.

도구 정의가 차지하는 토큰 (막대 전체 = context window 20만 토큰인 모델 기준)

이 글의 get_ticket 1개 (너무 작아 점으로)약 65
GitHub 도구 묶음 35개약 2만 6천
서비스 5곳(GitHub·Slack 등)의 도구 58개약 5만 5천
Anthropic 사내, 도구 정의를 정리하기 전약 13만 4천

도구 묶음(MCP 서버)은 여러 도구를 한데 모아 Agent에 꽂아 쓰게 해 주는 프로그램입니다. 첫 줄은 이 글의 메뉴판을 토큰으로 센 값입니다. 나머지는 Anthropic이 2025년 11월 24일에 공개한 값입니다(참고 자료). 20만 토큰은 비교용 기준입니다.

코드로는 이렇게 보내고 받습니다

ask_tool.pypython
# ... client = OpenAI(...), MODEL = "gpt-5.5" 준비는 생략
# 1 메뉴판: 도구 하나를 설명한다
tools = [{
    "type": "function",
    "function": {
        "name": "get_ticket",
        "description": "티켓 번호로 처리 상태와 담당자를 조회한다.",
        # 2 인자의 모양(JSON Schema)
        "parameters": {
            "type": "object",
            "properties": {"ticket_id": {"type": "string"}},
            "required": ["ticket_id"],
        },
    },
}]
  1. 메뉴판입니다. 도구 하나는 "type": "function"에 이름과 설명을 붙여 적습니다. 모델은 이 설명을 읽고 언제 이 도구를 쓸지 정하므로 사람에게 설명하듯 구체적으로 씁니다.
  2. 인자의 모양입니다. "type": "object"는 이름 붙은 칸 여러 개를 묶은 덩어리라는 뜻입니다. properties에 칸마다 이름과 타입(값의 종류)을, required에 꼭 넣어야 할 인자를 적습니다. required에서 빼면 모델이 ticket_id 없이 요청서를 쓸 수도 있습니다.
ask_tool.pypython
messages = [
    {"role": "system", "content": "너는 IT 헬프데스크 상담원이다."},
    {"role": "user", "content": "T-1042 티켓 어떻게 됐어요?"},
]
# 3 메뉴판을 메시지와 함께 보낸다
res = client.chat.completions.create(
    model=MODEL, messages=messages, tools=tools)
# 4 모델이 왜 멈췄나
print(res.choices[0].finish_reason)
# 5 답 대신 온 요청서
for call in res.choices[0].message.tool_calls:
    print(call.id, call.function.name, call.function.arguments)
터미널
$ python ask_tool.py tool_calls call_Qd7w get_ticket {"ticket_id": "T-1042"}

출력은 예시입니다. 실제 id는 이보다 긴 무작위 글자입니다. 코드는 openai 패키지 3.31 기준입니다.

  1. 메뉴판을 메시지와 함께 보냅니다. tools=를 빼면 모델은 도구가 있는 줄 모르고 글로만 답합니다. 맨 위 그림에서 "안 보냄"을 고른 것과 같습니다.
  2. 모델이 쓰기를 멈춘 이유입니다. "stop"은 답을 다 썼다는 뜻이고 "tool_calls"는 요청서를 쓰고 멈췄다는 뜻입니다. "length"는 길이 한도에 걸려 잘렸다는 뜻입니다.
  3. 요청서 목록입니다. choices[0]은 목록의 첫 번째 칸이라는 뜻입니다. 한 장마다 번호(id), 도구 이름, 인자가 들어 있습니다. 인자(arguments)는 딕셔너리(이름과 값을 짝지어 담는 Python 자료형)가 아니라 JSON 글자 그대로 옵니다. 그래서 쓰려면 먼저 바꿔야 합니다.

도구를 쓸지 말지는 모델이 고릅니다

메뉴판을 보냈다고 모델이 늘 요청서를 쓰지는 않습니다. 질문을 읽고 도구가 필요하면 요청서를, 아니면 보통 답을 씁니다. 이 선택을 개발자가 미리 정해 두는 옵션이 tool_choice입니다.

auto는 모델이 고르게 둡니다. 메뉴판을 보내면 기본값이 auto입니다. none은 도구를 쓰지 못하게 하고 required는 하나 이상 꼭 쓰게 합니다. 도구 이름을 콕 집어 그 도구만 쓰게 할 수도 있습니다.

칸을 눌러 보세요. 빨간 점선 칸이 눈여겨볼 곳입니다.

글로 답함 (stop)요청서를 씀 (tool_calls)✕ 값을 지어낸 요청서

예시 응답입니다. 실제 모델의 판단은 질문 문장과 도구 설명에 따라 달라집니다.

required로 강제하면 모델은 어떻게든 요청서를 써야 합니다. 도구가 필요 없는 질문 1에서는 T-1000이라는 번호를 지어냈습니다. 번호를 모르는 질문 3에서는 T-1043을 지어냈는데, 실제로 있는 다른 직원의 티켓이라 확인 없이 실행하면 남의 티켓을 보여 주게 됩니다. 그래서 보통은 auto로 두고 꼭 도구를 거쳐야 하는 단계에서만 강제합니다.

Claude 모델로 같은 코드를 돌리면?

Anthropic은 OpenAI SDK로 Claude를 부를 수 있는 호환 주소를 열어 두었습니다. base_url을 https://api.anthropic.com/v1/로, 모델 이름을 Claude 모델로 바꾸면 이 글의 코드가 그대로 돌아갑니다. 다만 Anthropic은 이 경로를 시험하고 비교하는 용도로 안내합니다.

이 경로에서는 strict가 무시됩니다. 또 Claude Opus 5.5와 Sonnet 5.5는 도구를 강제하는 tool_choice(required나 이름 지정)를 받지 않고 오류를 냅니다. 모양을 보장받으려면 Claude 고유의 Messages API와 Structured Outputs를 씁니다.

요청서 한 장이 답이 되려면 모델을 두 번 부릅니다

도구를 한 번만 쓰는 질문도 API 호출은 두 번입니다. 첫 호출에서 요청서를 받고 실행 결과를 실어 다시 불러야 사람에게 줄 답이 나옵니다. API는 앞선 호출을 기억하지 못해서 두 번째 호출에도 처음부터의 메시지를 전부 실어 보냅니다.

메시지마다 누가 한 말인지 role을 붙입니다. 모델이 쓴 메시지는 assistant, 도구 결과를 담은 메시지는 tool입니다. 아래 그림은 두 번의 호출을 두 줄로 펼쳤습니다. 스크롤하면 1번부터 한 단계씩 켜지고 막대의 messages에 대화 기록이 쌓입니다.

Agent 프로그램과 모델 사이로는 JSON 글만 오갑니다
messages system지시 user질문 assistant요청서 Qd7w tool결과 Qd7w 모델 호출 0번
모델 (API 서버) Agent 프로그램
Agent 프로그램
모델 (API 서버)
Agent 프로그램
모델 (API 서버)
1질문을 목록에 넣음직원 메시지를 messages에 더합니다
21번째 호출을 받음메시지 2개와 도구 목록 get_ticket을 읽습니다
3답 대신 요청서를 씀get_ticket
{"ticket_id": "T-1042"}

API 서버가 표시를 떼고 번호 call_Qd7w를 붙임
4요청서를 받아 실행요청서 call_Qd7w를 기록에 넣고 get_ticket("T-1042")을 실행합니다
5결과를 번호표와 함께role: "tool"
tool_call_id: call_Qd7w
{"status": "부품 대기", …}
62번째 호출을 받음메시지 4개를 처음부터 다시 읽습니다
7결과를 읽고 답을 씀finish_reason: "stop"
"T-1042는 부품 대기 중입니다"
8답을 직원에게 보여 줌content를 화면에 띄웁니다. 대화가 끝납니다

두 번 부르는 코드

앞의 코드에 실행과 두 번째 호출을 더했습니다.

run_tool.pypython
# ... import json, client, MODEL, tools, messages 는 위와 같다
TICKETS = {
    "T-1042": {"status": "부품 대기", "owner": "김하늘",
               "note": "랜카드 10/18 도착"},
    "T-1043": {"status": "처리 중", "owner": "박서준",
               "note": "원격 점검 예정"},
}

def get_ticket(ticket_id: str):
    """티켓 번호로 처리 상태와 담당자를 조회한다."""
    return TICKETS.get(ticket_id, {"error": "없는 티켓입니다"})

TOOLS = {"get_ticket": get_ticket}  # 이름 → 실제 함수

first = client.chat.completions.create(
    model=MODEL, messages=messages, tools=tools)
reply = first.choices[0].message
# 1 모델이 쓴 요청서도 대화 기록에 넣는다
messages.append(reply)
for call in reply.tool_calls:
    # 2 이름으로 실행할 함수를 고른다
    fn = TOOLS[call.function.name]
    # 3 글자로 온 인자를 딕셔너리로 바꾼다
    args = json.loads(call.function.arguments)
    # 4 실제 실행은 Agent 프로그램이 한다
    result = fn(**args)
  1. 요청서 메시지도 기록에 넣습니다. 그림의 4단계입니다. 이 줄을 빼고 결과만 보내면 API가 오류로 거절합니다. 결과(tool 메시지)는 그걸 요청한 모델의 메시지(assistant 메시지) 바로 뒤에 와야 하기 때문입니다.
  2. 이름으로 실행할 함수를 고릅니다. 요청서의 도구 이름을 TOOLS에서 찾아 실제 함수를 꺼냅니다. 도구가 여러 개여도 이 한 줄로 갈라집니다. 메뉴에 없는 이름이면 여기서 KeyError(딕셔너리에 없는 이름을 찾을 때 나는 오류)가 나므로 아래 확인 단계가 필요합니다.
  3. 인자를 딕셔너리로 바꿉니다. json.loads는 JSON 글자를 딕셔너리로 읽어 들입니다. 모델이 JSON 모양을 틀리게 쓰면 여기서 오류가 납니다.
  4. 실제 실행은 이 줄에서 일어납니다. 회사 데이터베이스에 접속하는 것도, 오래 걸리는 것도 이 줄입니다. **args는 딕셔너리를 이름 붙은 인자로 풀어 넘기는 Python 문법이라서 fn(ticket_id="T-1042")와 같습니다.
run_tool.pypython
    messages.append({
        "role": "tool",
        # 5 요청서의 번호표를 그대로 적는다
        "tool_call_id": call.id,
        # 6 결과는 글자로 바꿔 담는다
        "content": json.dumps(result, ensure_ascii=False),
    })
# 7 결과를 실은 목록으로 한 번 더 부른다
second = client.chat.completions.create(
    model=MODEL, messages=messages, tools=tools)
print(second.choices[0].finish_reason)
print(second.choices[0].message.content)
터미널
$ python run_tool.py stop T-1042 티켓은 지금 부품 대기 중입니다. 교체할 무선 랜카드가 10월 18일에 도착할 예정입니다.

출력은 예시 답입니다. 모델이 결과의 note까지 읽고 날짜를 풀어 썼습니다.

  1. 요청서의 번호표를 그대로 적습니다. 그림의 5단계입니다. role이 "tool"인 메시지에 요청서의 id를 적어야 API가 어느 요청서의 결과인지 압니다.
  2. 결과는 글자로 바꿔 담습니다. content에는 글자만 들어가서 json.dumps로 딕셔너리를 JSON 글자로 바꿉니다. ensure_ascii=False는 한글을 그대로 두라는 옵션입니다.
  3. 한 번 더 부릅니다. 그림의 6단계입니다. 이번 목록에는 메시지가 4개 실립니다. tools를 다시 넣는 이유는 모델이 결과를 보고 다른 도구를 더 부를 수도 있어서입니다.
Claude API는 형식이 어떻게 다른가요?

흐름은 같고 이름이 다릅니다. Anthropic Messages API는 도구 정의에 input_schema를 쓰고 요청서를 tool_use 블록으로 보냅니다. 이때 stop_reason은 "tool_use"입니다. 인자(input)는 JSON 글자로 오지 않고 처음부터 객체로 옵니다.

결과를 담는 tool role은 따로 없습니다. user 메시지 안에 tool_result 블록을 넣고 tool_use_id로 짝을 맞춥니다. OpenAI의 Responses API도 function_call과 function_call_output을 call_id로 잇습니다.

tool_call_id는 결과를 요청서에 붙이는 번호표입니다

모델은 한 응답에 요청서를 여러 장 쓸 수 있습니다. 직원이 티켓 두 개를 한꺼번에 물으면 조회 요청서도 두 장이 옵니다. 이렇게 한 번에 여러 장 쓰는 걸 parallel tool calls라고 하고 OpenAI API는 이 기능을 기본으로 켜 둡니다.

세탁소 번호표와 비슷합니다. 옷을 맡기면 번호표를 받고 찾을 때는 맡긴 순서가 아니라 번호로 내 옷을 찾습니다. 요청서마다 붙은 id가 번호표이고 결과 메시지의 tool_call_id에 같은 번호를 적어 돌려줍니다.

요청서 두 장, 결과 두 개

결과가 돌아오는 순서
결과에 번호표를

assistant 메시지 한 장 속 요청서

call_Qd7wget_ticket {"ticket_id": "T-1042"}
call_Jm2xget_ticket {"ticket_id": "T-1043"}

Agent 프로그램이 붙이는 tool 메시지

call_Qd7w{"status": "부품 대기", …}
call_Jm2x{"status": "처리 중", …}

선이 엇갈려도 번호를 따라가서 짝이 맞습니다. 두 조회를 동시에 돌리면 어느 쪽이 먼저 끝날지 모릅니다. 결과를 번호로 잇기 때문에 먼저 끝난 것부터 붙여도 됩니다. 다만 결과 메시지들은 요청서를 담은 assistant 메시지 바로 다음에 모여 있어야 합니다.

세탁소와 다른 점도 하나 있습니다. 세탁소에서는 옷 한 벌을 안 찾아가도 다른 옷은 찾을 수 있습니다. API는 번호표가 하나라도 빠지면 요청 전체를 받지 않습니다. 대신 오류 문장에 어느 번호의 결과가 없는지 적어 줍니다.

요청서는 확인한 뒤에 실행합니다

요청서도 모델이 쓴 글이라 틀릴 수 있습니다. OpenAI Python 라이브러리의 arguments 설명에도 모델이 늘 올바른 JSON을 쓰지는 않고 JSON Schema에 없는 인자를 지어낼 수 있으니 함수를 부르기 전에 코드에서 확인하라고 적혀 있습니다.

모델이 쓴 요청서를 하나 골라 보세요.

1JSON인가json.loads
2메뉴에 있나도구 이름 확인
3JSON Schema와 맞나인자 이름·타입 확인
4실행get_ticket(...)
모델에게 돌려줄 결과

확인 단계는 이 글에서 정한 순서입니다. 실제로는 이 직원이 그 티켓을 볼 수 있는지 같은 권한 확인이 더 붙습니다.

확인에 걸리면 실행하지 않습니다. 무엇이 틀렸는지를 결과 메시지에 적어 돌려주면 모델이 읽고 다시 쓸 수 있습니다. 이 되풀이는 다음 글에서 루프를 직접 짜며 알아보겠습니다.

형식 실수를 막으려면 도구 정의에 "strict": true를 넣습니다. 다만 strict가 지켜 주는 건 모양까지입니다. T-1042가 정말 이 직원의 티켓인지, 실행해도 되는 작업인지는 여전히 Agent 프로그램이 확인해야 합니다.

strict를 켜려면 무엇을 바꾸나요?

strict를 켜면 API가 인자를 스키마 모양에 맞춰 만들어 줍니다. 대신 스키마에 두 가지를 지켜야 합니다. "type": "object"인 칸마다 "additionalProperties": false를 넣어 정해 둔 인자 말고는 받지 않게 하고 모든 인자를 required에 넣습니다. 빼도 되는 인자는 타입에 null을 함께 적어 표시합니다.

Tool Calling에서 모델은 요청서를 쓰고 실행은 Agent 프로그램이 합니다. 메뉴판(JSON Schema)은 모델이 고를 수 있는 도구와 인자의 범위를 정하고 번호표(tool_call_id)는 요청서와 결과를 잇습니다.

참고 자료