AI Function Calling 완벽 입문 — 모델에 실제 함수 연결하는 법
LLM은 자연어를 이해하지만, 날씨 API를 호출하거나 데이터베이스에서 사용자를 조회하는 등 실제 행동은 직접 하지 못합니다. Function Calling(도구 사용, tool use)은 이 간극을 메우는 핵심 기능입니다. 모델이 "서울 날씨 알려줘"라는 문장을 받으면, 자유 텍스트로 답하는 대신 get_weather(city="서울")라는 구조화된 함수 호출을 만들어 냅니다. 여러분의 코드가 그 함수를 실제로 실행하고 결과를 돌려주면, 모델은 그 결과를 사람이 읽을 문장으로 정리합니다. 이 글에서는 Claude(Python·Laravel/PHP)를 주 예제로 도구 정의부터 1라운드 루프까지 정확하게 다루고, OpenAI function calling과 1:1로 비교합니다.
왜 Function Calling인가 — 자연어를 액션으로
LLM의 약점은 명확합니다. 학습 시점 이후의 실시간 정보(오늘 환율, 재고 수량, 특정 사용자의 주문 내역)를 모르고, 외부 시스템을 직접 건드릴 수도 없습니다. Function Calling은 이 문제를 역할 분담으로 해결합니다. 모델은 "무엇을, 어떤 인자로 호출해야 하는지" 판단하는 일만 맡고, 실제 실행과 권한·보안 경계는 여러분의 애플리케이션이 책임집니다.
중요한 오해부터 정리하겠습니다. 모델이 함수를 직접 실행하지는 않습니다. 모델은 "이 도구를 이 인자로 호출하면 좋겠다"는 요청(tool_use)만 JSON 형태로 내놓습니다. 그 요청을 받아 함수를 실행하고, 결과를 다시 모델에게 전달하는 루프를 만드는 것이 개발자의 몫입니다. 이 구조 덕분에 모델이 위험한 작업(이메일 발송, 데이터 삭제)을 시도해도 여러분이 중간에서 검증·차단할 수 있습니다.
대표적인 활용처는 다음과 같습니다.
- 실시간 데이터 조회 — 날씨, 주가, 환율, 내부 DB 검색
- 액션 수행 — 예약 생성, 알림 발송, 결제 처리(승인 게이트와 함께)
- 구조화된 추출 — 자유 문장에서 필드를 뽑아 시스템에 입력
- RAG·에이전트의 기반 — 검색 도구를 연결해 근거 기반 답변 생성
도구 정의 — name, description, input_schema
도구 하나는 세 가지로 정의됩니다. name(함수 이름), description(언제 쓰는지 설명), 그리고 input_schema(JSON Schema로 표현한 입력 형식)입니다. 모델은 이 셋만 보고 어떤 도구를 호출할지, 어떤 인자를 채울지 결정합니다. 따라서 description은 "무엇을 하는지"뿐 아니라 "언제 호출해야 하는지"까지 처방적으로 쓰는 것이 정확도를 크게 높입니다.
Claude의 도구 정의는 다음 형태입니다. input_schema는 표준 JSON Schema이며, properties의 각 필드에 설명을 달고 필수 인자는 required에 명시합니다.
get_weather = {
"name": "get_weather",
"description": "특정 도시의 현재 날씨를 조회합니다. 사용자가 날씨, 기온, 비/눈 여부를 물을 때 호출하세요.",
"input_schema": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "도시 이름, 예: 서울, Busan, San Francisco",
},
"unit": {
"type": "string",
"enum": ["celsius", "fahrenheit"],
"description": "온도 단위 (기본값 celsius)",
},
},
"required": ["city"],
},
}
좋은 도구 정의의 핵심 원칙입니다.
- 구체적인 이름 —
weather보다get_weather가,search보다search_user_by_email이 낫습니다. - 처방적 설명 — "현재 날씨를 조회합니다. 사용자가 날씨를 물을 때 호출하세요."처럼 호출 조건을 명시합니다.
- 고정값은 enum으로 — 단위·상태처럼 값이 정해진 인자는
enum으로 제약합니다. - required 최소화 — 정말 필수인 것만
required에 넣고 나머지는 선택으로 둡니다.
모델이 도구를 호출할지 결정하는 원리 — tool_choice
도구를 넘겨도 모델이 항상 호출하는 것은 아닙니다. "안녕하세요" 같은 인사에는 도구 없이 답하고, "서울 날씨"에는 get_weather를 호출합니다. 이 판단을 tool_choice로 제어할 수 있습니다. Claude의 옵션은 다음과 같습니다.
| tool_choice | 동작 |
|---|---|
{"type": "auto"} | 모델이 도구 사용 여부를 스스로 결정 (기본값) |
{"type": "any"} | 반드시 도구 중 하나를 호출 (어떤 도구인지는 모델이 선택) |
{"type": "tool", "name": "get_weather"} | 지정한 특정 도구를 강제 호출 |
{"type": "none"} | 도구를 호출하지 못하게 함 |
모든 tool_choice에는 "disable_parallel_tool_use": true를 추가해 한 응답당 도구를 하나만 호출하도록 강제할 수 있습니다. 기본적으로 Claude는 한 응답에서 여러 도구를 동시에 요청할 수 있습니다.
tool_use 응답 파싱 — stop_reason과 블록 구조
모델이 도구를 호출하기로 했다면, 응답의 stop_reason이 "tool_use"가 됩니다. 그리고 msg.content는 블록 리스트인데, 그 안에 type=="tool_use"인 블록이 들어 있습니다. 이 블록의 핵심 필드는 세 가지입니다.
block.name— 호출할 도구 이름 (디스패치 기준)block.input— 인자 딕셔너리. 이미 파싱된 dict이므로 별도 JSON 파싱이 필요 없습니다.block.id— 이 도구 호출의 고유 ID. 결과를 돌려줄 때 짝을 맞추는 데 씁니다.
먼저 Claude 클라이언트로 도구를 넘기고 응답을 파싱하는 기본 코드입니다.
from anthropic import Anthropic
client = Anthropic() # ANTHROPIC_API_KEY 환경변수 사용
tools = [get_weather] # 위에서 정의한 도구
messages = [{"role": "user", "content": "서울 날씨 어때?"}]
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=tools,
messages=messages,
)
print(msg.stop_reason) # "tool_use"
for block in msg.content:
if block.type == "tool_use":
print(block.name) # "get_weather"
print(block.input) # {"city": "서울"} ← 이미 dict
print(block.id) # "toolu_01A..."
결과를 tool_result로 돌려주는 1라운드 루프
이제 핵심입니다. 도구를 실행한 뒤 그 결과를 모델에게 다시 보내 최종 답변을 받는 과정을 1라운드 루프라고 부르겠습니다. 절차는 정확히 이렇습니다.
- 모델 응답에서
tool_use블록을 찾아 실제 함수를 실행한다. messages에 assistant 턴(msg.content그대로)을 추가한다.messages에 user 턴을 추가하되, 내용은tool_result블록이며tool_use_id로 원래 호출과 짝을 맞춘다.- 같은
tools를 넘겨 다시create를 호출한다. 모델이 결과를 문장으로 정리해 준다.
def run_weather(city, unit="celsius"):
# 실제로는 외부 날씨 API를 호출. 여기서는 예시 값.
return f"{city}의 현재 날씨는 맑음, 기온 24도입니다."
# 1) tool_use 블록을 찾아 실행하고 tool_result를 모은다
tool_results = []
for block in msg.content:
if block.type == "tool_use":
result_text = run_weather(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id, # 원래 호출 id와 짝 맞춤
"content": result_text,
})
# 2) assistant 턴(원본 content)과 user 턴(tool_result)을 대화에 추가
messages.append({"role": "assistant", "content": msg.content})
messages.append({"role": "user", "content": tool_results})
# 3) 같은 tools로 다시 호출 → 모델이 최종 답변 생성
final = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
tools=tools,
messages=messages,
)
for block in final.content:
if block.type == "text":
print(block.text) # "서울은 현재 맑고 기온은 24도입니다."
주의할 점 두 가지입니다. 첫째, assistant 턴에는 msg.content(블록 리스트)를 그대로 넣어야 합니다. 텍스트만 뽑아 넣으면 tool_use 블록이 사라져 짝이 깨집니다. 둘째, 한 응답에 여러 tool_use 블록이 있을 수 있으므로 모두 실행한 뒤 결과를 하나의 user 메시지로 묶어 보냅니다. 실패 시에는 tool_result에 "is_error": true를 추가하고 오류 메시지를 담으면 모델이 이를 인지하고 대처합니다.
실전 예제 — 사용자 DB 조회
날씨보다 현실적인 예가 사내 DB 조회입니다. 이메일로 사용자를 찾아 플랜과 가입일을 알려주는 도구를 정의해 봅니다. 여기서는 도구 실행 함수가 실제 DB를 건드리는 지점이며, 권한 검증을 넣기 좋은 자리입니다.
lookup_user = {
"name": "lookup_user",
"description": "이메일로 사용자 계정을 조회합니다. 플랜, 가입일 등 계정 정보를 물을 때 호출하세요.",
"input_schema": {
"type": "object",
"properties": {
"email": {"type": "string", "description": "조회할 사용자의 이메일"},
},
"required": ["email"],
},
}
def run_lookup_user(email):
# 실제 DB 조회 (예시). 권한 검증은 이 지점에서.
rows = {"[email protected]": {"plan": "Enterprise", "joined": "2024-03-01"}}
user = rows.get(email)
if not user:
return {"found": False}
return {"found": True, **user}
messages = [{"role": "user", "content": "[email protected] 계정 플랜이 뭐야?"}]
msg = client.messages.create(
model="claude-opus-4-8", max_tokens=1024,
tools=[lookup_user], messages=messages,
)
import json
tool_results = []
for block in msg.content:
if block.type == "tool_use":
data = run_lookup_user(**block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(data, ensure_ascii=False), # 구조화 결과는 JSON 문자열로
})
messages.append({"role": "assistant", "content": msg.content})
messages.append({"role": "user", "content": tool_results})
final = client.messages.create(
model="claude-opus-4-8", max_tokens=1024,
tools=[lookup_user], messages=messages,
)
for block in final.content:
if block.type == "text":
print(block.text) # "[email protected] 님은 Enterprise 플랜이며 2024-03-01에 가입했습니다."
도구 결과가 구조화된 데이터(dict)라면 위처럼 json.dumps로 직렬화해 content에 담으면 됩니다. 모델은 JSON을 잘 읽어 자연어로 풀어 줍니다.
Laravel/PHP에서 Function Calling
국내 실무에는 Laravel 개발자가 많으므로 PHP 예제도 함께 보겠습니다. 공식 SDK는 composer require anthropic-ai/sdk로 설치합니다. PHP SDK는 도구 키에 camelCase를 씁니다 — inputSchema, toolUseID, stopReason처럼요(SDK가 내부적으로 API의 snake_case로 변환합니다).
use Anthropic\Client;
use Anthropic\Messages\ToolUseBlock;
$client = new Client(apiKey: getenv("ANTHROPIC_API_KEY"));
$tools = [
[
"name" => "get_weather",
"description" => "특정 도시의 현재 날씨를 조회합니다. 날씨를 물을 때 호출하세요.",
"inputSchema" => [ // camelCase (input_schema 아님)
"type" => "object",
"properties" => [
"city" => ["type" => "string", "description" => "도시 이름, 예: 서울"],
],
"required" => ["city"],
],
],
];
$messages = [["role" => "user", "content" => "서울 날씨 어때?"]];
$msg = $client->messages->create(
model: "claude-opus-4-8",
maxTokens: 1024,
tools: $tools,
messages: $messages,
);
// stopReason 이 "tool_use" 이면 도구 호출이 요청된 것
$toolResults = [];
foreach ($msg->content as $block) {
if ($block instanceof ToolUseBlock) {
// $block->name, $block->input(파싱된 배열), $block->id
$result = "{$block->input['city']}의 현재 날씨는 맑음, 24도입니다.";
$toolResults[] = [
"type" => "tool_result",
"toolUseID" => $block->id, // camelCase (tool_use_id 아님)
"content" => $result,
];
}
}
// assistant 턴 + tool_result user 턴을 추가하고 재호출
$messages[] = ["role" => "assistant", "content" => $msg->content];
$messages[] = ["role" => "user", "content" => $toolResults];
$final = $client->messages->create(
model: "claude-opus-4-8",
maxTokens: 1024,
tools: $tools,
messages: $messages,
);
foreach ($final->content as $block) {
if ($block->type === "text") {
echo $block->text; // 최종 답변
}
}
PHP SDK에는 베타 Tool Runner($client->beta->messages->toolRunner(...))도 있어, 도구 정의에 실행 클로저를 묶어 두면 루프를 자동으로 돌려줍니다. 위 수동 루프를 이해했다면 자동화는 선택 사항입니다.
OpenAI Function Calling과 1:1 비교
OpenAI도 같은 개념을 제공하지만 파라미터 이름과 응답 구조가 다릅니다. 마이그레이션이나 멀티 프로바이더 설계 시 아래 대응표가 유용합니다. (OpenAI 모델 ID는 비교적 안정적이지만 현재 제공 모델은 공식 문서에서 확인하는 것을 권장합니다.)
| 항목 | Claude (Anthropic) | OpenAI (Chat Completions) |
|---|---|---|
| 도구 전달 | tools=[{"name","description","input_schema"}] | tools=[{"type":"function","function":{"name","description","parameters"}}] |
| 스키마 키 이름 | input_schema | parameters (둘 다 JSON Schema) |
| 도구 강제 | tool_choice={"type":"auto"|"any"|"tool"|"none"} | tool_choice="auto"|"required"|{"type":"function",...}|"none" |
| 호출 신호 | stop_reason == "tool_use" | finish_reason == "tool_calls" |
| 호출 위치 | content의 tool_use 블록 | message.tool_calls 배열 |
| 인자 형식 | block.input (이미 dict) | tool_calls[].function.arguments (JSON 문자열 → 파싱 필요) |
| 호출 ID | block.id | tool_calls[].id |
| 결과 반환 | user 턴에 tool_result 블록 (tool_use_id) | role:"tool" 메시지 (tool_call_id) |
가장 자주 발목 잡히는 차이는 인자 형식입니다. Claude의 block.input은 이미 파싱된 dict인 반면, OpenAI의 arguments는 JSON 문자열이라 json.loads()로 직접 파싱해야 합니다. 참고로 OpenAI 동일 패턴은 다음과 같습니다.
from openai import OpenAI
import json
client = OpenAI()
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "특정 도시의 현재 날씨를 조회합니다.",
"parameters": { # Claude의 input_schema에 해당
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
# 모델 ID는 예시 — 현재 제공 모델은 OpenAI 공식 문서에서 확인하세요.
resp = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "서울 날씨 어때?"}],
tools=tools,
tool_choice="auto",
)
call = resp.choices[0].message.tool_calls[0]
args = json.loads(call.function.arguments) # 문자열이므로 파싱 필요
print(call.function.name, args) # get_weather {'city': '서울'}
한 걸음 더 — 구조화 출력(JSON 강제)
도구를 "호출"하지 않고 모델의 응답 자체를 정해진 JSON 스키마로 강제하고 싶을 때가 있습니다. 분류 라벨, 추출 결과처럼 항상 같은 형식이 필요한 경우입니다. Claude는 output_config로 이를 지원합니다.
msg = client.messages.create(
model="claude-opus-4-8",
max_tokens=1024,
messages=[{"role": "user", "content": "John ([email protected]), Enterprise 플랜에서 추출"}],
output_config={
"format": {
"type": "json_schema",
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"plan": {"type": "string"},
},
"required": ["name", "email", "plan"],
"additionalProperties": False, # 필수
},
}
},
)
import json
for block in msg.content:
if block.type == "text":
data = json.loads(block.text) # 스키마를 따르는 유효한 JSON
print(data)
스키마에는 additionalProperties: false가 필요하며, minLength·minimum 같은 수치/길이 제약은 미지원이라 클라이언트 측에서 검증합니다. 자동 검증이 필요하면 client.messages.parse(...)를 쓸 수 있습니다. 참고로 OpenAI는 response_format={"type":"json_schema","json_schema":{...,"strict":true}}로 같은 일을 합니다. (구버전 Claude의 output_format 파라미터는 폐기되었으니 output_config.format을 쓰세요.)
참고 — Claude 모델 ID와 가격
예제 기본값으로 쓴 모델과 단가입니다($/1M 토큰, 입력/출력 기준).
| 모델 | 모델 ID | 입력 / 출력 | 컨텍스트 |
|---|---|---|---|
| Opus 4.8 (권장 기본) | claude-opus-4-8 | $5 / $25 | 1M |
| Sonnet 4.6 (균형) | claude-sonnet-4-6 | $3 / $15 | 1M |
| Haiku 4.5 (빠름/저렴) | claude-haiku-4-5 | $1 / $5 | 200K |
| Fable 5 (최고 성능) | claude-fable-5 | $10 / $50 | 1M |
도구 호출이 잦은 워크플로는 입력/출력 토큰이 빠르게 쌓이므로, 단순 분류·라우팅에는 Haiku, 복잡한 에이전트 추론에는 Opus를 섞어 쓰는 전략이 비용 효율적입니다.
자주 묻는 질문 (FAQ)
Q. 모델이 함수를 직접 실행하나요?
아니요. 모델은 "이 도구를 이 인자로 호출하라"는 요청(tool_use 블록)만 만들어 냅니다. 실제 실행은 여러분의 코드가 하고, 결과를 tool_result로 다시 모델에 전달해야 답변이 완성됩니다. 이 구조 덕분에 위험한 작업에 승인·검증 게이트를 둘 수 있습니다.
Q. Claude의 block.input과 OpenAI의 arguments가 왜 다른가요?
Claude의 block.input은 SDK가 이미 dict로 파싱해 줍니다. 반면 OpenAI의 tool_calls[].function.arguments는 JSON 문자열이라 json.loads()로 직접 파싱해야 합니다. 두 경우 모두 원시 문자열을 직접 매칭하지 말고 정식 파싱을 쓰세요.
Q. 도구를 여러 개 넘기면 모델이 동시에 여러 개를 호출할 수 있나요?
네. Claude는 한 응답에서 여러 tool_use 블록을 낼 수 있습니다. 모두 실행한 뒤 결과를 하나의 user 메시지에 tool_result 블록들로 묶어 보내면 됩니다. 하나만 호출하게 하려면 tool_choice에 "disable_parallel_tool_use": true를 추가하세요.
Q. 도구 실행이 실패하면 어떻게 처리하나요?tool_result 블록에 "is_error": true를 넣고 오류 메시지를 content에 담아 보냅니다. 모델은 오류를 인지하고 다른 방식으로 재시도하거나 사용자에게 추가 정보를 요청합니다.
Q. Function Calling과 구조화 출력(JSON 강제)은 무엇이 다른가요?
Function Calling은 외부 함수를 호출해 행동하기 위한 것이고, 구조화 출력(output_config.format)은 모델의 응답 자체를 정해진 JSON 스키마로 강제하는 것입니다. 분류·추출처럼 외부 호출 없이 일정한 형식만 필요하면 구조화 출력이 더 간단합니다.
AI 개발이 필요하신가요?
DevTeam은 OpenAI·Claude 등 AI API 연동, 챗봇, RAG, 업무 자동화를 설계부터 배포까지 구현합니다. AI 연동 개발 또는 무료 견적 문의.