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

LLM Maliyeti, Gecikme ve Model Seçimi: Token'dan Faturaya

Mert Özen 6 Kas 2026 12 dk 2 görüntülenme
LLM Maliyeti, Gecikme ve Model Seçimi: Token'dan Faturaya

Token sayısından istek başına ve aylık maliyeti hesaplıyor, gecikmeyi yükleme, girdi işleme ve çıktı üretimi olarak üçe ayırıyoruz. gemma3:4b ile 12b'yi gerçek tokens/s ölçümüyle karşılaştırıyor, Q4_K_M'nin ne demek olduğunu tensor tiplerine bakarak görüyor, Microsoft.Extensions.AI ile cevapları cache'liyor ve yerel ile bulut arasındaki kararı tartışıyoruz.

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

Önce Tahmin Et

Bookstore asistanına aylık 50.000 istek geliyor. Bir senaryoda her istek kısa: kitap adını verip iki cümlelik bir tanıtım yazdırıyorsun. Diğerinde her isteğe RAG ile üç bin token'lık yorum metni ekleyip 150 token'lık bir özet istiyorsun. Çıktı token'ı girdi token'ından beş kat pahalıysa, ikinci senaryo birincinin kaç katı tutar? Bir de şunu düşün: aynı makinede 4 milyar parametreli model yerine 12 milyarlık modeli kullanırsan cevap üç kat mı yavaşlar, yoksa daha fazla mı?

İkisinin de cevabını bu yazıda gerçek sayılarla hesaplayacağız. Şimdiye kadar bir LLM'den nasıl doğru cevap alacağımıza odaklandık; bu yazıda o cevabın faturasına ve süresine bakıyoruz.

Maliyet Token'la Ölçülür

Bulut sağlayıcıları karakter ya da istek başına değil, token başına ücret alır. Fiyat genellikle milyon token (MTok) başına verilir ve girdi ile çıktı ayrı fiyatlanır. Çıktı token'ı neredeyse her zaman daha pahalıdır, çünkü model her çıktı token'ını tek tek üretir; girdiyi ise tek geçişte işler. Bir isteğin maliyeti bu yüzden iki çarpımın toplamıdır: girdi token'ı çarpı girdi fiyatı, artı çıktı token'ı çarpı çıktı fiyatı.

Token sayılarını tahmin etmene gerek yok; Ollama her cevapta prompt_eval_count ve eval_count alanlarını zaten gönderiyor. Aşağıdaki hesapta kitap tanıtımı için az sonra yapacağımız ölçümdeki gerçek sayıları (30 girdi, 62 çıktı) kullandım. Fiyatlar ise örnek: bu yazının yazıldığı tarihte orta sınıf bulut modellerinin fiyatlarıyla aynı mertebede seçtim, gerçek bir teklif değiller. Kendi hesabını yaparken sağlayıcının fiyat sayfasındaki güncel rakamı kullan.

// Ondalık ayırıcı her makinede nokta olsun
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

PrintCost("Book description", inputTokens: 30, outputTokens: 62, requestsPerMonth: 50_000);
PrintCost("Review summary (RAG)", inputTokens: 3_000, outputTokens: 150, requestsPerMonth: 50_000);

static void PrintCost(string name, int inputTokens, int outputTokens, int requestsPerMonth)
{
    // Örnek fiyatlar, milyon token başına dolar
    const decimal InputPricePerMTok = 2.00m;
    const decimal OutputPricePerMTok = 10.00m;

    decimal perRequest = inputTokens * InputPricePerMTok / 1_000_000m
                       + outputTokens * OutputPricePerMTok / 1_000_000m;
    decimal perMonth = perRequest * requestsPerMonth;

    Console.WriteLine($"{name,-22} {perRequest:0.000000} $/request  {perMonth,8:0.00} $/month");
}
Book description       0.000680 $/request     34.00 $/month
Review summary (RAG)   0.007500 $/request    375.00 $/month

Açılıştaki sorunun cevabı: on bir kattan fazla. Daha ilginç olanı ayrıntıda. Kitap tanıtımında 680 mikro doların 620'si çıktıdan geliyor. RAG senaryosunda ise tablo tersine dönüyor: 7.500 mikro doların 6.000'i girdi. Çıktı token'ı beş kat pahalı olsa bile, prompt'a eklediğin bağlam yeterince uzunsa faturayı o belirler. Sistem prompt'u, sohbet geçmişi, few-shot örnekleri ve RAG ile gelen chunk'lar her istekte yeniden gönderilir ve her seferinde yeniden ödenir. Context window yazısında konuştuğumuz "API stateless" gerçeğinin parayla ölçülen hâli bu.

