Flutter'da LLM Yanıtını Stream Etmek: Dio ile Kendi SSE İstemcinizi Yazmak

SSE kolay, zor olan aradaki katmanlar
Özet (TL;DR)
- SSE parser'ı baytlarla değil satırlarla çalışır. Önce
Utf8Decoderstream'i decode eder, sonraLineSplittersatırlara böler. Her parçaya ayrı ayrıutf8.decodeçağırmak Türkçe harfi ikiye böler. - Dio'nun
receiveTimeoutdeğeri stream modunda iki parça arasındaki sessizliği ölçer. Başlıklar geldikten sonra ilk parça hiç gelmezse sayaç hiç başlamaz, ilk token için ayrı bir sınır gerekir. async*içindeawait forile yazılmış bir transformer, iptali kaynaktan bir sonraki olay gelene kadar bekletir. Ağ sustuğu sürece soket de açık kalır.- Bağlantının kopması ile kullanıcının durdurması ayrı olaylardır. Üretimi bağlantıya değil mesaja bağlayın, durdurmayı ayrı bir istekle gönderin, kopan stream'e
Last-Event-IDile dönün. - nginx 1.29.8 ile yapılan ölçümde varsayılan tamponlama olayları tek başına geciktirmedi.
text/event-streamiçin gzip açılınca bütün yanıt en sona yığıldı.X-Accel-Buffering: noikisini birden çözdü.
Model yanıt üretiyor, kelimeler ekranda birer birer beliriyor ve bir yerde ��alışıyor yazıyor. Bir sonraki denemede aynı cümle tertemiz geliyor. Başka bir gün uygulama yirmi saniye boş ekran gösteriyor, sonra yanıtın tamamı tek seferde düşüyor. İki durumda da suç protokolde değil. Sorun ya istemcinin baytları nasıl okuduğunda ya da sunucuyla telefon arasındaki bir katmanda.
LayeredMindAI Mobile'da sohbet yanıtları Server-Sent Events ile stream ediliyor ve istemci hazır bir SSE paketi değil, doğrudan Dio üzerine yazıldı. Aşağıda önce protokolü, sonra Dart'ta bağımsız bir parser'ı, en son da mobil ağın ve sunucunun ona neler yaptığını anlatıyorum. Örnekler Dart 3.10, Dio 5.9.2 ve küçük bir Node sunucusuyla çalıştırıldı.
Protokol bir sayfaya sığar
SSE, WHATWG HTML standardının 9.2 numaralı bölümünde tanımlı ve kuralları bir solukta sayılır. Stream her zaman UTF-8'dir, başka bir karakter kodlaması seçmenin yolu yoktur. Satırlar CRLF, tek başına LF ya da tek başına CR ile biter. Boş satır olayı dispatch eder, yani tamamlayıp dinleyiciye iletir. İki noktayla başlayan satır yorumdur. Geri kalan her satır alan: değer biçimindedir ve iki noktadan sonraki tek bir boşluk değere dahil edilmez, ikinci boşluk edilir.
| Alan | Ne yapar | Gözden kaçan ayrıntı |
|---|---|---|
data | Değeri veri tamponuna ekler, arkasına LF koyar | Çok satırlı veri art arda data: satırlarıdır. Dispatch sırasında sondaki tek LF silinir. |
event | Olay türünü belirler | Boş bırakılırsa tür message olur. |
id | Son olay kimliği tamponunu ayarlar | NULL karakteri içeren değer yok sayılır. Değersiz id kimliği sıfırlar. |
retry | Yeniden bağlanma süresini milisaniye olarak ayarlar | Yalnızca ASCII rakamlarsa kabul edilir. 5s sessizce yok sayılır. |
: (yorum) | Hiçbir şey | Heartbeat için ideal, istemcide olay üretmez. |
Kendi parser'ınızı yazarken en sık atlanan üç kural var. İlki, id değeri olay tamamlanmadan kesinleşmez. Sunucu id: 8 yazıp boş satırı gönderemeden bağlantı koparsa yeniden bağlanırken bildirilecek kimlik hâlâ 7'dir. İkincisi, stream boş satırla bitmeden kapanırsa yarım olay dispatch edilmez, atılır. Üçüncüsü, alan adları büyük-küçük harfe duyarlıdır. Data: ile başlayan satır hiçbir şey ifade etmez.
Yeniden bağlanan istemci son kimliği Last-Event-ID başlığıyla sunucuya bildirir. Tarayıcıdaki EventSource bunu kendiliğinden yapar, 200 dışında bir durum kodu ya da text/event-stream olmayan bir içerik türü görünce de bağlantıyı kalıcı olarak kapatır. Sunucu yeniden bağlanmayı kesmek isterse 204 No Content döner.
Neden hazır bir paket değil
EventSource yalnızca bir URL ve withCredentials seçeneği alır. Yöntem her zaman GET'tir, gövde gönderemez, Authorization başlığı ekleyemezsiniz. LLM sohbeti ise doğası gereği bir POST: prompt, konuşma kimliği, model ayarları ve ekler gövdede gider. GET ile POST arasındaki anlam farkı yeniden bağlanırken de önemli.
Otomatik yeniden bağlanma GET için zararsızdır. Aynı davranışı bir POST'a uygularsanız her kopuşta prompt'u baştan gönderir ve sunucuda ikinci, üçüncü bir üretim başlatırsınız. Bu hem boşa yakılan token hem de ekranda birbirine karışan iki yanıt demek. Yeniden bağlanma, hangi mesaja hangi kimlikten devam edileceğini bilen ve tekrarlanmasında sakınca olmayan ayrı bir istek olmalı.
LayeredMindAI Mobile'da kimlik doğrulama, token yenileme, hata normalleştirme ve loglama zaten Dio interceptor'larında duruyor, SSL pinning de aynı istemcide. Kendi HTTP istemcisini getiren bir SSE paketi bunların hepsinin yanından geçer. Stream aynı Dio istemcisinden geçince süresi dolmuş bir access token stream'de de diğer isteklerdeki gibi ele alınır. Geriye üç iş kalıyor: CancelToken ile iptal, durum kodlarını kendi kurallarınızla yorumlamak ve baytları olaylara çeviren küçük bir parser.
Parser: satırdan olaya
Parser'ı Dio'dan ve Flutter'dan bağımsız tutmak işin yarısı. Satır alır, olay döndürür, saf Dart olduğu için dart test ile saniyeler içinde sınanır.
import 'dart:convert'; class SseEvent { const SseEvent(this.type, this.data, this.id); final String type; final String data; final String id; @override String toString() => 'SseEvent($type, ${jsonEncode(data)}, id=$id)';} /// WHATWG HTML §9.2.6'daki yorumlama kurallarının satır düzeyindeki hâli.class SseParser { final _data = StringBuffer(); var _type = ''; var _idBuffer = ''; /// Yalnızca bir olay dağıtıldığında güncellenir. Yeniden bağlanırken /// `Last-Event-ID` başlığına bu değer yazılır. String lastEventId = ''; /// Sunucu `retry:` gönderdiyse milisaniye cinsinden bekleme süresi. int? retryMs; SseEvent? addLine(String line) { if (line.isEmpty) return _dispatch(); if (line.startsWith(':')) return null; // yorum, heartbeat final colon = line.indexOf(':'); final field = colon == -1 ? line : line.substring(0, colon); var value = colon == -1 ? '' : line.substring(colon + 1); if (value.startsWith(' ')) value = value.substring(1); switch (field) { case 'data': _data ..write(value) ..write('\n'); case 'event': _type = value; case 'id': if (!value.contains('\u0000')) _idBuffer = value; case 'retry': if (RegExp(r'^[0-9]+$').hasMatch(value)) retryMs = int.parse(value); } return null; } SseEvent? _dispatch() { lastEventId = _idBuffer; if (_data.isEmpty) { _type = ''; return null; } var data = _data.toString(); if (data.endsWith('\n')) data = data.substring(0, data.length - 1); final event = SseEvent(_type.isEmpty ? 'message' : _type, data, lastEventId); _data.clear(); _type = ''; return event; }} /// Bayt akışını olay akışına çevirir.Stream<SseEvent> sseEvents(Stream<List<int>> bytes, [SseParser? parser]) { final p = parser ?? SseParser(); return const LineSplitter() .bind(const Utf8Decoder(allowMalformed: true).bind(bytes)) .expand((line) { final event = p.addLine(line); return event == null ? const <SseEvent>[] : [event]; });}_idBuffer ile lastEventId ayrımı standarttaki kimlik kuralının karşılığı. Tampon her id: satırında değişir ama dışarıya yalnızca boş satırda, yani olay tamamlanınca yansır. lastEventId dispatch'ten sonra sıfırlanmaz, bu yüzden kendi kimliği olmayan bir olay bir öncekinin kimliğini taşır. Testlerin bir kısmı doğrudan standarttaki örnek stream'lerden geliyor. En işe yarayanı, Türkçe bir satırı mümkün olan her bayt sınırından ikiye bölüp aynı sonucu bekleyen test:
test('Türkçe metin her bayt sınırından bölünse de bozulmaz', () async { final bytes = b('data: {"t":"Çalışıyor, ğüşİı"}\n\n'); for (var cut = 1; cut < bytes.length; cut++) { final events = await parse([bytes.sublist(0, cut), bytes.sublist(cut)]); expect(events.single.data, '{"t":"Çalışıyor, ğüşİı"}', reason: 'kesim $cut'); } }); test('id yalnızca olay tamamlanınca kesinleşir', () async { final p = SseParser(); await parse([b('id: 7\ndata: a\n\nid: 8\ndata: yarım')], p); expect(p.lastEventId, '7'); });$ dart test00:00 +11: All tests passed!Bayt sınırı harf sınırı değildir
Ağ baytları istediği yerden bölünmüş teslim eder. UTF-8'de ç iki bayttır (C3 A7) ve TCP bu iki baytı ayrı parçalara koymakta hiçbir sakınca görmez. Deneme sunucusu her olayı iki yazmaya böldüğünde Dio'nun verdiği 32 parçanın 11'i bir harfin ortasında bitti. Her parçayı ayrı decode eden kod bunu şöyle yaşar:
import 'dart:convert'; void main() { final bytes = utf8.encode('data: Şimdi çalışıyor\n\n'); // Ağ, "ç" harfinin iki baytını iki ayrı parçaya bölmüş olsun. final cut = bytes.indexOf(0xC3) + 1; final chunks = [bytes.sublist(0, cut), bytes.sublist(cut)]; print('parçalar: ${chunks.map((c) => c.length).toList()}'); try { print(chunks.map((c) => utf8.decode(c)).join()); } on FormatException catch (e) { print('utf8.decode: ${e.message}'); } print('allowMalformed: ${chunks.map((c) => utf8.decode(c, allowMalformed: true)).join().trim()}');}$ dart run bin/naive.dartparçalar: [14, 14]utf8.decode: Unfinished UTF-8 octet sequenceallowMalformed: data: Şimdi ��alışıyorallowMalformed kapalıyken exception, açıkken iki replacement character (U+FFFD) alırsınız. Baytları String.fromCharCodes ile karaktere çevirmek de Türkçe karakter bozulmasının tanıdık görüntüsünü üretir. Çare, decoder'ı parçalara değil stream'in kendisine uygulamak. Utf8Decoder yarım kalan baytları bir sonraki parçaya saklar. Ardından gelen LineSplitter SSE'nin istediğiyle birebir örtüşür: CR, LF ve CRLF'yi satır sonu sayar, bir parçanın sonunda kalan CR'den sonra gelen LF'yi sonraki parçada yutar.
Parser'daki allowMalformed: true bilinçli bir seçim. Standart, stream'in UTF-8 decode algoritmasıyla decode edilmesini ister ve bu algoritma bozuk diziyi hata fırlatmadan replacement character'a çevirir. Aynı algoritma baştaki BOM'u da atar. Dart'ın decoder'ı bunu kendisi yapıyor, testlerden biri bunu da doğruluyor.
Uyarı
Stream<Uint8List> tipindedir. Onu Stream<List<int>> bekleyen bir fonksiyona verip içeride .transform(utf8.decoder) çağırırsanız derleyici ses çıkarmaz, ama çalışma zamanında type 'Utf8Decoder' is not a subtype of type 'StreamTransformer<Uint8List, String>' hatası alırsınız. Birim testler List<int> parçalarla yazıldığı için bunu yakalamaz. Utf8Decoder().bind(stream) biçimi bu tuzağa düşmez.İptal neden üç saniye sürer
Parser'ı async* ile yazmak akla gelen ilk yol: await for ile satırları oku, olayları yield et. Bu sürüm bütün testleri geçer. Sorun iptalde ortaya çıkar. Kaynağın üç saniye sustuğu, dinleyicinin birinci saniyede vazgeçtiği bir deneme:
import 'dart:async'; // Ağdan 3 saniye hiç bayt gelmiyor. Dinleyici 1. saniyede vazgeçiyor.Future<void> measure(String name, Stream<String> Function(Stream<String>) wrap) async { final sw = Stopwatch()..start(); final source = StreamController<String>( onCancel: () => print('$name: kaynak ${sw.elapsedMilliseconds} ms\'de kapandı')); Timer(const Duration(seconds: 3), () => source.add('data: geç kalan')); final sub = wrap(source.stream).listen((_) {}); await Future<void>.delayed(const Duration(seconds: 1)); await sub.cancel(); print('$name: cancel() ${sw.elapsedMilliseconds} ms\'de döndü');} Stream<String> withAsyncStar(Stream<String> lines) async* { await for (final line in lines) { yield line.toUpperCase(); }} Future<void> main() async { await measure('async*', withAsyncStar); await measure('expand', (s) => s.expand((line) => [line.toUpperCase()]));}$ dart run bin/iptal.dartasync*: kaynak 3006 ms'de kapandıasync*: cancel() 3008 ms'de döndüexpand: kaynak 1003 ms'de kapandıexpand: cancel() 1003 ms'de döndüBir async* generator'ı await for içinde beklerken subscription'ı iptal ederseniz generator kaynaktan bir sonraki olay gelene kadar uyanmaz ve iptal ancak o zaman tamamlanır. Kaynak bir HTTP yanıtıysa ağ sustuğu sürece soket açık kalır. Daha sinsi olanı şu: await for döngüsü bir hata aldığında önce subscription'ı iptal eder, hatayı ancak iptal bitince teslim eder. Stream'e iki saniyelik timeout koyduğunuzda TimeoutException üçüncü saniyede gelir. Aynı dönüşüm expand ile yazılınca iptal anında kaynağa ulaşıyor. Yukarıdaki sseEvents bu yüzden async* değil.
Dio ile bağlamak
class ChatStream { ChatStream(this.dio); final Dio dio; final parser = SseParser(); CancelToken? _cancel; String? messageId; Stream<SseEvent> start(String prompt) => _open('POST', '/chat', {'prompt': prompt}); /// Kopan ya da arka planda bırakılan akışa kaldığı yerden bağlanır. Stream<SseEvent> resume() => _open('GET', '/chat/$messageId/stream', null); /// Bağlantıyı bırakır ama üretimi durdurmaz. void detach() => _cancel?.cancel('detach'); /// Kullanıcı "durdur"a bastı: önce ekranı serbest bırak, sonra sunucuya söyle. Future<void> stop() async { _cancel?.cancel('stop'); if (messageId != null) await dio.post<void>('/chat/$messageId/stop'); } Stream<SseEvent> _open(String method, String path, Object? body) async* { final cancel = _cancel = CancelToken(); final Response<ResponseBody> res; try { res = await dio.request<ResponseBody>( path, data: body, cancelToken: cancel, options: Options( method: method, responseType: ResponseType.stream, headers: { 'Accept': 'text/event-stream', if (parser.lastEventId.isNotEmpty) 'Last-Event-ID': parser.lastEventId, }, ), ); } on DioException catch (e) { final data = e.response?.data; if (e.type == DioExceptionType.badResponse && data is ResponseBody) { // Akış modunda hata gövdesi de bir akıştır, okumadan göremezsiniz. throw ChatHttpException( e.response!.statusCode!, await utf8.decodeStream(data.stream)); } rethrow; } final type = res.headers.value(Headers.contentTypeHeader) ?? ''; if (!type.startsWith('text/event-stream')) { cancel.cancel(); throw ChatHttpException(res.statusCode ?? 0, 'beklenmeyen içerik türü: $type'); } messageId ??= res.headers.value('x-message-id'); yield* sseEvents(res.data!.stream, parser); }}Koddan okunmayan üç ayrıntı var. Birincisi, ResponseType.stream ile yapılan istekte sunucu 401 dönerse Dio yine DioException fırlatır, ama e.response.data bir ResponseBody olur ve hata gövdesi de bir stream'dir. Okumazsanız sorunun süresi dolmuş token mı yoksa dolan kota mı olduğunu bilemezsiniz. İkincisi, içerik türü kontrol edilmeli. Yanlış yapılandırılmış bir proxy ya da bakım sayfası 200 ile HTML dönebilir ve parser bunu tek olay üretmeden sessizce yutar. Üçüncüsü, stream'in ortasında kopan bağlantı her zaman DioException olarak gelmiyor. Deneme sunucusu bağlantıyı yarıda kestiğinde Dio'nun IO adaptörü dart:io'nun HttpException'ını olduğu gibi iletti:
== bağlantı koparsa[istemci + 44ms] olay id=0 "Yanıt "[istemci + 70ms] olay id=1 "parça "[istemci + 163ms] HttpException: HttpException: Connection closed while receiving data, uri = http://127.0.0.1:8787/chat?dropAfter=4&gap=30, 4 olay, lastEventId=3 == Last-Event-ID ile devam[istemci + 7ms] olay id=4 "ğ, "[istemci + 25ms] olay id=5 "ü, "[istemci + 324ms] olay id=15 "<done>"[istemci + 325ms] akış bitti, 12 olay: ğ, ü, ş, ı, İ, ö ve ç harfleri de sağlam.<done>Sunucu kopuştan hemen önce id: 4 olayını yazmıştı ama olay istemciye ulaşmadı, lastEventId 3'te kaldığı için de resume() 4'ten devam etti. Hiçbir parça kaybolmadı, hiçbiri iki kez gelmedi.
Mobilde bağlantı sizin değil
Arka plana geçiş
iOS arka plana geçen uygulamayı kısa süre içinde askıya alır ve askıdaki uygulama soketten okuyamaz. Android'de süreç bir süre daha çalışabilir, ama Doze ve App Standby kısıtları devreye girince ağ erişimi kesilir, süreç de her an sonlandırılabilir. Kırk saniyelik bir yanıtın ortasında kullanıcının bir bildirime bakması yeter. Bağlantıyı açık bırakıp şansınızı deneyebilirsiniz ya da Flutter 3.13 ile gelen AppLifecycleListener ile uygulama gizlendiğinde bağlantıyı kendiniz bırakıp ön plana dönünce kaldığınız kimlikten devam edebilirsiniz. Ben ikincisini tercih ederim, çünkü o zaman kopuş ile arka plan aynı koda iner: detach() ve resume().
== arka plana geçiş[istemci + 39ms] olay id=0 "Yanıt "[istemci + 136ms] olay id=1 "parça "[istemci + 136ms] uygulama arka plana geçti, bağlantı bırakıldı[istemci + 139ms] DioException.cancel, 2 olay, lastEventId=1 == 2 sn sonra ön plana dönüş[istemci + 8ms] olay id=2 "parça "[istemci + 14ms] olay id=3 "geliyor: "[istemci + 84ms] olay id=15 "<done>"[istemci + 86ms] akış bitti, 14 olay: parça geliyor: ğ, ü, ş, ı, İ, ö ve ç harfleri de sağlam.<done>Bunun çalışması için sunucunun üretimi bağlantıya değil mesaja bağlaması şart. Deneme sunucusunda her istek bir iş başlatıyor ve bağlantı o işin yalnızca dinleyicisi. Bağlantı kopunca dinleyici ayrılıyor ama üretim sürüyor, yeni bağlantı da Last-Event-ID'den sonrasını tekrar oynatıp canlı stream'e katılıyor. Yukarıdaki örnekte iki saniyelik arada üretim bitmişti, dönüşte kalan on dört olay bir anda geldi.
Ağ değişimi
Wi-Fi'den hücresel ağa geçişte açık TCP bağlantısı yeni arayüze taşınmaz. Eski bağlantı kimi zaman hemen hata verir, kimi zaman tek kelime etmeden susar. Susan bağlantıyı yakalamanın güvenilir tek yolu sessizliği ölçmek. connectivity_plus gibi bir paketin bildirdiği ağ değişimini zaman aşımını beklemeden yeniden bağlanmak için tetik olarak kullanabilirsiniz, ama ağ değişmeden de bağlantı ölebilir.
Zaman aşımı
Dio'nun belgeleri receiveTimeout için iki şey söylüyor: bağlantı kurulup ilk yanıt baytları gelene kadar geçen süre ve aktarım sırasında iki bayt olayı arasındaki süre. Toplam süreden söz etmiyor. Stream'de bu tam aranan şey, çünkü üç dakika süren bir yanıt parçalar arasında uzun sessizlik olmadıkça kısa bir receiveTimeout ile de sorunsuz akar. Ölçünce yine de bir boşluk çıktı:
| Senaryo (receiveTimeout 2 sn) | Sonuç |
|---|---|
| Başlıklar hemen geliyor, ilk olay 3 sn sonra | Zaman aşımı yok, ilk olay 3044 ms'de geldi |
| Olaylar arası 3 sn | 2019 ms'de DioException.receiveTimeout |
| Olaylar arası 3 sn, saniyede bir heartbeat | Zaman aşımı yok, 45 sn'lik stream tamamlandı |
Olay stream'ine .timeout(2 sn) | 2012 ms'de TimeoutException, sunucu bağlantının kapandığını gördü |
Dio 5.9.2'nin stream'i işleyen kodu sayacı ilk veri parçasıyla kuruyor. Sunucu başlıkları hemen gönderip ilk token'ı bekletirse, ki model düşünürken olan tam olarak budur, aradaki süreyi hiçbir şey saymıyor. Bu yüzden iki ayrı sınır kullanmayı doğru buluyorum. receiveTimeout bağlantının canlı olup olmadığını ölçer ve heartbeat aralığının birkaç katı olur, olay stream'ine uygulanan Stream.timeout ise modelin cevap vermediğine karar verir. Yorum satırları olay üretmediği için heartbeat bu ikinci sayacı sıfırlamaz, olması gereken de bu.
Kullanıcı durdurduğunda
Durdur düğmesi iki iş yapar ve sıraları önemli. Önce CancelToken ile bağlantıyı bırakın ki arayüz hemen serbest kalsın, sonra sunucuya ayrı bir istekle üretimi kesmesini söyleyin. Yalnızca bağlantıyı kesmek yetmez, çünkü bu tasarımda kopuş bilerek üretimi durdurmuyor. Fark sunucu logunda iki satırla görünüyor:
[sunucu +14401ms] m3: dinleyici ayrıldı, üretim sürüyor[sunucu +14403ms] POST /chat/m3/stop last-event-id=-[sunucu +14403ms] m3: kullanıcı durdurdu, üretim kesildiİptal edilen stream DioException.cancel ile biter. Bunu arayüzde hata olarak göstermeyin. Kullanıcının kendi isteği.
Bilgi
req.on('close') değil res.on('close') kullanın. Gövdeli bir POST'ta req gövde okunduğu anda kapanır, deneme sunucusunda bu olay isteğin geldiği milisaniyede tetiklendi. Olay dinleyicisini yanıta bağlayın, yanıtın erken kapandığını da res.writableFinished değerinin false olmasından anlayın.Sunucu ve nginx: tampon nerede
Sunucunun ilk işi başlıkları beklemeden göndermek: Content-Type: text/event-stream, Cache-Control: no-cache ve arkada nginx varsa X-Accel-Buffering: no. Node'da res.flushHeaders() başlıkları ilk token'ı beklemeden yollar. Aşağıdaki sunucu bu yazıdaki tasarımın özeti: üretim mesaja bağlı, bağlantı yalnızca dinleyici, durdurma ayrı bir istek.
import http from 'node:http'; const jobs = new Map(); // mesaj kimliği -> { events, done, listeners } function sse(req, res, job) { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', 'X-Message-Id': job.id, }); res.flushHeaders(); const send = (ev) => { // Her olay tek write: heartbeat iki yazmanın arasına giremez. res.write(`id: ${ev.id}\n${ev.event ? `event: ${ev.event}\n` : ''}data: ${JSON.stringify(ev.data)}\n\n`); if (ev.event) res.end(); }; const last = Number(req.headers['last-event-id'] ?? -1); job.events.filter((ev) => ev.id > last).forEach(send); if (job.done) return; job.listeners.add(send); const hb = setInterval(() => res.write(': ping\n\n'), 15_000); res.on('close', () => { // req.on('close') değil clearInterval(hb); job.listeners.delete(send); // üretim sürer, yalnızca dinleyici gider });} function startJob(tokens) { const job = { id: `m${jobs.size + 1}`, events: [], done: false, listeners: new Set() }; const emit = (ev) => { job.events.push(ev); job.listeners.forEach((l) => l(ev)); }; const timers = tokens.map((t, i) => setTimeout(() => emit({ id: i, data: { t } }), i * 100)); timers.push(setTimeout(() => { job.done = true; emit({ id: tokens.length, event: 'done', data: {} }); }, tokens.length * 100)); job.stop = () => { timers.forEach(clearTimeout); // gerçek sunucuda: LLM isteğini AbortController ile kes job.done = true; emit({ id: job.events.length, event: 'stopped', data: {} }); }; jobs.set(job.id, job); return job;} http.createServer(async (req, res) => { let body = ''; for await (const chunk of req) body += chunk; const [, id, action] = req.url.match(/^\/chat\/(m\d+)\/(stream|stop)$/) ?? []; if (req.method === 'POST' && req.url === '/chat') { return sse(req, res, startJob(['Merhaba, ', 'ğüşıöç ', 'tamam.'])); } if (jobs.has(id) && action === 'stream') return sse(req, res, jobs.get(id)); if (jobs.has(id) && action === 'stop') jobs.get(id).stop(); res.writeHead(jobs.has(id) ? 204 : 404).end();}).listen(8787);nginx'te proxy_buffering varsayılan olarak açık. Belgelere göre açıkken yanıt tamponlara alınıyor, kapalıyken istemciye eşzamanlı aktarılıyor ve aynı ayar yanıt başlığındaki X-Accel-Buffering ile yanıt başına değiştirilebiliyor. nginx 1.29.8 ile yapılan ölçümde olaylar 150 ms arayla üretildi:
| Yapılandırma | İlk olay | Son olay |
|---|---|---|
| Varsayılan, başlık yok | 40 ms | 2291 ms |
proxy_buffering off | 14 ms | 2266 ms |
text/event-stream için gzip, başlık yok | 2271 ms | 2271 ms |
gzip, X-Accel-Buffering: no | 13 ms | 2262 ms |
gzip, proxy_buffering off | 10 ms | 2264 ms |
| nginx yok, Node'da gzip, flush yok | 2297 ms | 2298 ms |
| nginx yok, Node'da gzip, her olayda flush | 24 ms | 2260 ms |
Tamponlama bu ölçümde olayları tek başına geciktirmedi. Sorun gzip ile birleşince çıktı. Sıkıştırıcı iyi oran için veri biriktirir, tamponlu modda nginx ona flush etmesini söylemez ve bütün yanıt en sona yığılır. Tamponlama kapanınca her parça anında sıkıştırılıp gönderiliyor. Aynı tuzak uygulama katmanında da var. Node'da gzip stream'ini her olaydan sonra flush() etmezseniz nginx olmadan da aynı tabloyu görürsünüz. nginx yalnızca text/html yanıtları kendiliğinden sıkıştırır, yani bu tablo biri gzip_types listesine text/event-stream eklediği gün ortaya çıkar. Kuralım basit. SSE yanıtı sıkıştırılmaz, sıkıştırılacaksa her olaydan sonra flush edilir ve X-Accel-Buffering: no her durumda gönderilir.
İki ayar daha önemli. proxy_read_timeout varsayılan olarak 60 saniye ve toplam süreye değil, iki okuma arasındaki süreye uygulanıyor. Standart eski proxy'lere karşı yaklaşık 15 saniyede bir yorum satırı göndermeyi öneriyor, bu aralık nginx'i de rahatlıkla canlı tutar. proxy_ignore_client_abort ise varsayılan olarak kapalı: telefon bağlantıyı bırakınca nginx de sunucuyla bağlantısını kapatır ve sunucunuz res.on('close') olayını alır. Heartbeat'i zamanlayıcıyla yazıyorsanız olay sınırına dikkat edin. Bir olayı birden fazla write ile gönderen kodda zamanlayıcı iki yazmanın arasına düşerse : ping satırı JSON'un ortasına girer. Deneme sunucusunun ilk hâli bu hatayı yaptı ve istemci FormatException: Unterminated string verdi.
Sonuç
Her kopuşu, her iptali ve her zaman aşımını tek bir soruya bağlayın: bu mesaj sunucuda hâlâ üretiliyor mu? Cevap evetse Last-Event-ID ile dönersiniz, hayırsa durdurmayı kullanıcı kendisi istemiştir. Bağlantıyı mesajın kendisi sanan bir tasarım mobil ağda er geç yanıt kaybeder. Parser ise işin en kolay kısmı, yeter ki baytları stream üzerinde decode edin ve iptali bekletmeyin.
Sıkça Sorulan Sorular
- SSE yerine WebSocket kullansam bu sorunların hangisi ortadan kalkar?
- Yalnızca bayt sınırı. WebSocket metin mesajını bütün olarak teslim eder. Arka plan, ağ değişimi ve susan bağlantı aynen kalır, üstelik kaldığı yerden devam etmek için
Last-Event-IDgibi bir standart yoktur, kendi protokolünüzü yazarsınız. nginx'te deUpgradeveConnectionbaşlıklarını ayrıca iletmeniz gerekir. Tek yönlü bir token stream'i için WebSocket'in çift yönlülüğüne ihtiyaç yok. - Riverpod kullanıyorsam stream'i nereye bağlamalıyım?
- Stream'i bir provider içinde başlatın ve
ref.onDisposeiçindedetach()çağırın. Kullanıcı sohbet ekranından çıkınca bağlantı bırakılır ama sunucudaki üretim sürer, ekrana dönünceresume()ile kaldığı yerden devam edilir.stop()yalnızca durdur düğmesine bağlanmalı. Ekrandan çıkmak durdurmak anlamına gelmez. - Stream'i gerçek bir sunucu olmadan nasıl test ederim?
- Dio'nun
httpClientAdapteralanına kendiHttpClientAdaptersınıfınızı verin vefetchiçindenResponseBody(Stream.fromIterable(parçalar), 200, headers: ...)döndürün. Parçaları bir harfin ortasından bölersenizChatStream'i interceptor'larıyla birlikte ağsız sınarsınız. Bu yazıdaki testlerden biri tam olarak bunu yapıyor. Parser ayrı durduğu için testlerin çoğu Dio'ya bile ihtiyaç duymaz. - Sunucunun gönderdiği `retry:` değerine uymalı mıyım?
- Uyun ve onu alt sınır sayın. Standart, tarayıcılara başarısız denemeden sonra exponential backoff eklemeye ve işletim sistemi ağ olmadığını söylüyorsa ağın dönmesini beklemeye izin veriyor. Mobilde ikisini de yapın: her başarısız denemede bekleme süresini katlayın, ağ yokken hiç denemeyin, ağ dönünce sayacı sıfırlayın.
Kaynakça
- Chinese Flutter User Group. (t.y.). receiveTimeout property: Options class [dio paketi API dokümantasyonu]. pub.dev. https://pub.dev/documentation/dio/latest/dio/Options/receiveTimeout.html
- Dart. (t.y.-a). LineSplitter class. Dart API. https://api.dart.dev/dart-convert/LineSplitter-class.html
- Dart. (t.y.-b). Utf8Decoder class. Dart API. https://api.dart.dev/dart-convert/Utf8Decoder-class.html
- Fielding, R., Nottingham, M., ve Reschke, J. (Ed.). (2022). HTTP semantics (RFC 9110). Internet Engineering Task Force. https://doi.org/10.17487/RFC9110
- nginx. (t.y.). Module ngx_http_proxy_module. nginx.org. https://nginx.org/en/docs/http/ngx_http_proxy_module.html
- WHATWG. (t.y.). HTML Living Standard: 9.2 Server-sent events. https://html.spec.whatwg.org/multipage/server-sent-events.html

Mustafa Kürşad Başer
Kıdemli Yazılım Mühendisi
Karmaşık sorunlara zarif çözümler üretmekten keyif alan, tutkulu bir yazılım mühendisi. Kodlamanın ötesinde, teknoloji, sanat ve insan bilincinin kesişim noktalarını keşfetmekle derinden ilgileniyor.

