Mert Özen Her Satırda Daha İleri
Yapay Zeka .NET ile Yapay Zeka — #02

IChatClient Pipeline'ı: ChatClientBuilder, Middleware Sırası ve Kendi DelegatingChatClient'ın

Mert Özen 8 Kas 2026 13 dk 5 görüntülenme
IChatClient Pipeline'ı: ChatClientBuilder, Middleware Sırası ve Kendi DelegatingChatClient'ın

ChatClientBuilder'da ilk eklenen middleware en dışta durur ve sıra davranışı değiştirir: cache function invocation'ın dışındaysa tool hiç çalışmaz, içindeyse her istekte taze veri okur. Bookstore için token bütçesi ve kişisel veri maskeleyen iki DelegatingChatClient yazıyor, streaming override'ını unutmanın sessizce veri sızdırdığını gerçek koşuyla 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 ve yerel modelde Ollama 0.34.4 (gemma3:4b, qwen3:4b) kullanılmıştır.

Önce Tahmin Et

Bookstore asistanına stok soran bir tool ekledin ve pipeline'a cache koydun. Aşağıdaki iki zincir aynı paketlerden, aynı middleware'lerden oluşuyor; tek fark sıra:

// A
builder.UseDistributedCache(cache).UseFunctionInvocation();
// B
builder.UseFunctionInvocation().UseDistributedCache(cache);

Aynı soruyu ("How many copies of Refactoring are in stock?") iki kez soruyorsun. İkinci istekte, A ve B için ayrı ayrı, modele kaç kez gidilir ve stok tool'u kaç kez çalışır? Bir de şunu düşün: kendi middleware'inde yalnız GetResponseAsync'i override ettin. Uygulamanın chat ekranı cevabı stream ederek alıyor. Middleware'in o ekranda çalışır mı?

İkisinin cevabını da yazının ortasında gerçek koşuyla göreceğiz. İpucu: B'de ikinci istek modele hiç gitmiyor ama tool yine çalışıyor.

ChatClientBuilder: Zinciri Kim Kurar?

Serinin ilk yazısında IChatClient'ın katmanlı yapısını haritada gördük. Bu yazıda ortadaki katmana, MEAI'nin (Microsoft.Extensions.AI) middleware pipeline'ına iniyoruz. Mekanizma ASP.NET Core'un HTTP (Hypertext Transfer Protocol) middleware pipeline'ına çok benziyor; ASP.NET Core Web API (Application Programming Interface) serisinde gördüğümüz app.Use(...) zinciri gibi, burada da her halka bir sonrakini çağırır. Fark şu: halkalar HTTP isteğini değil, IEnumerable<ChatMessage> ve ChatOptions'ı taşır, ve her halka da bir IChatClient'tır.

Zinciri ChatClientBuilder kurar. Provider'dan bir builder alırsın, Use... çağrılarıyla halka eklersin, Build() ile tek bir IChatClient elde edersin. Kural: ilk eklenen en dışta durur. a.AsBuilder().UseX().UseY().Build() çağrısının sonucu X(Y(a)) olur; istek önce X'e, sonra Y'ye, en son provider'a gider, cevap ters yönde döner.

02-pipeline-onion-tr.png

İlk denemede beklemediğim bir derleyici hatası aldım:

IChatClient chat = new OllamaApiClient(ollama, "gemma3:4b").AsBuilder()
    .UsePiiRedaction()
    .Build();
error CS0121: The call is ambiguous between the following methods or properties: 'Microsoft.Extensions.AI.ChatClientBuilderChatClientExtensions.AsBuilder(Microsoft.Extensions.AI.IChatClient)' and 'Microsoft.Extensions.AI.EmbeddingGeneratorBuilderEmbeddingGeneratorExtensions.AsBuilder<TInput, TEmbedding>(Microsoft.Extensions.AI.IEmbeddingGenerator<TInput, TEmbedding>)'

OllamaApiClient hem IChatClient hem IEmbeddingGenerator uyguluyor, iki interface'in de AsBuilder() extension'ı var ve derleyici hangisini istediğini bilemiyor. Çözüm, değişkenin tipini sabitlemek. Yazı boyunca bunun için küçük bir local fonksiyon kullanacağım:

Uri ollama = new("http://localhost:11434");

