# AI 에이전트

2B 안팎 로컬 LLM은 도구를 제대로 부를까?

@Hong2026-10-04

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
2B 안팎의 작은 로컬 LLM에 도구를 주면 맞는 도구와 인자로 제대로 호출할까?
답
모델마다 무너지는 단계가 달라, 도구가 필요한 입력 90개 중 값까지 맞힌 수는 qwen3:1.7b(생각 끔) 79개, granite4:1b-h 60개, functiongemma:270m 9개였다.
조건
Ollama 0.34.4, CPU 추론, 로컬 모델 4개, temperature 0, 도구 4개, 입력 120개(한국어·영어 60쌍), 3회 반복

환경

Ollama
0.34.4, GPU 없이 CPU 추론, 요청마다 num_thread: 2
모델
qwen3:1.7b(생각 끔·켬), qwen2.5:1.5b, granite4:1b-h, functiongemma:270m
컨테이너
Docker Compose, Ollama 메모리 2,700MiB·CPU 2.25개, 클라이언트 300MiB·CPU 0.25개
요청 옵션
temperature: 0, seed: 42, num_ctx: 4096
데이터
도구 4개, 입력 120개(한국어·영어 60쌍), 3회 반복(2·3회째는 한국어 60개, 생각 켬은 20개)
시간 예산
3시간
목차
  1. 실습 준비
  2. 실습 1: 모델별 결과
  3. 실습 2: 참은 비율을 읽는 법
  4. 정리

이번 시간에는 2B 안팎의 작은 로컬 LLM 네 개에 도구 4개를 주고, 맞는 도구와 인자로 제대로 호출하는지 Ollama와 코드로 만든 채점기로 측정하며 배워가는 시간입니다.

입력은 질문 60개를 한국어와 영어로 짝지어 만든 120개이고, 모델마다 첫 응답을 네 단계로 나눠 사람 판단 없이 채점하는 방식으로 가볼게요. 도구가 필요 없는 질문에서 참았는지도 함께 볼게요.

실습 준비

이 글의 명령은 모두 실습 코드의 agent-tool-calling/ 디렉터리에서 실행합니다.

  • compose.yaml: Ollama 0.34.4 컨테이너와 Python 3.12 클라이언트 컨테이너 정의
  • requirements.txt: 클라이언트 컨테이너가 설치하는 패키지 두 개
  • agentlab/: Ollama 호출(llm.py)과 결과 줄 쓰기(runlog.py) 패키지(파일 3개)
  • toolset.py: 도구 4개의 정의와 JSON Schema, 한국어·영어 system 프롬프트
  • tasks.jsonl: 입력 120개와 정답 호출
  • run.py: 설정 이름(qwen3-off 등)을 받아 입력마다 첫 응답을 결과 파일에 붙이는 실행기
  • run_model.sh: 모델 하나의 받기부터 본 실행, 반복, 삭제까지 차례로 돌리는 스크립트
  • render.py: 도구 정의 렌더링 확인
  • wiring.py: 날씨 도구 하나로 배선 확인
  • think_repeat_ids.txt: 생각 켬 반복 입력 20개의 id
  • grade_call.py: 네 단계 채점
  • summary.py: 실습 결과 집계
  • results/: 이 글에서 기록한 설정별 응답 파일(파일 5개). run.py는 기록된 입력을 건너뛰므로 직접 돌리려면 먼저 results/를 비웁니다

환경은 Docker Compose 하나로 띄워요. Ollama 0.34.4에서 CPU 상한에 맞춰 num_thread를 주지 않으면 첫 응답이 수 분씩 걸려서, 모든 요청에 num_thread: 2를 넣었습니다. 그리고 프롬프트 캐시가 컨테이너 메모리 상한을 넘지 않도록 LLAMA_ARG_CACHE_RAM을 256으로 줄였어요.

compose.yaml은 이 두 컨테이너를 정의하고 Ollama를 호스트의 127.0.0.1:7500으로 엽니다. 클라이언트에는 httpx==0.28.1과 jsonschema==4.26.0만 설치해요.

