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

LLM Çağrılarını Gözlemlemek: UseOpenTelemetry, GenAI Semantic Conventions ve Aspire Dashboard

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
LLM Çağrılarını Gözlemlemek: UseOpenTelemetry, GenAI Semantic Conventions ve Aspire Dashboard

LLM çağrılarını gözlemlemek için UseOpenTelemetry'yi UseFunctionInvocation'ın içine koyar, AddSource ile aynı source adını dinler ve trace'i Aspire Dashboard'da okursun. Gerçek bir trace'te iki dakikalık gecikmenin modelden değil Ollama kuyruğundan geldiğini buluyor, EnableSensitiveData'nın e-postayı nasıl telemetry'ye taşıdığını görüyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2, OpenTelemetry .NET 1.20.1, Aspire Dashboard 13.6.4 ve Ollama 0.34.4 (qwen3:4b) kullanılmıştır.

Önce Tahmin Et

Bookstore asistanına "1042 numaralı siparişim nerede?" diye soruluyor ve cevap 1 dakika 54 saniye sonra geliyor. Cevap doğru: sipariş kargoda, takip numarası da yerinde. Elinde yalnız ASP.NET Core'un request log'u var ve orada tek satır görüyorsun: POST /assistant/ask, 200, 114 saniye.

Bu iki dakika nereye gitti? Model mi yavaş, tool mu yavaş, yoksa model aynı işi iki kez mi yaptı? Bir de şunu düşün: bu sorunun cevabını bulmak için kullanıcının e-posta adresini log'a yazman gerekir mi?

Yazının sonunda bu isteğin trace'ini span span okuyacağız. Cevap, çoğu kişinin ilk tahmini olan "model yavaş" değil.

LLM Çağrısı Neden Ayrı Bir Gözlem Konusu?

Normal bir HTTP (Hypertext Transfer Protocol) bağımlılığında süre ve status code çoğu zaman yeter. LLM (Large Language Model) çağrısında üç şey daha önemli: kaç token gitti ve geldi (maliyet buradan çıkar), model tool istedi mi ve kaç tur döndü, hangi model ve hangi parametrelerle çağrıldı. Bu bilgilerin her biri LLM Temelleri serisinde kavram olarak geçti; burada amaç onları her istekte otomatik olarak toplamak.

OpenTelemetry bu iş için ortak dil: trace (bir isteğin span'lerden oluşan ağacı), metrik ve log. Bunların LLM'e özel adlandırmasını ise GenAI (Generative Artificial Intelligence) semantic conventions belirliyor. Microsoft.Extensions.AI tarafında bu standardı uygulayan parça OpenTelemetryChatClient; pipeline'a UseOpenTelemetry() ile girer. Pipeline'ın kendisi ve Use... sırası bu serinin "IChatClient Pipeline'ı" yazısında anlatıldı, burada yalnız telemetry açısından bakıyoruz.

UseOpenTelemetry ile İlk Trace

Bookstore.Assistant.Api projesine OpenTelemetry paketlerini ekliyoruz. OTLP (OpenTelemetry Protocol) exporter'ı telemetry'yi dashboard'a gönderecek, console exporter'ı ise span'leri terminalde görmemizi sağlayacak:

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.AI" Version="10.11.0" />
  <PackageReference Include="OllamaSharp" Version="5.5.2" />
  <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
  <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
  <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.20.1" />
  <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.2" />
</ItemGroup>

Program.cs'in tamamı:

using System.ComponentModel;
using Microsoft.Extensions.AI;
using OllamaSharp;
using OpenTelemetry;
using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

const string AiSource = "Bookstore.Assistant.AI";
bool sensitive = builder.Configuration.GetValue<bool>("Ai:EnableSensitiveData");

builder.Services.AddSingleton<OrderTools>();

// İlk Use en dışta: function invocation → telemetry → logging → Ollama
builder.Services
    .AddChatClient(new OllamaApiClient(new Uri("http://localhost:11434"), "qwen3:4b"))
    .UseFunctionInvocation()
    .UseOpenTelemetry(sourceName: AiSource, configure: c => c.EnableSensitiveData = sensitive)
    .UseLogging();

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("bookstore-assistant-api"))
    .WithTracing(t => t
        .AddSource(AiSource)
        .AddAspNetCoreInstrumentation())
    .WithMetrics(m => m
        .AddMeter(AiSource)
        .AddAspNetCoreInstrumentation())
    .WithLogging()
    .UseOtlpExporter();