// OllamaApiClient hem IChatClient hem IEmbeddingGenerator: AsBuilder() için tipi IChatClient'a sabitle.
// Yerel model yavaş olabilir: HttpClient'ın varsayılan 100 saniyelik timeout'unu büyüt.
IChatClient Ollama(string model) =>
    new OllamaApiClient(new HttpClient { BaseAddress = ollama, Timeout = TimeSpan.FromMinutes(5) }, model);

Timeout satırı da gerçek bir hatadan geldi. OllamaApiClient'a yalnız adres verdiğinde kendi HttpClient'ını oluşturuyor ve onun varsayılan timeout'u 100 saniye. Aşağıdaki sıralama deneyini ilk koşturduğumda, aynı Ollama'yı başka işlerle paylaşırken qwen3:4b'nin düşünme süresi bu sınırı aştı ve istek TaskCanceledException: The request was canceled due to the configured HttpClient.Timeout of 100 seconds elapsing. ile düştü. Timeout ve retry'ı düzgün yönetmeyi bir sonraki yazıda IHttpClientFactory ile yapacağız.

Use Overload'ları: Class Yazmadan Middleware

ChatClientBuilder.Use'un dört overload'u var. İkisi factory alır: Use(inner => new MyClient(inner)) ve DI'dan (Dependency Injection) servis çekmek için Use((inner, services) => ...). Kalan ikisi class yazmadan middleware tanımlamanı sağlar. Tek delegate alan overload, işini istekten önce ve sonra yapıp next'i çağıran durumlar için; aynı delegate hem GetResponseAsync hem GetStreamingResponseAsync için kullanılır. Bookstore'da provider'a gerçekte ne gittiğini görmek için böyle bir "wire tap" yazdım:

// Provider'a gerçekten ne gittiğini gösteren tek delegate'li middleware (streaming ve non-streaming ikisinde de çalışır)
static ChatClientBuilder UseWireTap(ChatClientBuilder builder) =>
    builder.Use(async (messages, options, next, ct) =>
    {
        string user = messages.Last(m => m.Role == ChatRole.User).Text;
        Console.WriteLine($"  [wire] maxOutputTokens={options?.MaxOutputTokens}, user=\"{user}\"");
        await next(messages, options, ct);
    });

Bu delegate cevabı göremez; next bir Task döner, ChatResponse değil. Cevabı okuman ya da değiştirmen gerekiyorsa dördüncü overload'u kullanırsın: biri Task<ChatResponse>, biri IAsyncEnumerable<ChatResponseUpdate> dönen iki ayrı delegate. Microsoft Learn bu ayrımı açıkça yapıyor: dönüş tipleri farklı olduğu için iki farklı implementasyon gerektiğinde iki delegate'li overload kullanılır.

Bir de hazır bir yardımcı var: ConfigureOptions. Her çağrıda ChatOptions'ın bir kopyasını oluşturur ve senin delegate'inle doldurur; çağıran tarafın verdiği instance'a dokunmaz. Varsayılan değer koymak için idealdir: .ConfigureOptions(o => o.Temperature ??= 0).

Kendi DelegatingChatClient'ın: Token Bütçesi

Delegate yetmediğinde, state ya da test edilebilir bir class gerektiğinde DelegatingChatClient'tan türetirsin. Base class iki method'u ve Dispose'u iç client'a iletir; sen yalnız değiştirmek istediğini override edersin. Bookstore'un ilk ihtiyacı maliyet kontrolü: hiçbir istek 256 token'dan uzun cevap üretmesin ve kullanıcı modele roman göndermesin.

using Microsoft.Extensions.AI;

namespace Bookstore.Assistant.Pipeline;

