LLM Context Window: Model Neyi Hatırlar, Neyi Sessizce Keser?
LLM API'leri stateless'tır; sohbet geçmişini her istekte sen gönderirsin ve her tur faturayı büyütür. Gemma 3 128K token desteklese de Ollama varsayılan olarak 4096'lık bir pencere ayırıyor ve 4498 token'lık bir prompt'u hiçbir hata vermeden 2051 token'a indiriyor. num_ctx'i C# ve TypeScript'te ayarlıyor, geçmişi kırpma ve özetlemeyle yönetmeyi gerçek çıktılarla deniyoruz.
Sürüm notu: Bu yazıdaki örnekler Ollama 0.34 ile yerel modelde (gemma3:4b) denenmiştir; .NET 10 ve Node 22 kullanılmıştır.
Önce Tahmin Et
İki soru. Birincisi: modele bir istekte "Benim adım Ada" diyorsun, bir sonraki istekte "Adım ne?" diye soruyorsun. Model hatırlar mı? İkincisi: ollama show çıktısında Gemma 3'ün context length değeri 131072 yazıyor. Modele yaklaşık 4500 token'lık bir prompt gönderirsen, model prompt'un tamamını okur mu?
İlk sorunun cevabını çoğu geliştirici tahmin eder. İkincisi ise bu yazının asıl konusu, çünkü cevabı "hayır" ve Ollama bunu sana hiçbir hata vermeden yapıyor.
Model Hatırlamaz: API Stateless
LLM API'leri stateless'tır. Her istek kendi başına bir bütündür; sunucu senin bir önceki isteğini bir yere kaydetmez. Bunu C# ile deneyelim. Önce iki ayrı istek atıyoruz, sonra aynı konuşmayı geçmişi kendimiz tutarak tekrarlıyoruz:
using Microsoft.Extensions.AI;
using OllamaSharp;
IChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");
ChatOptions options = new() { Temperature = 0, Seed = 42 };
// 1) İki bağımsız istek: ikinci istek birinciden habersiz
await client.GetResponseAsync("My name is Ada. Reply in one short sentence.", options);
ChatResponse forgotten = await client.GetResponseAsync("What is my name? Reply in one short sentence.", options);
Console.WriteLine($"Without history: {forgotten.Text}");
Console.WriteLine();
// 2) Geçmişi biz tutuyoruz ve her istekte tamamını gönderiyoruz
List<ChatMessage> history = [];
string[] turns =
[
"My name is Ada. Reply in one short sentence.",
"What is my name? Reply in one short sentence.",
"Recommend one science fiction book for me. Reply in one short sentence.",
];
foreach (string turn in turns)
{
history.Add(new ChatMessage(ChatRole.User, turn));
ChatResponse response = await client.GetResponseAsync(history, options);
// Modelin cevabını da geçmişe ekliyoruz, yoksa bir sonraki turda kaybolur
history.AddMessages(response);
Console.WriteLine($"[input tokens: {response.Usage?.InputTokenCount}] {response.Text}");
}
Without history: I do not know your name, as I have no way of knowing personal information about you.
[input tokens: 21] Hello Ada, it’s nice to meet you!
[input tokens: 55] Your name is Ada.
[input tokens: 86] I recommend “Dune” by Frank Herbert for a sprawling, epic tale of politics, ecology, and adventure in a distant galaxy.
İlk denemede model adı bilmiyor, çünkü ikinci istekte ona söylenmiş bir ad yok. İkinci denemede biliyor, çünkü history listesini her seferinde baştan sona gönderiyoruz. AddMessages(response) modelin cevabını assistant rolüyle listeye ekliyor. Bu satırı unutursan model kendi verdiği cevapları hiç görmez.
Bunu web'den tanıyorsun: HTTP de stateless'tır ve oturum bilgisini sunucuda ya da cookie'de sen tutarsın. Burada da durum aynı. Sohbet geçmişi senin uygulamanın verisi; nerede saklayacağına, ne kadarını göndereceğine sen karar verirsin. Model her istekte işe sıfırdan başlayan bir danışman gibi: önüne koyduğun dosyayı okur, dosyada olmayanı bilmez.
Geçmişin Faturası
Çıktıdaki input token sayılarına bak: 21, 55, 86. Her turda model yalnızca yeni soruyu değil, bütün konuşmayı yeniden okuyor. Üçüncü yazıda gördüğümüz gibi faturayı ve gecikmeyi input token sayısı belirliyor. Yani on turluk bir sohbette son istek, ilk istekten kat kat pahalı ve sohbetin toplam maliyeti tur sayısıyla doğru orantılı değil, ondan daha hızlı büyüyor.
Bir de üst sınır var. Geçmiş her turda uzadığına göre bir noktada modelin tek seferde görebileceği alanı aşacak. O alanın adı context window.
Context Window Nedir?
Context window (bağlam penceresi), modelin tek bir istekte görebileceği en fazla token sayısıdır. Prompt'un, sohbet geçmişinin ve modelin üreteceği cevabın hepsi bu pencereye sığmak zorunda. Gemma 3'ün 4B, 12B ve 27B modelleri 128K token'a kadar input destekliyor; ollama show çıktısındaki 131072 bu.
Ama modelin destekleyebildiği ile Ollama'nın o an ayırdığı aynı şey değil. İkinci yazıdaki ollama ps çıktısını hatırla: CONTEXT sütununda 4096 yazıyordu. Ollama modeli yüklerken pencere için bellek ayırır ve varsayılan olarak bunu küçük tutar. Ollama dokümanına göre bu varsayılan GPU belleğine bağlı: 24 GiB'tan az VRAM'de 4K, 24–48 GiB arasında 32K, 48 GiB ve üstünde 256K. Bu yazıdaki denemeleri yaptığım makinede değer 4096. Yani 128K'lık bir model, kutudan çıktığı hâliyle 4K'lık bir pencereyle çalışıyor.
Sessiz Kesilme: 4096 Sınırı
Şimdi ikinci soruyu deneyelim. Prompt'un en başına bir mağaza kuralı koyuyoruz: üyelere özel indirim kodu. Arkasından 220 satırlık bir kitap kataloğu, en sona da soru geliyor. Aynı prompt'u bir kez varsayılan ayarla, bir kez de num_ctx: 8192 ile gönderiyoruz:
import ollama from "ollama";
const genres = ["fantasy", "history", "science fiction", "biography", "crime"];
const catalog = Array.from({ length: 220 }, (_, i) =>
`Book ${i + 1}: "Sample Title ${i + 1}", ${genres[i % genres.length]}, ${10 + (i % 30)} USD`);
// Bilgi en başta, soru en sonda
const prompt = [
"Store policy: the discount code for members is MOON-7731.",
"Catalog:",
...catalog,
"Question: What is the discount code for members? If you cannot find it, say so.",
].join("\n");
async function ask(numCtx?: number): Promise<void> {
const res = await ollama.chat({
model: "gemma3:4b",
messages: [{ role: "user", content: prompt }],
options: { temperature: 0, seed: 42, ...(numCtx ? { num_ctx: numCtx } : {}) },
});
console.log(`num_ctx=${numCtx ?? "default"} prompt_eval_count=${res.prompt_eval_count}`);
console.log(` ${res.message.content.trim()}`);
}
await ask();
await ask(8192);
num_ctx=default prompt_eval_count=2051
I cannot find any information about a discount code for members within the provided book list.
num_ctx=8192 prompt_eval_count=4498
The discount code for members is MOON-7731.
Prompt'un gerçek boyutu 4498 token; 8192'lik pencerede model hepsini okudu ve kodu buldu. Varsayılan pencerede ise prompt_eval_count 2051. Prompt pencereyi yalnızca 400 token aşmıştı, ama modelin gördüğü kısım yarıdan da aza indi. Üstelik model "bulamadım" dedi ve API hiçbir hata dönmedi. Uygulamanın gözünden bakınca her şey normal: HTTP 200, düzgün bir cevap.
Ne olduğunu yalnızca Ollama'nın sunucu log'u söylüyor:
time=2026-11-06T23:24:14.062+03:00 level=WARN source=llama_server.go:320 msg="truncating input prompt" limit=2051 prompt=4498 keep=5 new=2051
Ollama, pencereye sığmayan prompt'un baştaki birkaç token'ını (keep=5) tutmuş, gerisini sondan alarak prompt'u 2051 token'a indirmiş. Bu denemelerde sınır pencerenin yarısı kadardı: 4096'da 2051, 8192'de 4099. Kesilen kısım prompt'un başı oldu. İndirim kodu tam orada duruyordu, soru ise sonda olduğu için kurtuldu. Gerçek bir uygulamada başta duran şey genellikle en önemlisidir: system prompt'un, kuralların, belgenin girişi.
num_ctx'i Ayarlamak
TypeScript'te options.num_ctx yetiyor, yukarıda gördün. C# tarafında ChatOptions'ın ortak property'leri arasında context window ayarı yok, çünkü bu Ollama'ya özgü bir seçenek. OllamaSharp bunun için bir extension method veriyor:
using Microsoft.Extensions.AI;
using OllamaSharp;
using OllamaSharp.Models;
// Uzun prompt'lar meşgul bir sunucuda 100 saniyelik varsayılan timeout'u aşabiliyor
HttpClient http = new()
{
BaseAddress = new Uri("http://localhost:11434"),
Timeout = TimeSpan.FromMinutes(5),
};
IChatClient client = new OllamaApiClient(http, "gemma3:4b");
string[] genres = ["fantasy", "history", "science fiction", "biography", "crime"];
IEnumerable<string> catalog = Enumerable.Range(1, 220)
.Select(i => $"Book {i}: \"Sample Title {i}\", {genres[(i - 1) % genres.Length]}, {10 + (i - 1) % 30} USD");
string prompt = string.Join("\n",
[
"Store policy: the discount code for members is MOON-7731.",
"Catalog:",
.. catalog,
"Question: What is the discount code for members? If you cannot find it, say so.",
]);
ChatOptions options = new() { Temperature = 0, Seed = 42 };
// Ollama'ya özgü num_ctx ayarı
options.AddOllamaOption(OllamaOption.NumCtx, 8192);
ChatResponse response = await client.GetResponseAsync(prompt, options);
Console.WriteLine($"input tokens: {response.Usage?.InputTokenCount}");
Console.WriteLine(response.Text);
input tokens: 4498
The discount code for members is MOON-7731.
Koddaki HttpClient kısmı da bir gerçek deneyimden geliyor. Bu programın ilk hâlini, aynı Ollama sunucusunu başka işler de kullanırken çalıştırdım ve istek TaskCanceledException ile düştü: "The request was canceled due to the configured HttpClient.Timeout of 100 seconds elapsing." OllamaSharp'ın Uri alan constructor'ı varsayılan 100 saniyelik timeout'la geliyor; uzun prompt'larda kendi HttpClient'ını vermek daha güvenli.
Pencereyi büyütmenin bir bedeli var. İstekten hemen sonra ollama ps şunu gösterdi:
$ ollama ps
NAME ID SIZE PROCESSOR CONTEXT UNTIL
gemma3:4b a2af6cc3eb7f 3.9 GB 100% GPU 8192 4 minutes from now
qwen3:4b 359d7dd4bcda 3.2 GB 100% GPU 4096 4 minutes from now
Model 4096'lık pencerede 3.7 GB, 8192'de 3.9 GB yer tutuyor; pencere büyüdükçe bellek de büyür. Ayrıca num_ctx değiştiğinde Ollama modeli yeniden yükler. Bunu kısa bir "Hi" isteğiyle ölçtüm: model 4096 ile yüklüyken num_ctx: 8192 gönderen istekte load_duration 1,09 saniye, ardından varsayılan ayara dönen istekte yine 1,2 saniye oldu. Yani farklı num_ctx değerleriyle gelen istekler birbirini yavaşlatır. Bu yüzden bu değeri istek istek oynatmak yerine uygulama için bir kez seçmek daha iyidir. Sunucu genelinde ayarlamak için OLLAMA_CONTEXT_LENGTH ortam değişkeni var. On yedinci yazıda kullanacağımız OpenAI uyumlu /v1 endpoint'inde ise num_ctx gönderilemiyor; Ollama dokümanı bunun için Modelfile'da PARAMETER num_ctx ile yeni bir model oluşturmayı öneriyor.
Geçmişi Yönetmek: Kırpmak mı, Özet Çıkarmak mı?
Pencereyi büyütmek bir yere kadar çözüm. Uzun süren bir sohbette geçmişi yönetmen gerekiyor. İki yaygın yöntem var: eski mesajları atmak (kırpma) ya da onları kısa bir özete çevirmek. İkisini aynı konuşmada karşılaştıralım:
using Microsoft.Extensions.AI;
using OllamaSharp;
IChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");
ChatOptions options = new() { Temperature = 0, Seed = 42 };
List<ChatMessage> history = [];
string[] turns =
[
"My name is Ada. Reply in one short sentence.",
"I mostly read science fiction and I dislike horror. Reply in one short sentence.",
"My budget is 20 USD per book. Reply in one short sentence.",
"What is the capital of France? Reply in one short sentence.",
];
foreach (string turn in turns)
{
history.Add(new ChatMessage(ChatRole.User, turn));
history.AddMessages(await client.GetResponseAsync(history, options));
}
const string question = "Recommend one book for me and greet me by name. Reply in two sentences.";
// Kırpma: yalnızca son iki mesaj kalıyor
List<ChatMessage> trimmed = KeepLast(history, 2);
trimmed.Add(new ChatMessage(ChatRole.User, question));
ChatResponse fromTrimmed = await client.GetResponseAsync(trimmed, options);
Console.WriteLine($"Trimmed [input tokens: {fromTrimmed.Usage?.InputTokenCount}]: {fromTrimmed.Text}");
// Özetleme: eski mesajlar tek bir system mesajına dönüşüyor
List<ChatMessage> summarized = await SummarizeOld(client, history, 2, options);
Console.WriteLine($"Summary: {summarized[0].Text}");
summarized.Add(new ChatMessage(ChatRole.User, question));
ChatResponse fromSummary = await client.GetResponseAsync(summarized, options);
Console.WriteLine($"Summarized [input tokens: {fromSummary.Usage?.InputTokenCount}]: {fromSummary.Text}");
static List<ChatMessage> KeepLast(List<ChatMessage> history, int count) =>
[
.. history.Where(m => m.Role == ChatRole.System),
.. history.Where(m => m.Role != ChatRole.System).TakeLast(count),
];
static async Task<List<ChatMessage>> SummarizeOld(
IChatClient client, List<ChatMessage> history, int keep, ChatOptions options)
{
IEnumerable<ChatMessage> old = history.SkipLast(keep);
string transcript = string.Join("\n", old.Select(m => $"{m.Role}: {m.Text}"));
ChatResponse summary = await client.GetResponseAsync(
$"Summarize what we know about the user in at most two sentences:\n\n{transcript}", options);
return
[
new ChatMessage(ChatRole.System, $"Earlier conversation summary: {summary.Text.Trim()}"),
.. history.TakeLast(keep),
];
}
Trimmed [input tokens: 58]: Hello Sarah, I would highly recommend "Pride and Prejudice" by Jane Austen – it’s a timeless classic filled with wit and romance! It’s a truly delightful read that I think you’ll absolutely love.
Summary: Earlier conversation summary: Ada enjoys reading science fiction and has a budget of $20 per book.
Summarized [input tokens: 84]: Hello Ada, I would highly recommend “Dune” by Frank Herbert – it’s a sprawling, epic science fiction novel that explores themes of politics, religion, and ecology. I think you’ll really enjoy its immersive world and complex characters!
Kırpılmış geçmişte yalnızca Paris sorusu ve cevabı kaldı. Model adı bilmiyor, ama bunu söylemek yerine bir ad uydurdu: "Sarah". Bilim kurgu tercihini de bilmediği için bir klasik roman önerdi. Özetli geçmişte ise model Ada'yı ve bilim kurguyu biliyor, 84 token'la doğru bir öneri yaptı. Ama özete dikkatle bak: "korku sevmiyorum" bilgisi kaybolmuş. Özet bilgiyi sıkıştırır ve her sıkıştırma gibi kayıplıdır; neyin kalacağına model karar verir.
Pratikte ikisi birlikte kullanılır: son birkaç mesaj olduğu gibi kalır, daha eskileri özete dönüşür, kritik bilgiler (kullanıcının adı, siparişi, tercihleri) ise sohbet metninden bağımsız olarak senin veritabanından system mesajına yazılır. Microsoft.Extensions.AI'da bu işler için MessageCountingChatReducer ve SummarizingChatReducer class'ları da var. Ancak 10.11.0 sürümünde ikisi de experimental; kullanınca derleyici MEAI001 hatası veriyor ve bu tanılamayı bilerek kapatman gerekiyor.
Sık Düşülen Tuzaklar
İlk tuzak, modelin desteklediği pencere ile Ollama'nın ayırdığı pencereyi aynı sanmak. Model kartında 128K yazması, isteğinin 128K'lık bir pencereye gittiği anlamına gelmez. ollama ps çıktısındaki CONTEXT sütununa bak.
İkincisi, kesilmenin bir hata vereceğini beklemek. Vermez. Uzun prompt gönderen her kodda prompt_eval_count değerini beklediğin boyutla karşılaştır. Prompt'u kendin say ve pencereye yaklaşınca uyar. Sayının beklenmedik biçimde küçük çıkması, kesilmenin tek işaretidir.
Üçüncüsü, en önemli bilgiyi prompt'un en başına koyup bunun her zaman güvende olduğunu sanmak. Bu denemede kesilen kısım tam olarak baştı. Prompt'u pencereye sığacak boyutta tutmak, yerleşim taktiklerinden daha güvenilir.
Dördüncüsü, cevaba yer bırakmamak. Pencere input ile output'u birlikte taşır. Pencereyi tam dolduran bir prompt, cevaba yer bırakmaz.
Beşinci tuzak C#'a özgü: uzun prompt'lar ve meşgul bir sunucu, HttpClient'ın 100 saniyelik varsayılan timeout'unu aşabilir. Yerelde ilk istek model yüklemesini de içerdiği için bu süre sandığından çabuk dolar.
Küçük Bir Deneme
★☆☆ truncation.ts içindeki katalog satır sayısını 150, 200 ve 300 yap. Her seferinde prompt_eval_count değerine ve modelin cevabına bak. Kesilmenin başladığı noktayı bul.
★★☆ İndirim kodunu prompt'un başından alıp sorudan hemen önceye taşı ve varsayılan pencereyle tekrar dene. Model bu sefer kodu buluyor mu? Bu sonuç sence neden bir çözüm değil?
★★★ P04Trim örneğini genişlet: geçmiş belirli bir token sayısını aşınca en eski mesajları özete çeviren bir ReduceHistoryAsync method'u yaz. Sınırı Usage.InputTokenCount ile kontrol et ve on turluk bir sohbette input token'larının nasıl seyrettiğini yazdır.
Bir sonraki yazıda bu yazıda kenarından geçtiğimiz mesaj rollerine odaklanacağız: system, user ve assistant ne işe yarar, iyi bir system prompt nasıl yazılır, istemciden gelen bir sohbet geçmişine neden güvenilmez ve kullanıcının yazdığı metni talimatlardan ayırmak prompt injection'a karşı ilk adım olarak nasıl işe yarar.