AI Arayüzü Neden Farklı? Streaming, Belirsizlik ve Angular + ASP.NET Core Mimarisi
AI arayüzünü klasik CRUD ekranından ayıran şey cevabın parça parça, her seferinde farklı ve yarıda kesilebilir gelmesi: aynı soru iki denemede iki farklı kitap, 6 ve 13 saniyelik ilk token süresi ve 2,5 kat farklı token tüketimiyle cevaplandı. Bu yazıda Angular, ASP.NET Core ve model arasındaki mimariyi, API key'in neden bundle'a girmemesi gerektiğini ve /chat/sse'nin event sözlüğünü kuruyoruz.
Sürüm notu: Bu yazıdaki kodlar Angular 22.2 (TypeScript 6.0, Node.js 24) ve .NET 10 ile derlenip denenmiştir; backend'de Microsoft.Extensions.AI 11.0.0 ve OllamaSharp 5.5.2, yerel modelde Ollama 0.34.4 (qwen3:4b) kullanılmıştır.
Önce Tahmin Et
Bookstore asistanına (sitedeki serilerin ortak örnek uygulaması Bookstore, bir online kitapçı) aynı soruyu art arda iki kez soruyorsun: "Uzun bir tren yolculuğu için tek cümleyle bir klasik roman öner." Sunucu, prompt, model ve makine aynı. İki cevap aynı mı olur? Peki kullanıcının hissettiği bekleme hangisi: ekranda ilk kelimenin belirmesi mi, cevabın tamamının gelmesi mi?
Klasik bir CRUD (Create, Read, Update, Delete; kayıt ekleme, okuma, güncelleme ve silme işlemleri) ekranında bu soruların cevabı belli: aynı istek aynı cevabı döner ve cevap tek parça gelir. LLM destekli bir ekranda ikisi de değişiyor. Bu seride Bookstore asistanının Angular tarafını uçtan uca yazacağız: streaming cevabı signal'larla göstermek, hata ve iptal, LLM çıktısını güvenle render etmek, tool çağrıları ve onay arayüzü, standart protokoller, erişilebilirlik ve test. Bu ilk yazı, kodu yazmadan önce neyin farklı olduğunu ölçüyor ve seri boyunca kullanacağımız mimariyi kuruyor.
Aynı Soru, İki Farklı Cevap
Backend, .NET ile Yapay Zeka serisinin "ASP.NET Core'da LLM Streaming: Minimal API, IAsyncEnumerable ve Server-Sent Events" yazısındaki /chat/sse endpoint'i; yerelde Ollama ve qwen3:4b modeliyle çalışıyor. Soruyu curl ile iki kez gönderdim ve her satırın başına isteğin başından beri geçen süreyi yazdırdım:
$ curl -sN -i -X POST localhost:19640/chat/sse -H 'Content-Type: application/json' \
-d '{"message":"Recommend one classic novel for a long train trip, in one sentence."}'
# 1. deneme
1295 ms HTTP/1.1 200 OK
1295 ms Content-Type: text/event-stream
1296 ms event: thinking
1296 ms data: {}
6135 ms event: delta
6135 ms data: {"text":"For"}
# ...
6931 ms event: usage
6931 ms data: {"inputTokens":62,"outputTokens":253}
6931 ms event: done
6931 ms data: {}
# 2. deneme
31 ms HTTP/1.1 200 OK
32 ms event: thinking
32 ms data: {}
5033 ms event: ping
5033 ms data: {}
10035 ms event: ping
10035 ms data: {}
13411 ms event: delta
13411 ms data: {"text":"For"}
# ...
14166 ms event: usage
14166 ms data: {"inputTokens":62,"outputTokens":627}
14166 ms event: done
14166 ms data: {}
Delta'ları birleştirince ortaya çıkan iki cevap ve sunucu log'undaki ölçümler:
| 1. deneme | 2. deneme | |
|---|---|---|
| Önerilen kitap | Moby-Dick (Herman Melville) | Pride and Prejudice (Jane Austen) |
| İlk metin parçası (TTFT) | 6,1 sn | 13,4 sn |
| Toplam süre | 6,9 sn | 14,2 sn |
| Delta sayısı | 35 | 33 |
| Output token | 253 | 627 |
Önce Tahmin Et'teki ilk sorunun cevabı "hayır". Model her token'ı bir olasılık dağılımından seçiyor ve sıcaklık (temperature) sıfır değilse aynı prompt farklı yollara sapıyor; LLM Temelleri serisinin sampling yazısı bunu ayrıntılı anlatıyor. Arayüz için sonucu şu: cevabın metnine, uzunluğuna ya da biçimine bağlı hiçbir mantık güvenilir değil. Bugün tek cümle gelen cevap yarın üç paragraf ve bir tablo olabilir.
İkinci sorunun cevabı tablonun ortasında. TTFT (Time To First Token; isteğin başından ilk metin parçasının gelişine kadar geçen süre) ile toplam süre arasında iki denemede de bir saniyeden az fark var; kullanıcının beklediği sürenin neredeyse tamamı ilk kelimeden önce geçiyor. qwen3:4b bir reasoning modeli: önce düşünüyor, sonra yazıyor. Düşünme süresi de sabit değil; ikinci denemede model aynı soru için iki kat fazla düşündü ve 253 yerine 627 output token harcadı. Bulut sağlayıcıda output token'ları faturaya yazıldığı için aynı soru, aynı ekranda iki buçuk kat farklı maliyetle cevaplandı.
Klasik CRUD Ekranından Farkı Ne?
Bu iki ölçüm, AI arayüzünü klasik bir form ya da liste ekranından ayıran beş farkın ilk ikisini gösteriyor. Hepsini yan yana koyalım:
- Cevap parça parça gelir. Kullanıcının hissettiği hız TTFT'dir, toplam süre değil. İlk parçaya kadar ekranda "model düşünüyor" gibi bir durum göstermezsen kullanıcı donmuş bir sayfaya bakar.
- Cevap belirsizdir. Aynı soruya farklı cevap, farklı uzunluk ve farklı biçim gelir. Snapshot testleri, "cevap şu kelimeyle başlarsa" kontrolleri ve metinden veri ayıklayan regex'ler kırılır.
- Akış yarıda kesilebilir. Kullanıcı "durdur"a basar, ağ kopar ya da model sağlayıcısı akışın ortasında hata verir. Ekranda yarım bir cevap kalır ve arayüz bunun "bitti" mi, "kesildi" mi, "durduruldu" mu olduğunu bilmek zorundadır.
- Arada başka işler olur. Model bir tool çağırabilir (stok kontrolü, sipariş sorgusu), onay bekleyebilir ya da düşünebilir. Bu ara durumlar kullanıcıya gösterilmezse 20 saniyelik sessizlik "bozuk" diye okunur.
- Her istek para ve kota harcar. Token başına fiyatlanır, sağlayıcı ve kendi API'n rate limit uygular. 429 bir istisna değil, normal bir durumdur ve arayüzün ona hazır olması gerekir.
Bu farkların her biri serinin bir yazısına karşılık geliyor: streaming ve iptal 2. yazıda, yarım cevap, timeout ve 429 3. yazıda, tool çağrıları ve onay 5. yazıda, bekleme hissi 7. yazıda.
Mimari: Angular, ASP.NET Core ve Model