// Yalnız yerelde span'leri terminalde görmek için
if (builder.Configuration.GetValue<bool>("Telemetry:Console"))
{
    builder.Services.ConfigureOpenTelemetryTracerProvider(t => t.AddConsoleExporter());
}

WebApplication app = builder.Build();

app.MapPost("/assistant/ask", async (AskRequest request, IChatClient chat, OrderTools tools, CancellationToken ct) =>
{
    ChatOptions options = new()
    {
        Temperature = 0,
        Seed = 42,
        Tools = [AIFunctionFactory.Create(tools.GetOrderStatus)],
    };

    ChatResponse response = await chat.GetResponseAsync(
        [
            new(ChatRole.System, "You are the Bookstore assistant. Use tools for order questions. Answer in one sentence."),
            new(ChatRole.User, request.Question),
        ],
        options, ct);

    return Results.Ok(new
    {
        answer = response.Text,
        inputTokens = response.Usage?.InputTokenCount,
        outputTokens = response.Usage?.OutputTokenCount,
    });
});

app.Run();

public sealed record AskRequest(string Question);

public sealed class OrderTools
{
    [Description("Returns the shipping status of a Bookstore order.")]
    public string GetOrderStatus([Description("The order number")] int orderId) =>
        orderId == 1042 ? "Shipped on 2026-11-06, 2 items, tracking BKS-1042." : "Order not found.";
}

İki ayrıntı kritik. Birincisi sourceName: OpenTelemetryChatClient span'lerini bu adla bir ActivitySource üzerinden, metriklerini aynı adla bir Meter üzerinden yayar. Tracer tarafında AddSource(AiSource), meter tarafında AddMeter(AiSource) yazmazsan SDK (Software Development Kit) bu veriyi dinlemez ve hiçbir hata da görmezsin; telemetry sessizce kaybolur. Adı vermezsen kütüphanenin varsayılanı Experimental.Microsoft.Extensions.AI kullanılır. İkincisi UseOtlpExporter(): trace, metrik ve log'u tek çağrıyla OTLP'ye yönlendirir; hedef adresi OTEL_EXPORTER_OTLP_ENDPOINT ortam değişkeninden okur.

Uygulamayı console exporter açık çalıştırıp istek atıyoruz:

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:19651 Telemetry__Console=true \
  ASPNETCORE_URLS=http://localhost:19660 dotnet run

curl -s localhost:19660/assistant/ask -H "Content-Type: application/json" \
  -d '{"question":"Where is my order 1042?"}'
{"answer":"Your order 1042 is shipped on 2026-11-06 with 2 items and tracking number BKS-1042.","inputTokens":610,"outputTokens":546}

Terminale düşen ilk span, modele giden ilk çağrı:

Activity.TraceId:            a3411ea2ab92fe28dcd913bb4647b5f4
Activity.SpanId:             7af35b22a6b59e1f
Activity.ParentSpanId:       0c08514a277f5acb
Activity.DisplayName:        chat qwen3:4b
Activity.Kind:               Client
Activity.Duration:           00:00:05.8162760
Activity.Tags:
    gen_ai.operation.name: chat
    gen_ai.request.model: qwen3:4b
    gen_ai.provider.name: ollama
    server.address: localhost
    server.port: 11434
    gen_ai.request.seed: 42
    gen_ai.request.temperature: 0
    gen_ai.tool.definitions: [
  {
    "type": "function",
    "name": "GetOrderStatus"
  }
]
    gen_ai.response.finish_reasons: ["stop"]
    gen_ai.response.id: 2026-11-08T06:53:05.549717Z
    gen_ai.response.model: qwen3:4b
    gen_ai.usage.input_tokens: 169
    gen_ai.usage.output_tokens: 217