Kodun başındaki CultureInfo.InvariantCulture satırını atlama. Türkçe ayarlı bir makinede bu satır olmadan ondalık ayırıcı virgül çıkar; log'a ya da CSV'ye yazılan maliyet rakamlarında bu fark başka bir sistemin parse etmesini bozabilir.

Gecikme: Üç Ayrı Süre

"Model yavaş" dediğimizde aslında üç farklı şeyden birini kastediyoruz. Ollama'nın cevabındaki süre alanları bunları ayrı ayrı verir, hepsi nanosaniye cinsindendir. load_duration, modelin diskten belleğe yüklenmesi için geçen süredir; model zaten bellekteyse neredeyse sıfırdır. prompt_eval_duration, girdinin işlendiği süredir. eval_duration ise çıktının token token üretildiği süredir.

Bu ayrım önemli, çünkü her birinin çaresi farklı. Yükleme süresi ilk isteğe biner; Ollama modeli varsayılan olarak beş dakika bellekte tuttuğu için sonraki istekler bu bedeli ödemez. Girdi işleme genellikle hızlıdır, çünkü bütün token'lar paralel işlenir. Çıktı üretimi ise sıralıdır: her token bir öncekine bağlıdır. Bu yüzden toplam süreyi çoğunlukla çıktı uzunluğu belirler ve kabaca eval_count / tok/s olarak hesaplanır. Streaming yazısında ölçtüğümüz ilk token süresi ise yükleme ile girdi işlemenin toplamına yakındır.

4b mi 12b mi: Gerçek Ölçüm

Aynı soruyu aynı ayarlarla iki modele ikişer kez soralım ve süreleri cevabın kendisinden okuyalım:

import ollama from 'ollama';

const prompt =
  'Write a two-sentence description of the book "The Pragmatic Programmer" for an online bookstore.';

// Nanosaniyeyi milisaniyeye çevir
const ms = (ns: number) => (ns / 1e6).toFixed(0);

async function run(model: string) {
  const res = await ollama.chat({
    model,
    messages: [{ role: 'user', content: prompt }],
    options: { temperature: 0, seed: 42, num_predict: 120 },
  });
  const promptTps = res.prompt_eval_count / (res.prompt_eval_duration / 1e9);
  const evalTps = res.eval_count / (res.eval_duration / 1e9);
  console.log(
    `${model.padEnd(11)} load=${ms(res.load_duration)}ms ` +
      `prompt=${res.prompt_eval_count} tok (${promptTps.toFixed(0)} tok/s) ` +
      `output=${res.eval_count} tok (${evalTps.toFixed(1)} tok/s) ` +
      `total=${ms(res.total_duration)}ms`,
  );
  return res.message.content;
}

for (const model of ['gemma3:4b', 'gemma3:12b']) {
  for (let i = 0; i < 2; i++) {
    const text = await run(model);
    if (i === 1) console.log(`  -> ${text.trim()}\n`);
  }
}
gemma3:4b   load=1ms prompt=30 tok (293 tok/s) output=62 tok (43.5 tok/s) total=2632ms
gemma3:4b   load=1ms prompt=30 tok (589 tok/s) output=62 tok (43.4 tok/s) total=2046ms
  -> "The Pragmatic Programmer" is a timeless classic that teaches you how to build and maintain software effectively, focusing on practical skills and real-world problem-solving. Packed with insightful advice and actionable techniques, this book will transform the way you approach programming and help you become a more confident and productive developer.

gemma3:12b  load=3369ms prompt=30 tok (92 tok/s) output=65 tok (9.2 tok/s) total=10727ms
gemma3:12b  load=6ms prompt=30 tok (123 tok/s) output=65 tok (8.9 tok/s) total=7554ms
  -> "The Pragmatic Programmer" is a timeless guide offering practical advice and techniques for software development, focusing on real-world problem-solving rather than theoretical concepts. It's a must-read for developers of all levels seeking to improve their skills, write better code, and embrace a more pragmatic approach to their craft.

