Mert Özen Her Satırda Daha İleri
Yapay Zeka LLM Temelleri — #11

LLM Tool Calling (1): Model Fonksiyon Çağırmaz, Çağrı İster

Mert Özen 6 Kas 2026 12 dk 5 görüntülenme
LLM Tool Calling (1): Model Fonksiyon Çağırmaz, Çağrı İster

Tool calling'de model hiçbir fonksiyonu çalıştırmaz; bir çağrı isteği üretir, çalıştırmak senin koduna kalır. Ollama'nın /api/chat endpoint'ine qwen3:4b ile ham tools isteği atıyor, sonucu tool rolüyle geri gönderiyor, döngüyü TypeScript'te ve paketsiz C#'ta kuruyoruz.

Sürüm notu: Bu yazıdaki örnekler Ollama 0.34 ile yerel modellerde (qwen3:4b, gemma3:4b) denenmiştir; .NET 10 ve Node 22 kullanılmıştır.

Önce Tahmin Et

Bookstore asistanına get_order_status adında bir araç tanıttın ve kullanıcı "1042 numaralı siparişim nerede?" diye sordu. Model senin sipariş tablona bağlanıp kaydı okur mu? Yoksa bir şey uydurur mu? Ya da üçüncü bir yol mu var?

Bu yazıda cevabı ham HTTP istekleriyle, hiçbir soyutlama kullanmadan göreceğiz. Bir sonraki yazıda aynı işi Microsoft.Extensions.AI'ın hazır araçlarına devredeceğiz; ama önce kaputun altında ne olduğunu bilmek lazım.

Model Fonksiyon Çalıştırmaz, Çağrı İster

Tool calling (function calling) adı yanıltıcı. Model hiçbir şeyi çağırmaz. Model bir metin üreticisidir; tool calling'de ürettiği şey düz cevap yerine "şu fonksiyonu şu argümanlarla çalıştırmanı istiyorum" diyen yapılandırılmış bir istektir. Fonksiyonu çalıştıran, sonucu alan ve modele geri veren her zaman senin kodundur.

Akış dört adımdır. Önce isteğe tools listesini koyarsın: her aracın adı, açıklaması ve argümanlarının JSON schema'sı. Model soruyu ve araçları görür; gerekiyorsa cevap metni yerine tool_calls döner. Sen o çağrıyı kendi kodunda çalıştırır, sonucu tool rolüyle mesaj geçmişine eklersin. Model bu sonucu görerek son cevabı yazar. Sonuç yeterli değilse yeniden araç ister; bu tekrara agent döngüsü denir.

Bir restoran benzetmesi: garson (model) mutfağa girmez, sipariş fişi yazar. Fişi mutfak (senin kodun) okur, yemeği hazırlar ve garsona verir. Garson da masaya ne getirdiğini anlatır. Mutfağın kapısını kim açık bırakırsa sorumluluk ondadır; bu konuya güvenlik tarafıyla bir sonraki yazıda döneceğiz.

Hangi Model Araç Kullanabilir?

Her model tool calling için eğitilmemiştir. Serinin varsayılan modeli gemma3:4b'ye araç gönderince Ollama isteği daha modele ulaşmadan reddeder:

{"error":"registry.ollama.ai/library/gemma3:4b does not support tools"}

Bir modelin neyi desteklediğini /api/show cevabındaki capabilities alanından okuyabilirsin. Bu yazıda tool calling için qwen3:4b kullanıyoruz:

$ curl -s http://localhost:11434/api/show -d '{"model":"qwen3:4b"}'
...
"capabilities": ["completion", "tools", "thinking"]

gemma3:4b'de aynı liste yalnızca completion ve vision içeriyor. ollama.com'daki model sayfalarında da "tools" rozetini arayabilirsin. Kendi uygulamanda model adını yapılandırmadan okuyorsan, başlangıçta bu kontrolü bir kez yapmak sürpriz hataları önler.

İlk İstek: tools Alanı

İstek gövdesini bir dosyaya yazalım. messages her zamanki gibi; yeni olan tools array'i. Argüman tanımı structured output yazısında gördüğümüz JSON schema'nın ta kendisi:

{
  "model": "qwen3:4b",
  "stream": false,
  "options": {
    "temperature": 0,
    "seed": 42
  },
  "messages": [
    {
      "role": "user",
      "content": "Where is my order 1042?"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_order_status",
        "description": "Returns the shipping status of a bookstore order.",
        "parameters": {
          "type": "object",
          "properties": {
            "order_id": {
              "type": "integer",
              "description": "The order number"
            }
          },
          "required": [
            "order_id"
          ]
        }
      }
    }
  ]
}
$ curl -s http://localhost:11434/api/chat -d @request.json

Cevabı kısalttım: thinking metninin ortasını, created_at alanını ve sürelerin bir kısmını çıkardım.

{
  "model": "qwen3:4b",
  "message": {
    "role": "assistant",
    "content": "",
    "thinking": "Okay, the user is asking where their order 1042 is. Let me check the tools provided. [...] I'll generate the tool call with the order_id as 1042.\n",
    "tool_calls": [
      {
        "id": "call_0oq5nk8o",
        "function": {
          "index": 0,
          "name": "get_order_status",
          "arguments": {
            "order_id": 1042
          }
        }
      }
    ]
  },
  "done": true,
  "done_reason": "stop",
  "total_duration": 3281668583,
  "prompt_eval_count": 152,
  "eval_count": 129
}

Üç şeye dikkat et. content boş: model kullanıcıya hiçbir şey söylemedi, yalnızca bir çağrı istedi. arguments string değil, gerçek bir JSON object; order_id da schema'da istediğimiz gibi integer. Üçüncüsü thinking alanı: qwen3:4b bir thinking modeli ve karar vermeden önce kısa bir akıl yürütme yazıyor. Bu 129 çıktı token'ının büyük kısmı o düşünme metni.

id değeri her çalıştırmada değişir; ilk denememde call_9cohdcuh, bu denemede call_0oq5nk8o geldi. Ona dayanan bir test yazma.

Sonucu Geri Göndermek: tool Rolü

Şimdi mutfak tarafındayız. get_order_status(1042)'yi kendimiz çalıştırdık ve sonucu bulduk. İkinci istekte geçmişin tamamını göndeririz: kullanıcının sorusu, modelin çağrı isteği ve bizim sonucumuz. tools listesi de aynen kalır:

"messages": [
  { "role": "user", "content": "Where is my order 1042?" },
  { "role": "assistant", "content": "",
    "tool_calls": [{ "function": { "name": "get_order_status", "arguments": { "order_id": 1042 } } }] },
  { "role": "tool", "tool_name": "get_order_status",
    "content": "{\"order_id\":1042,\"status\":\"shipped\",\"carrier\":\"UPS\",\"eta\":\"2026-11-09\"}" }
]

tool mesajındaki tool_name, sonucun hangi araca ait olduğunu söyler; content her zaman string'dir, bu yüzden JSON'u string'e çevirip koyduk. Modelin cevabı:

"content": "Your order #1042 is currently **shipped** with UPS and is estimated to arrive by **November 9, 2026**. Let me know if you need further assistance! 📦"
"prompt_eval_count": 219,
"eval_count": 271

Model sipariş tablosunu hiç görmedi; yalnızca bizim verdiğimiz dört alanı cümleye döktü. Küçük bir ayrıntı da thinking alanında: model "Wait, that's a bit far in the future. Maybe a typo?" diye düşünmüş. Eğitim verisi 2026'dan önce bittiği için bu tarihi uzak bir gelecek sanıyor, ama "verilen veriye bağlı kalmalıyım" diyerek bizim tarihimizi kullanıyor. Araç sonucunun, modelin kendi "bildiğinden" daha güçlü bir kaynak olması tam olarak istediğimiz şey.

Döngüyü TypeScript'te Kurmak

Elle iki istek atmak öğretici ama gerçek uygulamada kaç tur gerekeceğini bilemezsin. Model iki siparişi birden sorgulamak isteyebilir, bir sonucu görüp başka bir araç isteyebilir. O yüzden bunu bir döngüye çeviririz: model araç istedikçe çalıştır, istemediğinde cevabı döndür. ollama paketiyle:

import ollama, { type Message, type Tool } from 'ollama';

