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

LLM Cevabında Streaming: NDJSON, IAsyncEnumerable ve İlk Token Süresi

Mert Özen 6 Kas 2026 11 dk 5 görüntülenme
LLM Cevabında Streaming: NDJSON, IAsyncEnumerable ve İlk Token Süresi

Streaming cevabı hızlandırmaz, beklemeyi kısaltır. Ollama'nın NDJSON ve OpenAI uyumlu SSE akışlarına curl ile bakıyor, C#'ta GetStreamingResponseAsync ve TypeScript'te async iterator ile cevabı okuyup ilk token süresini ölçüyor, akışı iki SDK'da iptal edip davranış farkını görüyoruz.

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

Önce Tahmin Et

Aynı soruyu iki şekilde soruyorsun: bir kez cevabın tamamını bekleyerek, bir kez de streaming ile parça parça alarak. Streaming'li istek daha erken mi biter? Model, cevabı sen beklediğin için daha hızlı mı üretir?

Çoğumuzun sezgisi "evet, streaming daha hızlı" diyor. Ölçünce göreceğiz ki toplam süre neredeyse aynı. Değişen şey, kullanıcının ilk kelimeyi ne zaman gördüğü. Bu yazının konusu tam olarak bu fark.

Neden Streaming?

Model cevabı token token üretir; serinin ilk yazısındaki döngüyü hatırla: bir token seç, sona ekle, tekrarla. Cevabın tamamını bekleyen bir istek, bu döngü bitene kadar elinde hiçbir şey olmadan bekler. Yüz token'lık bir paragraf yerel bir 4B modelde birkaç saniye sürüyor; uzun bir cevap ya da yavaş bir model söz konusuysa bu süre onlarca saniyeye çıkar. Kullanıcı o sırada boş bir ekrana bakar.

Streaming'de sunucu her token'ı üretildiği anda gönderir. Toplam süre değişmez, ama algılanan gecikme dramatik biçimde düşer: kullanıcı ilk kelimeyi yüzlerce milisaniye içinde görür ve okumaya başlar. Restoran benzetmesiyle: bütün yemeklerin hazır olunca tek seferde geldiği bir masa ile çorbanın hazır olur olmaz geldiği bir masa arasındaki fark. Mutfak aynı hızda çalışıyor, ama ikinci masadaki müşteri beklediğini daha az hissediyor.

Bu yüzden ölçmemiz gereken iki ayrı sayı var: ilk token süresi (time to first token, TTFT) ve toplam süre. Sohbet arayüzlerinde kullanıcı deneyimini çoğunlukla birincisi belirler.

Ollama'nın Akışı: NDJSON

Önce kablonun içine bakalım. Ollama'nın /api/chat endpoint'inde streaming varsayılan olarak açık; stream alanını hiç göndermezsen cevap akış olarak gelir:

curl -s http://localhost:11434/api/chat -d '{
  "model": "gemma3:4b",
  "messages": [{ "role": "user", "content": "Name the author of The Hobbit. Reply in one short sentence." }],
  "options": { "temperature": 0, "seed": 42 }
}'
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.536477Z","message":{"role":"assistant","content":"J"},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.630588Z","message":{"role":"assistant","content":"."},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.668004Z","message":{"role":"assistant","content":"R"},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.706532Z","message":{"role":"assistant","content":"."},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.745246Z","message":{"role":"assistant","content":"R"},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.789672Z","message":{"role":"assistant","content":"."},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.837245Z","message":{"role":"assistant","content":" Tolkien"},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.883892Z","message":{"role":"assistant","content":" wrote"},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.930959Z","message":{"role":"assistant","content":" The"},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:28.976957Z","message":{"role":"assistant","content":" Hobbit"},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:29.023111Z","message":{"role":"assistant","content":"."},"done":false}
{"model":"gemma3:4b","created_at":"2026-11-06T20:47:29.070258Z","message":{"role":"assistant","content":""},"done":true,"done_reason":"stop","total_duration":953237833,"load_duration":747458,"prompt_eval_count":23,"prompt_eval_cached_count":0,"prompt_eval_duration":417173000,"eval_count":12,"eval_duration":533648000}