Bu makinede (Apple M5, 24 GB) 4b saniyede yaklaşık 43, 12b yaklaşık 9 token üretti. Parametre sayısı üç kat artınca üretim hızı üç değil, neredeyse beş kat düştü. Açılıştaki ikinci sorunun cevabı da bu: "daha fazla". 12b'nin ilk çağrısında modeli belleğe yüklemek 3,4 saniye sürdü; ikinci çağrıda bu bedel kalktı. 12b'nin cevabı biraz daha derli toplu, ama iki cümlelik bir tanıtım için beş kat beklemeye değer mi? Çoğu zaman değmez.

Bir uyarı: total_duration üç parçanın toplamından biraz büyük çıkıyor. Cevap bu farkın neye harcandığını ayrıntılı söylemiyor, o yüzden kullanıcının hissettiği süreyi ölçmek istiyorsan istemci tarafında kendi saatinle de ölç. Ayrıca bu ölçümü üç kez tekrarladım ve makinede aynı anda başka istekler de çalıştığı için sayılar oynadı: 4b'de 22,7 ile 43,5, 12b'de 8,8 ile 15,3 token/s arası. Bir turda 12b'nin yükleme süresi 28 saniyeye çıktı; arka arkaya iki istek arasında model bellekten çıkarılıp yeniden yüklenmişti. Tek bir ölçüme güvenme; birkaç kez çalıştır ve ortancaya bak.

Quantization: Q4_K_M Ne Demek?

4,3 milyar parametreli bir modelin her ağırlığını 16 bit ile saklasaydık yalnız ağırlıklar 8,6 GB ederdi. gemma3:4b ise diskte 3,3 GB. Aradaki farkı quantization açıklar: ağırlıklar daha az bitle, daha düşük hassasiyetle saklanır. Kurulu modellerin parametre başına kaç bit tuttuğuna bakalım:

import ollama from 'ollama';

const { models } = await ollama.list();

for (const m of models) {
  // "4.3B" ya da "137M" gibi bir değer gelir
  const params = parseFloat(m.details.parameter_size) * (m.details.parameter_size.endsWith('B') ? 1e9 : 1e6);
  const bitsPerParam = (m.size * 8) / params;
  console.log(
    `${m.name.padEnd(24)} ${m.details.parameter_size.padStart(6)} ` +
      `${m.details.quantization_level.padEnd(7)} ${(m.size / 1e9).toFixed(2)} GB ` +
      `${bitsPerParam.toFixed(1)} bits/param`,
  );
}
qwen3:4b                   4.0B Q4_K_M  2.50 GB 5.0 bits/param
nomic-embed-text:latest    137M F16     0.27 GB 16.0 bits/param
gemma3:4b                  4.3B Q4_K_M  3.34 GB 6.2 bits/param
gemma3:12b                12.2B Q4_K_M  8.15 GB 5.3 bits/param

nomic-embed-text quantize edilmemiş, F16 ve tam 16 bit tutuyor. Diğerleri Q4_K_M. Bu ad llama.cpp'nin "k-quants" ailesinden geliyor. Q4_K yaklaşık 4,5 bit/ağırlık eder; sondaki M (medium) ise bir karışımı anlatır: bazı hassas tensor'lar daha yüksek bitli Q6_K ile, geri kalanı Q4_K ile saklanır. Peki gemma3:4b neden 4,5 değil de 6,2 bit? /api/show isteğine verbose: true verirsen her tensor'ın tipini görürsün:

type Tensor = { name: string; type: string; shape: number[] };

const res = await fetch('http://localhost:11434/api/show', {
  method: 'POST',
  body: JSON.stringify({ model: 'gemma3:4b', verbose: true }),
});
const { tensors } = (await res.json()) as { tensors: Tensor[] };

const totals = new Map<string, number>();
for (const t of tensors) {
  // "v." ile başlayan tensor'lar görüntü encoder'ına ait
  const part = t.name.startsWith('v.') ? 'vision' : 'text';
  const key = `${t.type} ${part}`;
  const count = t.shape.reduce((a, b) => a * b, 1);
  totals.set(key, (totals.get(key) ?? 0) + count);
}

for (const [key, count] of [...totals].sort((a, b) => b[1] - a[1])) {
  console.log(`${key.padEnd(12)} ${(count / 1e6).toFixed(0).padStart(5)}M params`);
}
Q4_K text     2721M params
Q6_K text     1159M params
F16 vision     416M params
F16 text         3M params
F32 vision       0M params
F32 text         0M params

