ASP.NET Core'da LLM Streaming: Minimal API, IAsyncEnumerable ve Server-Sent Events
LLM cevabını tarayıcıya akıtmanın güvenilir yolu .NET 10'un TypedResults.ServerSentEvents'i: delta, thinking, tool, usage, done ve error event'leri, sessiz bekleyişte heartbeat ve modele kadar taşınan CancellationToken. Düz IAsyncEnumerable'ın sınırlarını, kullanıcı sekmeyi kapatınca modelin gerçekten durduğunu ve token'ı vermeyi unutan endpoint'in 1024 token'ı boşa harcadığını gerçek ölçümlerle 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, yerel modelde Ollama 0.34.4 (qwen3:4b) ve istemci tarafında Node 22, TypeScript 5.9 ve tsx 4.23 kullanılmıştır.
Önce Tahmin Et
Bookstore asistanının cevabını kullanıcıya kelime kelime göstermek istiyorsun. İlk akla gelen en kısa yol: minimal API (Application Programming Interface) endpoint'inden IAsyncEnumerable<string> döndürmek. Tarayıcı token'ları geldikçe mi görür, yoksa cevabın tamamını sonunda mı?
İkinci soru: kullanıcı cevabın ortasında sekmeyi kapattı. Sunucuda model üretmeye devam eder mi? Endpoint'in imzasında CancellationToken var ama sen onu modele vermeyi unuttun. Ne değişir?
İki sorunun cevabını da gerçek ölçümle göreceğiz. LLM (Large Language Model) Temelleri'nin streaming yazısında satır satır JSON (JavaScript Object Notation) nesnelerinden oluşan NDJSON (Newline Delimited JSON) akışını ve ilk token süresini konuşmuştuk; burada konu, aynı akışı bir ASP.NET Core API'sinden tarayıcıya güvenilir şekilde taşımak.
En Kısa Yol: IAsyncEnumerable Döndürmek
// 1) Düz IAsyncEnumerable: minimal API bunu bir JSON array olarak yazar
app.MapPost("/chat/raw", (ChatStreamRequest request, IChatClient chat, CancellationToken ct) =>
chat.GetStreamingResponseAsync(request.Message, cancellationToken: ct)
.Where(u => u.Text.Length > 0)
.Select(u => u.Text));
Where ve Select'in IAsyncEnumerable üzerinde çalışması için ek paket gerekmiyor; .NET 10'da System.Linq.AsyncEnumerable framework'ün parçası. Minimal API dönen IAsyncEnumerable<T>'yi JSON array olarak serialize eder. curl çıktısının her satırının başına geliş zamanını yazdırdım:
70005 ms ["Martin"
70027 ms ," Fowler"
70050 ms ,"’s"
70072 ms ," *"
70095 ms ,"Ref"
70118 ms ,"actoring"
70140 ms ,":"
70163 ms ," Impro"
70185 ms ,"ving"
70207 ms ," the"
70230 ms ," Design"
70252 ms ," of"
70275 ms ," Existing"
70297 ms ," Code"
70320 ms ,"*"
70342 ms ," is"
70365 ms ," the"
70387 ms ," definitive"
70410 ms ," guide"
70433 ms ," to"
70455 ms ," ref"
70478 ms ,"actoring"
70500 ms ,"."
70523 ms ]
Önce Tahmin Et'teki ilk sorunun cevabı "evet, token token". Çıktıyı satır beklemeden, gelen baytları geldikleri anda zaman damgasıyla yazan küçük bir Perl betiğiyle okudum: array'in her elemanı modelden geldiği anda, yaklaşık 22 milisaniye arayla ayrı ayrı gönderilmiş. Ama bu yolun üç sorunu var. Birincisi, ilk 70 saniye boyunca istemci hiçbir şey almadı; model sıra beklerken ve düşünürken bağlantı tamamen sessiz. İkincisi, istemcinin elindeki şey yarım bir JSON array: JSON.parse ancak ] geldiğinde çalışır, parça parça okumak için kendi parser'ını yazman gerekir. Üçüncüsü, akış ortasında bir hata olursa array kapanmadan bağlantı kopar ve istemci geçersiz bir JSON ile kalır; "model yarıda kesildi" demenin bir yolu yok. Akışın durumunu (düşünüyor, tool çağırıyor, bitti, hata) taşıyacak bir protokole ihtiyacımız var.
Server-Sent Events: .NET 10'da TypedResults.ServerSentEvents
SSE (Server-Sent Events), tarayıcıların yıllardır desteklediği basit bir protokol: text/event-stream içerik tipiyle açık kalan bir HTTP (Hypertext Transfer Protocol) cevabı, içinde boş satırla ayrılmış event'ler. Her event'in bir tipi (event:) ve verisi (data:) olur. WebSocket gibi çift yönlü değil; ama LLM cevabı zaten tek yönlü akar, ve SSE düz HTTP olduğu için proxy, load balancer ve authentication altyapısıyla sorunsuz çalışır.
.NET 10 ile ASP.NET Core'a TypedResults.ServerSentEvents geldi. ASP.NET Core Web API serisinin ".NET 10 Yenilikleri: ASP.NET Core'da AddValidation, OpenAPI 3.1 ve Server-Sent Events" yazısında tanıştığımız bu sonuç tipi, IAsyncEnumerable<SseItem<T>> alır ve her öğeyi doğru biçimde yazıp flush eder. Bookstore endpoint'i:
// 2) Server-Sent Events: tiplenmiş event'ler, heartbeat ve iptal
app.MapPost("/chat/sse", (ChatStreamRequest request, IServiceProvider services, ILogger<Program> logger, CancellationToken ct) =>
{
IChatClient chat = request.UseTools
? services.GetRequiredKeyedService<IChatClient>("tools")
: services.GetRequiredService<IChatClient>();
ChatOptions? options = request.UseTools ? new ChatOptions { Tools = [getStock] } : null;
IAsyncEnumerable<ChatResponseUpdate> updates = chat.GetStreamingResponseAsync(request.Message, options, ct);
return TypedResults.ServerSentEvents(
ChatStreaming.ToSseAsync(updates, logger, ct).WithHeartbeatAsync(TimeSpan.FromSeconds(5), ct));
});
Endpoint'in imzasındaki CancellationToken'ı ASP.NET Core HttpContext.RequestAborted'a bağlar: istemci bağlantıyı kapatınca iptal edilir. Bu token'ı hem modele (GetStreamingResponseAsync) hem de kendi iterator'larımıza veriyoruz. Aynı istek curl ile:
5005 ms HTTP/1.1 200 OK
5005 ms Content-Type: text/event-stream
5005 ms Date: Sun, 08 Nov 2026 08:06:16 GMT
5005 ms Server: Kestrel
5005 ms Cache-Control: no-cache,no-store
5005 ms Content-Encoding: identity
5005 ms Pragma: no-cache
5005 ms Transfer-Encoding: chunked
5005 ms event: ping
5005 ms data: {}
# ...
35014 ms event: ping
35014 ms data: {}
39868 ms event: thinking
39868 ms data: {}
44870 ms event: ping
44870 ms data: {}
49872 ms event: ping
49872 ms data: {}
51001 ms event: delta
51001 ms data: {"text":"\""}
51023 ms event: delta
51023 ms data: {"text":"Ref"}
51046 ms event: delta
51046 ms data: {"text":"actoring"}
# ...
51510 ms event: delta
51510 ms data: {"text":" code"}
51533 ms event: delta
51533 ms data: {"text":".\""}
51558 ms event: usage
51558 ms data: {"inputTokens":21,"outputTokens":520}
51558 ms event: done
51558 ms data: {}
Satır satır okuyalım. Başlıklar 5 saniye sonra geldi: ServerSentEventsResult cevabı ilk event'i yazarken başlatıyor ve o ilk event heartbeat'in ping'i. İlk 35 saniye boyunca model Ollama'da sıra bekledi ve istemci beş saniyede bir ping aldı. 39,9. saniyede model düşünmeye başladı ve tek bir thinking event'i gitti; qwen3:4b bir reasoning modeli ve düşünürken metin üretmiyor. 51. saniyede cevap token'ları yaklaşık 23 milisaniye arayla delta event'leri olarak aktı, en sonda usage ve done geldi. Tek cümlelik cevap için 520 output token: neredeyse hepsi düşünme token'ı, 7. yazıda maliyet tarafına döneceğiz.
Event Tipleri: Delta, Tool, Usage, Done, Error
Arayüzün yalnız metin parçalarına değil, akışın durumuna da ihtiyacı var: model bir tool çağırıyorsa "stok kontrol ediliyor" göstermek, akış bitince gönder düğmesini açmak, token sayısını göstermek. Bunun için her ChatResponseUpdate'in içindeki content'leri tiplenmiş event'lere çeviriyorum:
namespace Bookstore.Assistant.Api;
public sealed record ChatStreamRequest(string Message, bool UseTools = false);
public sealed record DeltaEvent(string Text);
public sealed record ToolEvent(string Name, string Phase);
public sealed record UsageEvent(long? InputTokens, long? OutputTokens);
public sealed record ErrorEvent(string Message, bool Partial);
using System.Diagnostics;
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Api;
public static class ChatStreaming
{
// Model update'lerini tiplenmiş SSE event'lerine çevirir; hata olursa yarım cevabı "error" event'iyle bildirir
public static async IAsyncEnumerable<SseItem<object>> ToSseAsync(
IAsyncEnumerable<ChatResponseUpdate> updates,
ILogger logger,
[EnumeratorCancellation] CancellationToken ct = default)
{
int deltas = 0;
bool thinking = false;
Stopwatch sw = Stopwatch.StartNew();
IAsyncEnumerator<ChatResponseUpdate> e = updates.GetAsyncEnumerator(ct);
try
{
while (true)
{
ChatResponseUpdate update;
string? error = null;
try
{
if (!await e.MoveNextAsync())
{
break;
}
update = e.Current;
}
catch (Exception ex) when (!ct.IsCancellationRequested)
{
logger.LogWarning(ex, "Model stream failed after {Deltas} deltas", deltas);
error = "The model stopped responding.";
update = null!;
}
// yield catch bloğunda kullanılamaz; hatayı dışarıda yay
if (error is not null)
{
yield return new SseItem<object>(new ErrorEvent(error, Partial: deltas > 0), "error");
yield break;
}
foreach (AIContent content in update.Contents)
{
switch (content)
{
// Reasoning model düşünürken metin gelmez; arayüze bir kez haber ver
case TextReasoningContent when !thinking:
thinking = true;
yield return new SseItem<object>(new { }, "thinking");
break;
case TextContent { Text.Length: > 0 } text:
deltas++;
yield return new SseItem<object>(new DeltaEvent(text.Text), "delta");
break;
case FunctionCallContent call:
yield return new SseItem<object>(new ToolEvent(call.Name, "started"), "tool");
break;
case FunctionResultContent:
yield return new SseItem<object>(new ToolEvent("", "completed"), "tool");
break;
case UsageContent usage:
yield return new SseItem<object>(
new UsageEvent(usage.Details.InputTokenCount, usage.Details.OutputTokenCount), "usage");
break;
}
}
}
yield return new SseItem<object>(new { }, "done");
}
finally
{
await e.DisposeAsync();
logger.LogInformation("Stream closed: deltas={Deltas}, elapsed={Elapsed} ms, aborted={Aborted}",
deltas, sw.ElapsedMilliseconds, ct.IsCancellationRequested);
}
}
}
Event listesinde bir tane daha var: thinking. Bu yazıda model qwen3:4b, yani bir reasoning modeli. Düşünürken OllamaSharp update'lere metin yerine TextReasoningContent koyuyor; bu metni kullanıcıya göstermiyorum ama "model düşünüyor" bilgisini bir kez gönderiyorum. Yoksa kullanıcı onlarca saniye boş bir ekrana bakar.
Kodun garip görünen kısmı enumerator'ı elle yürütmek. Sebebi C#'ın bir kuralı: yield return bir catch bloğunun içinde kullanılamaz. Hatayı yakalayıp istemciye bir event olarak göndermek istiyorsak hatayı bir değişkene alıp catch'in dışında yaymamız gerekiyor.
Catch filtresinin ilk hâli de bir derstir. Önce when (ex is not OperationCanceledException) yazmıştım: "kullanıcı iptal ettiyse hata event'i gönderme". Ollama kalabalıkken yaptığım bir denemede istek hiç cevap almadı ve HttpClient'ın timeout'u doldu. O exception bir TaskCanceledException, yani bir OperationCanceledException; filtre onu "kullanıcı iptali" sanıp geçirdi, istemciye hiçbir hata event'i gitmedi ve akış ortasında koptu. Doğru soru "exception'ın tipi ne?" değil, "iptal edilen benim token'ım mı?": when (!ct.IsCancellationRequested).
Tool event'lerini görmek için aynı endpoint'i tool calling destekleyen qwen3:4b ve 2. yazıdaki get_stock tool'uyla çağırdım:
40761 ms event: thinking
40761 ms data: {}
50310 ms event: tool
50310 ms data: {"name":"get_stock","phase":"started"}
50310 ms event: usage
50310 ms data: {"inputTokens":145,"outputTokens":422}
50319 ms event: tool
50319 ms data: {"name":"","phase":"completed"}
102363 ms event: delta
102363 ms data: {"text":"There"}
102386 ms event: delta
102386 ms data: {"text":" are"}
# ...
102683 ms event: delta
102683 ms data: {"text":"."}
102706 ms event: usage
102706 ms data: {"inputTokens":604,"outputTokens":148}
102706 ms event: done
102706 ms data: {}
Bu sefer iki model turu var ve akış ikisini de gösteriyor. İlk turda model düşündü ve get_stock'u çağırmaya karar verdi (tool started); FunctionInvokingChatClient tool'u çalıştırdı (tool completed, 9 milisaniye sonra). İlk turun kullanımı ayrı bir usage event'iyle geldi: 145 input, 422 output token. İkinci tur, tool sonucunu da içeren daha uzun bir prompt'la (604 input token) başladı ve cevap 102. saniyede aktı. Arayüz bu event'lerle "stok kontrol ediliyor" gösterebilir ve toplam maliyeti iki usage'ı toplayarak hesaplayabilir. completed event'inde tool adı boş: FunctionResultContent yalnız CallId taşıyor, adı istiyorsan started event'indeki CallId ile eşleştirmen gerekir.
İptal: Kullanıcı Gidince Modeli Durdurmak
Şimdi açılıştaki ikinci soruya ölçümle cevap verelim. Kullanıcının sekmeyi kapatmasını taklit etmek için TypeScript istemcisini 5. delta'dan sonra bağlantıyı kesecek şekilde çalıştırdım ve modelden uzun bir hikâye istedim:
$ ./node_modules/.bin/tsx client.ts "Write a 300-word story about a cat who lives in a bookstore." 5
[ping]
[ping]
# ...
[aborted] AbortError, partial text: "Whisper wasn’t just"
# sunucu log'u
Stream closed: deltas=6, elapsed=201753 ms, aborted=True
# Ollama log'u (boşluklar kısaltıldı)
[GIN] 2026/11/08 - 11:10:12 | 200 | 3m21s | 127.0.0.1 | POST "/api/chat"
İstemci ilk 39 ping boyunca (model sıra bekleyip düşünürken) bekledi, beşinci delta'da bağlantıyı kesti ve elinde "Whisper wasn’t just" kaldı. Sunucu tarafında finally bloğu altıncı delta'dan sonra çalıştı: aborted=True. İstemcinin abort'u ile sunucunun bunu fark etmesi arasında bir delta daha yazılmış. Endpoint'in CancellationToken'ı iptal edildi, iterator dispose edildi ve OllamaSharp Ollama'ya giden HTTP bağlantısını kapattı. Ollama'nın log'u bunu doğruluyor: aynı istek orada da 3 dakika 21 saniyede, yani 201,7 saniyelik sunucu süresiyle aynı anda bitmiş; model 300 kelimelik hikâyenin geri kalanını üretmedi.
Token'ı modele vermeyi unutmanın etkisini ölçmek için iki non-streaming endpoint yazdım. İkisi de aynı uzun hikâyeyi istiyor; biri token'ı modele veriyor, diğeri vermiyor. İstemci (curl --max-time 3) ikisinde de 3 saniye sonra bağlantıyı kesiyor:
// 3) Ölçüm için: iptal token'ı modele taşınmayan ve taşınan iki non-streaming endpoint
app.MapPost("/chat/complete-leaky", async (ChatStreamRequest request, IChatClient chat, ILogger<Program> logger, HttpContext http) =>
{
Stopwatch sw = Stopwatch.StartNew();
ChatResponse response = await chat.GetResponseAsync(request.Message);
logger.LogInformation("Leaky: finished after {Elapsed} ms, outputTokens={Tokens}, clientGone={Gone}",
sw.ElapsedMilliseconds, response.Usage?.OutputTokenCount, http.RequestAborted.IsCancellationRequested);
return response.Text;
});
app.MapPost("/chat/complete", async (ChatStreamRequest request, IChatClient chat, ILogger<Program> logger, CancellationToken ct) =>
{
Stopwatch sw = Stopwatch.StartNew();
try
{
ChatResponse response = await chat.GetResponseAsync(request.Message, cancellationToken: ct);
return response.Text;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
logger.LogInformation("Cancelled after {Elapsed} ms, model call stopped", sw.ElapsedMilliseconds);
throw;
}
});
$ curl -s --max-time 3 -X POST localhost:19622/chat/complete-leaky ...
curl exit 28
$ curl -s --max-time 3 -X POST localhost:19622/chat/complete ...
curl exit 28
# sunucu log'u
Cancelled after 3007 ms, model call stopped
Leaky: finished after 46586 ms, outputTokens=1024, clientGone=True
# Ollama log'u (boşluklar kısaltıldı)
[GIN] 2026/11/08 - 11:10:31 | 500 | 3.003718125s | 127.0.0.1 | POST "/api/chat"
[GIN] 2026/11/08 - 11:11:12 | 200 | 46.582353375s | 127.0.0.1 | POST "/api/chat"
İki istemci de 3 saniye sonra vazgeçti (curl çıkış kodu 28: timeout). Token'ı modele veren endpoint 3007 milisaniyede iptal edildi; Ollama da o isteği 3,0 saniyede kapattı (log'daki 500, istemcinin bağlantıyı kapatmasının Ollama tarafındaki kaydı). Token'ı vermeyen endpoint ise kullanıcı çoktan gitmişken çalışmaya devam etti: 46,6 saniye ve 1024 output token, hiç kimsenin okumayacağı bir hikâye için. clientGone=True satırı bunun kanıtı. Yerel Ollama'da bu, kuyruktaki başka kullanıcıların beklediği GPU (Graphics Processing Unit) zamanı; bulut provider'ında ise faturaya yazılan 1024 token. Açılıştaki ikinci sorunun cevabı da bu.
Heartbeat, Proxy Buffering ve Compression
SSE denemelerinde dikkatimi çeken bir şey oldu: cevabın başlıkları ancak ilk event yazıldığında geliyor. Model kuyrukta beklerken istemci hiçbir şey görmüyor; tarayıcı, proxy ya da load balancer bu sessizliği "ölü bağlantı" sayıp isteği kesebilir. Çözüm klasik: kaynak sessiz kaldıkça araya küçük bir "ping" event'i sokmak.
using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
namespace Bookstore.Assistant.Api;
public static class Heartbeat
{
// Kaynak sessiz kaldıkça her interval'de bir "ping" event'i araya sokar
public static async IAsyncEnumerable<SseItem<object>> WithHeartbeatAsync(
this IAsyncEnumerable<SseItem<object>> source,
TimeSpan interval,
[EnumeratorCancellation] CancellationToken ct = default)
{
Channel<SseItem<object>> channel = Channel.CreateUnbounded<SseItem<object>>();
Task producer = Task.Run(async () =>
{
try
{
await foreach (SseItem<object> item in source.WithCancellation(ct))
{
await channel.Writer.WriteAsync(item, ct);
}
channel.Writer.Complete();
}
catch (Exception ex)
{
channel.Writer.Complete(ex);
}
}, CancellationToken.None);
Task<bool>? ready = null;
while (true)
{
ready ??= channel.Reader.WaitToReadAsync(ct).AsTask();
if (await Task.WhenAny(ready, Task.Delay(interval, ct)) != ready)
{
yield return new SseItem<object>(new { }, "ping");
continue;
}
if (!await ready)
{
break;
}
ready = null;
while (channel.Reader.TryRead(out SseItem<object> item))
{
yield return item;
}
}
await producer;
}
}
Bu helper'ın etkisini yukarıdaki SSE çıktısında gördük: model ilk 35 saniye sıra beklerken ve sonra düşünürken istemci beş saniyede bir ping aldı ve bağlantı canlı kaldı.
Başlıklarda iki şey var. Cache-Control: no-cache,no-store, ara katmanların cevabı saklamamasını söylüyor. Content-Encoding: identity ise response compression'ı bu cevap için kapatıyor: gzip gibi bir sıkıştırıcı küçük parçaları biriktirip blok hâlinde gönderir, bu da streaming'i sessizce bozar. ServerSentEventsResult bu başlıkları kendisi ekliyor; kendi streaming cevabını elle yazıyorsan aynısını senin yapman gerekir.
Uygulamanın önünde nginx varsa bir adım daha var. nginx varsayılan olarak upstream cevabını buffer'lar; SSE için ya ilgili location'da proxy_buffering off; yazarsın ya da uygulama cevaba X-Accel-Buffering: no başlığını ekler. Başlık yöntemi, aynı nginx arkasındaki diğer endpoint'lerin buffer'lamaya devam etmesini sağlar.
Yarım Cevabı İstemciye Bildirmek
Streaming'in en az konuşulan sorunu hata yönetimi. Başlıklar ve ilk event gittikten sonra HTTP durum kodu artık 200'dür ve değiştirilemez; model yarıda kesilirse istemciye "500" diyemezsin. Bu yüzden hatayı protokolün içinde, bir error event'iyle bildiriyoruz. ErrorEvent'teki Partial alanı arayüze iki farklı durumu ayırt ettiriyor: hiç metin gelmediyse "tekrar dene" düğmesi, metnin bir kısmı geldiyse "cevap yarıda kesildi" uyarısı. Akışın düzgün bittiğini ise ayrı bir done event'i söylüyor; istemci bağlantının kapanmasını tek başına "bitti" saymamalı, çünkü proxy timeout'u da bağlantıyı kapatır.
TypeScript Tüketici
Tarayıcının EventSource API'si yalnız GET isteği atabilir ve gövde gönderemez. Mesajı POST ile göndermek istediğimiz için fetch ve ReadableStream ile küçük bir SSE okuyucu yazdım. Angular tarafındaki ayrıntılar ilerideki Angular ile AI (Artificial Intelligence) Arayüzleri serisinde; buradaki kod protokolün mantığını gösteriyor:
type SseEvent = { event: string; data: string };
// fetch + ReadableStream ile SSE okur; EventSource POST gövdesi gönderemediği için fetch kullanıyoruz
async function* readSse(response: Response): AsyncGenerator<SseEvent> {
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) return;
buffer += value;
let boundary: number;
while ((boundary = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
let event = 'message';
const data: string[] = [];
for (const line of block.split('\n')) {
if (line.startsWith('event:')) event = line.slice(6).trim();
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
}
yield { event, data: data.join('\n') };
}
}
}
const controller = new AbortController();
const message = process.argv[2] ?? 'Recommend one book about refactoring in one short sentence.';
const stopAfterDeltas = Number(process.argv[3] ?? 0);
const response = await fetch('http://localhost:19622/chat/sse', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message }),
signal: controller.signal,
});
let text = '';
let deltas = 0;
try {
for await (const { event, data } of readSse(response)) {
switch (event) {
case 'delta':
text += JSON.parse(data).text;
// Kullanıcının sekmeyi kapatmasını taklit et
if (stopAfterDeltas > 0 && ++deltas === stopAfterDeltas) controller.abort();
break;
case 'ping': console.log('[ping]'); break;
case 'usage': console.log('[usage]', data); break;
case 'error': console.log('[error]', data); break;
case 'done': console.log('[done]', text); break;
}
}
} catch (err) {
console.log(`[aborted] ${(err as Error).name}, partial text: "${text}"`);
}
AbortController.abort() fetch bağlantısını kapatır; sunucu tarafında bu, bir önceki bölümde ölçtüğümüz RequestAborted iptalini tetikler. Gerçek bir arayüzde aynı çağrıyı "durdur" düğmesine ve sayfadan ayrılma event'ine bağlarsın.
Üretimde Dikkat
Bağlantı sayısını hesapla. Her açık SSE bağlantısı sunucuda bir istek, bir iterator ve modele giden bir HTTP bağlantısı tutar. Uzun cevaplar ve yavaş modellerle eşzamanlı bağlantı sayısı hızla artar; Kestrel'in bağlantı sınırlarını ve önündeki proxy'nin timeout'larını buna göre ayarla.
Akışın toplam süresine sınır koy. Önceki yazıda gördüğümüz gibi HttpClient resilience handler'ının timeout'ları streaming'de yalnız ilk parçaya kadar olan süreyi kapsar. Bitmek bilmeyen bir akışı durdurmak için endpoint'te CancellationTokenSource.CreateLinkedTokenSource(ct) ile bir kopya alıp CancelAfter ile süre koy.
Ölç: ilk token süresi, toplam süre, iptal oranı. "Stream closed" log'undaki deltas, elapsed ve aborted alanları bu üç metriğin ham hâli; 8. yazıda bunları OpenTelemetry ile metric'e çevireceğiz. İptal oranı yüksekse kullanıcılar cevabı beklemiyor demektir; ya model yavaş ya cevaplar gereğinden uzun.
Sık Düşülen Tuzaklar
Birincisi, CancellationToken'ı modele vermemek. Ölçümde gördük: kullanıcı gittikten sonra da üretim sürer ve faturası sana yazılır.
İkincisi, iptali exception tipiyle ayırt etmek. HttpClient timeout'u da bir OperationCanceledException'dır. Kullanıcı iptali ile sistem hatasını ayırmanın güvenilir yolu kendi token'ının durumuna bakmaktır.
Üçüncüsü, düz IAsyncEnumerable'ı yeterli bir streaming protokolü sanmak. Token'lar parça parça gidiyor ama istemcinin elinde yarım bir JSON array var; hata, iptal, tool ve kullanım bilgisini taşıyacak bir yer yok ve akış ortada koparsa geriye geçersiz bir JSON kalıyor.
Dördüncüsü, response compression'ı ve proxy buffering'i unutmak. Uygulama doğru yazıyor olsa da gzip ya da nginx parçaları biriktirip toplu gönderirse kullanıcı yine bütün cevabı sonunda görür. TypedResults.ServerSentEvents compression'ı kendisi kapatıyor, proxy'yi ise senin ayarlaman gerekiyor.
Küçük Bir Deneme
★☆☆ Heartbeat aralığını 5 saniyeden 1 saniyeye indir ve uzun bir cevap iste. İstemci tarafında kaç ping görüyorsun? Model token üretirken ping gelmemesinin sebebi ne?
★★☆ ToSseAsync'e bir "ilk token süresi" ölçümü ekle: ilk delta yazıldığında geçen süreyi log'la ve usage event'ine timeToFirstTokenMs alanı olarak koy.
★★★ Bir "devam et" akışı tasarla: istemci her delta'nın sırasını tutsun, bağlantı koparsa Last-Event-ID başlığıyla yeniden bağlansın. Sunucu tarafında üretilmiş ama gönderilememiş parçaları nerede saklarsın? Model akışı kopunca o noktadan devam edebilir mi, yoksa cevabın tamamını mı yeniden üretmek gerekir?
Cevap artık kullanıcıya akıyor ve kullanıcı gidince model de duruyor. Ama her istek hâlâ tek başına; asistan bir önceki mesajı hatırlamıyor. Bir sonraki yazıda sohbet geçmişini ele alıyoruz: stateless bir provider'da geçmişi kim tutar, ChatMessage'ları tool call'larıyla birlikte EF (Entity Framework) Core'da saklamak ve context window dolarken chat reducer'larla geçmişi kırpmak.