LLM 도구 호출(function calling) 흐름¶
한 줄 요약: LLM은 글만 쓴다. 프로그램이 매 요청마다 도구 정의 전체를 함께 보내면, 모델이 도구를 고르고 인자를 글로 써 낸다. 실행은 프로그램이 하고, 결과를 다시 모델에게 보여 주며 반복한다. 이 반복 루프가 에이전트다.
📍 만난 상황¶
- 2026-10-27 — Gemma 어시스턴트 실패 원인 설명을 이해하려던 중이었다. "LLM과 에이전트를 잘 모른다"며 바닥부터 물었고, 순서를 여러 번 다시 확인했다. 7번 물음 → 그날 일지
- 14:13 매니저를 고치자는 이유를 들은 뒤 "LLM과 에이전트를 세세하게 설명해 달라" → LLM은 글만 쓰고, 도구 호출은 약속된 형식의 글이다.
- 14:23 "설명서를 어디서 뽑아 보냐, 실제로 설명서라고 부르냐" → 정식 용어는 도구 정의(tool definition), 인자 양식은 스키마다.
- 14:56 "스키마를 바꾸면 모든 LLM에 적용되냐" → 그렇다. 어떤 모델이든 같은 도구 정의를 보낸다.
- 16:23 "우리가 툴을 먼저 고르고 그 스키마로 요청하냐" → 반대다. 모델이 고른다.
- 16:25 "LLM이 도구를 고르고 스키마를 찾아 값을 넣냐" → 모델은 스키마를 참고해 글을 쓴다.
- 16:27 "스키마가 서버에 할당돼 있냐" → 아니다. 매 요청마다 같이 보낸다.
- 16:29 "처음에 스키마·타입을 다 주고, 파싱 후 처음 받은 스키마로 타입을 되돌리는 거구나" → 정확하다.
🧩 쉽게 말하면¶
손이 없는 지배인과 직원을 떠올리자. 지배인(모델)은 말만 할 수 있다. 직원(프로그램)이 실제로 몸을 움직인다.
직원은 매번 지배인에게 "할 수 있는 일 30가지 메뉴판"과 각 일의 주문서 양식을 건넨다. 지배인이 메뉴판을 보고 "7번 일을 이렇게 해 줘"라고 쪽지를 쓴다.
직원이 그 일을 하고 결과를 보고한다. 지배인이 결과를 보고 다음 지시를 쓴다. "이제 손님께 답하자"고 할 때까지 반복한다.
메뉴판이 없으면 지배인은 무슨 일을 시킬 수 있는지 모른다. 직원이 없으면 쪽지만 쌓이고 아무 일도 일어나지 않는다.
📖 제대로 알기¶
LLM은 실행하지 못한다¶
LLM(Claude, Gemma 등)은 지금까지의 글을 보고 다음 글을 이어 쓴다. DB 조회나 대시보드 생성은 직접 못 한다. 그래서 "이 도구를 이 인자로 불러 줘"라는 형식이 정해진 글을 쓰게 한다.
도구 정의 (tool definition)¶
도구 하나를 설명하는 묶음이다. 세 부분으로 이뤄진다.
- name: 도구 이름. 예: resource_plan_propose
- description: 언제·어떻게 쓰는지 안내 글. 오늘 본 것은 22,575자였다.
- 스키마(schema: 인자 양식): 어느 칸에 어떤 타입이 들어가는지. JSON Schema(JSON 구조를 설명하는 표준 문법)로 쓴다.
같은 "인자 양식"을 시스템마다 다른 이름으로 부른다.
| 시스템 | 이름 |
|---|---|
| OpenAI 호환 API(Gemma 경로) | parameters |
| Anthropic API(Claude) | input_schema |
| LangChain(어시스턴트 코드) | args_schema |
| 매니저 카탈로그 JSON | function.inputs |
에이전트 = LLM + 도구 정의 + 반복 루프¶
- 에이전트(agent): 모델 호출 → 도구 실행 → 결과 전달을 반복하는 프로그램이다.
- 턴(turn): 사용자 질문 하나에 답하기까지의 한 묶음. 그 안에서 모델을 여러 번 부른다.
- dhub2-assistant의
_run_assistant_loop가 이 루프다. 안전장치가 둘 있다. - 반복 상한: 한 턴에 최대 10번(앱은 20번).
- 반복 실패 감지(
_first_repeated_failure): 이미 실패한 호출과 이름·인자가 글자 하나까지 같으면 턴을 끊는다. 이때422 "The assistant repeated a failing tool call."이 나간다.
tool_choice¶
모델이 도구를 쓸지 정하는 요청 옵션이다. auto(모델이 알아서), none(쓰지 않음), required(반드시 하나), 특정 도구 지정이 있다. 보통 auto를 쓴다.
⚙️ 어떻게 동작하나¶
- 우리가 요청을 보낸다. 대화 내용과 도구 30개 전체의 정의가 함께 간다. 서버에 미리 등록된 건 없다.
- 서버가 모델에게 보여 준다. 오픈 모델이면 서빙 서버(vLLM)가 채팅 템플릿으로 모델 형식 프롬프트를 만든다.
- 모델이 도구를 고르고 인자를 글로 쓴다. 양식에 값을 채우는 게 아니다. 스키마와 안내 글을 참고해 쓰므로 틀릴 수 있다.
- 서버 파서가 그 글을 JSON으로 옮긴다. 그리고 1번에서 받은 스키마로 타입을 맞춘다.
- 우리가 응답을 받는다. "이 도구를 이 인자로 불러 달라"는 내용이다.
- 우리가 도구를 실행한다. 결과(성공 또는 오류)를 대화에 붙여 1번으로 돌아간다.
sequenceDiagram
participant A as 어시스턴트
participant S as vLLM 서버
participant G as Gemma
participant M as 매니저 코어
A->>S: 대화 + 도구 30개 정의
S->>G: Gemma 형식 프롬프트
G-->>S: 도구 호출 글
S-->>A: 도구 이름 + 인자 JSON
A->>M: 도구 실행과 검증
M-->>A: 성공 또는 오류
A->>S: 결과를 붙여 다시 요청
스키마는 두 번 쓰인다. 모델이 읽고 참고할 때, 그리고 파서가 타입을 맞출 때다. 매니저 코어는 별도 서버가 아니라 어시스턴트 프로세스 안에서 돈다.
스키마를 쓰는 두 주체¶
| 누가 | 언제 | 성격 |
|---|---|---|
| 모델(Gemma) | 3단계 | 읽고 이해해서 인자를 직접 씀. 틀릴 수 있음 |
| 파서(vLLM 코드) | 4단계 | 칸 이름으로 대조해 타입만 고침. 기계적 |
오늘 문제는 3단계가 아니라 4단계에서 생겼다.
🧪 예시¶
-
예시 1 · 일상에 빗대어: 직원이 메뉴판 30장을 들고 간다. 지배인은 그중 "자원 계획 제안" 한 장을 고르고 쪽지에 "포장: true"라고 쓴다. 직원이 일을 해 보니 창고가 "포장 칸 형식이 틀렸다"고 돌려보낸다. 직원은 그 오류를 들고 메뉴판 30장을 다시 전부 들고 간다. 지배인이 같은 쪽지를 또 쓰면 직원은 "같은 실수 두 번"으로 보고 멈춘다.
-
예시 2 · 실제로는: OpenAI 형식 요청(줄임)이다.
①{ "model": "gemma-4-31b-it", "messages": [{"role": "user", "content": "파이프라인 만들어줘"}], "tools": [{ "type": "function", "function": { "name": "resource_plan_propose", "description": "Validate and preview ...", "parameters": {"type": "object", "properties": {"plan": {"type": "object"}}} } }] }messages는 대화, ②tools는 도구 정의 목록이다. 실제로는 30개가 들어간다. ③parameters가 스키마다.
응답과 다음 요청은 이렇게 이어진다.
// 응답: 모델이 고른 도구
{"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "resource_plan_propose", "arguments": "{\"plan\": {...}}"}}]}
// 다음 요청에 붙이는 실행 결과
{"role": "tool", "tool_call_id": "call_1", "content": "nullable: Input should be a valid boolean"}
tool_calls가 "이 도구를 불러 달라"는 부분이다. ② arguments는 JSON이 담긴 글자다. ③ 결과는 role: "tool" 메시지로, 어느 호출의 결과인지 tool_call_id로 잇는다.
⚖️ 비교 · 흔한 오해¶
| 내가 생각한 것 | 실제 |
|---|---|
| 우리가 도구를 먼저 고른다 | 모델이 고른다. 우리는 30개 전부 보낸다 |
| 스키마가 서버에 등록돼 있다 | 매 요청마다 tools 칸에 실어 보낸다 |
| 모델이 양식에 값을 채운다 | 스키마를 참고해 글을 쓴다. 틀릴 수 있다 |
| 파서가 요청을 받을 때 바꾼다 | 모델이 답을 쓴 뒤에 바꾼다 |
| 스키마를 바꾸면 Gemma에만 적용 | 같은 정의를 받는 모든 모델에 적용 |
왜 헷갈리기 쉬운가 - "함수 호출"이라는 이름 때문에 모델이 직접 실행한다고 느끼기 쉽다. 실행은 늘 프로그램이 한다. - 웹 폼(입력 칸에 값을 넣는 화면)을 떠올리면 "모델이 칸을 채운다"고 생각하게 된다. 모델은 칸이 아니라 글을 만든다. - 도구 정의가 매번 같으니 "서버에 저장해 둔 것"처럼 보인다. 실제로는 매번 보낸다. 그래서 매 호출마다 비용이 든다.
🛠️ 실무에서는¶
- 비용: 도구 정의는 매 호출마다 간다. 오늘 기준 도구 정의 약 18,000토큰, 시스템 프롬프트 약 10,500토큰이었다. 도구 수와 안내 글 길이를 줄이면 비용과 속도가 같이 좋아진다.
- 스키마 변경은 모든 모델에 영향을 준다. 고친 뒤 Gemma만 보지 말고 Claude·Gemini 등 공급자별로 확인한다. 힌트가 틀리면 모든 모델이 틀린 규칙을 따른다.
- 모델별로 다른 정의를 보내는 건 가능하지만 권하지 않는다. 코드와 테스트가 두 벌이 된다.
- 문제가 생기면 먼저 볼 것
- 모델이 고른 도구와 인자(
tool_calls)가 무엇이었나? - 도구 실행 결과로 어떤 오류가 돌아갔나?
- 다음 호출에서 모델이 인자를 바꿨나, 같은 걸 다시 보냈나?
❓ 자주 묻는 질문¶
- Q. 모델에게 실제로 가는 도구 정의는 어디서 볼 수 있나?
A. 원본은 매니저 저장소의 카탈로그 JSON과 pydantic 모델이다. 실제 전송 모양은 어시스턴트가 LangChain으로 바꾼 OpenAI 형식
tools를 뽑아 보면 된다. - Q. 모델이 도구를 안 쓰고 바로 답하면?
A.
tool_calls없이 일반 답변이 온다.tool_choice="auto"에서는 정상이다. 루프는 거기서 끝난다. - Q. 같은 실패를 왜 두 번째에 바로 끊나? A. 인자가 글자 하나까지 같으면 결과도 같을 가능성이 높다. 계속 돌면 시간과 비용만 쓴다.
🔭 더 깊이 보기¶
- 병렬 도구 호출(한 응답에 도구 여러 개)은 어떻게 처리할까 — 루프 구조와 결과 연결(
tool_call_id)이 더 복잡해져서. - 도구 수가 많을 때 고르는 정확도를 어떻게 지킬까 — 30개를 매번 보내는 구조의 비용·정확도 한계라서.
- 관련 주제: LLM 도구 호출 파서와 타입 복원, JSON Schema 규약과 해석, 파이썬 패키지 설치와 import 의존
📚 참고 자료¶
- 함수 호출(Function calling) (Gemini API 문서 한국어) — 도구 정의 → 모델 선택 → 실행 → 결과 전달 흐름 · 링크
- Function calling (OpenAI 공식 문서, 영문) —
tools,tool_calls,role: "tool"메시지 형식 · 링크 - Tool use with Claude (Anthropic 공식 문서, 영문) —
input_schema와tool_use응답 모양 · 링크
🗓️ 이력¶
- 2026-10-27: 도구를 누가 고르는지, 스키마가 서버에 있는지, 모델이 값을 채우는지를 7번 바꿔 물었다. 마지막엔 순서를 스스로 정확히 정리했다 → 일지