ol과 lab은 컨테이너 안의 ollama와 python을 부르는 셸 함수예요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export COMPOSE_PROJECT_NAME=atool
docker compose up -d

ol() { docker compose exec ollama ollama "$@"; }

lab() { docker compose exec -T client python "$@"; }

qwen3:1.7b는 생각 모드가 기본으로 켜져 있습니다. 생각 모드는 모델이 답하기 전에 추론 과정을 글로 먼저 생성하게 하는 기능이고, 요청의 think에 true나 false를 주면 켜고 끌 수 있어요. 나머지 모델은 think를 null로 보냈어요.

  • qwen3:1.7b: Q4_K_M, 메모리 1.9GB(ollama ps 기준)
  • qwen2.5:1.5b: Q4_K_M, 메모리 1.4GB
  • granite4:1b-h: Q8_0, 메모리 1.9GB
  • functiongemma:270m: Q8_0, 메모리 503MB. Gemma 3 270M을 함수 호출용으로 미세조정한 모델이에요. 이 글에서는 추가 미세조정 없이 그대로 쓰는 비교군입니다.

도구는 실행하지 않고 정의만 있어요. 설명은 질문과 같은 언어로 두 벌 만들었습니다.

  • get_like_count(slug): 글의 좋아요 수. slug는 영문 소문자, 숫자, 하이픈만
  • search_articles(query, limit?): 제목으로 글 찾기. limit은 1~10, 생략 가능
  • list_articles(tag, sort, limit): 태그별 글 목록. tag는 Kafka, MySQL, Redis, RAG, Docker 중 하나, sort는 likes 또는 latest, limit은 1~10
  • send_mail(to, subject, body): 메일 보내기. to는 이메일 형식

시스템 프롬프트는 모든 모델에 같은 두 문장을 썼어요. 영어 질문에는 같은 뜻의 영어 문장을 썼습니다.

당신은 이 기술 블로그 사이트의 운영 도우미입니다. 도구는 필요할 때만 쓰고, 도구로 답할 수 없는 질문에는 도구 없이 짧게 답하세요.

질문 60개는 네 유형으로 15개씩이에요. 영어 질문은 한국어 질문과 같은 인자 값을 쓰는 짝입니다.

  • single: 필수 인자 하나로 끝나는 호출. "slug kafka-lag-basics 글 좋아요 수 알려줘" → get_like_count(slug="kafka-lag-basics")
  • multi_arg: 인자 2개 이상. "Redis 태그 글 중 좋아요 많은 순으로 3개 보여 줘" → list_articles(tag="Redis", sort="likes", limit=3)
  • parallel: 한 응답에 호출 2건이 필요한 질문. "kafka-lag-basics와 redis-ttl-notes 두 글의 좋아요 수를 각각 알려줘" → get_like_count 2건(순서는 따지지 않음)
  • no_tool: 도구가 필요 없는 질문. 인사 같은 일반 대화 5개("고마워요, 덕분에 해결했어요"), 일반 지식 5개("HTTP 404는 무슨 뜻이야?"), 도구와 비슷하지만 도구가 지원하지 않는 요청 5개("kafka-lag-basics 글 조회수 알려줘", "redis-ttl-notes 글 삭제해 줘")

slug, 이메일 주소, 숫자는 모두 지어낸 값이라 모델이 추측으로 맞힐 수 없어요. 입력 id의 첫 글자는 유형(S는 single, M은 multi_arg, P는 parallel, N은 no_tool), 숫자는 유형 안의 순번, -ko·-en은 질문 언어예요.

요청은 모두 /api/chat에 stream: false로 보내고, 메시지는 system 1개와 user 1개만 넣어요. 옵션은 temperature: 0, seed: 42, num_ctx: 4096, num_predict: 512(생각 켬은 2048), num_thread: 2로 고정했습니다.