Seri boyunca kullanacağımız mimari üç parçalı. Angular uygulaması (bookstore-web) yalnız kullanıcının mesajını gönderiyor. ASP.NET Core API'si (Bookstore.Assistant.Api) system prompt'u ekliyor, modeli çağırıyor ve modelin ChatResponseUpdate akışını tiplenmiş SSE (Server-Sent Events; sunucunun açık bir HTTP cevabı üzerinden istemciye tek yönlü event göndermesi) event'lerine çeviriyor. Model ise bu yazıda yerel Ollama; production'da bir bulut sağlayıcı olabilir. API'nin tamamı şu kadar:
using Bookstore.Assistant.Api;
using Microsoft.Extensions.AI;
using OllamaSharp;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Model adresi, model adı (ve bulut sağlayıcıda API key) yalnız sunucunun configuration'ında durur
string endpoint = builder.Configuration["Assistant:OllamaEndpoint"] ?? "http://localhost:11434";
string model = builder.Configuration["Assistant:Model"] ?? "qwen3:4b";
builder.Services.AddChatClient(new OllamaApiClient(
new HttpClient { BaseAddress = new Uri(endpoint), Timeout = TimeSpan.FromMinutes(10) }, model));
WebApplication app = builder.Build();
// System prompt da sunucuda: tarayıcı yalnız kullanıcının mesajını gönderir
const string SystemPrompt =
"You are the assistant of Bookstore, an online bookstore. Answer briefly and only about books, " +
"orders and the store. If you are not sure, say so.";
app.MapPost("/chat/sse", (ChatStreamRequest request, IChatClient chat, ILogger<Program> logger, CancellationToken ct) =>
{
List<ChatMessage> messages = [new(ChatRole.System, SystemPrompt), new(ChatRole.User, request.Message)];
IAsyncEnumerable<ChatResponseUpdate> updates = chat.GetStreamingResponseAsync(messages, cancellationToken: ct);
return TypedResults.ServerSentEvents(
ChatStreaming.ToSseAsync(updates, logger, ct).WithHeartbeatAsync(TimeSpan.FromSeconds(5), ct));
});
app.Run();
ChatStreaming.ToSseAsync ve WithHeartbeatAsync doğrudan .NET ile Yapay Zeka serisinin streaming yazısından geliyor; mekaniğini orada anlattık. Bu seride sunucu tarafını yalnız arayüzün ihtiyacı olduğu kadar değiştireceğiz.
Akla gelen kısa yol, Angular'dan sağlayıcının API'sine doğrudan istek atmak: arada bir backend yok, daha az kod var. Bunu bir kez deneyelim. Anahtarı environment.ts'e koyan bir component:
// YAPMA: bu dosya tarayıcıya giden bundle'ın parçasıdır
export const environment = {
llmApiKey: 'sk-demo-1234567890abcdef',
llmBaseUrl: 'https://llm-provider.example.com/v1',
};
$ npx ng build
Output location: .../dist/bookstore-web
$ grep -o '.\{30\}sk-demo.\{40\}' dist/bookstore-web/browser/main-*.js
var gl={llmApiKey:"sk-demo-1234567890abcdef",llmBaseUrl:"https://l
Production build minify edilmiş olsa da anahtar bundle'da düz metin olarak duruyor; sitenin her ziyaretçisi tarayıcının geliştirici araçlarından okuyabilir. Angular CLI'ın "environment" dosyaları yalnız build zamanı ayarı içindir, gizli bilgi için değil. Aynı sızıntının SSR (Server-Side Rendering; sayfanın sunucuda render edilmesi) tarafını Frontend Güvenliği serisinin "Angular SSR Güvenliği: allowedHosts, Host Header Doğrulama, TransferState ve Bundle'a Sızan Secret'lar" yazısı gösteriyor.
Anahtar tek sorun da değil. Doğrudan istekte system prompt da tarayıcıda olur ve kullanıcı onu değiştirebilir. Kullanıcı başına rate limit, maliyet sınırı, log ve denetim kaydı koyacak bir yer kalmaz. Sağlayıcıyı değiştirmek istediğinde her istemciyi yeniden deploy etmen gerekir. Backend'in görevi bu yüzden yalnız bir proxy olmak değil: hangi modelin, hangi prompt'la, kimin adına ve ne kadar çağrılacağına karar veren yer orası.
Event Sözlüğü: /chat/sse Ne Gönderiyor?
Arayüz yalnız metin parçalarını değil akışın durumunu da bilmek zorunda. Bu yüzden API, modelden gelen her update'i adı ve şekli belli bir event'e çeviriyor. Sunucudaki record'lar:
namespace Bookstore.Assistant.Api;
public sealed record ChatStreamRequest(string Message);
// /chat/sse event sözlüğü: Angular tarafındaki chat-events.ts ile birebir aynı alanlar
public sealed record DeltaEvent(string Text);
public sealed record ToolEvent(string CallId, string Name, string Phase);
public sealed record UsageEvent(long? InputTokens, long? OutputTokens);
public sealed record ErrorEvent(string Message, bool Partial);
.NET ile Yapay Zeka serisindeki hâlinden tek farkı ToolEvent'e CallId eklemem. Orada completed event'inde tool adı boş geliyordu, çünkü FunctionResultContent yalnız CallId taşıyor. ToSseAsync artık started event'inde gördüğü CallId'yi adıyla eşleyip completed event'ine de adı yazıyor; arayüz "stok kontrol ediliyor" satırını hangi tool'un bitirdiğini bilebilsin diye.
Angular tarafında aynı sözlüğü bir discriminated union olarak tanımlıyoruz:
// /chat/sse event sözlüğü: Bookstore.Assistant.Api'deki record'larla aynı alanlar
export type ChatEvent =
| { type: 'ping' }
| { type: 'thinking' }
| { type: 'delta'; text: string }
| { type: 'tool'; callId: string; name: string; phase: 'started' | 'completed' }
| { type: 'usage'; inputTokens: number | null; outputTokens: number | null }
| { type: 'done' }
| { type: 'error'; message: string; partial: boolean };
// SSE'den gelen ham event adını ve JSON verisini tiplenmiş ChatEvent'e çevirir
export function toChatEvent(event: string, data: string): ChatEvent | null {
switch (event) {
case 'ping':
case 'thinking':
case 'done':
return { type: event };
case 'delta':
case 'tool':
case 'usage':
case 'error':
return { type: event, ...JSON.parse(data) };
default:
// Bilinmeyen event'i yok say: sunucu yeni bir event eklediğinde eski istemci bozulmasın
return null;
}
}
Event'lerin anlamı: ping model sessizken bağlantının canlı olduğunu söyler ve arayüzde hiçbir şey değiştirmez. thinking reasoning modelinin düşünmeye başladığını bildirir; düşünce metnini göndermiyoruz, yalnız durumu. delta cevabın bir parçasıdır. tool bir tool çağrısının başladığını ya da bittiğini söyler. usage bir model turunun input ve output token sayılarını taşır; tool kullanan bir istekte birden fazla gelebilir. done akışın düzgün bittiğini, error ise yarıda kesildiğini bildirir ve partial alanı o ana kadar metin gidip gitmediğini söyler.
İki kural bu sözlüğü dayanıklı yapıyor. Birincisi, istemci bağlantının kapanmasını tek başına "bitti" saymıyor; yalnız done bitti demek. Proxy timeout'u ya da sunucu restart'ı da bağlantıyı kapatır ve o durumda ekrandaki cevap tamamlanmış değil, kesilmiştir. İkincisi, toChatEvent bilinmeyen event'leri sessizce atlıyor. Sunucuya 5. yazıda approval ve ui event'lerini eklediğimizde eski sürümdeki bir istemci bozulmayacak, yalnız yeni özelliği göstermeyecek.
Protokol Seçenekleri
Bu event'ler bizim kendi sözleşmemiz. Aynı işi yapan iki standart da var ve 6. yazıda Bookstore asistanını bunlardan birine taşıyıp karşılaştıracağız:
- AG-UI (Agent-User Interaction Protocol; agent ile kullanıcı arayüzü arasındaki event akışını tanımlayan açık protokol): run yaşam döngüsü, metin mesajı başlangıç/içerik/bitiş, tool call ve paylaşılan state snapshot/delta event aileleri. Microsoft Agent Framework'ün .NET tarafında hazır endpoint'i var.
- AI SDK UI message stream: AI SDK'nın SSE üzerinden taşıdığı
text-delta,tool-input-available,finishgibi parçalardan oluşan format; özel bir backend bu formatı ürettiğindex-vercel-ai-ui-message-stream: v1header'ını göndermesi gerekiyor. Angular için@ai-sdk/angularpaketi var.
Kendi sözleşmemizle başlamamın sebebi öğretmek: Angular tarafında akışı, durumu ve hataları elle yönetince bir kütüphanenin neyi sakladığını ve neyi çözdüğünü görmek kolaylaşıyor.
Bookstore Asistanının Ekran Taslağı
Seri sonunda asistan ekranında şunlar olacak: mesaj listesi (kullanıcı ve asistan baloncukları), alt kısımda mesaj kutusu ve akış sürerken "Gönder"in yerini alan "Durdur" butonu, ilk token'a kadar "düşünüyor" durumu, tool çalışırken "stok kontrol ediliyor" satırı, hassas işlemlerde onay kartı, kitap önerilerinde metin yerine "sepete ekle" butonlu kitap kartları, cevabın altında kaynaklar ve token kullanımı. Ama AI arayüzü yalnız bu chat kutusundan ibaret değil. Bookstore'da aynı backend'le yapılabilecek başka örnekler: kitap detay sayfasında okur yorumlarının tek paragraflık özeti, arama kutusunda "20 TL altı polisiye" gibi serbest metni filtrelere çevirmek, Kurumsal Sipariş formunu okulun gönderdiği e-postadan önceden doldurmak. Bunların hiçbiri sohbet değil; 2. yazıda resource ile tek seferlik bir AI özelliği de yazacağız.
Kod iki klasörde duruyor: api/ altında ASP.NET Core projesi, web/ altında Angular uygulaması. Geliştirmede Angular dev server'ı /chat ile başlayan istekleri API'ye yönlendiriyor; böylece tarayıcı açısından her şey aynı origin'de ve CORS (Cross-Origin Resource Sharing; tarayıcının farklı origin'e yapılan isteklere uyguladığı izin mekanizması) ayarı gerekmiyor:
{
"/chat": {
"target": "http://localhost:19640",
"secure": false
}
}
Bu dosya angular.json'daki serve hedefinde proxyConfig olarak tanımlı. Çalıştırmak için Ollama kurulu ve qwen3:4b modeli indirilmiş olmalı (LLM Temelleri serisinin ilk istek yazısında kurduk):
cd api
dotnet run
# ikinci terminalde
cd web
npm ci
npm start
Production'da aynı origin kuralını bir reverse proxy ya da Angular uygulamasını sunan ASP.NET Core uygulaması sağlar. Angular 22.2 TypeScript 6.0 istediği için proje TypeScript 6.0 ile kurulu.
Üretimde Dikkat
Rate limit'i kullanıcı başına koy. Sağlayıcının kotası bütün uygulamanın ortak kotasıdır; tek bir kullanıcı ya da bir bot onu bitirirse herkes 429 alır. Sınırı API'de kullanıcı kimliğine göre uygula ve arayüze Retry-After ile bildir; 3. yazıda bu header'a göre geri sayım gösteriyoruz.
Zaman aşımlarını uzun bekleyişe göre ayarla. İkinci denemede ilk token 13 saniyede geldi; kuyruk dolu bir sağlayıcıda bu süre daha da uzar. Önündeki load balancer ya da reverse proxy'nin idle timeout'u bundan kısaysa bağlantı sessizce kapanır. ping event'i bu sessizliği doldurmak için var.
Kullanımı ölç ve göster. usage event'leri her isteğin maliyetini taşıyor; bunları kullanıcı ve özellik bazında topla. Aynı soru için 253 ile 627 token arasında gidip gelen bir reasoning modelinde ortalama değil, dağılım önemlidir.
Log'a ne yazdığına dikkat et. Kullanıcı mesajları kişisel veri içerebilir. Prompt ve cevabı log'luyorsan saklama süresini ve kimin okuyabileceğini baştan belirle.
Sık Düşülen Tuzaklar
Birincisi, API key'i environment.ts'e ya da herhangi bir frontend dosyasına koymak. Yukarıda gördük: minify edilmiş bundle'da bile düz metin olarak duruyor. "Bunu kimse bulmaz" bir güvenlik önlemi değil.
İkincisi, AI özelliğini yalnız sağ altta açılan bir chat kutusu olarak düşünmek. Çoğu zaman en işe yarayan AI özelliği, kullanıcının zaten bulunduğu ekrandaki küçük bir yardımdır: bir özet, bir öneri, önceden doldurulmuş bir form.
Üçüncüsü, bağlantının kapanmasını cevabın bitmesi sanmak. Sözleşmede ayrı bir done event'i olmazsa kesilmiş bir cevabı tamamlanmış gibi gösterirsin ve kullanıcı yarım bilgiyle karar verir.
Dördüncüsü, model çıktısının biçimine iş mantığı bağlamak. "Cevapta kitap adı tırnak içinde gelir" varsayımı ilk denemede tutar, ikincide tutmaz. Yapısal veri gerekiyorsa structured output ya da tool çağrısı kullan; 5. yazıda modelin metin yerine component seçtiği örnekte buna döneceğiz.
Küçük Bir Deneme
★☆☆ Aynı soruyu beş kez gönder ve her denemede TTFT'yi, toplam süreyi ve output token sayısını not et. En hızlı ve en yavaş deneme arasındaki fark ne kadar? Kullanıcıya göstereceğin "ortalama bekleme" bu dağılımda anlamlı bir sayı mı?
★★☆ System prompt'a "Always answer in exactly one sentence." ekle ve aynı ölçümü tekrarla. Output token sayısı düşüyor mu? Düşünme token'ları cevabın uzunluğundan bağımsız mı?
★★★ Sözleşmeye bir meta event'i ekle: akışın başında model adını ve sunucunun verdiği bir istek kimliğini gönder. toChatEvent'i güncellemeden önce eski istemcinin bu event'i gerçekten yok saydığını curl ve tarayıcı konsoluyla doğrula.
Mimari ve event sözlüğü hazır. Bir sonraki yazıda bu akışı Angular'da okuyoruz: EventSource POST gönderemediği için fetch ve ReadableStream ile bir SSE parser'ı yazacak, delta'ları signal'larla ekrana basacak, "Durdur" butonunu AbortController ile sunucudaki CancellationToken'a bağlayacağız.