public sealed class TokenBudgetChatClient(IChatClient innerClient, int maxOutputTokens, int maxPromptChars)
    : DelegatingChatClient(innerClient)
{
    public override Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        EnsurePromptFits(messages);
        return base.GetResponseAsync(messages, WithCap(options), cancellationToken);
    }

    public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
    {
        EnsurePromptFits(messages);
        return base.GetStreamingResponseAsync(messages, WithCap(options), cancellationToken);
    }

    // Çağıranın ChatOptions instance'ına dokunma: kopyala, kopyayı değiştir
    private ChatOptions WithCap(ChatOptions? options)
    {
        ChatOptions copy = options?.Clone() ?? new ChatOptions();
        if (copy.MaxOutputTokens is null || copy.MaxOutputTokens > maxOutputTokens)
        {
            copy.MaxOutputTokens = maxOutputTokens;
        }
        return copy;
    }

    private void EnsurePromptFits(IEnumerable<ChatMessage> messages)
    {
        int length = messages.Sum(m => m.Text.Length);
        if (length > maxPromptChars)
        {
            throw new PromptTooLongException(length, maxPromptChars);
        }
    }
}

public sealed class PromptTooLongException(int length, int limit)
    : Exception($"Prompt has {length} characters; the limit is {limit}.")
{
    public int Length { get; } = length;
    public int Limit { get; } = limit;
}

Prompt sınırını token yerine karakterle koydum. Gerçek token sayısını ancak modelin tokenizer'ı bilir ve LLM (Large Language Model) Temelleri'nin token yazısında gördüğümüz gibi Türkçe metin İngilizceden daha çok token tutar. Karakter sınırı kaba ama ucuz bir ön kontrol; kesin sayıyı provider'ın döndürdüğü Usage'dan okursun. 150 tekrarlı bir metinle denediğimde istek modele gitmeden kesildi:

PromptTooLongException: Prompt has 5249 characters; the limit is 4000.

Çıktı tarafını da gerçek bir istekle görelim. Çağıran taraf MaxOutputTokens = 2_000 gönderiyor, middleware bunu 64'e indiriyor:

GetResponseAsync:
  [wire] maxOutputTokens=64, user="My email is [email] and my card is [card]. Recommend one book about refactoring in one sentence."
  finishReason=length, outputTokens=64
  Okay, based on your email and card (which I unfortunately can't see!), I recommend **"Refactoring: Improving the Design of Existing Code" by Martin Fowler** – a classic that provides a comprehensive guide to systematically improving the structure and quality of your code. 

To help me give you a more tailored
  caller's options after call: MaxOutputTokens=2000
GetStreamingResponseAsync:
  [wire] maxOutputTokens=64, user="My email is [email] and my card is [card]. Recommend one book about refactoring in one sentence."

Üç şeye dikkat et. Wire tap provider'a giden değerin 64 olduğunu gösteriyor. Cevap cümlenin ortasında kesildi ve FinishReason length geldi; bütçe gerçekten uygulandı, ama kullanıcıya yarım cümle göstermemek de senin işin (cevabı kısa isteyen bir system prompt ya da length geldiğinde arayüzde "devamı kesildi" uyarısı). Ve çağrıdan sonra çağıranın shared instance'ı hâlâ 2000: middleware Clone() ile kopya üzerinde çalıştığı için aynı options'ı paylaşan başka istekler etkilenmedi.

Kişisel Veriyi Maskeleyen Middleware

İkinci ihtiyaç: kullanıcı mesajındaki e-posta adresi ve kart numarası modele, özellikle bir bulut sağlayıcıya gitmesin. PII (Personally Identifiable Information) maskeleme bir güvenlik katmanı değil, bir veri minimizasyonu adımıdır; yine de modelin hiç görmesine gerek olmayan veriyi baştan kesmek iyi bir varsayılan.

using System.Text.RegularExpressions;
using Microsoft.Extensions.AI;

namespace Bookstore.Assistant.Pipeline;

public sealed partial class PiiRedactingChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
{
    public override Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
        base.GetResponseAsync(Redact(messages), options, cancellationToken);

    public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
        base.GetStreamingResponseAsync(Redact(messages), options, cancellationToken);

    // Yalnız kullanıcı mesajlarındaki TextContent'leri maskele; diğer content'lere dokunma
    private static List<ChatMessage> Redact(IEnumerable<ChatMessage> messages) =>
    [
        .. messages.Select(m => m.Role != ChatRole.User
            ? m
            : new ChatMessage(m.Role, [.. m.Contents.Select(c => c is TextContent t ? new TextContent(Mask(t.Text)) : c)])
            {
                AuthorName = m.AuthorName,
                MessageId = m.MessageId,
            }),
    ];

    internal static string Mask(string text) =>
        CardRegex().Replace(EmailRegex().Replace(text, "[email]"), "[card]");

    [GeneratedRegex(@"[\w.+-]+@[\w-]+\.[\w.-]+")]
    private static partial Regex EmailRegex();

    [GeneratedRegex(@"\b(?:\d[ -]?){12,15}\d\b")]
    private static partial Regex CardRegex();
}

İki ayrıntı önemli. Middleware çağıranın mesaj listesini değiştirmiyor, yeni ChatMessage'lar üretiyor; uygulamanın sakladığı sohbet geçmişi orijinal metni korur. Ve yalnız TextContent'e dokunuyor; görsel ya da tool sonucu gibi diğer content'ler olduğu gibi geçiyor.

Component yazarları için Microsoft Learn'ün önerisi her middleware'e bir Use* extension method'u eklemek. Böylece zincir okunur kalır:

using Microsoft.Extensions.AI;

namespace Bookstore.Assistant.Pipeline;

public static class PipelineExtensions
{
    public static ChatClientBuilder UseTokenBudget(
        this ChatClientBuilder builder, int maxOutputTokens = 256, int maxPromptChars = 4_000) =>
        builder.Use(inner => new TokenBudgetChatClient(inner, maxOutputTokens, maxPromptChars));

    public static ChatClientBuilder UsePiiRedaction(this ChatClientBuilder builder) =>
        builder.Use(inner => new PiiRedactingChatClient(inner));
}

Yukarıdaki çıktıyı üreten zincir de bu:

IChatClient chat = UseWireTap(
        Ollama("gemma3:4b").AsBuilder()
            .UsePiiRedaction()
            .UseTokenBudget(maxOutputTokens: 64)
            .ConfigureOptions(o => o.Temperature ??= 0))
    .Build();

ChatOptions shared = new() { MaxOutputTokens = 2_000 };

Wire tap en içte, provider'ın hemen üstünde. Bu yüzden modele giden metni görüyor: [email] ve [card]. Model de maskeli metni aldığını fark etti ve cevabına "which I unfortunately can't see!" diye ekledi. Küçük bir model bile verinin eksik olduğunu söyleyebiliyor; production'da bunu system prompt'la yönetmek gerekir.

Streaming Override'ını Unutursan

Şimdi Önce Tahmin Et'teki ikinci soruya dönelim. Aynı maskelemeyi yalnız GetResponseAsync'i override eden bir class'la yazdım:

using Microsoft.Extensions.AI;

namespace Bookstore.Assistant.Pipeline;

// Yanlış: yalnız GetResponseAsync override edilmiş
public sealed class NaiveRedactingChatClient(IChatClient innerClient) : DelegatingChatClient(innerClient)
{
    public override Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
        base.GetResponseAsync(
            [.. messages.Select(m => m.Role == ChatRole.User ? new ChatMessage(m.Role, PiiRedactingChatClient.Mask(m.Text)) : m)],
            options, cancellationToken);
}

Aynı soruyu önce normal, sonra streaming olarak gönderdim:

GetResponseAsync:
  [wire] maxOutputTokens=32, user="My email is [email] and my card is [card]. Recommend one book about refactoring in one sentence."
GetStreamingResponseAsync:
  [wire] maxOutputTokens=32, user="My email is ada@example.com and my card is 4111 1111 1111 1111. Recommend one book about refactoring in one sentence."

Derleyici uyarı vermedi, test edilen yol (non-streaming) doğru çalıştı, ama streaming isteğinde e-posta ve kart numarası olduğu gibi modele gitti. DelegatingChatClient'ın override edilmeyen method'u isteği hiç dokunmadan iç client'a iletir. Chat arayüzleri neredeyse her zaman streaming kullandığı için bu hata en çok kullanılan yolda sessizce yaşar. Kural: bir DelegatingChatClient yazıyorsan iki method'u da override et ya da tek delegate'li Use overload'unu kullan, ve testinde iki yolu da çağır.

Sıra Neden Önemli: Cache ve Function Invocation

Açılıştaki soruyu gerçek bir tool'la ölçelim. Tool calling için qwen3:4b kullanıyorum; get_stock her çağrıldığında bir sayaç artıyor, provider'ın hemen üstündeki bir middleware de modele giden her gidiş-dönüşü sayıyor:

async Task RunOrderAsync(bool cacheOutside)
{
    int modelCalls = 0, toolCalls = 0;
    IDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));

    AIFunction getStock = AIFunctionFactory.Create(
        (string title) =>
        {
            toolCalls++;
            return new { title, inStock = 3 };
        },
        "get_stock",
        "Returns how many copies of a book are in stock.");

    ChatClientBuilder builder = Ollama("qwen3:4b").AsBuilder();
    builder = cacheOutside
        ? builder.UseDistributedCache(cache).UseFunctionInvocation()
        : builder.UseFunctionInvocation().UseDistributedCache(cache);

    // Provider'a giden her gidiş-dönüşü say
    IChatClient chat = builder
        .Use(async (messages, options, next, ct) =>
        {
            modelCalls++;
            await next(messages, options, ct);
        })
        .Build();

    ChatOptions options = new()
    {
        Tools = [getStock],
        Temperature = 0,
        Seed = 42,
    };

    Console.WriteLine(cacheOutside ? "cache OUTSIDE function invocation:" : "cache INSIDE function invocation:");
    for (int i = 1; i <= 2; i++)
    {
        modelCalls = toolCalls = 0;
        ChatResponse response = await chat.GetResponseAsync("How many copies of Refactoring are in stock?", options);
        Console.WriteLine($"  request {i}: modelCalls={modelCalls}, toolCalls={toolCalls}, answer=\"{response.Text.Trim()}\"");
    }
}
cache OUTSIDE function invocation:
  request 1: modelCalls=2, toolCalls=1, answer="There are currently **3 copies** of *Refactoring* in stock."
  request 2: modelCalls=0, toolCalls=0, answer="There are currently **3 copies** of *Refactoring* in stock."
