LLM Maliyetini Kontrol Etmek: DistributedCachingChatClient, Rate Limiting ve Token Kullanımı
LLM maliyetini üç katmanda kontrol ediyoruz: Redis'li DistributedCachingChatClient tekrar eden soruyu sıfır token'la cevaplar, kullanıcı başına rate limiting kötüye kullanımı sınırlar, UsageDetails ile tutulan günlük token bütçesi de asıl harcamayı. Cache key'inin içeriğini, kayıtların varsayılan olarak süresiz yazıldığını ve token muhasebesinin neden cache'in içinde durması gerektiğini gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2, Microsoft.Extensions.Caching.StackExchangeRedis 10.0.14, Redis 8.2 (Docker) ve yerel modelde Ollama 0.34.4 (qwen3:4b) kullanılmıştır.
Önce Tahmin Et
Bookstore'un ürün sayfası her kitap için asistana tek cümlelik bir tanıtım metni yazdırıyor. Pipeline'a UseDistributedCache() ekledin ve arkasına Redis koydun. Ada "Refactoring" sayfasını açıyor, bir dakika sonra grace aynı sayfayı açıyor. Modele kaç istek gider? Redis'e yazılan kayıt ne zaman silinir?
Bir de şu: token kullanımını sayan bir middleware yazdın ve onu cache'in dışına koydun. Ada sayfayı ikinci kez açtığında cevap cache'ten 3 milisaniyede geliyor. Muhasebe defterine kaç token yazılır?
LLM (Large Language Model) Temelleri'nin maliyet yazısında token'dan faturaya giden hesabı yapmıştık. Bu yazıda o hesabı koda döküyoruz: cache ile hiç harcanmayan token, rate limiting ile kullanıcı başına sınır ve UsageDetails ile kullanıcı ve özellik bazında günlük bütçe.
UseDistributedCache ve Redis
Redis'i kendi container'ında çalıştırdım ve ASP.NET Core'un IDistributedCache kaydını ona bağladım. UseDistributedCache() parametre almadığında cache'i DI'dan (Dependency Injection) alıyor:
docker run -d --name aiwork1-redis -p 19601:6379 redis:8.2
builder.Services.AddStackExchangeRedisCache(o =>
{
o.Configuration = "localhost:19601";
o.InstanceName = "bookstore-ai:";
});
IChatClient ollama = new OllamaApiClient(
new HttpClient { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(30) }, "qwen3:4b");
// Sıra: cache dışta, muhasebe içte. Cache'ten dönen cevap token harcamaz, bütçeden düşülmez.
builder.Services.AddChatClient(ollama)
.UseDistributedCache()
.Use((inner, services) => new UsageTrackingChatClient(
inner,
services.GetRequiredService<TokenLedger>(),
services.GetRequiredService<IHttpContextAccessor>(),
dailyLimit: 600));
Endpoint basit; cevapla birlikte süreyi ve token sayılarını da dönüyor:
app.MapPost("/books/blurb", async (BlurbRequest request, IChatClient chat, CancellationToken ct) =>
{
Stopwatch sw = Stopwatch.StartNew();
try
{
ChatResponse response = await chat.GetResponseAsync(
$"Write a one-sentence shop blurb for the book '{request.Title}'. Reply with the sentence only.",
new ChatOptions { Temperature = 0, Seed = 42 }, ct);
return Results.Ok(new
{
text = response.Text.Trim(),
elapsedMs = sw.ElapsedMilliseconds,
inputTokens = response.Usage?.InputTokenCount,
outputTokens = response.Usage?.OutputTokenCount,
});
}
catch (TokenBudgetExceededException ex)
{
return Results.Json(new { error = ex.Message }, statusCode: StatusCodes.Status429TooManyRequests);
}
}).RequireRateLimiting("per-user");
Ada aynı kitabı iki kez istiyor, ardından iki farklı kitap daha:
$ ada: Refactoring
HTTP/1.1 200 OK
{"text":"Refactoring: Write better code by making small, incremental improvements that reduce technical debt and boost maintainability.","elapsedMs":93139,"inputTokens":31,"outputTokens":581}
$ ada: Refactoring
HTTP/1.1 200 OK
{"text":"Refactoring: Write better code by making small, incremental improvements that reduce technical debt and boost maintainability.","elapsedMs":3,"inputTokens":31,"outputTokens":581}
$ ada: Clean Code
HTTP/1.1 429 Too Many Requests
{"error":"Daily token budget exceeded for 'ada': 612/600."}
$ ada: Domain-Driven Design
HTTP/1.1 429 Too Many Requests
{"error":"Daily token budget exceeded for 'ada': 612/600."}
$ GET /usage
[{"user":"ada","feature":"/books/blurb","tokens":612}]
İlk istek 93 saniye sürdü: paylaşılan Ollama'da sıra bekleme ve qwen3:4b'nin düşünme süresi. İkinci istek 3 milisaniye; modele hiç gitmedi. Aynı soru, aynı cevap, otuz bin kat hızlı ve sıfır token. Ama çıktıdaki iki sayıya dikkat et: cache'ten gelen cevap da inputTokens: 31, outputTokens: 581 diyor. Cache, cevabı Usage bilgisiyle birlikte saklıyor; bu sayılar "bu istek şu kadar harcadı" değil, "bu cevap üretilirken şu kadar harcanmıştı" demek. Açılıştaki ikinci sorunun cevabı burada: muhasebeyi cache'in dışına koysaydın bu cevap için defterine bir kez daha 612 token yazardın ve faturan olduğundan iki kat görünürdü.
Bir de 581 sayısına bak. Tek cümlelik bir tanıtım metni yaklaşık 25 token; geri kalanı qwen3:4b'nin düşünme (reasoning) token'ları. Reasoning modellerinde bu token'lar kullanıcıya görünmez ama output token olarak sayılır ve faturalanır. Basit işler için reasoning modeli seçmenin maliyetini en net bu satır gösteriyor.
Cache Key'e Neler Girer?
Redis'teki kayda bakalım:
$ redis-cli KEYS / TTL
bookstore-ai:3AF0B8848DAD0E650FEAF7FFA0AF4C18C0A613FB8DC8F63134B66D9FEFBFDA81695DE896D9ECCE976EEF751226DF8DA0 TTL=-1
$ grace: Refactoring
HTTP/1.1 200 OK
{"text":"Refactoring: Write better code by making small, incremental improvements that reduce technical debt and boost maintainability.","elapsedMs":2,"inputTokens":31,"outputTokens":581}
Key, InstanceName önekinden sonra 96 hex karakterlik bir hash; yani 384 bit. Microsoft.Extensions.AI'ın API (Application Programming Interface) dokümantasyonuna göre DistributedCachingChatClient bu key'i mesajların, ChatOptions'ın ve CacheKeyAdditionalValues'a eklediğin değerlerin JSON (JavaScript Object Notation) serileştirmesinden hesaplıyor. Pratikte bunun anlamı şu:
- Mesajlardaki tek bir karakter farkı (system prompt'u değiştirmek dahil) yeni bir key demek; prompt'u değiştirdiğinde eski cache kendiliğinden devre dışı kalır.
Temperature,MaxOutputTokens,ModelIdgibi options alanları key'e girer; aynı soruyu farklı ayarla soran iki endpoint aynı kaydı paylaşmaz.- HTTP (Hypertext Transfer Protocol) isteğinin kendisi, yani kullanıcı kimliği, başlıklar ya da route key'e girmez. Aynı metni gönderen iki kullanıcı aynı kaydı okur.
- Dokümantasyon key'in kütüphane sürümleri arasında sabit kalmayacağını açıkça söylüyor; paketi yükselttiğinde cache'in boşalmasını bekle.
ChatOptions.ConversationIddoluysa cache hiç devreye girmez; stateful bir sohbetin cevabı geçmişe bağlı olduğu içinEnableCaching'in varsayılan hâli bu istekleri doğrudan geçirir.
Üçüncü madde bir karar gerektiriyor. Kitap tanıtım metni için paylaşılan cache tam istediğimiz şey: grace aynı sayfayı açtığında Ada'nın ürettirdiği cevabı alıyor. Aynı pipeline "siparişlerim nerede?" sorusuna da hizmet etseydi, iki kullanıcının metni aynı olduğunda biri diğerinin siparişlerini görebilirdi. Kişisel veri içeren cevapları ya hiç cache'leme (ayrı, cache'siz bir keyed client) ya da kullanıcı kimliğini CacheKeyAdditionalValues'a ekleyerek kullanıcı başına ayır.
Kayıtların Süresi: TTL
Yukarıdaki çıktıda TTL=-1 var: Redis'te TTL (Time To Live) değerinin -1 olması "süresi yok" demek. DistributedCachingChatClient kayıtları süre belirtmeden yazıyor ve paketin bunu ayarlamak için bir property'si yok. Sonuç: her farklı prompt Redis'te sonsuza kadar kalır; tanıtım metni güncellense bile eski cevap dönmeye devam eder. Çözümü IDistributedCache seviyesinde, küçük bir decorator ile yaptım:
using Microsoft.Extensions.Caching.Distributed;
namespace Bookstore.Assistant.Api;
// DistributedCachingChatClient kayıtlara süre vermez; süresi belirtilmemiş her yazmaya varsayılan bir TTL ekler
public sealed class ExpiringDistributedCache(IDistributedCache inner, TimeSpan ttl) : IDistributedCache
{
public byte[]? Get(string key) => inner.Get(key);
public Task<byte[]?> GetAsync(string key, CancellationToken token = default) => inner.GetAsync(key, token);
public void Refresh(string key) => inner.Refresh(key);
public Task RefreshAsync(string key, CancellationToken token = default) => inner.RefreshAsync(key, token);
public void Remove(string key) => inner.Remove(key);
public Task RemoveAsync(string key, CancellationToken token = default) => inner.RemoveAsync(key, token);
public void Set(string key, byte[] value, DistributedCacheEntryOptions options) =>
inner.Set(key, value, WithTtl(options));
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) =>
inner.SetAsync(key, value, WithTtl(options), token);
private DistributedCacheEntryOptions WithTtl(DistributedCacheEntryOptions options) =>
options.AbsoluteExpiration is null && options.AbsoluteExpirationRelativeToNow is null && options.SlidingExpiration is null
? new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }
: options;
}
builder.Services.AddChatClient(ollama)
.Use((inner, services) => new DistributedCachingChatClient(
inner, new ExpiringDistributedCache(services.GetRequiredService<IDistributedCache>(), TimeSpan.FromHours(1))))
.Use((inner, services) => new UsageTrackingChatClient(
inner,
services.GetRequiredService<TokenLedger>(),
services.GetRequiredService<IHttpContextAccessor>(),
dailyLimit: 600));
$ grace: Refactoring
HTTP/1.1 200 OK
{"text":"Refactoring: Write better code by making small, incremental improvements that reduce technical debt and boost maintainability.","elapsedMs":92127,"inputTokens":31,"outputTokens":581}
$ redis-cli TTL
bookstore-ai:3AF0B8848DAD0E650FEAF7FFA0AF4C18C0A613FB8DC8F63134B66D9FEFBFDA81695DE896D9ECCE976EEF751226DF8DA0 TTL=3600
grace'in ilk isteği modele gitti (92 saniye) ve kayıt bu sefer 3600 saniyelik, yani bir saatlik bir TTL ile yazıldı. Süreyi neye göre seçtiğin tamamen içeriğe bağlı: kitap tanıtım metni için bir gün bile makul, stok ya da fiyat içeren bir cevap için dakikalar. Decorator yalnız süresi belirtilmemiş yazmalara dokunuyor; aynı IDistributedCache'i kendi süresini veren başka kodlar da kullanıyorsa onların ayarı korunuyor.
Kullanıcı Başına Rate Limiting
Cache tekrar eden soruları ucuzlatır ama kötü niyetli ya da hatalı bir istemciyi durdurmaz: her istekte farklı bir soru soran bir script cache'i hiç kullanmaz. İlk savunma ASP.NET Core'un rate limiting middleware'i. ASP.NET Core Web API serisinin ".NET 7 ile ASP.NET Core Yenilikleri" yazısında tanıştığımız yapının aynısı, bu sefer kullanıcı başına bölümlenmiş:
// Kullanıcı başına istek sınırı: dakikada 3 istek
builder.Services.AddRateLimiter(o =>
{
o.AddPolicy("per-user", http => RateLimitPartition.GetFixedWindowLimiter(
http.Request.Headers["X-Demo-User"].ToString(),
_ => new FixedWindowRateLimiterOptions { PermitLimit = 3, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
o.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan retryAfter))
{
context.HttpContext.Response.Headers.RetryAfter = ((int)retryAfter.TotalSeconds).ToString();
}
await context.HttpContext.Response.WriteAsJsonAsync(
new { error = "Too many requests. Please wait before asking again." }, ct);
};
});
İlk denemede bu limit hiç devreye girmedi ve sebebi öğretici. Ada'nın ilk isteği 93 saniye sürdü; fixed window'un ilk bir dakikası o istek sürerken doldu. Sonraki üç istek yeni bir pencereye düştü ve limiti aşmadı. İstek sayısına dayalı bir limit, istekler bu kadar uzun sürdüğünde beklediğin şekilde çalışmaz. Limiti görmek için grace'e cache'te olan aynı kitabı dört kez arka arkaya istettim:
$ grace: Refactoring
HTTP/1.1 200 OK
{"text":"Refactoring: Write better code by making small, incremental improvements that reduce technical debt and boost maintainability.","elapsedMs":2,"inputTokens":31,"outputTokens":581}
$ grace: Refactoring
HTTP/1.1 200 OK
{"text":"Refactoring: Write better code by making small, incremental improvements that reduce technical debt and boost maintainability.","elapsedMs":0,"inputTokens":31,"outputTokens":581}
$ grace: Refactoring
HTTP/1.1 200 OK
{"text":"Refactoring: Write better code by making small, incremental improvements that reduce technical debt and boost maintainability.","elapsedMs":0,"inputTokens":31,"outputTokens":581}
$ grace: Refactoring
HTTP/1.1 429 Too Many Requests
Retry-After: 60
{"error":"Too many requests. Please wait before asking again."}
Üç istek cache'ten milisaniyeler içinde döndü, dördüncüsü 429 ve Retry-After: 60 başlığıyla reddedildi. Dikkat: rate limiter HTTP katmanında duruyor ve cache'ten dönecek bir cevabı da sayıyor. Modelin hiç çalışmayacağı bir isteği reddetmek bazen gereksiz görünebilir, ama bu katman maliyeti değil kötüye kullanımı sınırlıyor; maliyeti sınırlayan şey bir sonraki bölümdeki token bütçesi.
Token Muhasebesi: UsageDetails ve Günlük Bütçe
İstek sayısı maliyetin kötü bir ölçüsü: "Merhaba" ile "bu 40 sayfalık siparişin özetini çıkar" aynı sayılır. Asıl harcanan şey token, ve her ChatResponse provider ne olursa olsun bunu Usage altında veriyor. Kullanıcı ve özellik (burada endpoint yolu) bazında günlük bir defter tutuyorum:
using System.Collections.Concurrent;
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Api;
// Kullanıcı ve özellik bazında günlük token muhasebesi. Demo için bellekte; production'da Redis ya da veritabanı.
public sealed class TokenLedger(TimeProvider clock)
{
private readonly ConcurrentDictionary<(string User, string Feature, DateOnly Day), long> _totals = new();
public long TodayFor(string user) =>
_totals.Where(e => e.Key.User == user && e.Key.Day == Today).Sum(e => e.Value);
public void Add(string user, string feature, UsageDetails? usage)
{
long tokens = (usage?.InputTokenCount ?? 0) + (usage?.OutputTokenCount ?? 0);
_totals.AddOrUpdate((user, feature, Today), tokens, (_, current) => current + tokens);
}
public IEnumerable<object> Snapshot() =>
_totals.Where(e => e.Key.Day == Today)
.OrderBy(e => e.Key.User).ThenBy(e => e.Key.Feature)
.Select(e => new { e.Key.User, e.Key.Feature, Tokens = e.Value });
private DateOnly Today => DateOnly.FromDateTime(clock.GetUtcNow().UtcDateTime);
}
public sealed class TokenBudgetExceededException(string user, long used, long limit)
: Exception($"Daily token budget exceeded for '{user}': {used}/{limit}.")
{
public long Used { get; } = used;
public long Limit { get; } = limit;
}
Bütçeyi uygulayan ve defteri dolduran middleware, 2. yazıdaki kuralla iki method'u da override ediyor; streaming'de kullanım bilgisi son update'lerdeki UsageContent'ten toplanıyor:
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Api;
// Cache'in İÇİNDE durur: yalnız modele gerçekten giden istekleri sayar ve bütçeyi o noktada uygular
public sealed class UsageTrackingChatClient(
IChatClient innerClient, TokenLedger ledger, IHttpContextAccessor http, long dailyLimit)
: DelegatingChatClient(innerClient)
{
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
(string user, string feature) = EnsureBudget();
ChatResponse response = await base.GetResponseAsync(messages, options, cancellationToken);
ledger.Add(user, feature, response.Usage);
return response;
}
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
(string user, string feature) = EnsureBudget();
UsageDetails usage = new();
await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken))
{
foreach (UsageContent content in update.Contents.OfType<UsageContent>())
{
usage.Add(content.Details);
}
yield return update;
}
ledger.Add(user, feature, usage);
}
private (string User, string Feature) EnsureBudget()
{
HttpContext context = http.HttpContext ?? throw new InvalidOperationException("No HTTP context.");
string user = context.Request.Headers["X-Demo-User"].ToString();
string feature = context.Request.Path.Value ?? "unknown";
long used = ledger.TodayFor(user);
if (used >= dailyLimit)
{
throw new TokenBudgetExceededException(user, used, dailyLimit);
}
return (user, feature);
}
}
Yukarıdaki ilk çıktıda Ada'nın üçüncü isteği bu yüzden 429 aldı: ilk cevap 612 token harcamıştı ve 600 token'lık günlük bütçe doldu. Bütçe kontrolü isteğin başında, harcama ise sonunda yazılıyor; yani son istek bütçeyi biraz aşabilir. Kesin bir tavan istiyorsan isteğe MaxOutputTokens koyup kalan bütçeyi buna göre hesaplarsın. Bir ayrıntı daha: bütçe aşıldığında cache'te olan cevaplar dönmeye devam ediyor, çünkü muhasebe cache'in içinde ve cache'ten dönen cevap bu middleware'e hiç ulaşmıyor. Token harcamayan bir cevabı bütçe yüzünden reddetmek için bir sebep yok.
IHttpContextAccessor kullanmamın sebebi de 3. yazıdaki lifetime meselesi: pipeline singleton, kullanıcı ise isteğe ait. Middleware'in constructor'ına scoped bir "current user" servisi veremezsin; isteğin bağlamını her çağrıda okuması gerekiyor.
Streaming Cevaplar ve Cache
DistributedCachingChatClient streaming çağrıları da cache'liyor: ilk akıştaki ChatResponseUpdate'leri toplayıp saklıyor, aynı istek tekrar geldiğinde onları yeniden akıtıyor. API dokümantasyonuna göre CoalesceStreamingUpdates varsayılan olarak true; ardışık update'ler tek bir update'te birleştirilip saklanıyor. Sonuç: cache'ten gelen bir streaming cevap, kelime kelime değil, büyük parçalar hâlinde ve neredeyse anında gelir. Arayüzün "yazıyor" animasyonu buna dayanıyorsa farkı kullanıcı görür. Bu çoğu zaman bir sorun değil, ama 4. yazıdaki usage ve thinking gibi event'lere dayanan bir arayüzde cache'ten dönen akışı da test etmen gerekir. Streaming'de bir de yarım akış sorusu var: akış ortada iptal edilirse eksik bir cevabın cache'e yazılmaması gerekir; bunu kendi kurulumunda bir iptal testiyle doğrula.
Üretimde Dikkat
Muhasebeyi kalıcı ve paylaşılan bir yerde tut. Bellekteki TokenLedger uygulama yeniden başlayınca sıfırlanır ve birden fazla instance'ta her biri kendi defterini tutar. Production'da Redis'te kullanıcı ve gün başına bir sayaç (INCRBY ve günün sonunda biten TTL) ya da veritabanında bir tablo kullan.
Bütçeyi kullanıcıya göster. "Günlük limitinize ulaştınız" mesajı, ne kadar kullanıldığını ve ne zaman sıfırlanacağını söylemiyorsa destek ekibine bilet olarak döner. Defterdeki veriyle kullanıcıya kalan kotayı gösteren bir endpoint ucuz bir yatırım.
Cache isabet oranını ölç. Kaç isteğin cache'ten döndüğünü bilmeden cache'in maliyeti ne kadar düşürdüğünü bilemezsin. 8. yazıda OpenTelemetry ile bunu metric olarak toplayacağız.
Sık Düşülen Tuzaklar
Birincisi, kişisel veri içeren cevabı paylaşılan cache'e yazmak. Key yalnız mesaj ve options'tan oluşur; kullanıcıyı ayırmak senin işin.
İkincisi, cache'e süre vermemek. Varsayılan hâliyle kayıtlar Redis'te sonsuza kadar kalıyor; bellek dolar, eski cevaplar dönmeye devam eder.
Üçüncüsü, rate limit'i yalnız istek sayısıyla yapmak. İki dakika süren tek bir istek ile milisaniyede dönen cache isabeti aynı sayılır; token'ı saymayan bir limit maliyeti sınırlamaz.
Dördüncüsü, token'ı cache'in dışında saymak. Cache'ten dönen cevap da Usage taşıyor; dışarıda sayarsan hiç harcamadığın token'ları faturana yazarsın.
Küçük Bir Deneme
★☆☆ /books/blurb'u Temperature = 0.7 ile çağıran ikinci bir endpoint ekle ve aynı kitabı iki endpoint'ten iste. Redis'te kaç key oluşuyor? Neden?
★★☆ Kişisel cevaplar için kullanıcıya göre ayrılmış bir cache kur: UseDistributedCache(configure: c => ...) ile CacheKeyAdditionalValues'a kullanıcı id'sini ekle. Pipeline singleton olduğu hâlde bu değeri istek başına nasıl verirsin? İpucu: DistributedCachingChatClient'tan türetip GetCacheKey'i override edebilirsin.
★★★ TokenLedger'ı Redis'e taşı: kullanıcı ve gün başına bir key, INCRBY ile artış ve gece yarısında biten bir TTL. İki uygulama instance'ını farklı portlarda çalıştırıp aynı kullanıcının bütçesinin ikisinde de doğru düştüğünü göster.
Bu yazıyla Blok A'nın maliyet tarafını da kapattık: pipeline, DI, streaming, sohbet geçmişi, tool'lar ve şimdi cache, rate limiting ve token bütçesi. Bir sonraki yazıda bütün bu katmanları görünür hâle getiriyoruz: UseOpenTelemetry, GenAI semantic convention'ları ve Aspire Dashboard ile her model çağrısını, tool turunu ve token'ı trace'te okumak.