// Sahte sipariş tablomuz; gerçek uygulamada burası veritabanı
const orders: Record<number, { status: string; carrier: string; eta: string }> = {
  1042: { status: 'shipped', carrier: 'UPS', eta: '2026-11-09' },
  1043: { status: 'preparing', carrier: 'DHL', eta: '2026-11-12' },
};

// Asıl fonksiyon bizim kodumuzda; model yalnızca çağrılmasını ister
function getOrderStatus(args: { order_id: number }): string {
  const order = orders[args.order_id];
  return JSON.stringify(order ? { order_id: args.order_id, ...order } : { error: 'order not found' });
}

// Modelin gördüğü: ad, açıklama ve argümanlar için bir JSON schema
const tools: Tool[] = [
  {
    type: 'function',
    function: {
      name: 'get_order_status',
      description: 'Returns the shipping status of a bookstore order.',
      parameters: {
        type: 'object',
        properties: {
          order_id: { type: 'integer', description: 'The order number' },
        },
        required: ['order_id'],
      },
    },
  },
];

// Araç adı -> gerçekten çalışan fonksiyon
const handlers: Record<string, (args: any) => string> = {
  get_order_status: getOrderStatus,
};

async function ask(question: string): Promise<string> {
  const messages: Message[] = [{ role: 'user', content: question }];

  // Kafası karışan model sonsuza kadar dönmesin diye üst sınır
  for (let round = 1; round <= 5; round++) {
    const response = await ollama.chat({
      model: 'qwen3:4b',
      messages,
      tools,
      options: { temperature: 0, seed: 42 },
    });
    // tool_calls taşıyan assistant mesajı geçmişte kalmalı
    messages.push(response.message);

    const calls = response.message.tool_calls ?? [];
    if (calls.length === 0) {
      // Başka araç isteği yok: bu son cevap
      return response.message.content;
    }

    for (const call of calls) {
      const handler = handlers[call.function.name];
      const result = handler
        ? handler(call.function.arguments)
        : JSON.stringify({ error: `unknown tool ${call.function.name}` });
      console.log(`round ${round}: ${call.function.name}(${JSON.stringify(call.function.arguments)}) -> ${result}`);
      // Sonuç 'tool' rolüyle geri gider
      messages.push({ role: 'tool', tool_name: call.function.name, content: result });
    }
  }
  throw new Error('Too many tool rounds');
}

const question = process.argv[2] ?? 'Where is my order 1042?';
console.log(await ask(question));

Üç soruyla çalıştıralım:

$ npx tsx tools-loop.ts "Where are my orders 1042 and 1043?"
round 1: get_order_status({"order_id":1042}) -> {"order_id":1042,"status":"shipped","carrier":"UPS","eta":"2026-11-09"}
round 1: get_order_status({"order_id":1043}) -> {"order_id":1043,"status":"preparing","carrier":"DHL","eta":"2026-11-12"}
Here are the current statuses for your orders:

- **Order #1042**: Shipped via UPS (Expected delivery: November 9, 2026)
- **Order #1043**: Preparing with DHL (Expected delivery: November 12, 2026)

Let me know if you need further assistance!

$ npx tsx tools-loop.ts "Where is my order 9999?"
round 1: get_order_status({"order_id":9999}) -> {"error":"order not found"}
Your order 9999 was not found. Please verify the order number or contact customer support for assistance.

$ npx tsx tools-loop.ts "Recommend a short science fiction novel."
I don't have access to book recommendation functionality or a science fiction novel database. My capabilities are limited to checking bookstore order statuses using the provided `get_order_status` tool. Would you like me to check the status of a specific order instead?

İlk soruda model tek turda iki çağrı istedi (parallel tool calls). Döngümüz tool_calls array'inin tamamını dolaştığı için ikisini de çalıştırdı ve iki tool mesajı ekledi. İkinci soruda araç bir hata döndü; hatayı fırlatmak yerine JSON olarak modele vermek, modelin kullanıcıya düzgün bir cümle kurmasını sağladı. Üçüncü soruda model hiç araç istemedi. Elinde kitap önerisi için araç olmadığını fark etti ve uydurmak yerine sınırını söyledi. Bu her zaman böyle olmaz; araç listesine ve system prompt'a göre değişir.

Aynı Döngü C#'ta, Paketsiz