cache INSIDE function invocation:
  request 1: modelCalls=2, toolCalls=1, answer="There are currently **3 copies** of *Refactoring* in stock."
  request 2: modelCalls=0, toolCalls=1, answer="There are currently **3 copies** of *Refactoring* in stock."

İlk isteklerde iki durum aynı: model önce get_stock çağrısı istiyor, FunctionInvokingChatClient tool'u çalıştırıp sonucu geri gönderiyor, model ikinci turda cevabı yazıyor. İki model çağrısı, bir tool çağrısı.

Fark ikinci istekte. Cache dışarıdayken (A) bütün konuşmanın sonucu tek bir kayıt olarak saklanmış; istek function invocation katmanına hiç inmiyor, ne model ne tool çalışıyor. Cache içerideyken (B) function invocation katmanı her seferinde çalışıyor ve her model turunu ayrı ayrı cache'e soruyor. İlk tur (yalnız kullanıcı mesajı) cache'ten geliyor, tool gerçekten çalışıyor, ikinci tur (kullanıcı mesajı + tool sonucu) da cache'ten geliyor, çünkü stok hâlâ 3 ve mesaj geçmişi birebir aynı.

Hangisi doğru? Soruya bağlı. A en ucuzu ama stok 3'ten 0'a düşse bile cache süresi dolana kadar "3 kopya var" der; canlı veri okuyan tool'larda tehlikeli. B'de tool her istekte taze veri okur; veri değişmemişse model turları yine cache'ten gelir, değişmişse ikinci tur cache'i ıskalar ve model yeni sonuçla cevap yazar. Bookstore'da stok, sipariş durumu gibi canlı veriler olduğu için ben B'yi seçiyorum. Telemetry ve logging için ise genel kural en dışta durmaları: cache'ten dönen cevaplar da dahil her isteği görmek istersin. OpenTelemetry tarafını 8. yazıda ayrıntılı ele alacağız.

GetService ve Dispose Zinciri

GetService<T>() zincir boyunca içeri doğru iner: her DelegatingChatClient önce kendisinin istenen tip olup olmadığına bakar, değilse soruyu iç client'a iletir. Bu sayede pipeline kurulduktan sonra da belirli bir halkaya ulaşabilirsin; örneğin chat.GetService<FunctionInvokingChatClient>() ile function invocation ayarlarını okuyabilirsin. Bizim zincirde:

GetService<TokenBudgetChatClient>: True
GetService<OllamaApiClient>: True