Instrumentation scope (ActivitySource):
    Name: Bookstore.Assistant.AI

Aynı trace'te toplam beş span var. Console çıktısındaki ParentSpanId değerlerini takip edince ağaç şöyle çıkıyor:

POST /assistant/ask                  22.95 s   (Microsoft.AspNetCore)
└─ orchestrate_tools                 22.90 s   input 610, output 546
   ├─ chat qwen3:4b                   5.82 s   input 169, output 217
   ├─ execute_tool GetOrderStatus     0.94 ms
   └─ chat qwen3:4b                  17.07 s   input 441, output 329

Tek bir HTTP isteği modele iki kez gitti. İlk çağrı tool istedi, FunctionInvokingChatClient tool'u çalıştırdı (1 milisaniyenin altında), ikinci çağrı sonucu cümleye çevirdi. orchestrate_tools span'i bu döngünün tamamını sarıyor ve iki çağrının token'larını topluyor: 169 + 441 = 610 input, 217 + 329 = 546 output. Endpoint'in döndürdüğü inputTokens ve outputTokens değerleri de bu toplam. İkinci çağrının input'u 441'e çıktı, çünkü ilk turun mesajları ve tool sonucu tekrar gönderiliyor.

GenAI Semantic Conventions: Attribute'ları Okumak

Span adı ve attribute'lar rastgele değil. Convention'a göre inference span'inin adı {gen_ai.operation.name} {gen_ai.request.model}, tool span'inin adı execute_tool {gen_ai.tool.name} biçiminde. En çok işine yarayacak attribute'lar:

AttributeNe söylerBu trace'teki değer
gen_ai.operation.nameİşlem tipi (zorunlu)chat, execute_tool
gen_ai.provider.nameSağlayıcı (zorunlu)ollama
gen_ai.request.model / gen_ai.response.modelİstenen ve cevabı üreten modelqwen3:4b
gen_ai.usage.input_tokens / output_tokensToken sayıları169 / 217
gen_ai.response.finish_reasonsCevabın neden bittiği["stop"]
gen_ai.tool.name / gen_ai.tool.call.idÇalışan tool ve çağrı kimliğiGetOrderStatus / 3c2635cd

Burada dikkat etmen gereken bir durum var: bu convention'lar hâlâ "Development" statüsünde ve 2026'da OpenTelemetry'nin ana semantic-conventions reposundan ayrılıp kendi reposuna (open-telemetry/semantic-conventions-genai) taşındı. Microsoft.Extensions.AI 10.11'in XML (Extensible Markup Language) dokümantasyonu, OpenTelemetryChatClient'ın convention'ların v1.41 sürümünü uyguladığını ve çıktının değişebileceğini açıkça yazıyor. Farkı metriklerde görebilirsin. Dashboard'da bu uygulamanın gönderdiği iki GenAI metriği gen_ai.client.operation.duration ve gen_ai.client.token.usage. Yeni repodaki taslak ise inference süresi için gen_ai.client.inference.duration adını kullanıyor ve token'ları gen_ai.client.inference.usage.input_tokens gibi ayrı counter'lara bölüyor. Alarmlarını ve dashboard sorgularını metrik adına bağlıyorsan, paket güncellemesinden sonra bu adları kontrol etmen gerekecek.

Bir de Ollama'ya özgü küçük bir tuhaflık var: gen_ai.response.id değeri bir zaman damgası. OllamaSharp, Ollama'nın cevabında ayrı bir id olmadığı için created_at değerini kullanıyor. Bulut sağlayıcılarda burada sağlayıcının response id'si olur.

Pipeline Sırası Trace'i Değiştirir

Yukarıdaki sırada UseFunctionInvocation() dışta, UseOpenTelemetry() içte. Microsoft Learn'deki pipeline örneği de bu sırayı kullanır. Bu sayede telemetry katmanı modele giden her tek çağrıyı görür. Sırayı ters çevirelim:

builder.Services
    .AddChatClient(new OllamaApiClient(new Uri("http://localhost:11434"), "qwen3:4b"))
    .UseOpenTelemetry(sourceName: AiSource, configure: c => c.EnableSensitiveData = sensitive)
    .UseFunctionInvocation()
    .UseLogging();

Aynı soruyu tekrar sorduğumuzda trace iki span'e iniyor:

Activity.DisplayName:        chat qwen3:4b
Activity.Duration:           00:00:48.9658390
    gen_ai.usage.input_tokens: 608
    gen_ai.usage.output_tokens: 562
Activity.DisplayName:        POST /assistant/ask
Activity.Duration:           00:00:49.0080990

Cevap yine doğru, toplam token da hâlâ görünüyor. Ama artık iki model turu tek bir "chat" span'i gibi görünüyor ve execute_tool span'i yok. "Model kaç tur döndü, tool kaç milisaniye sürdü, hangi turda prompt büyüdü?" sorularının hiçbirine cevap veremezsin. Telemetry dışta durunca, function invocation döngüsü onun gözünde tek bir çağrı olur. Pratik kural şu: UseOpenTelemetry(), UseFunctionInvocation()'ın içinde, cache ve rate limit gibi "çağrıyı hiç yapmayabilecek" katmanların da içinde dursun. Cache'ten dönen cevap model span'i üretmesin, gerçekten modele giden her çağrı ise bir span üretsin.

Aspire Dashboard ile Yerelde Trace Okumak

Console exporter tek istek için idare eder ama ağaç kurmak sana kalır. Aspire Dashboard'ın standalone container'ı yerelde bir OTLP alıcısı ve arayüz sağlar; Aspire AppHost'u kullanman gerekmez:

docker run -d --name aiwork2-aspire \
  -p 19650:18888 -p 19651:18889 \
  -e DASHBOARD__FRONTEND__AUTHMODE=Unsecured \
  mcr.microsoft.com/dotnet/aspire-dashboard:13.6.4

Container içinde 18888 arayüz, 18889 OTLP/gRPC (gRPC Remote Procedure Calls) portu. Biz bunları 19650 ve 19651'e map ettik, uygulamaya da OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:19651 verdik. Unsecured yalnız yerel makine için: dashboard açılışta "uç nokta güvenli değil" uyarısı gösteriyor, çünkü OTLP portuna herhangi bir uygulama veri gönderebilir.

Traces sayfasında her POST /assistant/ask satırının yanında küçük bir GenAI simgesi beliriyor. Dashboard gen_ai.* attribute'u olan span'leri tanıyor; simgeye tıklayınca açılan GenAI ayrıntı penceresi sistem mesajını, kullanıcı mesajını, asistanın tool çağrılarını ve span'in token sayısını sohbet görünümünde gösteriyor. Metrics sayfasında da Bookstore.Assistant.AI meter'ının altında yukarıdaki iki metrik var.

İki Dakika Nereye Gitti?

Açılıştaki 114 saniyelik isteğe dönelim. Trace'te orchestrate_tools 1 dakika 53 saniye, ilk chat qwen3:4b span'i 1 dakika 36 saniye sürmüş. Model mi yavaş? Aynı çağrının UseLogging() çıktısı, OllamaSharp'ın cevaba eklediği süreleri de gösteriyor:

"usage": {
  "inputTokenCount": 175,
  "outputTokenCount": 302,
  "totalTokenCount": 477
},
"additionalProperties": {
  "load_duration": "00:00:00.0019730",
  "total_duration": "00:01:36.7374836",
  "prompt_eval_duration": "00:00:00.1848700",
  "eval_duration": "00:00:06.8685780"
}

302 token üretmek 6,9 saniye, prompt'u işlemek 0,18 saniye, model yükleme 2 milisaniye sürmüş. Geriye kalan yaklaşık 89 saniyede istek Ollama'nın içinde sırasını beklemiş. Bu yazıyı hazırlarken aynı Ollama'yı başka işler de kullanıyordu. Yani ne model yavaştı ne tool; darboğaz paylaşılan model sunucusundaki kuyruktu. Bu teşhisi koymak için iki şeye ihtiyacın var: span süresi ve sağlayıcının kendi süre kırılımı. Birincisini OpenTelemetryChatClient her zaman verir. İkincisini Ollama veriyor ve span'e de attribute olarak ekleniyor. Bulut sağlayıcılarda karşılığı genelde bir response header'ı ya da bu kadar ayrıntılı olmayan bir usage alanıdır.

