시리즈: LLM Tool Calling 내부 원리부터 에이전트 직접 구현까지
이 시리즈는 사용자의 자연어 한 줄이 tool 실행으로 바뀌는 내부 처리 과정을 단계별로 해부하고, 최종적으로 오픈소스 모델 + 자체 middleware로 나만의 에이전트를 직접 구현하는 것까지 도달하는 과정이다.
| 편 | 내용 | 핵심 |
|---|---|---|
| 1편 | 전체 조감도 | 자연어 → tool 실행까지 5개 레이어의 존재를 확인 |
| 2편 | Chat Template | JSON이 모델에 직접 들어가지 않는다 |
| 3편 | Tokenization | 모델은 텍스트를 읽지 못한다 - 토큰 ID와 control token |
| 4편 (본문) | 모델 추론 | ”tool을 쓸까 말까” 판단과 constrained decoding |
| 5편 | Tool 실행 | tool_use를 받은 클라이언트의 실행 루프 |
| 6편 | Native vs Non-native | 같은 기능, 다른 구조 → Middleware |
| 7편 | Middleware 만들기 | 프롬프트 조립 + 출력 파싱 + 실행 루프 직접 구현 |
| 8편 | 오픈소스 모델 로컬 구축 | Ollama/vLLM으로 로컬 LLM 서빙 |
| 9편 | 나만의 에이전트 | 모델 + Middleware = 에이전트 완성 |
- 모델 추론과 출력 생성은 토큰 시퀀스를 입력받은 모델이 다음 토큰을 하나씩 예측하면서 tool call 패턴을 생성하는 핵심 판단 과정
- “tool을 쓸까 말까”는 마법이 아니라, 다음 토큰으로 tool_call 형식 토큰을 생성할 확률이 높은가의 문제
- Fine-tuning으로 학습된 판단력 + constrained decoding으로 항상 valid JSON이 나오도록 강제하는 두 가지 메커니즘의 조합
해당 개념이 필요한 이유
- 3편에서 텍스트가 토큰 ID 시퀀스로 변환되는 과정을 확인했다. 이제 모델이 이 시퀀스를 받아 어떻게 “tool을 쓰겠다”고 판단하는지가 남은 질문이다
- tool 정의의 description을 잘 작성해야 하는 이유, tool_choice 파라미터가 동작하는 원리, tool call의 인자가 항상 valid JSON인 이유가 모두 이 단계에서 설명된다
- 이 과정을 이해해야 5편에서 “이 레이어가 없는 모델”과 비교할 수 있다
AS-IS
sequenceDiagram autonumber box AI 서비스 영역 participant TK as Tokenizer participant LLM as LLM participant API as API 서버 end TK->>LLM: 토큰 시퀀스 입력 Note over LLM: 어떻게 tool_use를 판단하지? LLM-->>API: tool_use 응답
TO-BE
sequenceDiagram autonumber box AI 서비스 영역 participant TK as Tokenizer participant LLM as LLM participant CD as Constrained Decoding participant Parser as 출력 파서 end TK->>LLM: 토큰 시퀀스 입력 LLM->>LLM: 다음 토큰 예측 (autoregressive) LLM->>CD: tool_call 형식 토큰 생성 시작 CD->>CD: JSON schema에 맞는 토큰만 허용 CD->>Parser: 생성 완료 (stop_reason: tool_use) Parser->>Parser: 토큰 → structured JSON 변환
Autoregressive 생성 - 다음 토큰 예측의 반복
LLM의 출력 생성은 단순하다. 다음 토큰 하나를 예측하고, 그 결과를 다시 입력에 추가하고, 또 다음 토큰을 예측하는 반복이다.
입력: [토큰1, 토큰2, ..., 토큰N]
↓ 모델 연산
출력: 토큰(N+1)의 확률 분포
입력: [토큰1, 토큰2, ..., 토큰N, 토큰(N+1)]
↓ 모델 연산
출력: 토큰(N+2)의 확률 분포
... 반복 ...
모델은 vocabulary의 모든 토큰에 대해 확률을 계산하고, 가장 확률이 높은 (또는 샘플링된) 토큰을 선택한다. 이것을 autoregressive generation이라 한다.
tool calling도 예외가 아니다. 모델이 “tool을 사용하겠다”고 판단하는 것은, 다음 토큰으로 tool_call 형식의 토큰을 생성할 확률이 가장 높다는 것이다.
”tool을 쓸까 말까” - 판단의 실체
모델이 다음 토큰을 예측할 때, 선택지는 크게 두 갈래다:
입력: [...시스템 프롬프트(tool 정의 포함)..., "서울 날씨 알려줘"]
다음 토큰 확률:
"서울" → 0.01 (텍스트 응답 시작)
"현재" → 0.02 (텍스트 응답 시작)
"I" → 0.01 (영어 텍스트 응답)
[TOOL_CALLS] → 0.85 ← tool call 시작!
"{" → 0.03
...
여기서 “시스템 프롬프트(tool 정의 포함)“은 개발자가 코드에서 직접 정의한 tool이든, MCP 서버에서 등록한 tool이든 상관없다. 1편에서 확인한 것처럼, MCP tool도 결국 input_schema로 변환되어 시스템 프롬프트에 삽입된다. LLM 입장에서는 tool의 출처를 구별할 수 없고, 동일한 텍스트 시퀀스를 볼 뿐이다.
[TOOL_CALLS] control token(또는 tool_use 형식의 시작 토큰)의 확률이 가장 높으면, 모델은 tool call을 시작한다. 이후 생성되는 토큰들은 tool 이름, 인자 등의 JSON 구조를 형성한다.
이 확률이 높아지는 이유는 두 가지 학습의 결과다:
Fine-tuning - tool call 패턴 학습
모델은 기본 학습(pre-training) 이후에, tool calling에 특화된 데이터로 **추가 학습(fine-tuning)**을 받는다. 학습 데이터는 “입력(시스템 프롬프트 + 사용자 질문) → 정답(tool call 형식 응답)“의 쌍이다:
학습 데이터 예시:
[입력]
시스템: "You have access to get_weather(location: string)..."
사용자: "서울 날씨 알려줘"
[정답]
어시스턴트: [TOOL_CALLS] [{"name": "get_weather", "arguments": {"location": "Seoul"}}]
이런 “입력 → 정답” 쌍을 수만~수십만 건 학습시키면, 모델은 “tool이 정의되어 있고 + 사용자 질문이 tool과 관련되면 → tool call 형식으로 응답하는 것이 정답”이라는 패턴을 내재화한다.
In-context learning - 시스템 프롬프트가 확률을 활성화한다
Fine-tuning이 “tool call을 생성하는 능력”을 모델에 심어준다면, in-context learning은 **“이번 대화에서 그 능력을 켜는 스위치”**다.
2편에서 본 Claude의 시스템 프롬프트:
In this environment you have access to a set of tools you can use to answer the user's question.이 텍스트가 토큰 시퀀스에 포함되면, 모델이 다음 토큰을 예측할 때 tool_call 형식 토큰의 확률이 올라간다. 왜냐하면 fine-tuning 과정에서 이 텍스트가 존재하는 맥락에서 tool call을 생성하는 것이 “정답”인 데이터를 학습했기 때문이다.
반대로, 시스템 프롬프트에 tool 정의가 없으면 같은 “서울 날씨 알려줘”가 들어와도 tool call 확률은 낮아지고 일반 텍스트 응답(“서울의 현재 날씨를 확인할 수 없지만…“) 확률이 높아진다.
Fine-tuning vs In-context learning 비교
| Fine-tuning | In-context learning | |
|---|---|---|
| 시점 | 모델 배포 전 (학습 단계) | 모델 사용 시 (추론 단계) |
| 방식 | 학습 데이터로 모델 가중치 자체를 변경 | 프롬프트 텍스트로 맥락 제공 |
| 비유 | 운전면허 취득 (운전 능력 획득) | “지금 운전해줘” 요청 (능력 활성화) |
| 영구성 | 영구적. 한번 학습하면 모든 대화에 적용 | 일시적. 해당 대화에서만 유효 |
| 비용 | 높음 (GPU 시간, 학습 데이터 구축) | 낮음 (프롬프트에 텍스트 추가) |
| 누가 하는가 | 모델 제공사 또는 개발자 | 개발자 (tool 정의 작성) |
| tool calling에서 | ”tool call 형식으로 응답하는 법”을 학습 | ”이번 대화에서 이 tool들이 사용 가능”을 알림 |
개발자도 fine-tuning할 수 있다
모델 제공사만 fine-tuning하는 것이 아니다. OpenAI는 fine-tuning API를 제공하며, 개발자가 자신의 도메인에 맞는 tool call 패턴을 직접 학습시킬 수 있다.
예를 들어, 사내 HR 시스템용 에이전트를 만드는데 “연차 몇 일 남았어?”라는 질문에 모델이 tool을 호출하지 않고 텍스트로 답하는 문제가 반복된다면:
// fine-tuning 학습 데이터 (JSONL 형식)
{"messages": [
{"role": "system", "content": "You have access to check_leave_balance(employee_id: string)..."},
{"role": "user", "content": "연차 몇 일 남았어?"},
{"role": "assistant", "tool_calls": [{"function": {"name": "check_leave_balance", "arguments": "{\"employee_id\": \"current_user\"}"}}]}
]}
{"messages": [
{"role": "system", "content": "You have access to check_leave_balance(employee_id: string)..."},
{"role": "user", "content": "올해 휴가 얼마나 썼지?"},
{"role": "assistant", "tool_calls": [{"function": {"name": "check_leave_balance", "arguments": "{\"employee_id\": \"current_user\"}"}}]}
]}이런 데이터를 수백~수천 건 만들어 fine-tuning하면, 모델은 HR 관련 질문에서 tool call 확률을 높이게 된다.
Constrained Decoding - valid JSON을 100% 보장하는 방법
모델이 tool call을 시작하면, 인자는 JSON 형식이어야 한다. 하지만 autoregressive 생성은 기본적으로 어떤 토큰이든 선택할 수 있다. { 다음에 } 대신 hello를 생성할 수도 있다.
이 문제를 해결하는 것이 constrained decoding이다.
동작 원리
매 토큰 생성 시, JSON schema에 따라 유효하지 않은 토큰의 확률을 0으로 설정한다:
현재까지 생성된 토큰: {"name": "get_weather", "arguments": {"location": "
유효한 다음 토큰:
"Seoul" → 0.45 ✓ (문자열 값으로 유효)
"서울" → 0.30 ✓ (문자열 값으로 유효)
"Tokyo" → 0.15 ✓ (문자열 값으로 유효)
} → 0.00 ✗ (문자열이 닫히지 않아 무효 → 확률 0으로 마스킹)
[ → 0.00 ✗ (문자열 내부에서 무효 → 확률 0으로 마스킹)
JSON Schema → Context-Free Grammar 변환
OpenAI는 이 과정을 공식 블로그에서 상세히 설명했다:
- 제공된 JSON Schema를 **context-free grammar(CFG)**로 변환
- 매 토큰 생성 시, CFG 규칙에 따라 유효한 토큰 목록을 계산
- 유효하지 않은 토큰의 logit을
-∞로 설정 (확률 0) - 이 과정을 효율적으로 처리하기 위해 스키마를 캐싱 (첫 요청만 지연, 이후 빠름)
OpenAI는 이 접근으로 structured output eval에서 100% 스키마 준수율을 달성했다 (이전 40% → 100%).
tool calling에서의 적용
Constrained decoding은 tool calling의 strict: true 옵션으로 사용할 수 있다:
const response = await openai.chat.completions.create({
model: "gpt-4o",
tools: [{
type: "function",
function: {
name: "get_weather",
strict: true, // ← constrained decoding 활성화
parameters: {
type: "object",
properties: {
location: { type: "string" },
unit: { type: "string", enum: ["celsius", "fahrenheit"] }
},
required: ["location", "unit"],
additionalProperties: false
}
}
}],
messages: [{ role: "user", content: "서울 날씨 알려줘" }]
});strict: true를 설정하면 모델이 생성하는 tool call 인자는 반드시 정의한 schema에 맞는 valid JSON이 된다. unit 필드에는 "celsius" 또는 "fahrenheit"만 올 수 있고, 다른 값은 확률 0으로 마스킹된다.
stop_reason - 언제 생성을 멈추는가
모델이 tool call 형식의 토큰을 완성하면, API 서버는 생성을 중단하고 결과를 반환한다:
{
"stop_reason": "tool_use",
"content": [
{
"type": "tool_use",
"name": "get_weather",
"input": { "location": "Seoul", "unit": "celsius" }
}
]
}stop_reason의 종류:
"end_turn"- 모델이 자연스럽게 응답을 완료"tool_use"- tool call 형식의 토큰을 감지하여 중단"max_tokens"- 최대 토큰 수에 도달하여 중단
stop_reason: "tool_use"가 반환되면, 애플리케이션은 tool을 실행하고 결과를 모델에 다시 전달한다 (1편에서 본 라이프사이클의 3~4단계).
출력 파싱 - 토큰에서 JSON으로
모델이 생성한 토큰 시퀀스를 structured JSON으로 변환하는 마지막 단계다:
모델 생성 토큰: [TOOL_CALLS] [{"name": "get_weather", "arguments": {"location": "Seoul"}}]
↓ 출력 파서
API 응답:
{
"type": "tool_use",
"id": "toolu_01A09q90qw90lq917835lq9",
"name": "get_weather",
"input": { "location": "Seoul" }
}
파서는:
- tool call 패턴의 시작을 감지 (control token 또는 텍스트 패턴)
- JSON 부분을 추출하여 파싱
- tool 이름, 인자, ID를 structured 형태로 정리
- API 응답 형식에 맞게 변환
전체 흐름 요약
3편까지의 과정과 합쳐서, 하나의 tool call이 만들어지는 전체 과정:
sequenceDiagram autonumber box 개발자 영역 participant Dev as 개발자 end box AI 서비스 영역 participant API as API 서버 participant CT as Chat Template participant TK as Tokenizer participant LLM as 모델 participant CD as Constrained Decoding participant P as 출력 파서 end Dev->>API: tools JSON + "서울 날씨 알려줘" API->>CT: 2편 - JSON → 프롬프트 텍스트 CT->>TK: 3편 - 텍스트 → 토큰 ID TK->>LLM: 4편 - 토큰 시퀀스 입력 LLM->>LLM: 다음 토큰 예측 반복 LLM->>CD: tool_call 토큰 생성 시작 CD->>CD: valid 토큰만 허용 CD->>P: 생성 완료 (stop_reason: tool_use) P->>API: structured JSON 변환 API-->>Dev: tool_use: get_weather("Seoul")
다음 편: tool_use가 반환된 후, 사용자의 PC에서는 어떻게 실행되지?
이 글에서 모델이 tool_use 응답을 생성하는 전체 과정을 확인했다. 하지만 stop_reason: "tool_use"와 함께 get_weather("Seoul")이 반환되었을 때, 이것을 누가, 어디서, 어떻게 실제로 실행하는 걸까?
LLM은 tool을 실행하지 않는다. tool_use 응답을 받은 **클라이언트(개발자의 애플리케이션)**가 실행하고, 그 결과를 다시 모델에 전달한다. Claude Code, Cursor 같은 도구들이 이 과정을 자동으로 처리하고 있지만, 그 안에서는 어떤 일이 벌어지고 있을까?
다음 편에서 이 tool 실행 루프를 살펴본다.
참고 문서
- OpenAI - Introducing Structured Outputs - constrained decoding의 동작 원리, JSON Schema → CFG 변환, 100% 스키마 준수율 달성
- OpenAI - Structured Outputs Guide - strict: true 사용법, function calling과의 관계
- Anthropic - Tool Use Overview - stop_reason, tool_use 라이프사이클
- vLLM - Tool Calling - 서빙 레벨의 tool parser, guided decoding
- llguidance (GitHub) - OpenAI structured output의 기반 기술