IChatClient Kullanan Kodu Test Etmek: Fake Client, Deterministik Testler ve Microsoft.Extensions.AI.Evaluation
IChatClient kullanan kodun doğruluğunu fake bir client'la, modelsiz ve milisaniyeler içinde test edersin; modelin cevap kalitesini ise Microsoft.Extensions.AI.Evaluation ile, ayrı çalışan ve cache'lenen evaluation'larla ölçersin. Gerçek koşularda judge timeout'larını, 800 token'a takılan reasoning'i, eski cevap dönen cache'i ve prompt hatasını yakalayan Relevance skorunu görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Microsoft.Extensions.AI 10.11.0, Microsoft.Extensions.AI.Evaluation.Quality ve .Reporting 10.11.0, Microsoft.Extensions.AI.Evaluation.NLP 10.11.0-preview.1.26513.7, xUnit.net v3 4.0.2, OllamaSharp 5.5.2 ve Ollama 0.34.4 (qwen3:4b) kullanılmıştır.
Önce Tahmin Et
Bookstore asistanı için bir test yazmak istiyorsun: "Eski ve test edilmemiş kodu bozmadan değiştirmek istiyorum, hangi kitabı okumalıyım?" sorusuna Working Effectively with Legacy Code önerilsin. Testi gerçek modele bağlayıp Assert.Contains("Legacy Code", answer) yazdın. Bu test her CI (Continuous Integration) koşusunda aynı sonucu verir mi? Kaç saniye sürer? Model cevabı "Feathers'ın kitabı" diye verirse ne olur?
Bir de şunu düşün: bu test kırmızıya döndüğünde sorun senin kodunda mı, modelde mi, yoksa prompt'ta mı?
Bu yazıda iki ayrı soruyu iki ayrı yerde cevaplayacağız. "Benim kodum doğru mu?" sorusu fake bir IChatClient ile, milisaniyeler içinde, her build'de çalışan unit test'lerin işi. "Model iyi cevap veriyor mu?" sorusu ise Microsoft.Extensions.AI.Evaluation ile yapılan, yavaş ve maliyetli, ayrı çalıştırılan evaluation'ların işi. İkisini karıştırmak, bu yazının sonunda göreceğimiz gibi, hem yavaş hem kırılgan bir test suite'i demek.
Test Edilecek Kod
İki class'ımız var. BookRecommender modelden yapılandırılmış bir öneri alıyor ve katalogla karşılaştırıyor:
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant;
public sealed record BookRecommendation(string Title, string Reason);
public sealed class BookRecommender(IChatClient chat)
{
public async Task<BookRecommendation?> RecommendAsync(string question, CancellationToken ct = default)
{
List<ChatMessage> messages =
[
new(ChatRole.System,
$"Recommend exactly one book from this catalog: {string.Join("; ", Catalog.Titles)}."),
new(ChatRole.User, question),
];
ChatResponse<BookRecommendation> response = await chat.GetResponseAsync<BookRecommendation>(
messages, new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
// Model katalog dışında bir kitap uydurabilir ya da bozuk JSON dönebilir
if (!response.TryGetResult(out BookRecommendation? recommendation))
{
return null;
}
return Catalog.Titles.Contains(recommendation.Title, StringComparer.OrdinalIgnoreCase)
? recommendation
: null;
}
}
Burada test edilmeye değer olan modelin cevabı değil, bizim kodumuzun karar mantığı: model katalogdaki bir kitabı seçerse öneriyi döndür, uydurursa ya da bozuk JSON (JavaScript Object Notation) dönerse null dön, isteği de deterministik ayarlarla gönder. Structured output'un mantığı LLM (Large Language Model) Temelleri serisinin "LLM ile Structured Output" yazısında anlatıldı.
BookstoreAssistant ise bu serideki sipariş asistanının sade hali: system prompt, tool ve istek başına seçilebilen bir model id'si.
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant;
public sealed class BookstoreAssistant(IChatClient chat, OrderTools tools, string? modelId = null)
{
public const string SystemPrompt =
"You are the Bookstore assistant. Use tools for order questions. Answer in at most three sentences.";
public async Task<(List<ChatMessage> Messages, ChatResponse Response)> AskAsync(
string question, CancellationToken ct = default)
{
List<ChatMessage> messages = [new(ChatRole.System, SystemPrompt), new(ChatRole.User, question)];
ChatOptions options = new()
{
ModelId = modelId,
Temperature = 0,
Seed = 42,
Tools = [AIFunctionFactory.Create(tools.GetOrderStatus)],
};
ChatResponse response = await chat.GetResponseAsync(messages, options, ct);
return (messages, response);
}
}
Fake IChatClient: Sıradaki Cevabı Dönen Bir Model
IChatClient küçük bir interface; test için kendi uygulamasını yazmak birkaç satır:
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Tests;
// Sıradaki hazır cevabı döner ve gelen her isteği kaydeder
public sealed class ScriptedChatClient(params ChatResponse[] responses) : IChatClient
{
private readonly Queue<ChatResponse> _responses = new(responses);
public List<(List<ChatMessage> Messages, ChatOptions? Options)> Calls { get; } = [];
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
Calls.Add(([.. messages], options));
return Task.FromResult(_responses.Dequeue());
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ChatResponse response = await GetResponseAsync(messages, options, cancellationToken);
foreach (ChatResponseUpdate update in response.ToChatResponseUpdates())
{
yield return update;
}
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType.IsInstanceOfType(this) ? this : null;
public void Dispose() { }
public static ChatResponse Text(string text) => new(new ChatMessage(ChatRole.Assistant, text));
}
ScriptedChatClient iki iş yapıyor: sıradaki hazır cevabı dönüyor ve gelen her isteği, mesajları ve options'ıyla birlikte Calls listesine yazıyor. Streaming method'u da aynı hazır cevabı ToChatResponseUpdates() ile parçalara bölerek dönüyor; streaming kullanan kod da aynı fake'le test edilebilir. NSubstitute gibi bir mock kütüphanesiyle de aynı şeyi yapabilirsin. Ama GetResponseAsync<T> bir extension method olduğu için mock'lanamaz, asıl çağrılan GetResponseAsync'tir. Bu yüzden elle yazılmış küçük bir fake, mock'tan daha okunur oluyor. Mock ve fake ayrımı Unit Test serisinde ayrıntılı anlatılıyor.
Kodun Doğruluğunu Test Etmek
xUnit.net v3 ile, Unit Test serisindeki gibi Microsoft Testing Platform üzerinde çalışan bir test projesi:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="xunit.v3.mtp-v2" Version="4.0.2" />
<PackageReference Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.11.0" />
<PackageReference Include="Microsoft.Extensions.AI.Evaluation.Reporting" Version="10.11.0" />
<PackageReference Include="Microsoft.Extensions.AI.Evaluation.NLP" Version="10.11.0-preview.1.26513.7" />
<PackageReference Include="OllamaSharp" Version="5.5.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Bookstore.Assistant\Bookstore.Assistant.csproj" />
</ItemGroup>
</Project>
BookRecommender'ın dört davranışı:
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Tests;
public sealed class BookRecommenderTests
{
[Fact]
public async Task Returns_recommendation_when_model_picks_a_catalog_book()
{
ScriptedChatClient chat = new(ScriptedChatClient.Text(
"""{"title":"Working Effectively with Legacy Code","reason":"It shows how to get old code under test."}"""));
BookRecommendation? result = await new BookRecommender(chat)
.RecommendAsync("How do I change old code safely?", TestContext.Current.CancellationToken);
Assert.Equal("Working Effectively with Legacy Code", result?.Title);
}
[Fact]
public async Task Returns_null_when_model_invents_a_book()
{
ScriptedChatClient chat = new(ScriptedChatClient.Text(
"""{"title":"Legacy Code Mastery","reason":"A great book."}"""));
BookRecommendation? result = await new BookRecommender(chat)
.RecommendAsync("How do I change old code safely?", TestContext.Current.CancellationToken);
Assert.Null(result);
}
[Fact]
public async Task Returns_null_when_model_returns_broken_json()
{
ScriptedChatClient chat = new(ScriptedChatClient.Text("Sure! I recommend Refactoring."));
BookRecommendation? result = await new BookRecommender(chat)
.RecommendAsync("Any book on refactoring?", TestContext.Current.CancellationToken);
Assert.Null(result);
}
[Fact]
public async Task Sends_catalog_and_deterministic_options()
{
ScriptedChatClient chat = new(ScriptedChatClient.Text("""{"title":"Refactoring","reason":"r"}"""));
await new BookRecommender(chat).RecommendAsync("Any book on refactoring?", TestContext.Current.CancellationToken);
(List<ChatMessage> messages, ChatOptions? options) = Assert.Single(chat.Calls);
Assert.Equal(ChatRole.System, messages[0].Role);
Assert.Contains("Designing Data-Intensive Applications", messages[0].Text);
Assert.Equal(0f, options?.Temperature);
Assert.IsType<ChatResponseFormatJson>(options?.ResponseFormat);
}
}
İkinci ve üçüncü test, gerçek bir modelle yazılması neredeyse imkânsız durumları sınıyor: modelin olmayan bir kitap uydurması ve JSON yerine düz metin dönmesi. Gerçek model bunu ne zaman yapacağını sana söylemez; fake ile istediğin an yaptırırsın. Dördüncü test isteğin kendisine bakıyor: system mesajı katalogu içeriyor mu, Temperature 0 mı, GetResponseAsync<T> isteğe JSON response format'ı eklemiş mi?
Tool'lar için iki seviye var. Tool fonksiyonunun kendisi düz bir C# method'u; modelle hiçbir ilgisi yok ve doğrudan test edilir. Tool'un pipeline'a doğru bağlandığını, yani modelin istediği çağrının gerçekten yapılıp sonucun modele geri gittiğini ise fake bir model turuyla test ederiz:
using Microsoft.Extensions.AI;
namespace Bookstore.Assistant.Tests;
public sealed class OrderAssistantTests
{
[Theory]
[InlineData(1042, "Shipped on 2026-11-06, 2 items, tracking BKS-1042.")]
[InlineData(7, "Order not found.")]
public void GetOrderStatus_returns_status_without_any_model(int orderId, string expected) =>
Assert.Equal(expected, new OrderTools().GetOrderStatus(orderId));
[Fact]
public async Task Tool_call_from_model_runs_the_tool_and_sends_the_result_back()
{
// 1. tur: model tool ister; 2. tur: model cevabı yazar
ScriptedChatClient model = new(
new ChatResponse(new ChatMessage(ChatRole.Assistant,
[new FunctionCallContent("call-1", "GetOrderStatus", new Dictionary<string, object?> { ["orderId"] = 1042 })])),
ScriptedChatClient.Text("Your order 1042 has shipped."));
IChatClient pipeline = model.AsBuilder().UseFunctionInvocation().Build();
BookstoreAssistant assistant = new(pipeline, new OrderTools());
(_, ChatResponse response) = await assistant.AskAsync("Where is order 1042?", TestContext.Current.CancellationToken);
Assert.Equal("Your order 1042 has shipped.", response.Text);
Assert.Equal(2, model.Calls.Count);
FunctionResultContent result = model.Calls[1].Messages
.SelectMany(m => m.Contents).OfType<FunctionResultContent>().Single();
Assert.Equal("call-1", result.CallId);
Assert.Contains("BKS-1042", result.Result?.ToString());
}
}
İkinci test'te fake model ilk turda bir FunctionCallContent dönüyor, tıpkı LLM Temelleri serisinin tool calling yazısında gördüğümüz gerçek bir tool isteği gibi. UseFunctionInvocation() tool'u çalıştırıyor ve ikinci turda modele gönderilen mesajlarda, aynı CallId ile bir FunctionResultContent bulunması gerekiyor. Bu test tool adını, parametre adını (orderId) ve sonucun modele dönüşünü modelsiz doğruluyor. Birisi method'un adını ya da parametresini değiştirirse test kırılır.
Evaluation test'lerini bir trait ile ayırıp yalnız unit test'leri çalıştırıyoruz:
dotnet test --project tests/Bookstore.Assistant.Tests --filter-not-trait "Category=Evaluation"
Test run summary: Passed!
total: 7
failed: 0
succeeded: 7
skipped: 0
duration: 935ms
Yedi test, bir saniyenin altında. Ollama kapalı olsa da, ağ olmasa da aynı sonucu verir. CI'da her commit'te çalışması gereken kısım bu.
Modelin Cevap Kalitesini Ölçmek: Evaluation
Unit test'ler "model şunu dönerse kodum doğru davranır mı?" sorusunu cevaplıyor. Modelin gerçekten iyi cevap verip vermediğini söylemiyor. Bunun için Microsoft.Extensions.AI.Evaluation kütüphanelerini kullanıyoruz. Microsoft.Extensions.AI.Evaluation.Quality paketindeki evaluator'lar (RelevanceEvaluator, CoherenceEvaluator ve diğerleri) cevabı bir LLM'e, yani bir "judge" modele puanlatıyor. Microsoft.Extensions.AI.Evaluation.NLP paketindeki F1Evaluator ise LLM kullanmıyor: cevapla bir referans cevap arasındaki ortak kelimelere bakıyor. NLP (Natural Language Processing) paketi hâlâ preview. Microsoft.Extensions.AI.Evaluation.Reporting da sonuçları diske yazıyor ve LLM cevaplarını cache'liyor.
using Microsoft.Extensions.AI;
using Microsoft.Extensions.AI.Evaluation;
using Microsoft.Extensions.AI.Evaluation.NLP;
using Microsoft.Extensions.AI.Evaluation.Quality;
using Microsoft.Extensions.AI.Evaluation.Reporting;
using Microsoft.Extensions.AI.Evaluation.Reporting.Storage;
using OllamaSharp;
namespace Bookstore.Assistant.Tests;
[Trait("Category", "Evaluation")]
public sealed class AssistantEvaluationTests
{
// Judge da uygulama da qwen3:4b: paylaşılan sunucuda model değişimi beklemeyi önler.
// Evaluator'ların 800 token'lık cevap sınırı thinking yapan bir judge'a yetmiyor
private static readonly IChatClient Judge = new ChatClientBuilder(new OllamaApiClient(
new HttpClient { BaseAddress = new Uri("http://localhost:11434"), Timeout = TimeSpan.FromMinutes(10) },
"qwen3:4b"))
.ConfigureOptions(o => o.MaxOutputTokens = 4096)
.Build();
private static readonly ReportingConfiguration Reporting = DiskBasedReportingConfiguration.Create(
storageRootPath: Environment.GetEnvironmentVariable("EVAL_STORAGE_PATH") ?? "eval-results",
evaluators: [new RelevanceEvaluator(), new CoherenceEvaluator(), new F1Evaluator()],
chatConfiguration: new ChatConfiguration(Judge),
enableResponseCaching: true,
executionName: Environment.GetEnvironmentVariable("EVAL_EXECUTION") ?? $"{DateTime.UtcNow:yyyyMMddTHHmmss}");
[Theory]
[InlineData("legacy-code",
"I keep breaking old untested code when I change it. Which book should I read?",
"Read Working Effectively with Legacy Code; it shows how to get untested code under test so you can change it safely.")]
[InlineData("order-status",
"Where is my order 1042?",
"Order 1042 shipped on 2026-11-06 with 2 items; the tracking number is BKS-1042.")]
public async Task Assistant_answer_is_relevant_and_coherent(string scenario, string question, string reference)
{
await using ScenarioRun run = await Reporting.CreateScenarioRunAsync(
$"Bookstore.Assistant.{scenario}", cancellationToken: TestContext.Current.CancellationToken);
// Cache'li client'tan geçen uygulama çağrısı da cache'e girer
IChatClient app = run.ChatConfiguration!.ChatClient.AsBuilder().UseFunctionInvocation().Build();
BookstoreAssistant assistant = new(app, new OrderTools(), modelId: "qwen3:4b");
(List<ChatMessage> messages, ChatResponse response) =
await assistant.AskAsync(question, TestContext.Current.CancellationToken);
EvaluationResult result = await run.EvaluateAsync(messages, response,
additionalContext: [new F1EvaluatorContext(reference)],
cancellationToken: TestContext.Current.CancellationToken);
NumericMetric relevance = result.Get<NumericMetric>(RelevanceEvaluator.RelevanceMetricName);
NumericMetric coherence = result.Get<NumericMetric>(CoherenceEvaluator.CoherenceMetricName);
NumericMetric f1 = result.Get<NumericMetric>(F1Evaluator.F1MetricName);
TestContext.Current.TestOutputHelper?.WriteLine(
$"{scenario}: relevance={relevance.Value} ({relevance.Interpretation?.Rating}), " +
$"coherence={coherence.Value} ({coherence.Interpretation?.Rating}), f1={f1.Value:F2}");
TestContext.Current.TestOutputHelper?.WriteLine($"answer: {response.Text}");
Assert.False(relevance.Interpretation?.Failed ?? true, relevance.Reason);
Assert.False(coherence.Interpretation?.Failed ?? true, coherence.Reason);
}
}
Birkaç tasarım kararı var. Test sınıfı [Trait("Category", "Evaluation")] ile işaretli, böylece unit test'lerden ayrı çalışıyor. DiskBasedReportingConfiguration.Create evaluator'ları, judge'ın ChatConfiguration'ını, sonuçların yazılacağı klasörü ve enableResponseCaching: true'yu bir araya getiriyor. Execution adı ortam değişkeninden geliyor; Microsoft Learn'deki tutorial'ın önerdiği gibi CI'da bunu build numarası yapabilirsin. Asistanı da run.ChatConfiguration.ChatClient üzerinden çağırıyoruz: bu client cache'li olduğu için uygulamanın kendi model çağrıları da cache'e giriyor. Uygulama modeli istek başına ModelId ile seçiliyor.
Judge Model Seçimi: Dört Denemenin Hikâyesi
Koddaki judge ayarı ilk denemede böyle değildi. Bu bölüm, gerçek koşularda karşılaştığımız sorunları sırasıyla anlatıyor, çünkü her biri production'da da karşına çıkacak türden.
İlk denemede judge olarak daha büyük modeli, gemma3:12b'yi seçtik. Judge'ın değerlendirdiği modelden güçlü olması mantıklı görünüyor. Paylaşılan yerel Ollama'da ise ilk çağrı HttpClient'ın varsayılan 100 saniyelik timeout'una takıldı. Timeout'u 10 dakikaya çıkardık; Relevance ve Coherence bu sefer "The request was canceled due to the configured HttpClient.Timeout of 600 seconds elapsing" diagnostic'iyle döndü. Judge'ı gemma3:4b yapınca da sonuç değişmedi. Nedeni /api/ps çıktısında görünüyordu: sunucuda o anda yalnız qwen3:4b yüklüydü ve diğer işlerin qwen3 istekleri bitmeden gemma3 sırası hiç gelmiyordu. Uygulama ve judge farklı modelse, paylaşılan bir sunucuda judge model değişimini beklemek zorunda kalabilir.
Üçüncü denemede judge'ı uygulamayla aynı model yaptık: qwen3:4b. Bu kez evaluator'lar "Failed to parse score for 'Relevance' from the following evaluation response" diye boş bir cevaptan şikâyet etti. Cache'teki ham cevaba bakınca sebep çıktı: evaluator'lar judge'a istek atarken cevabı 800 token ile sınırlıyor. qwen3:4b'nin 800 token'ın hepsi reasoning'e gitmiş, finishReason değeri length, metin kısmı boş. Thinking'i kapatmayı denedik (ChatOptions.Reasoning ile ReasoningEffort.None). Model bu sefer düşüncesini doğrudan metne yazdı ve yine 800 token'da kesildi.
Dördüncü denemede judge client'a ConfigureOptions(o => o.MaxOutputTokens = 4096) ekledik. Testi çalıştırdık ve 1,7 saniyede bitti, sonuçlar ise değişmemişti. Cache, eski bozuk cevapları dönüyordu. ConfigureOptions bizim client'ımızın içinde, Reporting'in cache katmanı ise onun dışında. Cache key'i bizim değiştirdiğimiz options'tan önce hesaplanıyor. dotnet aieval clean-cache çalıştırdık, ama bu komut yalnız süresi dolmuş kayıtları siliyor ("Deleting expired cache entries..."). Cache klasörünü elle silince gerçek sonuçlar geldi:
dotnet test --project tests/Bookstore.Assistant.Tests --filter-trait "Category=Evaluation" --output detailed
failed ...Assistant_answer_is_relevant_and_coherent(scenario: "legacy-code", ...) (3m 37s 671ms)
The response addresses the query but omits specific book recommendations, which are key details for the user.
Standard output
legacy-code: relevance=3 (Average), coherence= (Inconclusive), f1=0,09
answer: I don't have access to book recommendations or programming resources through my current tools. My capabilities are limited to checking order statuses using the provided function. Please consult a programming book or resource for code stability best practices.
failed ...Assistant_answer_is_relevant_and_coherent(scenario: "order-status", ...) (5m 28s 450ms)
Assert.False() Failure
Expected: False
Actual: True
Standard output
order-status: relevance= (Inconclusive), coherence=4 (Good), f1=0,60
answer: Your order #1042 was shipped on November 6, 2026, with 2 items. The tracking number is BKS-1042. You can track its delivery using this number.
Test run summary: Failed!
total: 2
failed: 2
succeeded: 0
Toplam süre 9 dakika 7 saniye. İlk sonuç tam evaluation'dan beklenen şey: asistan kitap önerisi istenince "bu benim işim değil" dedi. Relevance 3 (Average) aldı, gerekçesi de doğru: cevap soruyla ilgili ama asıl istenen bilgiyi, yani bir kitap önerisini vermiyor. F1 (F1 score: ortak kelimelerden hesaplanan precision ve recall'un harmonik ortalaması) 0,09, referans cevapla neredeyse hiç ortak kelime yok. Sebep model değil, bizim system prompt'umuz: "Use tools for order questions" cümlesi küçük modeli yalnız sipariş sorularına bakması gerektiğine ikna etmiş. Unit test'lerimiz bunu hiçbir zaman yakalayamazdı; kod doğru çalışıyor, ürün davranışı yanlış.
İkinci sonuç ise judge'ın kendisinin güvenilmezliğini gösteriyor. Cevap iyi (Coherence 4, F1 0,60), ama Relevance yine "Inconclusive": qwen3:4b puanı evaluator'ın beklediği biçimde yazmadı. Test, cevap kötü olduğu için değil, judge biçim hatası yaptığı için kırmızı. Ayrıca bu düzende judge, kendi ürettiği cevabı puanlıyor; aynı modelin kendi çıktısını kayırması bilinen bir önyargı. Bu yazıda bunu yerel kısıtlar yüzünden kabul ettik, production'da judge'ın uygulama modelinden farklı ve güçlü bir model olması gerekir.
Cache, Rapor ve CI Maliyeti
Aynı testi hiçbir şeyi değiştirmeden tekrar çalıştırdık:
EVAL_EXECUTION=run8 dotnet test --project tests/Bookstore.Assistant.Tests --filter-trait "Category=Evaluation"
total: 2
failed: 2
succeeded: 0
duration: 767ms
9 dakika yerine 767 milisaniye. Prompt'lar, options ve model aynı kaldığı sürece bütün model çağrıları diskteki cache'ten geliyor. Microsoft Learn'deki tutorial'a göre cache kayıtları varsayılan olarak 14 gün yaşıyor. CI'da bu klasörü build'ler arasında saklarsan, yalnız prompt'u değişen senaryolar modele gider. Rapor için Microsoft.Extensions.AI.Evaluation.Console aracını global değil, projenin tool manifest'iyle kuruyoruz:
dotnet new tool-manifest
dotnet tool install Microsoft.Extensions.AI.Evaluation.Console --version 10.11.0
DOTNET_AIEVAL_TELEMETRY_OPTOUT=1 dotnet aieval report --path eval-results --output report.html -n 2
info: aieval[0] Storage root path: eval-results
info: aieval[0] Execution: run8 Scenario: Bookstore.Assistant.legacy-code Iteration: 1
info: aieval[0] Execution: run8 Scenario: Bookstore.Assistant.order-status Iteration: 1
info: aieval[0] Execution: run7 Scenario: Bookstore.Assistant.legacy-code Iteration: 1
info: aieval[0] Execution: run7 Scenario: Bookstore.Assistant.order-status Iteration: 1
info: aieval[0] Report: report.html [html]
Rapor tek bir HTML (HyperText Markup Language) dosyası (bu koşuda 918 KB). Senaryolar, scenario adındaki noktalara göre ağaç şeklinde gruplanıyor; her metriğin kartında puan, yorum ve judge'ın gerekçesi var. DOTNET_AIEVAL_TELEMETRY_OPTOUT: araç varsayılan olarak anonim kullanım verisi topluyor ve bu değişken onu kapatıyor.
Eşik ve Flaky Evaluation
Bu testlerde metrik başına Interpretation.Failed'a bakıp assert ettik. Kütüphanenin varsayılan yorumunda 1–5 ölçeğinde 4 ve üstü "Good" ya da "Exceptional", altı başarısız. Microsoft Learn'deki tutorial da tek tek evaluation sonuçlarını build'i kıran assert'lere çevirmeme konusunda uyarıyor: modeller ve skorlar zamanla değişir; tek bir testin kırılması yerine raporlardaki eğilimi izlemek ve ancak birçok senaryoda belirgin bir düşüş olduğunda build'i kırmak daha sağlıklı.
Bizim koşumuz bunun nedenini somut olarak gösteriyor. İki kırmızı testten biri gerçek bir ürün hatası, diğeri judge'ın biçim hatası. Pratik bir düzen şöyle: unit test'ler her commit'te ve build'i kıracak şekilde çalışır. Evaluation'lar ayrı bir job'da, gece ya da prompt değişikliğinde çalışır, sonuçları rapora gider. "Inconclusive" sonuç başarısızlık değil, tekrar denenecek bir durum olarak ele alınır. Eşiği de bir kerelik bir sayı olarak değil, birkaç koşunun ortalamasına göre belirlersin.
Üretimde Dikkat
- Judge, değerlendirdiği modelden ayrı ve tercihen daha güçlü olmalı. Aynı model kendi cevabını puanlarsa önyargı oluşur; küçük bir model de puan biçimine uymayabilir.
- Judge'ın kotası ve gecikmesi uygulamanınkinden ayrı planlanmalı. Paylaşılan bir model sunucusunda judge istekleri uygulama trafiğiyle yarışır.
- Cache klasörü bir build artifact'i gibi ele alınmalı: saklanmalı, ama judge ayarı, evaluator sürümü ya da prompt şablonu değişince bilerek temizlenmeli.
- Evaluation verisi (sorular, referans cevaplar) kod kadar değerlidir. Gerçek kullanıcı sorularından, kişisel veriden arındırılarak üretilmeli ve versiyonlanmalı.
- RAG (Retrieval-Augmented Generation) ve agent'a özel evaluator'lar (
GroundednessEvaluator,RetrievalEvaluator,ToolCallAccuracyEvaluator,TaskAdherenceEvaluator) ilgili serilerde kullanılacak.
Sık Düşülen Tuzaklar
En yaygın tuzak, gerçek modele giden testi unit test suite'ine koymak. Bu yazıdaki iki evaluation testi 9 dakika sürdü ve Ollama kapalı olsa hiç çalışmazdı. Aynı suite'teki yedi unit test bir saniyenin altında bitti. İkisini aynı dotnet test çağrısında karıştırırsan, her commit 9 dakika bekler ve her model dalgalanmasında kırmızıya döner. Trait ya da ayrı test projesiyle ayır.
İkincisi, judge modelin önyargısını ve kapasitesini hesaba katmamak. qwen3:4b bir cevabı "Inconclusive" bıraktı, bir başka seferde puanı yazdı. Judge'ın kendi tutarlılığını da ölç: aynı cevabı birkaç kez puanlat ve dağılıma bak.
Üçüncüsü, cache'i güncellemeyi unutmak. Yukarıda gördük: judge ayarını değiştirdik, test 1,7 saniyede eski sonuçları döndü. clean-cache yalnız süresi dolmuş kayıtları siliyor. Judge ya da evaluator ayarı değiştiğinde cache'i elle temizlemek ya da execution adı gibi cache key'ine giren bir değeri değiştirmek gerekiyor.
Son olarak, assert'i serbest metin üzerine kurmak. Assert.Contains("Legacy Code", answer) türü bir test, modelin "Feathers'ın kitabı" demesiyle kırılır ve gerçek bir hatayı da kaçırabilir. Metin karşılaştırması gerekiyorsa F1 gibi bir NLP metriğini eşikle kullan; daha iyisi, yapılandırılmış çıktıyı unit test'teki gibi tipli bir nesneye çevirip onun alanlarını doğrula.
Küçük Bir Deneme
★☆☆ BookstoreAssistant.SystemPrompt'u "You recommend books from the catalog and answer order questions with tools." diye değiştir ve evaluation'ı tekrar çalıştır. legacy-code senaryosunun Relevance ve F1 değerleri nasıl değişiyor? order-status senaryosu cache'ten mi geliyor, yoksa yeniden mi hesaplanıyor? Neden?
★★☆ ScriptedChatClient'ı kullanarak, model ilk turda var olmayan bir tool adı ("GetOrderStatusV2") istediğinde BookstoreAssistant'ın ne yaptığını gösteren bir unit test yaz. FunctionInvokingChatClient modele ne döndürüyor?
★★★ Microsoft Learn'deki WordCountEvaluator örneğinden yola çıkarak LLM kullanmayan bir CatalogTitleEvaluator yaz: cevap katalogdaki bir kitap adını geçiriyorsa 1, geçirmiyorsa 0 dönsün. Bu evaluator'ı judge tabanlı Relevance'ın yanına koy; hangisi daha kararlı sonuç veriyor?
Bu yazıyla Microsoft.Extensions.AI tarafındaki temel bloklar tamamlandı. Sonraki yazıda bunların üstüne oturan katmana geçiyoruz: Microsoft Agent Framework ile aynı Bookstore asistanını bir agent olarak yazacağız.