Aynı trace'ten bir şey daha çıkıyor: qwen3:4b thinking yapan bir model ve ilk turun 302 output token'ının büyük kısmı reasoning. Bu reasoning, history ile ikinci tura da input olarak gidiyor. Bu da maliyete ve gecikmeye doğrudan yansıyor. Böyle bir şeyi ancak içeriği görünce fark edersin. Peki içeriği görmenin bedeli ne?

EnableSensitiveData ve UseLogging: İçeriği Görmenin Bedeli

Varsayılan durumda OpenTelemetryChatClient yalnız metadata yazar: model, token, süre, tool adı. Yukarıdaki gen_ai.tool.definitions attribute'unda bile yalnız tool'un adı ve tipi var, açıklaması ve parametre şeması yok. EnableSensitiveData = true olunca (ya da OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true ortam değişkeni varsa) mesajların tamamı span'e girer. Aynı uygulamayı Ai__EnableSensitiveData=true ile çalıştırıp e-posta içeren bir soru sorduk:

    gen_ai.input.messages: [
  ...
  {
    "role": "user",
    "parts": [
      {
        "type": "text",
        "content": "Where is my order 1042? My email is ada@example.com"
      }
    ]
  }
]
...
    gen_ai.tool.call.arguments: {
  "orderId": 1042
}
    gen_ai.tool.call.result: "Shipped on 2026-11-06, 2 items, tracking BKS-1042."

Kullanıcının e-posta adresi artık trace'te. Aspire Dashboard'ın GenAI ayrıntı penceresinde de bu cümleyi aynen görüyorsun. Model reasoning'inde de "They provided their email as ada@example.com" diye geçiyor; yani adres span'e en az iki yerden giriyor. Tool sonucu da (sipariş durumu, takip numarası) gen_ai.tool.call.result içinde.

UseLogging() aynı riski log tarafında taşır. LoggingChatClient, mesajları ve options'ı yalnız Trace log seviyesinde yazar; bu yazıdaki log çıktısı Logging__LogLevel__Microsoft.Extensions.AI=Trace ile alındı. Varsayılan Information seviyesinde hiçbir şey yazmaz. Kütüphanenin kendi dokümantasyonu da bu seviyenin production'da açılmamasını söylüyor.

Sağlıklı bir ayar şöyle görünür: production'da EnableSensitiveData kapalı ve configuration'dan geliyor; içerik ancak geliştirme ortamında ya da kısa süreli, erişimi kısıtlı bir hata ayıklama oturumunda açılıyor. Trace hacmini düşürmek için de sampling kullanılıyor:

double ratio = builder.Configuration.GetValue("Telemetry:SampleRatio", 1.0);

builder.Services.AddOpenTelemetry()
    .WithTracing(t => t
        .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(ratio)))
        .AddSource(AiSource)
        .AddAspNetCoreInstrumentation())
    .UseOtlpExporter();

ParentBasedSampler üst servisin kararına uyar; trace kök span'de başlıyorsa isteklerin ratio kadarını örnekler. Sampling yalnız trace'i etkiler; token metrikleri her istekte toplanmaya devam eder. Bu da maliyet takibinin örneklemeden etkilenmemesini sağlar.

