LLM Function Calling의 실제 작동 원리 — 토큰에서 도구 오케스트레이션(Tool Orchestration)까지
요약
LLM의 Function Calling 작동 원리와 구조화된 데이터 반환 과정을 상세히 설명합니다. 일반 텍스트 생성, JSON Mode와 비교하여 Function Calling이 어떻게 정확한 스키마를 보장하는지 다룹니다.
핵심 포인트
- Function Calling은 정의된 스키마를 강제하여 구조화된 출력을 보장함
- Plain Text 방식은 파싱 오류에 취약하며 신뢰도가 낮음
- JSON Mode는 유효한 JSON은 보장하나 스키마 일치성은 보장하지 않음
- LLM이 토큰 단위로 생성하면서도 어떻게 딕셔너리 형태를 유지하는지 분석
원래 내 블로그에 게시되었습니다. 정식 링크(canonical link)와 함께 이곳에 교차 게시되었습니다.
LLM에게 "도쿄와 베를린의 날씨를 비교해줘"라고 요청하면 실제로 어떤 일이 일어날까요? 모델은 인터넷을 브라우징할 수는 없지만, 날씨 API를 호출하기로 결정할 수는 있습니다. 그것도 한 번의 턴(turn) 내에서 두 번이나 말이죠.
이 글에서는 Function Calling(함수 호출)이 어떻게 작동하는지, LLM이 토큰을 하나씩 생성하면서도 어떻게 구조화된 데이터(structured data)를 반환하는지, 그리고 모델이 단일 질문에 답하기 위해 여러 도구 호출(tool calls)을 오케스트레이션(orchestrate)해야 할 때 어떤 일이 발생하는지를 다룹니다.
Part 1: Function Calling이란 무엇인가?
"Function Calling (함수 호출)"은 LLM API로부터 출력을 얻는 여러 방법 중 하나입니다. 주요 세 가지 방법은 다음과 같습니다:
Method A: Plain Text Completion (가장 단순한 방식)
response = client.chat.completions.create(
model="gemini-2.5-flash-lite",
messages=[{"role": "user", "content": "이 이메일을 분류하세요: ..."}],
...
**자유 형식의 텍스트(free-form text)**를 받게 됩니다. 그런 다음 정규 표현식(regex)을 사용하거나, LLM이 "JSON으로만 응답하세요"와 같은 지침을 따르기를 바라며 직접 파싱(parse)해야 합니다. 이는 LLM이 다음과 같이 말할 수 있기 때문에 취약합니다:
"이 이메일은 ... 때문에 job_search 카테고리에 속하는 것 같습니다."
...이렇게 되면 여러분의 정규 표현식이 깨지게 됩니다.
Method B: JSON Mode
response = client.chat.completions.create(
model="...",
messages=[...],
...
LLM은 유효한 JSON을 출력하도록 강제되지만, 스키마(schema)에 대한 보장은 여전히 없습니다. 예를 들어 {"category": "job_search"} 대신 {"cat": "job"}를 반환할 수도 있습니다.
Method C: Function Calling (우리가 사용하는 방식)
response = client.chat.completions.create(
model="...",
messages=[...],
...
필드 이름, 타입, 열거형(enums), 필수 필드 등 원하는 **정확한 스키마(exact schema)**를 정의합니다. API는 LLM이 해당 스키마를 채우도록 강제합니다. 결과는 다음과 같이 반환됩니다:
{
"category": "job_search",
"confidence": 0.95,
...
이것이 구조화된 출력 (structured output)을 얻는 가장 신뢰할 수 있는 방법입니다. LLM은 스키마 (schema)에서 벗어날 수 없습니다.
비교 (Comparison)
| 방법 | 출력 | 스키마 보장 | 신뢰도 |
|---|---|---|---|
| 일반 텍스트 (Plain text) | 자유 형식 문자열 | 없음 | 낮음 — 수동 파싱 (manual parsing) 필요 |
| ... |
파트 2: LLM은 토큰을 생성하면서 어떻게 딕셔너리 (Dict)를 반환하는가?
LLM은 여전히 토큰을 하나씩 생성합니다. LLM이 파이썬 딕셔너리 (Python dictionary)를 "네이티브하게" 반환하는 것이 아닙니다. 내부적으로 실제로 어떤 일이 일어나는지 설명하겠습니다.
LLM이 실제로 생성하는 것
토큰 (Tokens): { " category " : " job _ search " , " confidence " : 0 . 95 }
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑
토큰 토큰 토큰 ... (여전히 단순한 텍스트 토큰임)
LLM은 여전히 **텍스트 (text)**를 생성하고 있습니다. 단지 우연히 유효한 JSON 형식을 갖춘 텍스트일 뿐입니다. API 계층 (API layer)이 마법을 부리는 것입니다.
제약된 디코딩 과정 (The Constrained Decoding Process)
함수 호출 (function calling)을 사용할 때, API는 제약된 디코딩 (constrained decoding) (또는 "가이드 생성 (guided generation)"이라고도 함)을 적용합니다.
- API가 사용자의
tools스키마 정의를 수신합니다. - API는 LLM의 토큰 생성을 제약 (constrain) 합니다. 각 단계에서 사용자의 스키마와 일치하는 유효한 JSON을 생성할 수 있는 토큰만 허용됩니다.
- `
요약 (TL;DR): LLM은 여전히 텍스트/토큰을 생성하고 있습니다. Function calling (함수 호출)은 생성할 수 있는 토큰의 범위를 제약하며 (사용자의 스키마와 일치해야 함), API는 그 결과를 구조화된 형식으로 감쌉니다. 그런 다음 우리는 해당 문자열을 json.loads()를 통해 Python 딕셔너리(dict)로 변환합니다.
파트 3: 여러 개의 도구 — "도쿄와 베를린의 날씨를 비교해줘"
지금까지 우리는 하나의 함수가 한 번 호출되는 것을 보았습니다. 하지만 사용자의 질문이 **여러 번의 도구 호출 (multiple tool calls)**을 필요로 한다면 어떤 일이 벌어질까요?
설정: 날씨 도구 정의하기
tools = [{
"type": "function",
"function": {
...
우리는 get_weather라는 하나의 도구 정의를 가지고 있습니다. 이제 사용자가 이 도구를 두 번 호출해야 하는 질문을 던졌을 때 어떤 일이 일어나는지 살펴보겠습니다.
요청 (The Request)
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
...
LLM의 반환 값: 두 개의 도구 호출
LLM은 텍스트 답변을 반환하지 않습니다. 대신, 단일 응답 내에 두 개의 도구 호출을 반환합니다:
message = response.choices[0].message
# message.content는 None입니다 — 텍스트 응답이 없음
...
출력:
ID: call_abc123
Function: get_weather
Args: {"city": "Tokyo", "units": "celsius"}
...
LLM은 스스로 다음과 같이 결정했습니다:
- 서로 다른 인자(arguments)를 사용하여 동일한 함수를 두 번 호출하기
- 단위를 "celsius"로 선택하기 (이 도시들에 대한 합리적인 기본값)
- 동일한 턴에 두 호출을 모두 반환하기 (병렬 도구 호출, parallel tool calls)
사용자의 코드가 도구를 실행합니다
이제 두 호출을 모두 실행하고 그 결과를 다시 입력으로 제공합니다:
import json
# 각 도구 호출을 실행
...
최종 답변
이제 LLM은 두 가지 날씨 결과를 모두 가졌으며, 자연스러운 비교 문장을 생성합니다:
"현재 도쿄는 구름이 조금 끼어 있고 22°C인 반면, 베를린은 비가 내리고 있으며 8°C입니다. 도쿄가 14도 더 따뜻합니다. 오늘 두 도시 중 하나를 선택하신다면, 도쿄의 날씨가 더 좋습니다."
전체 흐름 (The Complete Flow)
사용자: "도쿄와 베를린의 날씨를 비교해줘"
│
▼
...
병렬 도구 호출 vs 순차적 도구 호출 (Parallel vs Sequential Tool Calls)
Parallel (위에서 일어난 일): LLM이 단일 응답 내에서 여러 개의 tool_calls를 반환합니다. 두 호출은 모두 독립적이며, 여러분의 코드는 이를 동시에(concurrently) 실행할 수 있습니다:
import asyncio
async def execute_tools_parallel(tool_calls):
...
Sequential: 때때로 LLM은 다음 호출을 수행하기 전에 이전 호출의 결과가 필요할 수 있습니다. 예를 들어: "프랑스 수도의 날씨는 어떤가요?"
Turn 1: LLM이 get_capital(country="France")를 호출
→ 여러분의 코드가 "Paris"를 반환
Turn 2: LLM이 get_weather(city="Paris")를 호출
...
LLM은 호출들이 서로 의존하는지 여부에 따라 어떤 패턴을 사용할지 결정합니다.
Part 4: 한 턴 내의 서로 다른 도구들 (Different Tools in One Turn)
LLM은 동일한 턴 내에서 서로 다른 도구들을 호출할 수도 있습니다. 두 개의 도구를 정의했다고 가정해 봅시다:
tools = [
{
"type": "function",
...
만약 사용자가 다음과 같이 질문한다면: "다음 주에 뉴욕(NYC)에서 도쿄(Tokyo)로 여행을 가요. 날씨는 어떻고, 1달러(USD)가 엔화(Yen)로 얼마인가요?"
LLM은 한 턴에 **두 개의 서로 다른 도구 호출(tool calls)**을 반환합니다:
# tool_calls[0]
{"name": "get_weather", "arguments": '{"city": "Tokyo"}'}
...
여러분의 코드는 각 호출을 적절한 함수로 라우팅(route)합니다:
tool_handlers = {
"get_weather": handle_weather,
"get_exchange_rate": handle_exchange_rate,
...
이것은 본질적으로 **레지스트리 패턴 (registry pattern)**입니다. 즉, 딕셔너리(dictionary)가 함수 이름과 핸들러(handler)를 매핑합니다. 별도의 if/else 체인이 필요하지 않습니다.
이 동작을 제어하는 것은 무엇인가요?
tool_choice 파라미터는 LLM이 도구를 사용할지 여부와 그 방식을 제어합니다:
tool_choice | 동작 (Behavior) | 사용 사례 (Use Case) |
|---|---|---|
"auto" | LLM이 도구를 호출할지 아니면 텍스트로 응답할지 결정함 | 범용 에이전트 (General-purpose agents) |
| ... |
날씨 비교의 경우, 우리는 "auto"를 사용합니다. LLM은 스스로 get_weather를 두 번 호출해야 한다는 것을 결정합니다.
핵심 요약 (Key Takeaways)
-
LLM으로부터 구조화된 데이터를 얻기 위해서는 Function calling > JSON mode > plain text 순으로 권장됩니다. Function calling은 단순히 프롬프트 지시사항을 통해서가 아니라, 토큰 생성(token generation) 단계에서 사용자의 스키마(schema)를 강제합니다.
-
LLM은 여전히 토큰을 생성합니다 — LLM이 네이티브하게 딕셔너리(dict)를 반환하는 것은 아닙니다. API 계층에서 토큰 출력이 사용자의 스키마와 일치하도록 제약된 디코딩(constrained decoding)을 적용하며, 그 후 결과 문자열을
json.loads()로 파싱합니다. -
하나의 질문이 여러 개의 도구 호출(tool calls)을 트리거할 수 있습니다. LLM은 동일한 도구를 서로 다른 인자(예: 도쿄 + 베를린)로 호출할지, 아니면 완전히 다른 도구(예: 날씨 + 환율)를 호출할지를 단 한 번의 턴(turn) 내에서 결정합니다.
-
병렬(Parallel) 호출과 순차(Sequential) 호출은 LLM에 의해 결정됩니다. 독립적인 호출(두 도시의 날씨)은 한 번의 턴 내에 반환됩니다. 의존적인 호출(수도 찾기 → 해당 도시의 날씨 찾기)은 여러 번의 턴에 걸쳐 발생합니다.
-
도구 호출의 라우팅은 if/else 문이 아닌 레지스트리(registry)를 사용하세요. 함수 이름과 핸들러(handler)를 매핑하는 딕셔너리를 사용하면 코드를 깔끔하게 유지하고 확장성을 높일 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기