Dispose da aynı yolu izler. DelegatingChatClient.Dispose iç client'ı da dispose eder, yani en dıştaki client'ı dispose ettiğinde provider'a kadar bütün zincir kapanır. İki sonucu var: aynı provider instance'ını iki ayrı pipeline'da paylaşıyorsan birini dispose etmek diğerini de bozar; ve middleware'in kendi kaynağı varsa (bir RateLimiter gibi) onu Dispose(bool) override'ında sen kapatmalısın.

Üretimde Dikkat

Middleware'leri provider'sız test et. TokenBudgetChatClient ve PiiRedactingChatClient saf C#; altlarına gelen mesajları kaydeden sahte bir IChatClient koyup iki method'u da test edebilirsin. Bu testler milisaniyede koşar ve streaming yolunu unutma hatasını yakalar.

Sırayı belgele. Pipeline'ı kuran satırların üstüne neden o sırada olduklarını bir cümleyle yaz. Altı ay sonra biri "cache'i en dışa alalım, daha hızlı olur" dediğinde, canlı stok verisinin neden bayatlayacağını o yorum anlatır.

Maskelemenin sınırını bil. Regex tabanlı maskeleme yaygın biçimleri yakalar; boşluksuz yazılmış bir IBAN (International Bank Account Number), yazıyla söylenmiş bir telefon numarası ya da bir resmin içindeki metin regex'e takılmaz. Hassas veride gerçek çözüm, o veriyi prompt'a hiç koymamak ve kullanıcının kimliğini tool'lara oturumdan vermek; bunu 6. yazıda tool calling'le birlikte göreceğiz.

Sık Düşülen Tuzaklar

Birincisi, streaming override'ını unutmak. Yukarıda gördük: derleme temiz, non-streaming testi yeşil, chat ekranında middleware yok.

İkincisi, paylaşılan ChatOptions instance'ını mutate etmek. Uygulamalar options'ı çoğu zaman bir kez oluşturup her istekte kullanır. Middleware options.MaxOutputTokens = 64 diye doğrudan yazarsa bu değişiklik sonraki bütün isteklere sızar ve thread-safe de değildir. Önce Clone(), sonra değiştir; ya da ConfigureOptions kullan.

Üçüncüsü, sırayı "nasılsa hepsi çalışıyor" diye rastgele kurmak. Cache, function invocation, telemetry ve maskeleme farklı sıralarda farklı davranış üretir. Örneğin maskeleme cache'in içinde kalırsa cache key'i maskelenmemiş metinle oluşur ve kişisel veri cache deposuna yazılır.

Dördüncüsü, OllamaApiClient üzerinde AsBuilder()'ı doğrudan çağırıp CS0121 hatasını (IChatClient) cast'leriyle kod geneline yaymak. Tipi bir kez, client'ı oluşturduğun yerde sabitle.

Küçük Bir Deneme

★☆☆ Wire tap'i zincirin en dışına, UsePiiRedaction()'dan önceye taşı. Çıktıdaki user= satırı ne gösteriyor? maxOutputTokens neden değişti?

★★☆ UsePiiRedaction()'ı UseDistributedCache(cache)'in içine al (cache dışta, maskeleme içte). Aynı e-postalı soruyu iki kez sor ve cache'e yazılan key'in hangi metinle hesaplandığını bul. İpucu: DistributedCachingChatClient'tan türetip GetCacheKey'i override edebilir ya da IDistributedCache'i saran küçük bir sınıfla SetAsync'e gelen key'leri yazdırabilirsin.

★★★ İki delegate'li Use overload'uyla bir "yasaklı kelime" middleware'i yaz: cevapta rakip bir kitapçının adı geçerse (örnek veriyle, ör. OtherBooks) non-streaming'de cevabı değiştirsin, streaming'de akışı kessin. Streaming'de cevabı değiştirmenin neden çok daha zor olduğunu düşün: kelime iki ayrı update'e bölünmüş gelebilir.

Pipeline'ı kurmayı ve kendi halkalarımızı yazmayı gördük. Bir sonraki yazıda bu pipeline'ı ASP.NET Core'un DI container'ına taşıyoruz: AddChatClient, "fast" ve "smart" adlı iki keyed client, options pattern ile provider seçimi, secret'lar ve provider'ın HttpClient'ına timeout ve retry eklemek.