각 모델의 실행은 run_model.sh가 순서대로 해요. 모델 받기, ollama show, 렌더링 확인(render.py, 도구 정의가 프롬프트에 어떻게 들어가는지 찍음), 배선 확인(wiring.py, 날씨 도구 하나로 "서울 날씨 알려줘"를 세 번 보냄), 본 실행 1회, 한국어 반복 2회, ol rm 순서입니다. 첫 인자가 모델 이름, 나머지가 그 모델로 돌릴 설정 이름이고, 각 단계의 출력은 out/ 아래 파일로 남겨요.

mkdir -p out
chmod +x run_model.sh
./run_model.sh qwen3:1.7b qwen3-off qwen3-on
./run_model.sh qwen2.5:1.5b qwen25
./run_model.sh granite4:1b-h granite
./run_model.sh functiongemma:270m fgemma

채점은 grade_call.py가 해요. 정답 호출을 미리 정해 두고 응답을 코드로만 채점하고, 앞 단계를 통과한 응답만 다음 단계로 넘어가요.

  1. 부를지 말지: tool_calls가 1개 이상이면 부른 것으로 봐요. no_tool은 부르지 않아야, 나머지는 불러야 통과입니다.
  2. 도구 선택: 부른 도구 이름의 목록이 정답 이름 목록과 같아야 해요. 순서는 따지지 않고 개수는 따집니다. parallel에서 맞는 도구를 1건만 부른 경우는 "부분"으로 따로 세요.
  3. 인자 형식: 모든 호출의 인자가 그 도구의 JSON Schema를 통과해야 해요.
  4. 인자 값: 정답 호출과 짝을 지어 정답에 적힌 인자 값이 모두 같아야 해요. search_articles의 limit처럼 질문에서 요구하지 않은 선택 인자는 값을 보지 않아요.

실습 1: 모델별 결과

본 실행 1회차의 단계별 통과 수를 볼게요.

출력의 single (of 15) ko 15/15/15/15는 한국어 15개 중 부른 수, 도구를 맞힌 수, 형식을 통과한 수, 값까지 맞힌 수를 차례로 적은 거예요. summary.py lab3은 결과 파일을 grade_call.py의 grade로 다시 채점한 뒤, 언어별과 유형별로 단계 통과 수(funnel)를 셉니다. 그 아래 줄들은 병렬 부분 정답, 본문 호출, 길이 잘림, HTTP 500 수와 스키마 오류 종류, 호출 시간, 한국어·영어 짝의 정답 비교예요.

lab summary.py lab3 qwen3-off qwen25 granite fgemma
qwen3:1.7b think=false (120 inputs)
  ko need 45: called 45 -> right tools 44 -> schema ok 44 -> values ok 44 | held back 12/15
  en need 45: called 37 -> right tools 35 -> schema ok 35 -> values ok 35 | held back 14/15
  single    (of 15) ko 15/15/15/15  en 13/13/13/13
  multi_arg (of 15) ko 15/15/15/15  en 15/15/15/15
  parallel  (of 15) ko 15/14/14/14  en 9/7/7/7
  parallel partial 1, text-only calls 3, cut by length 0, HTTP 500 0
  total_duration per call: median 3.7s, min 1.1s, max 10.1s
  pairs (60): both right 47, only ko right 9, only en right 2
qwen2.5:1.5b (120 inputs)
  ko need 45: called 45 -> right tools 44 -> schema ok 44 -> values ok 44 | held back 12/15
  en need 45: called 45 -> right tools 45 -> schema ok 43 -> values ok 42 | held back 11/15
  single    (of 15) ko 15/15/15/15  en 15/15/15/15
  multi_arg (of 15) ko 15/15/15/15  en 15/15/15/14
  parallel  (of 15) ko 15/14/14/14  en 15/15/13/13
  parallel partial 0, text-only calls 0, cut by length 0, HTTP 500 0
  schema errors: extra:tag x4
  total_duration per call: median 2.4s, min 0.7s, max 9.5s
  pairs (60): both right 52, only ko right 4, only en right 1