Tablo durumu açıklıyor. 416 milyonluk görüntü encoder'ı (gemma3:4b görsel de okuyabiliyor) hiç quantize edilmemiş, F16 duruyor. Q6_K tarafının büyük kısmı ise 671 milyon parametrelik token embedding tablosu. Asıl dil katmanları Q4_K. Yani "Q4_K_M" etiketi dosyanın tamamının 4 bit olduğunu söylemiyor; ağırlıklı karışımın adını veriyor.

Pratikte bilmen gereken: quantization belleği ve diski küçültür, aynı makineye daha büyük bir model sığdırır ve üretimi genellikle hızlandırır, çünkü her token için bellekten okunan veri azalır. Bedeli doğrulukta küçük bir kayıptır ve bit sayısı düştükçe bu kayıp büyür. Ollama kütüphanesindeki varsayılan etiketlerin çoğu Q4_K_M ile gelir; Gemma 3'ün -it-qat etiketleri ise quantization düşünülerek eğitilmiş (quantization-aware training) sürümlerdir.

Aynı Soruya İki Kez Ödeme Yapma

En ucuz token, hiç gönderilmeyen token'dır. Bookstore'da "Clean Code ne anlatıyor?" sorusu yüzlerce kullanıcıdan aynı biçimde gelebilir. Microsoft.Extensions.AI bunun için hazır bir middleware veriyor: UseDistributedCache. Aynı mesajlar ve aynı ayarlarla gelen isteği modele göndermeden cache'ten cevaplar.

using System.Diagnostics;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using OllamaSharp;

// Örnek için bellek içi cache; gerçek uygulamada Redis gibi paylaşılan bir cache
IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));

IChatClient ollama = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");

IChatClient client = ollama
    .AsBuilder()
    .UseDistributedCache(cache)
    .Build();

ChatOptions options = new() { Temperature = 0, Seed = 42 };

string[] questions =
[
    "In one sentence, what is the book \"Clean Code\" about?",
    "In one sentence, what is the book \"Clean Code\" about?",
    "In one sentence, what is the book \"Refactoring\" about?",
];

foreach (string question in questions)
{
    Stopwatch sw = Stopwatch.StartNew();
    ChatResponse response = await client.GetResponseAsync(question, options);
    sw.Stop();
    Console.WriteLine($"{sw.ElapsedMilliseconds,6} ms | {response.Text.Trim()}");
}
  1731 ms | "Clean Code" by Robert C. Martin is a practical guide to writing code that is easy to understand, maintain, and evolve, emphasizing readability, simplicity, and best practices.
     1 ms | "Clean Code" by Robert C. Martin is a practical guide to writing code that is easy to understand, maintain, and evolve, emphasizing readability, simplicity, and best practices.
  6990 ms | "Refactoring" is a guide to improving the internal structure of existing code without changing its external behavior, ultimately leading to more maintainable and flexible software.

İkinci soru 1 milisaniyede döndü, çünkü modele hiç gitmedi. Üçüncü soru farklı olduğu için yine modele gitti; 7 saniye sürmesinin sebebi de aynı anda makinede çalışan diğer istekler (programı bir kez daha çalıştırdığımda aynı soru 1,4 saniyede döndü). Bu cache birebir eşleşme ister: "Clean Code ne anlatıyor" ile "Clean Code neyi anlatır" iki ayrı anahtardır. Anlam benzerliğiyle çalışan "semantic cache" embedding gerektirir ve RAG serisinin konusu.

Koddaki küçük ayrıntı gerçek bir derleme hatasından geliyor. new OllamaApiClient(...).AsBuilder() diye zincirleyince derleyici CS0121 verdi: OllamaApiClient hem IChatClient hem IEmbeddingGenerator uyguladığı için iki AsBuilder extension method'u arasında seçim yapamadı. Önce IChatClient tipinde bir değişkene atamak sorunu çözüyor.

Cache'in iki uyarısı var. Birincisi, yalnız belirlenebilir isteklerde anlamlı; yaratıcı bir metin istiyorsan herkese aynı cevabı vermek istemeyebilirsin. İkincisi, kullanıcıya özel veri içeren cevaplar (sipariş durumu gibi) kullanıcılar arasında paylaşılan bir cache'e girmemeli. Bulut sağlayıcılarının bir de "prompt caching" özelliği var: uzun ve sabit bir prompt başını (system prompt, doküman) tekrar gönderdiğinde o kısmı indirimli fiyatlarlar. Bu, cevabı değil girdinin işlenmesini cache'ler ve ayrıntıları sağlayıcıdan sağlayıcıya değişir; kullanmadan önce dokümanına bak.

