LLM yanıtını token token ekrana basmak istediğinde karşına çıkan ilk soru şu olur: LLM streaming SSE nasıl yapılır ve bu gerçekten kullanıcı deneyimini değiştirir mi? Kısa cevap evet — ama SSE bağlantısını açmak işin sadece başlangıcı. Kısmi Markdown render etmek, AbortController ile isteği düzgün iptal etmek ve nginx gibi bir ters vekilin araya girip akışı sessizce tamponlamasını önlemek, gerçek işin büyük kısmını oluşturuyor. Bu yazıda Claude Messages API'nin ve OpenAI'ın resmi streaming davranışından yola çıkarak SSE'yi uçtan uca kuruyor, kısmi render ve iptal mekaniğini adım adım işliyoruz.
💡 Pro Tip: Akışı test ederken tarayıcının DevTools'unda Network sekmesinde yanıtı "EventStream" görünümünde izle — chunk'ların gerçekten parça parça mı yoksa tek seferde mi geldiğini burada görürsün; bir proxy tamponlama sorununu console.log satırlarından çok daha hızlı bu görünümde yakalarsın.
İçindekiler
- Algılanan gecikme: TTFT vs toplam süre
- SSE, WebSocket ve chunked yanıt karşılaştırması
- Sunucu tarafı: proxy, buffer ve timeout tuzakları
- Kısmi Markdown/kod bloğu render etmek
- AbortController ile iptal ve maliyet durdurma
- Hata, yeniden bağlanma ve kısmi yanıt kurtarma
- Mobil tarafta streaming (Flutter/Dart)
- SSS
- LLM yanıtı nasıl stream edilir?
- SSE mi WebSocket mi kullanmalıyım?
- Kullanıcı iptal edince token maliyeti durur mu?
- Stream sırasında Markdown/JSON güvenle nasıl render edilir?
- nginx arkasında SSE neden çalışmıyor gibi görünüyor?
- Güncelleme (Eylül 2026)
- Sonuç
- Kaynaklar
Algılanan gecikme: TTFT vs toplam süre
Kullanıcı bir LLM yanıtı beklerken hissettiği gecikme, modelin toplam üretim süresinden çok ilk token'ın ekrana düştüğü ana (time-to-first-token, TTFT) bağlıdır. Streaming kapalıyken istemci, model tüm yanıtı bitirene kadar hiçbir şey görmez; streaming açıkken ise ilk parça çok daha erken gelir ve arayüz "sistem çalışıyor" sinyalini erken verir.
OpenAI'ın kendi cookbook örneği bunu somut biçimde gösteriyor: "with the streaming request, we received the first token after 0.1 seconds, and subsequent tokens every ~0.01-0.02 seconds" — yani streaming açık bir istekte ilk token yaklaşık 0,1 saniyede geliyor, sonraki tokenlar da 10-20 milisaniye aralıklarla akıyor. Bu rakamlar OpenAI'ın kendi örnek çalıştırmasına ait, genel bir performans garantisi değil; ama TTFT'nin toplam süreden neden ayrı ölçülmesi gerektiğini net gösteriyor.
Bu davranışın API yüzeyindeki karşılığı da farklı: OpenAI'ın streaming yanıtlarında her chunk'ta tam bir message alanı yerine delta alanı gelir — "delta can hold things like: a role token (e.g., {\"role\": \"assistant\"}), a content token (e.g., {\"content\": \"\\n\\n\"}), nothing (e.g., {}), when the stream is over". usage alanını görebilmek için stream_options={"include_usage": true} göndermen gerekir; bunu ayarladığında son chunk hariç tüm chunk'larda usage null gelir, tüm isteğin token sayımı yalnızca son (ekstra) chunk'ta bulunur. Pratik sonuç: kendi token/maliyet sayacını kurarken ara chunk'lara güvenemez, ya son chunk'ı ya da sağlayıcının kümülatif sayaç olayını (Claude'da message_delta.usage, bkz. bir sonraki bölüm) beklemen gerekir.
Özellik | stream=false (klasik) | stream=true (SSE) |
|---|---|---|
İlk içerik istemciye ne zaman ulaşır | Tüm yanıt bitince | İlk token üretilir üretilmez |
usage/token sayacı | Tek seferde, yanıtla birlikte | Ara chunk'larda null, son chunk'ta (veya kümülatif event'te) dolu |
İçerik moderasyonu | Tam metin üzerinde, tek geçişte | Kısmi metin üzerinde, daha zor — OpenAI bunu kendi dokümanında açıkça belirtiyor |
İstemci karmaşıklığı | Düşük (tek response parse) | Yüksek (event/parça biriktirme, kısmi render, iptal) |
SSE, WebSocket ve chunked yanıt karşılaştırması
Sunucudan istemciye tek yönlü bir veri akışı kurmak için üç seçeneğin var: Server-Sent Events (SSE), WebSocket ve fetch'in ham ReadableStream gövdesi. Neredeyse tüm LLM sağlayıcıları SSE'yi seçiyor, çünkü ihtiyaç genelde tek yönlü: sunucu konuşuyor, istemci dinliyor.
MDN'in tanımıyla SSE, "it's possible for a server to send new data to a web page at any time, by pushing messages to the web page" — yani sunucunun sayfaya istediği an mesaj "push" edebildiği bir modeldir. EventSource arayüzü bunu tarayıcıda soyutlar ve iki önemli davranışı ücretsiz verir: "By default, if the connection between the client and server closes, the connection is restarted" (otomatik yeniden bağlanma) ve sunucunun retry: alanıyla bu yeniden bağlanma süresini milisaniye cinsinden önerebilmesi.
WebSocket ise MDN'e göre "makes it possible to open a two-way interactive communication session between the user's browser and a server" — yani iki yönlü, etkileşimli bir oturum açar. LLM sohbetinde istemciden sunucuya giden trafik genelde tek bir istek (prompt) olduğundan, WebSocket'in getirdiği iki yönlülük çoğu zaman gereksiz karmaşıklıktır.
Üçüncü seçenek, SSE'nin protokol katmanını hiç kullanmadan doğrudan fetch üzerinden okumak: "the body read-only property of the Response interface is a ReadableStream of the body contents" — yani fetch() çağrısının döndürdüğü Response.body, parça parça (chunk chunk) okunabilen bir stream'dir. Bazı SDK'lar SSE zarfını (event:/data: satırları) hiç kullanmadan, ham chunked body üzerinden kendi JSON-lines formatlarını akıtır; mantık aynıdır, yalnızca zarf farklıdır.
Kriter | SSE | WebSocket | Chunked fetch (ham ReadableStream) |
|---|---|---|---|
Yön | Tek yönlü (sunucu → istemci) | İki yönlü | Tek yönlü (sunucu → istemci) |
Protokol | HTTP üzerinde, text/event-stream | Ayrı ws:///wss:// el sıkışması | Düz HTTP |
Otomatik yeniden bağlanma | Var ( EventSource yerleşik) | Yok, elle yazılır | Yok, elle yazılır |
Tarayıcı bağlantı sınırı | HTTP/1.1'de tarayıcı+domain başına 6 eşzamanlı bağlantı | Ayrı limit, SSE'den bağımsız | Normal HTTP bağlantı sınırları |
Tipik LLM kullanımı | Çoğu sağlayıcının varsayılanı | Nadiren (çift yön gerektiğinde) | SDK'ların iç implementasyonu |
SSE'nin bilinen sınırı da yine MDN'de resmi olarak belgeli: "SSE suffers from a limitation to the maximum number of open connections... the limit is per browser and is set to a very low number (6)" — HTTP/2 kullanılmıyorsa bu tavan çok sekmeli kullanım senaryolarında sorun çıkarabilir; pratik çözüm sunucuyu HTTP/2 (veya HTTP/3) üzerinden terminasyon etmektir.
Sunucu tarafı: proxy, buffer ve timeout tuzakları
SSE'nin "çalışmıyor gibi görünmesinin" en sık nedeni istemci kodu değil, aradaki ters vekildir. nginx dokümantasyonu varsayılan davranışı net söylüyor: "nginx receives a response from the proxied server as soon as possible, saving it into the buffers set by the proxy_buffer_size and proxy_buffers directives" — yani nginx varsayılan olarak yukarı akış yanıtını tamponlara toplar, sana chunk chunk değil, dolduktan sonra iletir. Sonuç: kodun doğru, ama tarayıcıda yanıt hâlâ "hepsi bir anda" geliyor.
Çözüm iki yoldan biri:
nginx
1location /api/stream {2 proxy_pass http://upstream_llm;3 proxy_buffering off;4 proxy_read_timeout 3600s;5}nginx dokümantasyonu proxy_buffering off davranışını şöyle tarif ediyor: "the response is passed to a client synchronously, immediately as it is received" — yanıt alındığı anda senkron biçimde istemciye geçer. Aynı kontrolü tüm location için değil, tek bir yanıt için istiyorsan yukarı akış uygulamasının başlığına da yazabilirsin: "Buffering can also be enabled or disabled by passing 'yes' or 'no' in the 'X-Accel-Buffering' response header field" — yani backend'in kendisi X-Accel-Buffering: no başlığı döndürerek, nginx konfigürasyonuna dokunmadan tamponlamayı kapatabilir.
İkinci tuzak proxy_read_timeout ile ilgili: "The timeout is set only between two successive read operations, not for the transmission of the whole response" — yani bu süre toplam yanıt süresini değil, art arda iki okuma arasındaki boşluğu sınırlar. Model uzun bir araç çağrısı hazırlarken bir süre hiç delta göndermezse (örneğin büyük bir tool_use bloğu oluşturuyorsa), bağlantı toplam süre dolmadan da bu sessiz aralık yüzünden kapanabilir; bu yüzden streaming uç noktalarında proxy_read_timeout'u makul biçimde yükseltmek gerekir.
Kısmi Markdown/kod bloğu render etmek
İstemci tarafında gördüğün her text_delta, kendi başına anlamlı bir Markdown/kod parçası değildir — bir kod fence'inin ortasında ya da bir tablo satırının ortasında kesilebilir. Anthropic'in dokümantasyonu, benzer bir problemi araç girdisi (tool_use) için şöyle tarif ediyor: "the deltas are partial JSON strings, whereas the final tool_use.input is always an object" — yani parçalar birer JSON string kırıntısı, tam nesne yalnızca birikim bitince ortaya çıkıyor. Aynı prensip metin render'ı için de genellenebilir: ham deltaları bir arabellekte biriktir, her yeni parçada elindeki tüm birikmiş metni yeniden ayrıştır (re-parse), DOM'a yalnızca son hâli yaz.
ts
1let buffer = "";2 3function onTextDelta(delta: string) {4 buffer += delta;5 // Her delta'da TÜM birikmiş metni yeniden parse et,6 // ham kırık Markdown'ı DOM'a asla doğrudan basma.7 const html = renderMarkdownSafely(buffer);8 articleEl.innerHTML = html;9}10 11function onContentBlockStop() {12 // Blok bittiğinde nihai, tam hâli bir kez daha çiz.13 articleEl.innerHTML = renderMarkdownSafely(buffer);14}Yapılandırılmış çıktı (JSON Schema) döndüren akışlarda aynı "biriktir, sınırda birleştir" mantığı geçerli; o konuyu ayrıca LLM'den yapılandırılmış çıktı almak: JSON Schema yazısında derinlemesine işledik — burada asıl fark, kısmi JSON'un ekrana hiç basılmadan yalnızca arka planda toplanmasıdır, kısmi Markdown'da ise kullanıcı gördüğü için her delta'da yeniden çizim gerekir. Kod fence'i tespiti için pratik bir kural: birikmiş metindeki kod fence işaretinin (üç ters tırnak) sayısı tekse, son fence'i "kapalıymış gibi" render edip kapanış işaretini gelene kadar açık bırakmak, kullanıcıya yarım kalmış bir kod bloğunun daha okunabilir görünmesini sağlar.
AbortController ile iptal ve maliyet durdurma
Kullanıcı "dur" dediğinde ihtiyacın olan araç tarayıcıda zaten hazır: AbortController. MDN'in AbortController.abort() tanımıyla bu API "Aborts an asynchronous operation before it has completed. This is able to abort fetch requests, the consumption of any response bodies, or streams" — yani hem devam eden fetch isteğini hem de gövde tüketimini (dolayısıyla stream okumasını) tek çağrıyla durdurabiliyorsun.
ts
1let controller: AbortController | null = null;2const decoder = new TextDecoder();3 4async function streamAnswer(prompt: string) {5 controller = new AbortController();6 const res = await fetch("/api/stream", {7 method: "POST",8 body: JSON.stringify({ prompt }),9 signal: controller.signal,10 });11 12 const reader = res.body!.getReader();13 while (true) {14 const { done, value } = await reader.read();15 if (done) break;16 onTextDelta(decoder.decode(value, { stream: true }));17 }18}19 20// Kullanıcı "durdur" butonuna bastığında:21cancelButton.addEventListener("click", () => controller?.abort());Burada dikkat etmen gereken bir sınır var: controller.abort() istemci tarafında bağlantıyı ve okuma döngüsünü kesin olarak durdurur, ama sağlayıcı sunucusunun modelin üretimini o an durdurup durdurmayacağı ya da o ana kadar üretilen token'ların faturalandırılıp faturalandırılmayacağı, resmi Anthropic ya da OpenAI dokümantasyonunda net biçimde açıklanmıyor. Bu ayrıntıyı sağlayıcıdan sağlayıcıya değişen bir davranış olarak kabul et; "istemci iptal etti, faturalandırma da anında durdu" gibi kesin bir varsayımla kod yazma — bağlantıyı kapatman garanti, sunucunun ne yaptığı garanti değil.
Hata, yeniden bağlanma ve kısmi yanıt kurtarma
SSE'nin kendi protokolü, hata ve kısmi kurtarma için zaten bir sözleşme sunuyor. Claude Messages API akışı sırasında bir hata oluşursa, bunu ayrı bir event olarak gönderiyor:
text
1event: error2data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}Bu, normal bir HTTP 529 hata kodunun akış-içi karşılığı gibi düşünülebilir — bağlantı açık kalır, hata bir olay olarak akışın içinden geçer. İstemci tarafında EventSource kullanıyorsan bağlantı koparsa zaten otomatik olarak yeniden bağlanır ve sunucu retry: alanıyla bekleme süresini önerebilir: "the browser will wait for the specified time before attempting to reconnect. This must be an integer, specifying the reconnection time in milliseconds." Kendi fetch+ReadableStream implementasyonunu yazdıysan bu otomatik yeniden bağlanmayı elle kurman gerekir; EventSource'un sana ücretsiz verdiği bu davranışı kaybedersin.
Kaldığın yerden devam etme (resume) konusunda dikkatli ol: hem Anthropic hem OpenAI dokümantasyonunda "bağlantı koptu, kaldığı token'dan devam et" garantisi resmi olarak belgeli değil; pratikte istemciler genelde isteği baştan tekrar gönderir. Yeniden bağlanma kodunu buna göre tasarla — "SSE otomatik yeniden bağlanır" ile "isteğin kaldığı yerden devam eder" birbirinden farklı iki iddia, sadece ilki resmi olarak garanti altında.
Mobil tarafta streaming (Flutter/Dart)
Flutter/Dart tarafında akışı tüketmek, web tarafındaki ReadableStream mantığının doğal bir karşılığı. Dart'ın resmi dokümantasyonu HttpClientResponse için şunu söylüyor: "The body of an HttpClientResponse object is a Stream of data from the server" ve sınıfın implemente ettiği tipler arasında doğrudan Stream<List<int>> yer alıyor. Yani gelen HTTP yanıtı, transform/listen gibi standart Stream API'leriyle parça parça işlenebiliyor:
dart
1final request = await httpClient.getUrl(uri);2final response = await request.close();3 4final buffer = StringBuffer();5await for (final chunk in response.transform(utf8.decoder)) {6 buffer.write(chunk);7 onPartialText(buffer.toString());8}ALTIN İPUCU
Bu yazının en değerli bilgisi
Bu ipucu, yazının en önemli çıkarımını içeriyor.
Easter Egg
Gizli bir bilgi buldun!
Bu bölümde gizli bir bilgi var. Keşfetmek ister misin?
Okuyucu Ödülü
Bu bölümü açtığın için teşekkürler — burada, yazının geri kalanında dağınık biçimde geçen pratik kuralları tek bir kontrol listesinde topladık. Bir SSE tabanlı streaming özelliğini production'a çıkarmadan önce bu listeyi bir kez gözden geçir.
SSS
LLM yanıtı nasıl stream edilir?
Sunucu tarafında modelin stream: true (Claude Messages API) veya stream=True (OpenAI) parametresiyle çağrılması, yanıtı text/event-stream içerik tipiyle parça parça (SSE olay dizisi olarak) döndürmesini sağlar; istemci tarafında ise bu olaylar ya EventSource ile ya da fetch'in Response.body üzerinden okunan ReadableStream'iyle tüketilir.
SSE mi WebSocket mi kullanmalıyım?
Sadece sunucudan istemciye tek yönlü bir metin akışı gönderiyorsan (LLM sohbetinin standart senaryosu budur) SSE yeterli ve daha basittir: EventSource otomatik yeniden bağlanma ve retry alanını ücretsiz verir. İstemciden sunucuya da sürekli, düşük gecikmeli veri göndermen gerekiyorsa (ör. ortak imleç konumu, canlı ses) WebSocket'in iki yönlü modeli daha uygun olur.
Kullanıcı iptal edince token maliyeti durur mu?
İstemci tarafında AbortController.abort() bağlantıyı ve okuma döngüsünü kesin olarak durdurur. Ancak sağlayıcının o ana kadar üretilen token'ları faturalandırıp faturalandırmayacağı ya da modelin sunucu tarafında üretime devam edip etmeyeceği, resmi Anthropic/OpenAI dokümantasyonunda açıkça belirtilmiyor; bunu sağlayıcıya özgü, doğrulanması gereken bir davranış olarak ele al.
Stream sırasında Markdown/JSON güvenle nasıl render edilir?
Ham delta'ları asla doğrudan DOM'a yazma. Bunun yerine tüm parçaları bir string arabellekte biriktir, her yeni parçada birikmiş metnin tamamını güvenli bir Markdown/JSON ayrıştırıcıdan geçir ve yalnızca dönüştürülmüş sonucu ekrana bas; blok tamamlandığında (content_block_stop) son bir kez daha tam render yap.
nginx arkasında SSE neden çalışmıyor gibi görünüyor?
Büyük olasılıkla nginx'in varsayılan proxy_buffering on davranışı yanıtı tamamen toplayıp sonra gönderiyordur. proxy_buffering off ayarını location bloğuna eklemek ya da backend'den X-Accel-Buffering: no başlığı döndürmek, yanıtın alındığı anda senkron biçimde istemciye iletilmesini sağlar.
Güncelleme (Eylül 2026)
Bu yazının gövdesi 2026-06-09 tarihindeki SSE/EventSource temel mekaniğini, nginx buffering davranışını ve OpenAI'ın stream=True sözleşmesini anlatıyor; bu temel mekanikler o tarihten bu yana değişmedi. Ancak kısmi render ve iptal konusuna doğrudan değen iki gelişme, resmi kaynaklarla doğrulanabildiği için burada not düşülüyor.
30 Haziran 2026'da Claude'un ajan oturum akışına (GET /v1/sessions/{id}/events/stream) parça-parça önizleme ekleyen bir güncelleme geldi: ajan mesaj metni artık tek parça bir olay yerine önceden, kısım kısım akıtılabiliyor — makalenin "kısmi render" bölümündeki biriktir-ve-yeniden-çiz deseninin, sohbet dışı ajan akışlarına da uzandığının bir göstergesi. Kaynak: platform.claude.com/docs/en/release-notes/api.
1 Eylül 2026'da ise düşünme/reasoning akışı için thinking.display: "updates" (beta) seçeneği yayınlandı: model artık düşünme metninin tamamını stream etmek yerine yalnızca araç çağrıları arasındaki ilerleme güncellemelerini akıtabiliyor. Bu, "stream sırasında kullanıcıya ne kadarını göstermeliyim" sorusuna sağlayıcı tarafından eklenmiş yeni bir seçenek. Kaynak: platform.claude.com/docs/en/build-with-claude/streaming.
Sonuç
SSE ile LLM yanıtı stream etmek, tek bir stream: true parametresinden ibaret değil; asıl iş kısmi render'ı güvenle yapmakta, AbortController ile iptali doğru bağlamakta ve aradaki her ters vekilin (nginx, CDN) akışı tamponlamadığından emin olmakta. tool_use çıktısı üretiyorsan parçalı JSON'u nasıl toplayıp ayrıştıracağını LLM'den yapılandırılmış çıktı almak: JSON Schema yazısında, bu akışı bir ajan döngüsüne bağlayacaksan planlayıcı deseni Agentic AI: tool use, planner loop ve production yazısında bulabilirsin. Streaming'in altında çalışan protokolün kendisini merak ediyorsan MCP (Model Context Protocol) ile AI entegrasyonu ve Claude Code ve MCP yazıları temel event-tabanlı iletişim modelini daha geniş bağlamda anlatıyor. Token maliyetini stream açıkken de kontrol altında tutmak istiyorsan Prompt Caching ile maliyeti 10 kat azaltmak yazısı tamamlayıcı bir kaynak.
Pratikte en çok kırılan yer neredeyse hiç istemci kodu olmuyor — bir ters vekilin varsayılan tamponlama ayarı ya da bir timeout değeri, aylarca doğru yazılmış bir streaming implementasyonunu "çalışmıyormuş gibi" gösterebiliyor. Yukarıdaki kontrol listesini bir kez uyguladıktan sonra bu sınıf hataların çoğu ortadan kalkar.
Kaynaklar
- Claude Messages API — Streaming Messages — SSE event tipleri,
text_delta,tool_useparçalı JSON vemessage_delta.usagedavranışının resmi tanımı. - MDN — Server-sent events — SSE'nin temel modeli.
- MDN — Using server-sent events —
EventSourceotomatik yeniden bağlanma,retryalanı ve tarayıcı bağlantı sınırı. - MDN — AbortController — fetch isteklerini ve stream tüketimini iptal etme.
- MDN — AbortController.abort() —
abort()metodunun birebir tanımı. - MDN — The WebSocket API — iki yönlü iletişim oturumu tanımı.
- MDN — Response.body —
ReadableStreamolarak gövde okuma. - nginx — ngx_http_proxy_module —
proxy_buffering,X-Accel-Buffering,proxy_read_timeoutdavranışı. - OpenAI Cookbook — How to stream completions —
stream=True,deltaalanı ve TTFT örneği. - Dart — HttpClientResponse class —
Stream<List<int>>olarak yanıt gövdesi.