granite4:1b-h (120 inputs)
  ko need 45: called 45 -> right tools 31 -> schema ok 31 -> values ok 31 | held back 10/15
  en need 45: called 45 -> right tools 31 -> schema ok 29 -> values ok 29 | held back 6/15
  single    (of 15) ko 15/15/15/15  en 15/15/13/13
  multi_arg (of 15) ko 15/14/14/14  en 15/14/14/14
  parallel  (of 15) ko 15/2/2/2  en 15/2/2/2
  parallel partial 25, text-only calls 0, cut by length 1, HTTP 500 0
  schema errors: pattern:slug x2
  total_duration per call: median 11.9s, min 9.4s, max 54.6s
  pairs (60): both right 33, only ko right 8, only en right 2
functiongemma:270m (120 inputs)
  ko need 45: called 21 -> right tools 12 -> schema ok 4 -> values ok 4 | held back 15/15
  en need 45: called 22 -> right tools 10 -> schema ok 5 -> values ok 5 | held back 15/15
  single    (of 15) ko 5/2/1/1  en 8/5/2/2
  multi_arg (of 15) ko 8/7/3/3  en 8/3/2/2
  parallel  (of 15) ko 8/3/0/0  en 6/2/1/1
  parallel partial 2, text-only calls 0, cut by length 16, HTTP 500 1
  schema errors: enum:sort x11, enum:tag x9, pattern:slug x5
  total_duration per call: median 1.6s, min 0.0s, max 10.1s
  pairs (60): both right 18, only ko right 1, only en right 2

모델마다 무너지는 단계가 달랐어요.

  • qwen3:1.7b(생각 끔)는 부르는 단계에서 가장 많이 떨어졌어요. 영어 질문 45개 중 8개에서 도구를 부르지 않았습니다. 일단 부른 뒤로는 형식과 값에서 떨어진 것이 없었어요.
  • qwen2.5:1.5b는 90개를 모두 불렀고, 형식과 값에서 몇 개씩 떨어졌어요. 형식 실패 4건은 모두 get_like_count에 스키마에 없는 tag를 붙인 것입니다({'slug': 'kafka-lag-basics', 'tag': 'Kafka'}). 출력은 싣지 않았지만 run_model.sh의 렌더링 확인(render.py)을 보면, Ollama가 도구 정의를 프롬프트 글자로 바꿔 넣을 때 additionalProperties: false는 빠져서 모델은 이 규칙을 볼 수 없었어요.
  • granite4:1b-h는 도구 선택에서 크게 떨어졌어요. parallel 30개 중 2건씩, 4개만 맞혔고 25개는 맞는 도구를 1건만 부른 부분 정답이었습니다. 두 글의 좋아요 수를 물으면 첫 번째 글만 부르는 식이에요.
  • functiongemma:270m은 부르는 단계부터 절반 아래였어요. 형식 실패는 enum:sort 11건, enum:tag 9건, pattern:slug 5건이었습니다. sort에 "likes 많은 순" 같은 설명 문장을 넣거나 slug 앞에 공백을 붙였어요. 출력은 싣지 않았지만 배선 확인(wiring.py)에서도 이 모델은 날씨 도구 하나를 세 번 중 한 번도 제대로 부르지 못했으니, 낮은 점수는 이 사실과 함께 읽어 주셔야 합니다.

호출당 시간(Ollama의 total_duration) 중앙값은 qwen2.5:1.5b 2.4초, qwen3:1.7b 3.7초, granite4:1b-h 11.9초, functiongemma:270m 1.6초였어요.

실습 2: 참은 비율을 읽는 법

예를 들어서 어떤 모델이 도구가 필요 없는 질문에서 한 번도 도구를 부르지 않았다고 해볼게요. 그럼 "잘 참는 모델"이라고 봐도 될까요?

그렇게 보기 전에, 같은 모델이 도구가 필요한 질문에서 얼마나 불렀는지를 옆에 놓아야 해요. 출력의 마지막 두 줄은 실제 모델이 아니라 계산으로 만든 가상 응답기예요. 하나는 어떤 질문에도 도구를 부르지 않고, 하나는 언제나 정답 도구를 부릅니다.