Yerel mi Bulut mu?

Bu kararı tek bir ölçüt vermez; birkaç sorunun cevabına bakarsın. Veri makineden çıkabilir mi? Müşteri verisi, sözleşme, sağlık bilgisi gibi bir kısıt varsa yerel model ya da kendi altyapında çalışan bir model öne geçer. Görev ne kadar zor? Kısa bir tanıtım, sınıflandırma ya da yapılandırılmış veri çıkarma gibi dar işlerde 4b bir model çoğu zaman yeter; uzun akıl yürütme, geniş bilgi ve uzun bağlam gerektiren işlerde büyük bulut modelleri belirgin şekilde daha iyidir. Hacim ve gecikme ne? Yerelde token başına ücret yok ama donanım, elektrik ve bakım var; tek bir makine aynı anda sınırlı sayıda isteğe hizmet eder. Bulutta ölçek senin sorunun değil ama fatura token'la büyür.

Pratik bir yol: geliştirmeyi ve testleri yerel modelle yap, ürün için ihtiyaca göre seç. Bunu yapabilmek için kodunun belirli bir sağlayıcıya yapışmaması gerekir; bir sonraki yazının konusu tam olarak bu.

Sık Düşülen Tuzaklar

Birincisi, maliyeti yalnız çıktıya bakarak hesaplamak. Her istekte tekrar gönderilen system prompt, sohbet geçmişi ve RAG bağlamı faturanın büyük kısmını oluşturabilir. Hesabı prompt_eval_count ile gerçek isteklerden yap.

İkincisi, ilk isteğin süresini modelin hızı sanmak. İlk istek yükleme süresini de içerir. Ölçüm yaparken bir ısınma isteği at ve load_duration alanını ayrı tut.

Üçüncüsü, tek ölçümle karar vermek. Aynı makinede başka işler çalışırken sayılar iki kat oynayabiliyor. Birkaç tekrar yap, ortancayı al.

Dördüncüsü, "büyük model her zaman daha iyi" varsaymak. Küçük ve dar bir görevde büyük model biraz daha iyi cümle kurabilir ama birkaç kat yavaş ve pahalıdır. Kendi örneklerinle, 15. yazıdaki gibi küçük bir değerlendirme setiyle karşılaştır.

Beşincisi, belleği hesaba katmamak. Model dosyasının boyutu, bellekte kaplayacağı alanın alt sınırıdır; context window büyüdükçe KV cache için ek bellek gerekir. 24 GB'lık bir makinede 8 GB'lık bir model ile birkaç küçük model aynı anda yüklü kalamayabilir ve Ollama birini bellekten çıkarır.

Küçük Bir Deneme

★☆☆ Ölçüm script'ini kendi makinende çalıştır ve gemma3:4b için saniyedeki token sayısını not et. Sonra num_predict değerini 120'den 400'e çıkarıp daha uzun bir cevap iste. Toplam süre çıktı uzunluğuyla doğru orantılı mı büyüyor?

★★☆ Bookstore'un kendi senaryosu için maliyet hesabı yap: gerçek bir system prompt ve birkaç turluk sohbet geçmişiyle istek gönder, prompt_eval_count ve eval_count değerlerini al, PrintCost fonksiyonuna ver. Sohbet geçmişi uzadıkça istek başına maliyet nasıl değişiyor?

★★★ Cache'li istemciye bir soru sor, sonra aynı soruyu Temperature değerini değiştirerek tekrar sor. Cache'ten mi döndü, modele mi gitti? Süreye bakarak bul ve bunun neden doğru davranış olduğunu düşün.

Bu yazıda kodumuz bir yere sıkıca bağlıydı: OllamaSharp ve ollama paketi. Bir sonraki yazıda aynı kodu sağlayıcıdan bağımsız yazacağız: IChatClient soyutlaması, Ollama'nın OpenAI uyumlu /v1 endpoint'i, yapılandırmadan sağlayıcı değiştirmek, API anahtarlarını güvenle saklamak ve hepsini ASP.NET Core'a DI ile bağlamak.