Unbounded Consumption: Denial of Wallet, Context Bombası ve Agent Döngüsünü Sınırlamak
LLM'de maliyetin birimi istek değil token'dır; bu yüzden input, output, süre ve bütçe her istekte çağrıdan önce sınırlanır. 120 bin karakterlik bir dokümanın Ollama'da sessizce kırpılıp yanlış cevap ürettiğini, qwen3:4b'nin yedi kelimelik soruya 8.000 token düşünüp boş döndüğünü ölçüyor, rezervasyonlu kullanıcı ve tenant bütçesi, timeout ve state hash'li bir loop guard kuruyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ve ASP.NET Core 10 ile derlenip denenmiştir; Microsoft.Extensions.AI 11.0.0, OllamaSharp 5.5.2 ve yerel modelde Ollama 0.34.4 (gemma3:4b, qwen3:4b) kullanılmıştır. Fiyat hesapları Anthropic'in fiyat sayfasından (14 Kasım 2026) alınmış örneklerdir; bu yazıda bulut sağlayıcıyla koşulmadı.
Önce Tahmin Et
Bookstore.Assistant'a yeni bir özellik geldi: kullanıcı bir dokümanı sohbete yapıştırıp soru sorabiliyor. Biri 120 bin karakterlik bir metin yapıştırıyor; metnin ilk satırı iade süresini söylüyor, gerisi kargo SSS'si (Sıkça Sorulan Sorular). Soru: "Kitabı teslimattan sonra kaç gün içinde iade edebilirim?" Asistan bu isteği yerel gemma3:4b ile işliyor ve hiçbir limit yok. Ne olur: bir hata mı alırsın, uzun bir bekleme mi, yoksa yanlış bir cevap mı?
İkinci tahmin: aynı asistanda qwen3:4b'ye yedi kelimelik bir soru soruluyor, "1000003 asal mı? Yalnız evet ya da hayır de." Cevap tek kelime. Bu soru kaç output token'a mal olur?
Bu yazı LLM06 Unbounded Consumption'ı ele alıyor. OWASP (web uygulama güvenliği üzerine çalışan açık topluluk) LLM (büyük dil modeli) Top 10 2026'da madde dört sıra yükselip onuncudan altıncıya çıktı. Dokümanın tanımında maddenin merkezinde bir asimetri var: saldırgan kendisine neredeyse hiçbir maliyeti olmayan bir istekle senin tarafında orantısız pahalı bir hesaplama tetikleyebiliyor. Hedef bazen servisin erişilebilirliği, bazen fatura (denial of wallet), bazen de modelin kendisi.
LLM06 2026'da Neyi Kapsıyor?
2026 sürümü maddeyi klasik "çok istek gönderme" senaryosunun ötesine taşıyor. Dokümanın saydığı risklerden Bookstore'u doğrudan ilgilendirenler şunlar: değişken uzunlukta input seli ve output patlaması, denial of wallet, context penceresinin sınırına yakın isteklerle yapılan büyük context istismarı, reasoning modellerini uzun ya da bitmeyen düşünme döngülerine sokan kısa ve masum görünen prompt'lar, tool çağrılarının birbirini tetikleyerek çoğalması ve model extraction. Önlem tarafında da vurgu değişiyor: dokümana göre yalnız istek sayısını sınırlamak artık yeterli değil; token farkındalığı olan maliyet kontrolü, aşılamayan harcama tavanları, agent seviyesinde devre kesiciler ve sürekli maliyet izleme gerekiyor.
.NET ile Yapay Zeka serisinin 7. yazısında maliyetin üç temel katmanını kurmuştuk: DistributedCachingChatClient ile cache, kullanıcı başına rate limiting ve UsageDetails ile günlük token bütçesi. Bu yazı onların üstüne oturuyor ve bir saldırganın ya da dikkatsiz bir istemcinin o katmanların arasından nasıl geçebildiğine bakıyor. API tarafındaki genel karşılığı, yani API4 Unrestricted Resource Consumption, API Güvenliği serisinin 5. yazısındaydı; sayfa boyutu, timeout ve kota fikirleri burada da geçerli, ama LLM'de birim istek değil token.
Önce Ölç: Büyük Context Sessizce Kırpılıyor
Açılıştaki tahmini ölçtüm. İade süresi dokümanın ilk satırında, ardından hedef boyuta ulaşana kadar kargo SSS'si geliyor; soru en sonda:
IChatClient gemma = new OllamaApiClient(http, "gemma3:4b");
foreach (int size in args[1].Split(',').Select(int.Parse))
{
string document = PolicyDocument.Build(size);
ChatOptions options = new() { MaxOutputTokens = 60, Temperature = 0 };
if (numCtx is int ctx)
{
options.AddOllamaOption(OllamaOption.NumCtx, ctx);
}
Stopwatch sw = Stopwatch.StartNew();
ChatResponse response = await gemma.GetResponseAsync($"{document}\n\n{PolicyDocument.Question}", options);
bool correct = response.Text.Contains("14");
Console.WriteLine(
$"chars {document.Length,7} | input tokens {response.Usage?.InputTokenCount,6} | {sw.Elapsed.TotalSeconds,5:F1} s | correct {(correct ? "yes" : "no "),-3} | {response.Text.ReplaceLineEndings(" ").Trim()[..Math.Min(60, response.Text.Trim().Length)]}");
}
$ dotnet run -- context 2000,10000,40000,120000
chars 2067 | input tokens 666 | 3.0 s | correct yes | According to the document, books can be returned within **14
chars 10066 | input tokens 3204 | 3.4 s | correct yes | According to the document, books can be returned within **14
chars 40082 | input tokens 2051 | 2.5 s | correct no | The document doesn't provide information about return delive
chars 120051 | input tokens 2051 | 2.5 s | correct no | The document doesn't provide information about returns or de
# Ollama sunucusunun log'u
level=WARN source=llama_server.go:320 msg="truncating input prompt" limit=2051 prompt=12920 keep=5 new=2051
level=WARN source=llama_server.go:320 msg="truncating input prompt" limit=2051 prompt=38980 keep=5 new=2051
$ dotnet run -- context 40000,120000 40960
chars 40082 | input tokens 12920 | 14.2 s | correct yes | A book can be returned within 14 days of delivery.
chars 120051 | input tokens 38980 | 34.1 s | correct yes | A book can be returned within 14 days of delivery.
Hata yok. 40 bin ve 120 bin karakterlik istekler 2,5 saniyede, başarıyla dönüyor ve ikisi de yanlış cevap veriyor. Usage alanı 2.051 input token diyor; oysa Ollama'nın log'u prompt'un 12.920 ve 38.980 token olduğunu ve 2.051'e kırpıldığını yazıyor. Bu makinede ve bu Ollama sürümünde gemma3:4b 4.096 token'lık bir context'le yüklendi (varsayılan değer sürüme ve donanıma göre değişebiliyor); sığmayan prompt'un baş tarafı atılıyor ve iade süresi tam o kısımda. Geriye yalnız WARN seviyesinde bir sunucu log satırı kalıyor; istemcinin aldığı HTTP yanıtı 200. Bu yüzden context boyutu bir maliyet ayarı olduğu kadar bir doğruluk ayarı. Senin uygulamanın bunu bilmesi gerekiyor, sunucu log'una bakan birinin değil.
Context'i 40.960 token'a çıkarınca cevap doğru, ama bedeli görünür hâle geliyor: 120 bin karakter 38.980 input token ve 34 saniye. Bu metin için ölçülen oran token başına yaklaşık 3,1 karakter. Aynı isteği bulutta düşünelim. Anthropic'in fiyat sayfası 14 Kasım 2026 itibarıyla Claude Sonnet 5.5 için milyon input token başına 2 dolar yazıyor; 38.980 token tek istekte yaklaşık 0,08 dolar eder. Küçük görünüyor. Ama doküman sohbet geçmişinde kalırsa her yeni turda yeniden gönderiliyor: 20 turluk bir sohbet yaklaşık 780 bin input token ve 1,56 dolar. Günde bin sohbet açan bir bot ayda kırk bin doları geçen bir fatura üretir; hiçbir istek tek başına rate limit'e takılmadan. 2026 dokümanı da tam bu senaryoyu anlatıyor: context'i yavaş yavaş büyüyen agent oturumlarında her turun maliyeti tırmanıyor ama tek tek istekler bütçenin içinde kalıyor.
Input Sınırları: Byte, Karakter ve Sohbet Boyutu
İlk savunma, pahalı bir şey olmadan önce reddetmek. Limitleri tek bir record'da topladım; token tahmini ölçtüğümüz orandan biraz daha kötümser:
public sealed record AssistantLimits
{
public int MaxMessageChars { get; init; } = 8_000;
public int MaxConversationTokens { get; init; } = 6_000;
public int MaxOutputTokens { get; init; } = 400;
public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(30);
// Ölçülen oran ~3,1 karakter/token; tahmin bilerek kötümser
public static int EstimateTokens(string text) => (int)Math.Ceiling(text.Length / 3.0);
}
Endpoint üç kontrolü sırayla uyguluyor: gövde boyutu Kestrel'de (model binding'e bile girmeden), mesaj uzunluğu, ve sohbetin her turda modele göndereceği tahmini token sayısı. Model çağrısı, istemcinin bağlantısına ve bir duvar saati süresine bağlı bir CancellationToken ile yapılıyor:
public static class AssistantEndpoint
{
private static readonly ConcurrentDictionary<string, List<ChatMessage>> Conversations = new();
public static void MapAssistant(this WebApplication app)
{
app.MapPost("/api/assistant/conversations/{id}/messages", HandleAsync)
.WithMetadata(new RequestSizeLimitAttribute(32_768)); // gövde 32 KB'tan büyükse model çağrılmadan 413
}
private static async Task<IResult> HandleAsync(
string id, MessageRequest body, HttpContext http, IChatClient model, TokenLedger ledger, AssistantLimits limits, ILogger<MessageRequest> logger)
{
// Demo: kimlik header'dan; gerçek uygulamada token claim'lerinden gelir
Caller caller = new(http.Request.Headers["X-Demo-Tenant"].ToString(), http.Request.Headers["X-Demo-User"].ToString());
if (body.Message.Length > limits.MaxMessageChars)
{
return Results.Problem(statusCode: 400, title: "Message too long",
detail: $"Messages are limited to {limits.MaxMessageChars} characters. Upload long documents as files instead.");
}
List<ChatMessage> history = Conversations.GetOrAdd(id, _ => [new(ChatRole.System, "You are the Bookstore assistant. Answer briefly.")]);
ChatMessage user = new(ChatRole.User, body.Message);
int conversationTokens = history.Sum(m => AssistantLimits.EstimateTokens(m.Text)) + AssistantLimits.EstimateTokens(user.Text);
if (conversationTokens > limits.MaxConversationTokens)
{
return Results.Problem(statusCode: 422, title: "Conversation too long",
detail: $"This conversation would send about {conversationTokens} tokens per turn. Start a new conversation.");
}
// İstemci bağlantıyı kapatırsa ya da süre dolarsa model çağrısı da iptal edilir
using CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(http.RequestAborted);
cts.CancelAfter(limits.RequestTimeout);
Stopwatch sw = Stopwatch.StartNew();
try
{
IChatClient chat = new BudgetChatClient(model, ledger, caller);
ChatResponse response = await chat.GetResponseAsync(
[.. history, user], new ChatOptions { MaxOutputTokens = limits.MaxOutputTokens }, cts.Token);
history.Add(user);
history.AddRange(response.Messages);
return Results.Ok(new
{
answer = response.Text,
inputTokens = response.Usage?.InputTokenCount,
outputTokens = response.Usage?.OutputTokenCount,
finishReason = response.FinishReason?.Value,
});
}
catch (BudgetExceededException ex)
{
http.Response.Headers.RetryAfter = ((int)ex.RetryAfter.TotalSeconds).ToString();
return Results.Problem(statusCode: 429, title: "Assistant budget exhausted", detail: ex.Message);
}
catch (OperationCanceledException) when (http.RequestAborted.IsCancellationRequested)
{
logger.LogInformation("Client disconnected; model call cancelled after {Seconds:F1} s", sw.Elapsed.TotalSeconds);
return Results.Empty;
}
catch (OperationCanceledException)
{
logger.LogWarning("Model call timed out after {Seconds:F1} s", sw.Elapsed.TotalSeconds);
return Results.Problem(statusCode: 504, title: "The assistant took too long", detail: "Please try again with a shorter question.");
}
}
}
# 40 000 karakterlik mesaj
$ curl -i -X POST .../conversations/c2/messages --data-binary @big.json
HTTP/1.1 413 Payload Too Large
# 9 000 karakterlik mesaj
$ curl -i -X POST .../conversations/c2/messages --data-binary @mid.json
HTTP/1.1 400 Bad Request
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Message too long","status":400,"detail":"Messages are limited to 8000 characters. Upload long documents as files instead."}
# 6 052 karakterlik mesaj, aynı sohbette iki kez
$ curl -X POST .../conversations/long/messages --data-binary @faq.json
{"answer":"We ship nationwide! ...","inputTokens":1930,"outputTokens":29,"finishReason":"stop"}
$ curl -X POST .../conversations/long/messages --data-binary @faq.json
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Conversation too long","status":422,"detail":"This conversation would send about 4094 tokens per turn. Start a new conversation."}
40 bin karakterlik gövde 413 ile Kestrel'de kaldı, 9 bin karakterlik mesaj 400 aldı; ikisi de modele ulaşmadı. Üçüncü durum farklı: tek başına makul olan 6 bin karakterlik mesaj, aynı sohbette ikinci kez gelince 422 aldı, çünkü geçmişle birlikte her tur yaklaşık 4.094 token gönderecekti. Sohbet boyutu limiti, yukarıdaki "her turda yeniden gönderilen doküman" faturasının çözümü. Uzun sohbetleri reddetmek yerine eski turların özetini çıkarmak da bir seçenek; chat reducer'ları .NET ile Yapay Zeka serisinin 5. yazısında görmüştük. Uzun dokümanlar için de doğru yer sohbet mesajı değil, boyut limiti, sayfa sayısı limiti ve tarama hattı olan bir dosya yükleme akışı.
Hata mesajları da bir tasarım kararı. "Message too long" demek yeterli; limitin tam değerini söylemek kullanıcıya yardım eder ama saldırgana da en büyük geçerli isteğin boyutunu verir. Bookstore'da karakter limiti zaten UI'da görünür, bu yüzden mesajda yazmasında sakınca yok; sohbet limitinde ise yaklaşık bir sayı veriyoruz.
Zaman Sınırı: Timeout ve Kopan Bağlantı
Token limiti maliyeti sınırlar ama süreyi sınırlamaz; yavaş bir model, kuyruğa girmiş bir GPU ya da bitmeyen bir reasoning, bir istek thread'ini dakikalarca meşgul edebilir. Endpoint'teki linked token iki durumu yakalıyor:
# istemci 2 saniye sonra bağlantıyı kapatıyor
$ curl --max-time 2 -X POST .../conversations/d1/messages -d '{"message":"Write a detailed 2000-word essay about the history of the printing press."}'
curl exit 28
info: Bookstore.Assistant.Consumption.MessageRequest[0]
Client disconnected; model call cancelled after 2.0 s
# Ollama tarafı
srv stop: cancel task, id_task = 368
slot release: id 0 | task 368 | stop processing: n_tokens = 119, truncated = 0
# Assistant__RequestTimeout=00:00:03 ile aynı istek
$ curl -X POST .../conversations/t1/messages -d '{"message":"Write a detailed 2000-word essay about the history of the printing press."}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.5","title":"The assistant took too long","status":504,"detail":"Please try again with a shorter question."}
warn: Bookstore.Assistant.Consumption.MessageRequest[0]
Model call timed out after 3.0 s
İstemci iki saniye sonra vazgeçince RequestAborted tetiklendi, model çağrısı iptal edildi ve Ollama tarafında da üretim durdu: 44 token'lık prompt'a yalnız 75 token üretilip görev kapatıldı. Bu bağlantı kurulmasaydı model, kimsenin okumayacağı bir cevabı 400 token boyunca üretmeye devam ederdi; bulutta bu output token'lar faturaya yazılır. Duvar saati sınırı aşılınca da istemci 504 aldı. İptal edilen isteğin bütçe rezervasyonu geri verilmiyor; bu bilinçli bir tercih, çünkü bulut sağlayıcı iptal anına kadar üretilen token'ları zaten ücretlendiriyor.
Output Sınırı ve Reasoning Token'ları
İkinci tahmine geçelim. qwen3:4b'ye aynı soruyu üç ayarla sordum:
IChatClient qwen = new OllamaApiClient(http, "qwen3:4b");
string question = "Is 1000003 a prime number? Answer only yes or no.";
(string Label, ChatOptions Options)[] variants =
[
("thinking off", new() { Reasoning = new ReasoningOptions { Effort = ReasoningEffort.None }, MaxOutputTokens = 2000 }),
("thinking on", new() { Reasoning = new ReasoningOptions { Effort = ReasoningEffort.Medium }, MaxOutputTokens = 8000 }),
("thinking on, cap 256", new() { Reasoning = new ReasoningOptions { Effort = ReasoningEffort.Medium }, MaxOutputTokens = 256 }),
];
foreach ((string label, ChatOptions options) in variants)
{
Stopwatch sw = Stopwatch.StartNew();
ChatResponse response = await qwen.GetResponseAsync(question, options);
int reasoningChars = response.Messages.SelectMany(m => m.Contents).OfType<TextReasoningContent>().Sum(c => c.Text.Length);
Console.WriteLine(
$"{label,-21} | output tokens {response.Usage?.OutputTokenCount,5} | reasoning chars {reasoningChars,6} | {sw.Elapsed.TotalSeconds,5:F1} s | finish {response.FinishReason?.Value,-6} | answer: \"{response.Text.Trim()}\"");
}
$ dotnet run -- think
thinking off | output tokens 2000 | reasoning chars 0 | 58.4 s | finish length | answer: "Okay, let's see. I need to figure out if 1000003 is a ..."
thinking on | output tokens 8000 | reasoning chars 11060 | 216.8 s | finish length | answer: ""
thinking on, cap 256 | output tokens 256 | reasoning chars 626 | 5.9 s | finish length | answer: ""
Üç satır da öğretici. Düşünme açıkken model 8.000 token'ın tamamını düşünmeye harcadı, 217 saniye sürdü ve cevap boş döndü; yedi kelimelik, tek kelimelik cevabı olan bir soru için. 2026 dokümanının "reasoning-loop" riski tam olarak bu: kısa ve masum görünen bir prompt, input filtrelerinin hiçbirine takılmadan büyük bir düşünme bütçesi tüketiyor. Bulutta Sonnet 5.5'in output fiyatıyla (milyon token başına 10 dolar) bu tek soru 0,08 dolar.
"Düşünmeyi kapat" satırı daha da şaşırtıcı. ReasoningEffort.None ile düşünme kapanmadı; aynı isteği doğrudan Ollama API'sine think: false ile gönderdiğimde de sonuç değişmedi. Model akıl yürütmesini cevap metnine yazdı ve 2.000 token'lık sınıra takıldı. Yani "reasoning kapalı" ayarına güvenip output sınırını gevşetmek, maliyeti yalnız başka bir alana taşır. Güvenilir olan tek kontrol MaxOutputTokens: 256'lık sınırla aynı istek 5,9 saniyede bitti. Cevap yine boş, ama bu kez bedeli belli ve küçük. Pratik sonuç şu: reasoning modelini yalnız gerçekten gereken iş akışlarında kullan, her istekte bir output tavanı koy ve FinishReason değeri length olan cevapları kullanıcıya boş baloncuk olarak değil, anlamlı bir mesajla göster.
Bütçe: Önce Rezervasyon, Sonra Mutabakat
.NET ile Yapay Zeka serisinin 7. yazısındaki günlük bütçe cevaptan sonra gerçek kullanımı topluyordu. Saldırı senaryosunda bunun bir açığı var: aynı anda gelen yüz istek, hiçbiri henüz bitmediği için bütçeyi aşmış görünmez. 2026 dokümanı da harcama tavanlarının uyarı değil, çıkarımı durduran bir zorlama olması gerektiğini ve isteği çıkarım başlamadan önceden tahminle reddetmeyi öneriyor. Bookstore'da bunu iki seviyeli bir hesapla yaptım: kullanıcı başına günlük, tenant başına aylık sınır. Çağrıdan önce en kötü durum (input tahmini artı izin verilen en fazla output) rezerve ediliyor, cevap gelince gerçek kullanımla değiştiriliyor:
// Kullanıcı başına günlük, tenant başına aylık sert sınır; çağrıdan ÖNCE rezervasyon
public sealed class TokenLedger(TimeProvider time, ILogger<TokenLedger> logger, long userDailyLimit, long tenantMonthlyLimit)
{
private static readonly Meter Meter = new("Bookstore.Assistant");
private static readonly Counter<long> Tokens = Meter.CreateCounter<long>("bookstore.assistant.tokens", "{token}");
private static readonly Counter<long> Rejections = Meter.CreateCounter<long>("bookstore.assistant.budget.rejections", "{request}");
private readonly ConcurrentDictionary<string, long> _used = new();
private readonly ConcurrentDictionary<string, bool> _alerted = new();
private readonly Lock _gate = new();
public long Reserve(Caller caller, long estimate)
{
DateTimeOffset now = time.GetUtcNow();
string userKey = $"user:{caller.UserId}:{now:yyyy-MM-dd}";
string tenantKey = $"tenant:{caller.TenantId}:{now:yyyy-MM}";
lock (_gate)
{
if (_used.GetValueOrDefault(userKey) + estimate > userDailyLimit)
{
Reject(caller, "user");
throw new BudgetExceededException($"user {caller.UserId}", now.UtcDateTime.Date.AddDays(1) - now.UtcDateTime);
}
if (_used.GetValueOrDefault(tenantKey) + estimate > tenantMonthlyLimit)
{
Reject(caller, "tenant");
throw new BudgetExceededException($"tenant {caller.TenantId}", TimeSpan.FromHours(1));
}
_used[userKey] = _used.GetValueOrDefault(userKey) + estimate;
_used[tenantKey] = _used.GetValueOrDefault(tenantKey) + estimate;
}
return estimate;
}
// Cevap gelince rezervasyon gerçek kullanımla değiştirilir
public void Settle(Caller caller, long reserved, long input, long output)
{
DateTimeOffset now = time.GetUtcNow();
string tenantKey = $"tenant:{caller.TenantId}:{now:yyyy-MM}";
long delta = input + output - reserved;
lock (_gate)
{
_used.AddOrUpdate($"user:{caller.UserId}:{now:yyyy-MM-dd}", delta, (_, v) => v + delta);
_used.AddOrUpdate(tenantKey, delta, (_, v) => v + delta);
}
Tokens.Add(input, new("tenant", caller.TenantId), new("user", caller.UserId), new("direction", "input"));
Tokens.Add(output, new("tenant", caller.TenantId), new("user", caller.UserId), new("direction", "output"));
// Alarm sınırı: %80'de bir kez uyar, sınırın kendisi sert
long tenantUsed = _used[tenantKey];
if (tenantUsed >= tenantMonthlyLimit * 0.8 && _alerted.TryAdd(tenantKey, true))
{
logger.LogWarning("Tenant {TenantId} used {Used} of {Limit} tokens this month", caller.TenantId, tenantUsed, tenantMonthlyLimit);
}
}
public long Used(string key) => _used.GetValueOrDefault(key);
private void Reject(Caller caller, string scope)
{
Rejections.Add(1, new("tenant", caller.TenantId), new("scope", scope));
logger.LogWarning("Budget rejection for {Scope}: tenant {TenantId}, user {UserId}", scope, caller.TenantId, caller.UserId);
}
}
// Her model çağrısı önce bütçeden düşer; tahmin = input tahmini + izin verilen en fazla output
public sealed class BudgetChatClient(IChatClient inner, TokenLedger ledger, Caller caller) : DelegatingChatClient(inner)
{
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
List<ChatMessage> list = messages as List<ChatMessage> ?? [.. messages];
int maxOutput = options?.MaxOutputTokens
?? throw new InvalidOperationException("MaxOutputTokens must be set; an unbounded output cannot be budgeted.");
long estimate = list.Sum(m => AssistantLimits.EstimateTokens(m.Text)) + maxOutput;
long reserved = ledger.Reserve(caller, estimate);
ChatResponse response = await base.GetResponseAsync(list, options, cancellationToken);
ledger.Settle(caller, reserved, response.Usage?.InputTokenCount ?? estimate, response.Usage?.OutputTokenCount ?? maxOutput);
return response;
}
public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
throw new NotSupportedException("Streaming is not budgeted in this sample.");
}
MaxOutputTokens belirtilmemiş bir çağrı bütçelenemez; client bunu bir yapılandırma hatası sayıp çağrıyı hiç yapmıyor. Aynı tenant'tan üç kullanıcı, her biri 6 bin karakterlik bir istek gönderiyor:
# Budget__UserDailyTokens=6000, Budget__TenantMonthlyTokens=9000; her istek 6 052 karakter
ada r1 -> 200 retry-after= {"inputTokens": 1930, "outputTokens": 39}
ada r2 -> 200 retry-after= {"inputTokens": 1930, "outputTokens": 38}
ada r3 -> 429 retry-after=4558 {"detail": "Token budget exhausted for user ada."}
grace r4 -> 200 retry-after= {"inputTokens": 1930, "outputTokens": 38}
grace r5 -> 200 retry-after= {"inputTokens": 1930, "outputTokens": 42}
linus r6 -> 429 retry-after=3600 {"detail": "Token budget exhausted for tenant acme."}
# sunucu konsolu
metric bookstore.assistant.tokens +1930 tenant=acme user=ada direction=input
metric bookstore.assistant.tokens +39 tenant=acme user=ada direction=output
metric bookstore.assistant.tokens +1930 tenant=acme user=ada direction=input
metric bookstore.assistant.tokens +38 tenant=acme user=ada direction=output
metric bookstore.assistant.budget.rejections +1 tenant=acme scope=user
warn: Bookstore.Assistant.Consumption.TokenLedger[0]
Budget rejection for user: tenant acme, user ada
metric bookstore.assistant.tokens +1930 tenant=acme user=grace direction=input
metric bookstore.assistant.tokens +38 tenant=acme user=grace direction=output
metric bookstore.assistant.tokens +1930 tenant=acme user=grace direction=input
metric bookstore.assistant.tokens +42 tenant=acme user=grace direction=output
warn: Bookstore.Assistant.Consumption.TokenLedger[0]
Tenant acme used 7877 of 9000 tokens this month
metric bookstore.assistant.budget.rejections +1 tenant=acme scope=tenant
warn: Bookstore.Assistant.Consumption.TokenLedger[0]
Budget rejection for tenant: tenant acme, user linus
Ada'nın üçüncü isteği, o ana kadar yalnız 3.937 token kullanmış olmasına rağmen reddedildi; çünkü rezervasyon en kötü durumu sayıyor ve yaklaşık 2.430 token'lık tahminle toplam günlük 6.000 sınırını aşıyordu. Bu kötümserlik bilinçli: rezervasyon gerçek kullanımdan büyük olduğu için aynı anda gelen istekler sınırı aşamaz. Grace'in ikinci isteğinden sonra tenant aylık sınırının yüzde 80'ini geçti ve bir kez uyarı düştü; Linus ise kendi günlük bütçesi bomboşken tenant sınırına takıldı. Retry-After değeri de kapsama göre farklı: kullanıcı için UTC (Coordinated Universal Time) gece yarısına kalan süre, tenant için bir saat. Production'da _used sözlüğü Redis gibi paylaşılan bir store'a taşınır; atomik artırma ve TTL (verinin geçerli kalacağı süre) tam bu iş için var.
Metrikler alarmın hammaddesi. bookstore.assistant.tokens sayacı tenant, kullanıcı ve yön etiketleriyle OpenTelemetry üzerinden dışarı çıktığında (GenAI telemetry'sini .NET ile Yapay Zeka serisinin 8. yazısında kurmuştuk) iki basit kural çok şey yakalar: bir kullanıcının son bir saatteki token tüketimi kendi 30 günlük medyanının birkaç katına çıktığında, ve reddedilen istek oranı birden yükseldiğinde. Kullanıcı etiketinin kardinalitesine dikkat: on binlerce kullanıcı varsa kullanıcı bazlı metrik yerine en çok tüketen ilk N kullanıcıyı log'dan çıkarmak daha ucuz.
Agent Döngüsü: State Hash ile Devre Kesici
4. yazıda agent loop'una iterasyon sınırı, istek başına toplam tool çağrısı bütçesi ve duvar saati süresi koymuştuk. 2026 dokümanı bunların yanına bir şey daha öneriyor: döngüyü durum hash'iyle tespit etmek. Fikir basit; aynı tool, aynı argümanlarla çağrılıp aynı sonucu döndürüyorsa ilerleme yok demektir. Bookstore'un kargo entegrasyonu bunun gerçekçi bir kaynağı: firmanın API'si durum güncellenene kadar "tekrar sor" diyor.
// Kargo firmasının entegrasyonu: hep "tekrar dene" diyen bir sonuç döndürüyor
public sealed class ShippingTools
{
[Description("Returns the shipment status of one of the current customer's orders.")]
public object GetShipmentStatus(int orderId) => new
{
orderId,
status = "pending",
message = "Status is not final yet. Call get_shipment_status again with the same orderId before answering the customer.",
};
}
// Aynı tool, aynı argüman, aynı sonuç: ilerleme yok demektir; döngüyü state hash'iyle yakala
public sealed class LoopGuard(int maxRepeats)
{
private readonly Dictionary<string, int> _seen = [];
public int Calls { get; private set; }
public async ValueTask<object?> InvokeAsync(FunctionInvocationContext context, CancellationToken ct)
{
Calls++;
object? result = await context.Function.InvokeAsync(context.Arguments, ct);
string state = Hash($"{context.Function.Name}|{JsonSerializer.Serialize(context.Arguments)}|{JsonSerializer.Serialize(result)}");
int count = _seen[state] = _seen.GetValueOrDefault(state) + 1;
Console.WriteLine($" call {Calls,2}: {context.Function.Name}({JsonSerializer.Serialize(context.Arguments)}) state {state[..8]} seen {count}x");
if (count > maxRepeats)
{
context.Terminate = true;
return new { error = "Stopped: the same call returned the same result repeatedly." };
}
return result;
}
private static string Hash(string text) => Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
}
Önce gerçek modelle denedim. qwen3:4b iki ayrı mesaj metniyle de, tool sonucu açıkça "tekrar çağır" dediği hâlde, tek çağrıdan sonra müşteriye "durum henüz kesinleşmedi" diye cevap verdi. İyi davranış; ama bir modelin bugün iyi davranması yarın aynı davranacağı anlamına gelmiyor, özellikle 6. yazıda gördüğümüz gibi model artifact'ı sessizce değişebiliyorsa. Guard'ın çalıştığını görmek için döngüyü .NET ile Yapay Zeka serisinin 10. yazısındaki gibi sahte bir modelle ürettim: her turda aynı tool çağrısını isteyen bir IChatClient.
foreach (bool withGuard in new[] { false, true })
{
FakeLoopingChatClient fake = new();
LoopGuard loopGuard = new(maxRepeats: withGuard ? 2 : int.MaxValue);
ShippingTools tools = new();
IChatClient looping = fake.AsBuilder()
.UseFunctionInvocation(configure: f => f.FunctionInvoker = loopGuard.InvokeAsync)
.Build();
ChatResponse r = await looping.GetResponseAsync("Where is my order 1042?",
new ChatOptions { Tools = [AIFunctionFactory.Create(tools.GetShipmentStatus, "get_shipment_status")] });
Console.WriteLine($"guard {(withGuard ? "on " : "off")}: model calls {fake.ModelCalls}, tool calls {loopGuard.Calls}");
}
$ dotnet run -- loop
call 1: get_shipment_status({"orderId":1042}) state fcc936a3 seen 1x
tool calls 1 | tokens in 602 out 1065 | 24.8 s
answer: Your order 1042 is currently **pending**. The status is not final yet, so please check again later for updates.
$ dotnet run -- loop-fake
guard off: model calls 41, tool calls 40
guard on : model calls 3, tool calls 3
Guard yokken FunctionInvokingChatClient 40 tool çağrısı ve 41 model çağrısı yaptı. 40, MaximumIterationsPerRequest'in varsayılan değeri; yani hiçbir ayar yapmamış bir uygulamada tek bir kullanıcı mesajı en fazla 41 model çağrısına dönüşebilir. Guard aynı durumu üçüncü kez görünce Terminate ile döngüyü kesti. Sonucu da hash'e kattım, çünkü aynı tool'u aynı argümanla tekrar çağırmak her zaman hata değil: kargo durumu gerçekten değiştiyse yeni sonuç yeni bir durumdur ve guard karışmaz. Production'da bu guard, 4. yazıdaki ToolBudget ile aynı FunctionInvoker delegate'inde birleşir.
Pahalı Yol ve Model Extraction
İki risk daha var ve ikisinin de çaresi kodda değil, tasarımda. Birincisi pahalı modele yönlendirme. Bazı uygulamalar istemcinin hangi modeli kullanacağını seçmesine izin veriyor ya da "zor soru" algılayınca büyük modele yönlendiriyor. İstemciden gelen bir model adı, ChatOptions.ModelId dahil, kullanıcı input'u gibi ele alınmalı: sunucu izinli bir listeden, kullanıcının planına göre seçer. Router'ın kendisi de bir saldırı yüzeyi; "bu soru çok karmaşık" dedirten bir metin, kullanıcıyı ucuz modelden pahalı reasoning modeline taşıyabilir. Router kararını da bütçeye bağla: pahalı yolun kendi, daha küçük kotası olsun.
İkincisi model extraction. Saldırgan API'ne çok sayıda, dikkatle seçilmiş sorgu gönderip cevapları toplayarak senin ince ayarlı modeline işlevsel olarak benzeyen bir model eğitebilir. Doküman, log-probability'leri (logprobs) dışarı açmanın bu işi belirgin şekilde hızlandırdığını not ediyor; Bookstore'un API'si bu yüzden logprob döndürmüyor. Geri kalan önlemler bu yazının zaten kurduğu şeyler: kullanıcı başına kota, anomali metrikleri ve hesap başına tüketim izleme. Madalyonun öbür yüzü ticari koşullar: Anthropic'in ticari koşulları, örneğin, servisi rakip bir ürün ya da rakip bir AI modeli eğitmek için kullanmayı yasaklıyor. Kendi API'ni dışarı açıyorsan aynı türde bir maddeyi kendi kullanım koşullarına koymak, teknik önlemlerin yetmediği yerde hukuki bir dayanak verir (bu bir hukuki tavsiye değil).
Üretimde Dikkat
Context boyutunu bilinçli ayarla ve ölç. Ollama'da num_ctx, bulutta modelin context penceresi. Usage'daki input token sayısını gönderdiğin metnin tahminiyle karşılaştıran bir kontrol, sessiz kırpmayı yakalar: tahmin 12 bin, usage 2 bin diyorsa cevap büyük ihtimalle yanlıştır.
Her limitin bir metriği olsun. 413, 400 ve 422 yanıtları, timeout'lar, kopan bağlantılar, length ile biten cevaplar, bütçe reddi ve loop guard'ın kestiği istekler ayrı ayrı sayılmalı. Bir sabah hepsi birden yükselirse sistemin yoklandığını bilirsin; yalnız biri yükselirse büyük ihtimalle bir istemci hatasıdır.
Sınırları plana bağla, koda değil. Ücretsiz kullanıcı, ücretli kullanıcı ve kurumsal tenant için farklı token sınırları ve farklı modeller olur; bu değerler yapılandırmada ve tenant kaydında durur, yeniden deploy etmeden değişir.
Graceful degradation planla. Tenant bütçesi bittiğinde asistanı tamamen kapatmak yerine cache'li cevaplara, daha küçük bir modele ya da yalnız arama sonucunu gösteren bir moda düşmek, hem kullanıcıyı hem faturayı korur.
Sık Düşülen Tuzaklar
Birincisi, istek sayısını sınırlayıp token'ı unutmak. Dakikada beş istek limiti, beş tane 120 bin karakterlik isteği memnuniyetle kabul eder. LLM'de maliyetin birimi token; rate limit token üzerinden de olmalı.
İkincisi, bütçeyi cevap geldikten sonra kontrol etmek. Eşzamanlı istekler o kontrolü hep birlikte geçer. Rezervasyon önce, mutabakat sonra.
Üçüncüsü, reasoning ayarına güvenip MaxOutputTokens koymamak. Yukarıdaki ölçümde "kapalı" düşünme 2.000 token, açık düşünme 8.000 token harcadı ve ikisi de işe yarar bir cevap üretmedi.
Dördüncüsü, iptali model çağrısına taşımamak. HttpContext.RequestAborted'ı model çağrısına geçirmeyen bir endpoint, kullanıcı sayfayı kapattıktan sonra da üretim için para öder.
Beşincisi, hatayı sessiz başarıya çevirmek. Kırpılan context 200 döndü ve yanlış cevap verdi; boş reasoning cevabı da 200 döndü. Bir sınıra takılan her cevap, kullanıcıya ve log'a sınıra takıldığını söylemeli.
Küçük Bir Deneme
★☆☆ Context deneyini iade süresini dokümanın sonuna taşıyarak tekrarla. Kırpılmış context'te cevap doğru çıkıyor mu? Bu sonuç sessiz kırpmayı daha mı az, yoksa daha mı tehlikeli yapar?
★★☆ BudgetChatClient'a streaming desteği ekle: rezervasyon yine çağrıdan önce, mutabakat son ChatResponseUpdate'teki UsageContent ile yapılsın. İstemci akışın ortasında koparsa ne kadar kullanım yazılmalı?
★★★ TokenLedger'ı Redis'e taşı: rezervasyonu bir Lua script'iyle atomik yap, anahtarlara TTL koy ve iki uygulama örneğinden aynı anda gelen yirmi istekle tenant sınırının bir token bile aşılmadığını bir testle doğrula.
Girdiyi, yetkiyi, çıktıyı, tedarik zincirini ve maliyeti tek tek ele aldık. Bir sonraki yazıda hepsini bir araya getiriyoruz: guardrail'leri IChatClient pipeline'ına doğru sırayla yerleştirmek, fail-closed kararı, safety evaluator'ları ve bu seride kullandığımız zararsız canary'leri CI'da koşan bir red team regresyon setine dönüştürmek.