Kavramı tam oturtmak için C# tarafını da hiçbir AI paketi kullanmadan, HttpClient ve System.Text.Json ile yazalım. Bir sonraki yazıda bunun ne kadarını Microsoft.Extensions.AI'ın üstlendiğini görünce farkı daha iyi anlayacaksın:

using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Nodes;

// Sahte sipariş tablosu, TypeScript sürümüyle aynı veri
Dictionary<int, object> orders = new()
{
    [1042] = new { status = "shipped", carrier = "UPS", eta = "2026-11-09" },
    [1043] = new { status = "preparing", carrier = "DHL", eta = "2026-11-12" },
};

string GetOrderStatus(JsonNode? args)
{
    int orderId = args?["order_id"]?.GetValue<int>() ?? 0;
    return orders.TryGetValue(orderId, out object? order)
        ? JsonSerializer.Serialize(order)
        : """{"error":"order not found"}""";
}

// curl ile gönderdiğimiz araç tanımının aynısı
JsonArray tools = JsonNode.Parse("""
[{
  "type": "function",
  "function": {
    "name": "get_order_status",
    "description": "Returns the shipping status of a bookstore order.",
    "parameters": {
      "type": "object",
      "properties": { "order_id": { "type": "integer", "description": "The order number" } },
      "required": ["order_id"]
    }
  }
}]
""")!.AsArray();

using HttpClient http = new() { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(5) };

string question = args.Length > 0 ? args[0] : "Where is my order 1043?";
JsonArray messages = [new JsonObject { ["role"] = "user", ["content"] = question }];

// En fazla 5 tur, sonra vazgeç
for (int round = 1; round <= 5; round++)
{
    JsonObject request = new()
    {
        ["model"] = "qwen3:4b",
        ["stream"] = false,
        ["messages"] = messages.DeepClone(),
        ["tools"] = tools.DeepClone(),
        ["options"] = new JsonObject { ["temperature"] = 0, ["seed"] = 42 },
    };

    HttpResponseMessage httpResponse = await http.PostAsJsonAsync("/api/chat", request);
    httpResponse.EnsureSuccessStatusCode();
    JsonNode body = (await httpResponse.Content.ReadFromJsonAsync<JsonNode>())!;
    JsonObject message = body["message"]!.AsObject();
    // assistant mesajını (tool_calls'uyla birlikte) geçmişte tut
    messages.Add(message.DeepClone());

    JsonArray? calls = message["tool_calls"]?.AsArray();
    if (calls is null || calls.Count == 0)
    {
        // Araç çağrısı yok: model son cevabını verdi
        Console.WriteLine(message["content"]?.GetValue<string>());
        Console.WriteLine($"prompt tokens: {body["prompt_eval_count"]}, output tokens: {body["eval_count"]}");
        return;
    }

    foreach (JsonNode? call in calls)
    {
        string name = call!["function"]!["name"]!.GetValue<string>();
        JsonNode? arguments = call["function"]!["arguments"];
        string result = name == "get_order_status"
            ? GetOrderStatus(arguments)
            : $$"""{"error":"unknown tool {{name}}"}""";
        Console.WriteLine($"round {round}: {name}({arguments?.ToJsonString()}) -> {result}");
        // Sonucu 'tool' mesajı olarak geri gönder
        messages.Add(new JsonObject { ["role"] = "tool", ["tool_name"] = name, ["content"] = result });
    }
}

Console.WriteLine("Too many tool rounds");
$ dotnet run
round 1: get_order_status({"order_id":1043}) -> {"status":"preparing","carrier":"DHL","eta":"2026-11-12"}
Your order #1043 is currently **preparing** with DHL. The estimated delivery date is **November 12, 2026**. Let me know if you need further assistance!
prompt tokens: 317, output tokens: 273

HttpClient'ın Timeout değerini bilerek beş dakikaya çektim. Varsayılan 100 saniye, thinking modeli yavaş bir makinede ya da paylaşılan bir Ollama'da bunu kolayca aşar. Bir de DeepClone çağrılarına dikkat et: JsonNode aynı anda tek bir parent'a bağlı olabilir; aynı mesajı hem geçmişe hem isteğe koymak için kopyası gerekir.

Araç Tanımları da Token Harcar