Üretimde Dikkat

  • Token metriği, LLM maliyetinin ham verisidir. gen_ai.client.token.usage histogramını model ve gen_ai.token.type (input/output) boyutlarında topla; bu serinin "LLM Maliyetini Kontrol Etmek" yazısındaki bütçe hesabının gözlem tarafı budur.
  • Span süresini sağlayıcının kendi süreleriyle karşılaştır. Fark büyükse sorun modelde değil kuyrukta, network'te ya da rate limit'tedir. Kendi Ollama'nı işletiyorsan bu, ölçeklemenin ilk sinyalidir.
  • Tool span'lerine bak. Bir tool'un çağrılma sayısı soru sayısından belirgin biçimde fazlaysa model aynı tool'u tekrar tekrar istiyor demektir. Tool açıklamasını ya da dönüş biçimini düzeltmek gerekir.
  • Prompt'un büyümesini input token'ın turlar arasındaki artışından izle. Bu trace'te 169'dan 441'e çıktı; sohbet geçmişi ve reasoning biriktikçe bu sayı büyür.
  • Telemetry ayarları kod değil, configuration olsun: source adı sabit, ama EnableSensitiveData, sampling oranı ve OTLP endpoint'i ortam bazında değişir.

Collector, backend seçimi ve log/trace korelasyonunun ayrıntıları ilerideki Observability serisinin konusu.

Sık Düşülen Tuzaklar

En sık karşılaşılan tuzak, AddSource ile sourceName'in uyuşmaması. UseOpenTelemetry(sourceName: "Bookstore.Assistant.AI") yazıp tracer'a başka bir ad verirsen ya da hiç vermezsen, OpenTelemetry SDK'sı span'leri dinlemez; uygulama çalışır, HTTP span'leri gelir, GenAI span'leri gelmez. Metrikler için de AddMeter aynı adı ister.

İkincisi, telemetry'yi pipeline'da yanlış yere koymak. Yukarıda gördük: UseFunctionInvocation()'ın dışına konduğunda tool turları görünmez olur. Cache'in dışına konduğunda ise cache'ten dönen cevaplar da model çağrısı gibi sayılır ve süre metriğin aşağı kayar.

Üçüncüsü, içerik loglama ayarlarını geliştirmeden production'a taşımak. Bir appsettings.Development.json satırının yanlışlıkla ana dosyaya geçmesi, bütün kullanıcı mesajlarının telemetry backend'ine akması demektir. Bu veri çoğu zaman uygulama veritabanından daha geniş bir ekibin erişimindedir. KVKK (Kişisel Verilerin Korunması Kanunu) ya da GDPR (General Data Protection Regulation) açısından da ayrı bir veri işleme noktası oluşur.

Dördüncüsü, API (Application Programming Interface) key'in telemetry'ye düşmesi. OpenTelemetryChatClient key'i yazmaz, ama HTTP client instrumentation'ı ya da kendi yazdığın bir log satırı header'ları yazıyorsa key oradan sızar. Bu serinin "ASP.NET Core'a IChatClient Bağlamak" yazısındaki secret yönetimi, telemetry tarafında da geçerli.

Son olarak, convention adlarını kalıcı sanmak. GenAI convention'ları Development statüsünde. Paket güncellemesinden sonra dashboard'daki metrik ve attribute adlarını kontrol etmeden alarm kurallarına güvenme.

Küçük Bir Deneme

★☆☆ Programdaki AddSource(AiSource) satırını sil ve aynı isteği at. Console'da hangi span kalıyor? Sonra satırı geri koyup sourceName parametresini kaldır; span'leri tekrar görmek için tracer'a hangi adı vermen gerekiyor?

★★☆ Pipeline'a UseDistributedCache() ekle (bellek içi IDistributedCache yeterli). Önce telemetry'nin içine, sonra dışına koy ve aynı soruyu iki kez sor. İkinci istekte hangi durumda chat qwen3:4b span'i oluşuyor, hangi durumda oluşmuyor?

★★★ Kendi DelegatingChatClient'ını yaz: Activity.Current'a bookstore.feature attribute'u eklesin ve bu değeri ChatOptions.AdditionalProperties'ten okusun. Bu client'ı pipeline'ın neresine koyarsan attribute'u chat span'inde görürsün, neresine koyarsan orchestrate_tools'ta?

Artık her LLM çağrısını görebiliyorsun. Ama görmek, cevabın iyi olduğunu kanıtlamaz. Sonraki yazılarda önce Microsoft.Extensions.VectorData ile ilk vector collection'ımızı kuracağız, ardından IChatClient kullanan kodu test etmeye ve modelin cevap kalitesini ölçmeye geçeceğiz.