Bu biçimin adı NDJSON (newline-delimited JSON): her satır kendi başına geçerli bir JSON object'i. Her satırda message.content içinde yeni gelen parça var ve bu çalıştırmada her parça tam bir token'a karşılık geliyor: "J", ".", "R", ".", " Tolkien". Son satırda done: true geliyor ve istatistikler yalnızca orada: prompt_eval_count 23 token, eval_count 12 token, süreler nanosaniye cinsinden. eval_count / eval_duration hesabı burada saniyede yaklaşık 22,5 token veriyor.

Ollama'nın OpenAI uyumlu /v1 endpoint'i ise aynı akışı başka bir biçimde gönderiyor:

curl -s http://localhost:11434/v1/chat/completions -H "Content-Type: application/json" -d '{
  "model": "gemma3:4b",
  "messages": [{ "role": "user", "content": "Name the author of The Hobbit. Reply in one short sentence." }],
  "temperature": 0, "seed": 42, "stream": true
}'
data: {"id":"chatcmpl-955","object":"chat.completion.chunk","created":1793998049,"model":"gemma3:4b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"role":"assistant","content":"J"},"finish_reason":null}]}

data: {"id":"chatcmpl-955","object":"chat.completion.chunk","created":1793998049,"model":"gemma3:4b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{"content":"."},"finish_reason":null}]}

...

data: {"id":"chatcmpl-955","object":"chat.completion.chunk","created":1793998049,"model":"gemma3:4b","system_fingerprint":"fp_ollama","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Bu Server-Sent Events (SSE). Çıktının ortasını kısalttım: her event data: önekiyle başlıyor ve boş bir satırla ayrılıyor, parçalar choices[0].delta.content içinde, akışın sonunu da data: [DONE] satırı haber veriyor. Varsayılan hâliyle token istatistikleri de yok. İki biçimi elle parse etmen gerekmeyecek; SDK'lar bunu senin yerine yapıyor. Ama bir proxy'de, bir log'da ya da tarayıcının network sekmesinde bu satırları gördüğünde neye baktığını bilmek işini kolaylaştırır.

C#'ta Akışı Okumak: IAsyncEnumerable

Microsoft.Extensions.AI'de streaming için ayrı bir method var: GetStreamingResponseAsync. Dönüş tipi IAsyncEnumerable<ChatResponseUpdate>, yani await foreach ile gezilen asenkron bir akış. Aşağıdaki kod aynı prompt'u önce streaming olmadan, sonra streaming ile gönderiyor ve iki süreyi ölçüyor:

using System.Diagnostics;
using Microsoft.Extensions.AI;
using OllamaSharp;

IChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");
string prompt = "Recommend 'The Hobbit' to a new reader in one short paragraph.";
ChatOptions options = new() { Temperature = 0, Seed = 42 };

// 1) Streaming olmadan: cevabın tamamı hazır olana kadar gösterecek bir şey yok
Stopwatch sw = Stopwatch.StartNew();
ChatResponse whole = await client.GetResponseAsync(prompt, options);
Console.WriteLine($"[non-streaming] first text after {sw.ElapsedMilliseconds} ms");

// 2) Streaming ile: her parçayı geldiği anda yaz
List<ChatResponseUpdate> updates = [];
sw.Restart();
long firstTokenMs = -1;
await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(prompt, options))
{
    if (firstTokenMs < 0 && update.Text.Length > 0)
    {
        firstTokenMs = sw.ElapsedMilliseconds;
    }
    Console.Write(update.Text);
    updates.Add(update);
}
Console.WriteLine();
Console.WriteLine($"[streaming] first text after {firstTokenMs} ms, finished after {sw.ElapsedMilliseconds} ms");

ChatResponse merged = updates.ToChatResponse();
Console.WriteLine($"updates: {updates.Count}, output tokens: {merged.Usage?.OutputTokenCount}, finish: {merged.FinishReason}");
Console.WriteLine($"same text as non-streaming: {merged.Text == whole.Text}");
[non-streaming] first text after 2819 ms
If you’re looking for a fantastic adventure, I wholeheartedly recommend “The Hobbit” by J.R.R. Tolkien! It’s a charming and exciting story about a comfortable hobbit named Bilbo Baggins who gets swept up in a thrilling quest to reclaim a treasure from a fearsome dragon. Filled with memorable characters, fantastical creatures, and a touch of magic, it’s a perfect introduction to the world of Middle-earth and a wonderfully engaging read for anyone who loves a good tale of bravery, friendship, and a little bit of mischief.
[streaming] first text after 56 ms, finished after 2804 ms
updates: 114, output tokens: 114, finish: stop
same text as non-streaming: True

Sayılar açılıştaki soruyu cevaplıyor. Streaming olmadan ilk metni 2819 ms sonra gördük, çünkü ilk metin aynı zamanda son metin. Streaming ile ilk parça 56 ms'de geldi, akış 2804 ms'de bitti. Toplam süre aynı; fark, kullanıcının 2,7 saniye boyunca boş ekrana bakıp bakmadığı. 56 ms'nin bu kadar küçük olmasının bir sebebi de prompt'un kısa olması ve hemen önceki istekte aynı prompt'un işlenmiş olması; uzun bir prompt'ta ilk token daha geç gelir.

Her ChatResponseUpdate bir parça taşıyor: update.Text o an gelen metin. Akış bitince parçaları kaybetmemek için hepsini bir listede topladık ve ToChatResponse() extension method'uyla tek bir ChatResponse'a birleştirdik. Böylece Usage ve FinishReason gibi yalnızca sona doğru gelen bilgilere de ulaşıyoruz. Birleşik metin, streaming'siz cevapla birebir aynı çıktı; temperature: 0 ve sabit seed'in işe yaradığı bir yer daha. Sohbet geçmişi tutuyorsan aynı listeyi history.AddMessages(updates) ile geçmişe ekleyebilirsin.

TypeScript'te Async Iterator

ollama paketinde stream: true verdiğinde chat bir async iterator döndürüyor; for await ile geziliyor. Bu kez son parçadaki istatistikleri de okuyalım:

import ollama from 'ollama';

const started = performance.now();
let firstTokenMs = -1;

const stream = await ollama.chat({
  model: 'gemma3:4b',
  messages: [{ role: 'user', content: "Recommend 'The Hobbit' to a new reader in one short paragraph." }],
  options: { temperature: 0, seed: 42 },
  stream: true,
});

for await (const chunk of stream) {
  if (firstTokenMs < 0 && chunk.message.content.length > 0) {
    firstTokenMs = performance.now() - started;
  }
  process.stdout.write(chunk.message.content);

  if (chunk.done) {
    // İstatistikleri yalnızca son parça taşır (süreler nanosaniye cinsinden)
    const tokensPerSecond = chunk.eval_count / (chunk.eval_duration / 1e9);
    console.log();
    console.log(`first text: ${firstTokenMs.toFixed(0)} ms, total: ${(performance.now() - started).toFixed(0)} ms`);
    console.log(`load: ${(chunk.load_duration / 1e6).toFixed(0)} ms, prompt: ${chunk.prompt_eval_count} tokens in ${(chunk.prompt_eval_duration / 1e6).toFixed(0)} ms`);
    console.log(`output: ${chunk.eval_count} tokens, ${tokensPerSecond.toFixed(1)} tokens/s, done_reason: ${chunk.done_reason}`);
  }
}
If you’re looking for a fantastic adventure, I wholeheartedly recommend “The Hobbit” by J.R.R. Tolkien! It’s a charming and exciting story about a comfortable hobbit named Bilbo Baggins who gets swept up in a thrilling quest to reclaim a treasure from a fearsome dragon. Filled with memorable characters, fantastical creatures, and a touch of magic, it’s a perfect introduction to the world of Middle-earth and a wonderfully engaging read for anyone who loves a good tale of bravery, friendship, and a little bit of mischief.
first text: 26981 ms, total: 31858 ms
load: 20658 ms, prompt: 24 tokens in 155 ms
output: 114 tokens, 23.4 tokens/s, done_reason: stop

İlk token 27 saniyede geldi. Model değişmedi, prompt değişmedi; değişen tek şey, modelin o an bellekte olmamasıydı. load_duration 20658 ms diyor: model diskten yeniden yüklendi. Aynı kodu iki kez daha çalıştırdım, son çalıştırma şuydu:

first text: 1592 ms, total: 6019 ms
load: 1 ms, prompt: 24 tokens in 151 ms
output: 114 tokens, 25.7 tokens/s, done_reason: stop

Yükleme 1 ms'ye, ilk token 1,6 saniyeye indi. Saniyedeki token sayısı ise iki çalıştırmada da 23–26 bandında kaldı. Yani üretim hızı modelin ve donanımın özelliği; ilk token süresi ise o anki koşulların.

İlk Token Süresini Doğru Okumak

İlk token süresi tek bir şeyin ölçüsü değil, birkaç bekleyişin toplamı: isteğin sunucuda sıraya girmesi, modelin belleğe yüklenmesi (load_duration), prompt'un işlenmesi (prompt_eval_duration) ve ilk token'ın üretilmesi. Sıcak çalıştırmada yükleme 1 ms, prompt 151 ms; ama ilk token 1592 ms'de geldi. Aradaki farkın Ollama'nın verdiği sayılarda karşılığı yok. Bu makinede aynı Ollama'yı o sırada başka işler de kullanıyordu ve istek büyük olasılıkla sırasını bekledi. C# örneğini yine yoğun bir anda tekrar çalıştırdığımda zaman satırları şöyleydi:

[non-streaming] first text after 3946 ms
[streaming] first text after 11016 ms, finished after 15307 ms

Streaming'li istekte ilk token 11 saniyede geldi; kod, 56 ms ölçen programın birebir aynısı. Ders şu: TTFT'yi tek bir ölçümle değerlendirme. Uygulamanda ölç, dağılımına bak (ortalama yerine yüzdelikler) ve son parçadaki load_duration ile prompt_eval_duration'ı kendi ölçtüğün süreyle karşılaştır. Bu üç sayı, gecikmenin modelden mi, prompt'un uzunluğundan mı yoksa sıradan mı geldiğini ayırmanı sağlar. Maliyet ve gecikme yazısında bu ölçümleri büyüteceğiz.

Akışı Yarıda Kesmek

Streaming'in bir faydası da cevabı bitmeden durdurabilmek: kullanıcı "Durdur"a bastığında, sayfadan çıktığında ya da cevabın yanlış yöne gittiğini gördüğünde. C#'ta bunun aracı her zamanki gibi CancellationToken:

using Microsoft.Extensions.AI;
using OllamaSharp;

IChatClient client = new OllamaApiClient(new Uri("http://localhost:11434"), "gemma3:4b");
ChatOptions options = new() { Temperature = 0, Seed = 42 };

using CancellationTokenSource cts = new();
int received = 0;
try
{
    await foreach (ChatResponseUpdate update in client.GetStreamingResponseAsync(
        "Summarize the plot of 'The Lord of the Rings' in detail.", options, cts.Token))
    {
        Console.Write(update.Text);
        received++;
        if (received == 20)
        {
            // Örneğin kullanıcı "Durdur" düğmesine bastı
            cts.Cancel();
        }
    }
}
catch (OperationCanceledException)
{
    // Bazı istemciler burada exception fırlatır; OllamaSharp bu çalıştırmada fırlatmadı
    Console.WriteLine("\nOperationCanceledException");
}

Console.WriteLine();
Console.WriteLine(cts.IsCancellationRequested
    ? $"cancelled after {received} updates"
    : $"completed with {received} updates");
Okay, here's a detailed summary of *The Lord of the Rings*, broken down into its
cancelled after 20 updates

Burada beklemediğim bir şey oldu. Token iptal edilince await foreach döngüsü sessizce bitti; OperationCanceledException fırlatılmadı. OllamaSharp 5.5.2 bu çalıştırmada iptali bir hata olarak değil, akışın sonu olarak ele aldı. .NET'te iptalin genel davranışı exception fırlatmaktır ve başka bir IChatClient implementasyonu bunu yapabilir. Bu yüzden kod ikisini de karşılıyor: catch bloğu exception'ı yakalıyor, döngüden sonraki IsCancellationRequested kontrolü de sessiz bitişi ayırt ediyor. ASP.NET Core'da bu token'ı kendin üretmek yerine HttpContext.RequestAborted'ı geçirirsin; kullanıcı bağlantıyı kapattığında model isteği de iptal edilir.

TypeScript tarafında ollama paketinin döndürdüğü iterator'ın kendi abort() method'u var:

import ollama from 'ollama';

const stream = await ollama.chat({
  model: 'gemma3:4b',
  messages: [{ role: 'user', content: "Summarize the plot of 'The Lord of the Rings' in detail." }],
  options: { temperature: 0, seed: 42 },
  stream: true,
});

let received = 0;
try {
  for await (const chunk of stream) {
    process.stdout.write(chunk.message.content);
    received++;
    if (received === 20) {
      // Yalnızca bu akışı durdurur; ollama.abort() istemcinin bütün akışlarını durdururdu
      stream.abort();
    }
  }
} catch (error) {
  console.log();
  console.log(`${(error as Error).name} after ${received} chunks`);
}
Okay, here's a detailed summary of *The Lord of the Rings*, broken down into its
AbortError after 20 chunks

Burada davranış farklı: abort() çağrıldıktan sonra döngü bir AbortError fırlattı. Paket içeride bir AbortController kullanıyor ve iptal, fetch'in alışık olduğumuz hatasıyla geliyor. Aynı iş, iki SDK'da iki farklı sinyal. İptal kodunu yazarken kullandığın SDK'nın gerçekte ne yaptığını bir kez denemeden varsayımda bulunma.

Sık Düşülen Tuzaklar

İlk tuzak, akışı elle okurken her ağ okumasının tam bir satır getirdiğini sanmak. NDJSON'u kendin parse ediyorsan, gelen byte'ları satır sonuna kadar biriktir; bir JSON satırı iki okumaya bölünebilir. SSE'de de aynı kural event ayırıcısı için geçerli. Mümkünse bu işi SDK'ya bırak.

İkincisi, her parçanın bir token olduğunu varsaymak. Bu çalıştırmalarda 114 update ile 114 token denk geldi, ama bu bir sözleşme değil. Token sayısını parça sayarak değil, son parçadaki eval_count'tan ya da birleştirilmiş cevabın Usage bilgisinden al.

Üçüncüsü, iptal edilen akışın maliyetini unutmak. İstatistikler son parçada geliyor; akışı yarıda kestiğinde o parça hiç gelmez ve kaç token harcandığını bilemezsin. Bulut sağlayıcılarında iptal anına kadar üretilen token'lar genellikle yine faturalanır; kendi sağlayıcının dokümanına bak. Kullanımı ölçüyorsan iptal edilen istekleri ayrı say.

Dördüncüsü, iptali zincirin sonuna kadar taşımamak. Kullanıcı sekmeyi kapatsa bile token'ı model çağrısına geçirmediysen istek arka planda sürmeye devam eder. Yerelde bu boşuna harcanan GPU zamanı, bulutta boşuna ödenen para demek.

Beşincisi, araya giren katmanları unutmak. Bir reverse proxy ya da sıkıştırma middleware'i yanıtı tamponlarsa, sunucuda token token üretilen cevap tarayıcıya tek parça hâlinde ulaşır. Streaming'i uçtan uca, kullanıcının gördüğü yerde test et.

Altıncısı, yarım metni bitmiş metin gibi işlemek. Akış sırasında elindeki metin her an yarım bir cümle, kapanmamış bir Markdown işareti ya da yarım bir JSON olabilir. Ekrana basmak için sorun değil; parse etmek, veritabanına yazmak ya da başka bir işleme vermek için akışın bitmesini bekle.

Küçük Bir Deneme

★☆☆ curl komutunu "stream": false ekleyerek tekrar çalıştır. Kaç satır geldi, istatistikler nerede? Sonra aynı komutu daha uzun bir cevap isteyerek çalıştır ve satırların akışını terminalde izle.

★★☆ stream.ts'i aynı prompt için on kez çalıştıran bir döngüye çevir. Her çalıştırmada ilk token süresini, load_duration'ı ve saniyedeki token sayısını yazdır; en küçük, en büyük ve ortanca değerleri karşılaştır.

★★★ C# iptal örneğini 20 update yerine cts.CancelAfter(TimeSpan.FromSeconds(1)) ile zamana bağla. Sonra aynı akışı ToChatResponse() ile birleştirmeyi dene: iptal edilmiş bir akışta Usage ve FinishReason ne oluyor?

Akışı okumayı, ölçmeyi ve kesmeyi gördük; artık cevabın ne kadar hızlı geldiğini kontrol edebiliyoruz. Sırada cevabın kendisi var. Bir sonraki yazıda prompt yazmaya geçiyoruz: açık talimat, ayraçlar, örnekli (few-shot) prompt'lar ve çıktı biçimini tarif etmek. Kötü ve iyi prompt'ları aynı model üzerinde gerçek çıktılarla yan yana koyacağız.