Araçlar ücretsiz değil. "Where is my order 1042?" sorusu tek başına 20 prompt token'ı tuttu. Aynı soru tek bir araç tanımıyla 152 token oldu. Yani o küçük JSON schema her istekte yaklaşık 130 token ekliyor ve döngünün her turunda yeniden gönderiliyor. On araçlık bir listede bu, context window'un (bağlam penceresi) ve maliyetin hissedilir bir kısmı demek. Açıklamaları kısa ve net tut, o istekte gerçekten gerekmeyen aracı listeye koyma.

Araçsız aynı soruya qwen3:4b'nin verdiği cevap da öğretici: 890 token boyunca siparişe erişemediğini anlatıp Amazon, Shopify ve Walmart'ın yardım sayfalarına link verdi. Araç yoksa model elindeki tek şeyi, genel bilgisini kullanır.

Sık Düşülen Tuzaklar

İlk tuzak, assistant mesajını geçmişe eklemeyi unutmak. Yalnızca tool mesajını eklersen model hangi çağrının sonucuna baktığını bilemez. Sıra her zaman şu: assistant (tool_calls), ardından her çağrı için bir tool mesajı.

İkincisi, döngüye üst sınır koymamak. Model aynı aracı tekrar tekrar isteyebilir; özellikle araç her seferinde hata dönüyorsa. Bizim örneklerde beş tur sınırı var. Sınır aşılınca kullanıcıya düzgün bir hata ver.

Üçüncüsü, bilinmeyen araç adlarına hazırlıksız olmak. Model listede olmayan bir ad uydurabilir. Döngü bunu çökmeden yakalamalı ve modele "böyle bir araç yok" demeli; bizim kod unknown tool hatasını tam bunun için döndürüyor.

Dördüncüsü, qwen3:4b'de thinking'i kapatmaya çalışmak. ollama show bilgilerine göre bu etiketin arkasında Qwen3 4B Thinking 2507 sürümü var (general.finetune: Thinking), yani yalnızca düşünen bir model. Bu model sürümüyle "think": false gönderdiğimde düşünme metni kaybolmadı, content'in içine akıp </think> etiketiyle bitti. think alanını hiç göndermeyince düşünme ayrı thinking alanına gidiyor ve content temiz kalıyor. Ayrıntılar reasoning yazısında; burada bilmen gereken, content'i olduğu gibi kullanıcıya basmadan önce bir kez bakman.

Beşincisi ve en önemlisi, modelin ürettiği argümanlara güvenmek. order_id modelin yazdığı bir sayıdır; başka bir müşterinin siparişi de olabilir, hiç var olmayan bir sayı da. Araç kodu her argümanı kullanıcıdan gelmiş gibi doğrulamalı. Yetki, onay ve doğrulama bir sonraki yazının ana konusu.

Küçük Bir Deneme

★☆☆ tools-loop.ts'e kitap aramak için ikinci bir araç ekle (search_books, argümanı genre). Önce "Recommend a short science fiction novel." sorusunu tekrar sor ve modelin bu kez aracı çağırıp çağırmadığına bak.

★★☆ get_order_status'un description metnini boş bırak ya da alakasız bir cümle yaz. Aynı soruları sorup modelin aracı hâlâ doğru seçip seçmediğini ve prompt_eval_count'un nasıl değiştiğini not et.

★★☆ C# programında döngü sınırını 1'e indir ve "Where are my orders 1042 and 1043?" sorusunu sor. Paralel iki çağrı aynı turda geldiği için sınır yine yeter mi, yoksa son cevabı alamadan mı çıkarsın?

★★★ Aracın bilerek her seferinde {"error":"database timeout"} döndürdüğü bir sürüm yaz. Model kaç tur boyunca tekrar dener, sonunda kullanıcıya ne söyler? Döngü sınırı olmasaydı ne olurdu?

Ham akışı artık biliyorsun: tanım, çağrı isteği, senin çalıştırman, sonuç, son cevap. Bir sonraki yazıda bu döngüyü Microsoft.Extensions.AI'ın UseFunctionInvocation middleware'ine ve AIFunctionFactory'ye devredeceğiz, sonra asıl zor soruya geçeceğiz: modelin istediği her çağrıyı çalıştırmalı mıyız?