lab summary.py lab4 qwen3-off qwen25 granite fgemma qwen3-on
qwen3:1.7b think=false   called when needed 82/90  held back 26/30  (chat 10/10, knowledge 10/10, unsupported 6/10)  wrong calls: {'get_like_count': 3, 'list_articles': 1}
qwen2.5:1.5b             called when needed 90/90  held back 23/30  (chat 10/10, knowledge 10/10, unsupported 3/10)  wrong calls: {'get_like_count': 3, 'search_articles': 1, 'list_articles': 3}
granite4:1b-h            called when needed 90/90  held back 16/30  (chat 6/10, knowledge 9/10, unsupported 1/10)  wrong calls: {'get_like_count': 7, 'search_articles': 7}
functiongemma:270m       called when needed 43/90  held back 30/30  (chat 10/10, knowledge 10/10, unsupported 10/10)
qwen3:1.7b think=true    called when needed 90/90  held back 29/30  (chat 10/10, knowledge 10/10, unsupported 9/10)  wrong calls: {'list_articles': 1}
never calls a tool       called when needed  0/90  held back 30/30
always calls the gold tool called when needed 90/90  held back  0/30

출력을 보면 도구를 아예 부르지 않는 응답기는 참은 비율 30/30을 받아요. functiongemma:270m도 30/30이지만, 도구가 필요한 질문 90개 중 43개만 불렀습니다. 이 모델의 높은 참은 비율은 도구를 원래 잘 부르지 않는 데서 나온 거예요.

두 숫자가 모두 높은 것은 생각 모드를 켠 qwen3:1.7b(90/90, 29/30)였어요. 생각 끔에서는 qwen3:1.7b(82/90, 26/30)와 qwen2.5:1.5b(90/90, 23/30)가 서로 반대쪽에 치우쳤습니다.

하위 유형으로 나누면 잘못 부르는 곳이 한쪽에 몰려 있어요. 일반 대화와 일반 지식은 granite4:1b-h를 빼면 모두 참았고, 지원하지 않는 요청에서 많이 떨어졌습니다. "글 조회수 알려줘"에 get_like_count를, "글 제목을 바꿔 줘"에 list_articles를 부르는 식이에요.

정리

범위: Ollama 0.34.4, CPU 추론, qwen3:1.7b(생각 끔·켬), qwen2.5:1.5b, granite4:1b-h, functiongemma:270m, temperature 0, 도구 4개, 입력 120개(한국어·영어 60쌍).

  • 도구가 필요한 90개에서 qwen3:1.7b(생각 끔)는 부르는 단계에서 8개, granite4:1b-h는 도구 선택에서 28개, functiongemma:270m은 부르는 단계에서 47개가 떨어졌어요. 채점은 부를지, 도구, 형식, 값의 네 단계로 나눠 보세요.
  • 출력을 싣지 않은 렌더링 확인에서 qwen3:1.7b의 렌더링 결과에는 pattern과 additionalProperties: false가 없었고, qwen2.5:1.5b의 형식 실패 4건은 모두 스키마에 없는 tag를 붙인 것이었어요. 지켜야 할 제약은 설명 문장에도 적고, 받은 인자는 코드에서 다시 검증하세요.
  • 참은 비율 30/30은 도구를 부르지 않는 가상 응답기와, 필요한 90개 중 43개만 부른 functiongemma:270m이 받았어요. 참은 비율은 부른 비율과 짝으로 보세요.
  • 잘못 부른 호출은 지원하지 않는 요청에 몰렸고, qwen2.5:1.5b는 그런 요청 10개 중 7개에서 도구를 불렀어요. 쓰기 작업을 하는 도구를 붙이면 프롬프트나 실행 전 확인 단계로 따로 막아 주세요.
  • 이 글에 싣지 않은 추가 측정에서는 생각 모드를 켜자 qwen3:1.7b의 값 통과 수가 79에서 88로 올랐고, 호출당 시간 중앙값은 3.7초에서 14.3초가 되었어요. temperature 0, seed 42로 세 번 보낸 응답은 모든 설정에서 글자 하나까지 같았습니다. 생각 모드는 시간을 더 쓸 수 있는 경로에서만 켜세요.
내 링크 만들기