Tüm yazılar
ENTR
Tanışalım
YazılarGüvenli Sistemler Oluşturmak · 2/4

“Bilmiyorum” Diyebilen Yapay Zekâ

Refusal ve citation sözleşmesini prompt’a değil, .NET 10 koduna yazmak.

Bir önceki yazımda bir cümle kurdum: "Modelin verdiği her cevabın arkasında ya doğrulanabilir bir kaynak vardır ya da açık bir refusal vardır. Üçüncü bir seçenek yoktur."

Sonra özelden bazı yorumlar geldi. Bir kısmı "katılıyorum"du. Bir kısmı da şuna benziyordu: "Tamam ama bunu nereye yazıyoruz peki? Sistem prompt'una mı?"

İşte bu yazı serisinin çıkış noktası tam olarak burası: sözleşme, sistem prompt'unda yazan şey değildir. Çünkü model için sistem prompt'u çoğu zaman bağlayıcı bir mekanizma değil, takip edilmesi beklenen bir talimattır. Sözleşme dediğimiz şey, ihlal edildiğinde sistemi durdurabilen kuraldır. Bunu kodda zorunlu hâle getirmiyorsak aslında bir sözleşme değil, sadece bir iyi niyet beyanı yazmış oluruz.

Bu yazıda, önceki bölümde bahsettiğim gerçek kodun içine giriyoruz. Hangi sözleşme nerede duruyor, neden orada duruyor ve ihlal edildiğinde sistem ne yapıyor; bunları kod üzerinden anlatacağım.

Azure tarafı bir sonraki yazının konusu; bu yazıda özellikle Azure'a girmeyeceğim. Çünkü iyi bir sözleşme Azure'a, AWS'e ya da herhangi bir bulut sağlayıcısına bağlı olmadan da anlamlı olmalıdır.

Bölüm 1Domain sözleşmeyi tanımlar, Application uygular

İlk soru şu: bu sözleşme hangi katmana ait?

İlk cevap genelde Application olur. Çoğu .NET geliştiricisi refusal mantığını handler'ın içine yazar ve akışı oradan yönetir. Bu yaklaşım tamamen yanlış değildir ama önemli bir şeyi kaçırır.

Application katmanı sözleşmenin sahibi değildir. Onun görevi sözleşmeyi uygulamak ve sonucu orkestre etmektir. Sözleşmenin kendisi Domain'de durmalıdır. Çünkü "hangi durumda cevap verilir, hangi durumda reddedilir, hangi durumda sistem durur?" soruları teknik akıştan önce iş kuralıdır.

Pratik karşılığı şu:

C#
public sealed record AssistantAnswer
{
    public required string AnswerText { get; init; }
    public required IReadOnlyList<Citation> Citations { get; init; }
    public required ConfidenceLevel ConfidenceLevel { get; init; }
    public required RiskClass RiskClass { get; init; }
    public required bool EscalationRequired { get; init; }
    public required bool Refused { get; init; }
    public string? RefusalReason { get; init; }

    public static AssistantAnswer Grounded(
        string answerText,
        IReadOnlyList<Citation> citations,
        ConfidenceLevel confidenceLevel,
        RiskAssessment riskAssessment)
    {
        ArgumentNullException.ThrowIfNull(citations);
        if (citations.Count is 0)
        {
            throw new UngroundedAnswerException();
        }
        return new AssistantAnswer { /* ... */ };
    }

    public static AssistantAnswer RefusedAnswer(string reason, RiskAssessment riskAssessment) =>
        new()
        {
            AnswerText = reason,
            Citations = [],
            ConfidenceLevel = ConfidenceLevel.Low,
            RiskClass = riskAssessment.RiskClass,
            EscalationRequired = riskAssessment.EscalationRequired,
            Refused = true,
            RefusalReason = reason
        };

    public void EnsureCitationInvariant()
    {
        if (!Refused && Citations.Count is 0)
        {
            throw new UngroundedAnswerException();
        }
    }
}

Kodda özellikle dikkat edilmesi gereken üç nokta var.

Birincisi, AssistantAnswer iki yerden üretiliyor: Grounded(...) ve RefusedAnswer(...). Burada şunu istemiyorum: kodun herhangi bir yerinde biri gelsin, new AssistantAnswer { ... } yazsın, citation boş kalsın ve bu cevap API'den dışarı çıksın. Çünkü o noktada sözleşme bozulmuş olur.

O yüzden nesneyi üretmenin normal yolu factory metotları. Grounded(...) kullanıyorsanız citation vermek zorundasınız; boş citation listesiyle geçmeye çalışırsanız exception fırlatılır. RefusedAnswer(...) tarafında ise citation beklemiyorum; çünkü refusal zaten kaynaklı bir cevap değil, güvenli bir yönlendirme cevabı.

Yani sistemde iki geçerli durum var: ya kaynaklara dayalı, citation içeren bir cevap üretirsiniz ya da bilinçli olarak refusal dönersiniz. Benim burada kapatmak istediğim üçüncü yol şu: cevap var ama citation yok. Canlı sistemlerde en tehlikeli durumlardan biri bence bu. Sistem cevap veriyor gibi görünür ama cevabın arkasında doğrulanabilir bir kaynak yoktur.

İkincisi, EnsureCitationInvariant() metodu. Domain invariant dediğimiz şeyi burada çok büyütmeye gerek yok; aslında tek cümlelik bir kuraldan bahsediyoruz: refused olmayan bir cevabın en az bir citation'ı olmalı.

Bu kontrolü sadece handler'ın iyi niyetine bırakmak istemiyorum. Çünkü handler değişir, mapping değişir, response modeli değişir. Bir refactor sırasında citation yanlışlıkla boş kalabilir. O yüzden Application katmanı, orchestrator'ın sonunda bu metodu çağırıyor. Cevap refusal değilse ve citation yoksa, sistem response dönmeden önce patlıyor.

Bence sözleşme dediğimiz şey tam olarak bu: "lütfen citation ekleyelim" değil; citation yoksa cevap dışarı çıkmasın. Tam bir kesinlik hâli.

Üçüncüsü: refusal'ı Result<T>.Failure yapmadım. Bu, küçük görünen ama sistem davranışını ciddi etkileyen bir karar. Çünkü refusal bir hata değil.

Kullanıcı "ilaç dozu nedir?" diye soruyor ve sistem yeterli kaynak bulamadığı için "Bunu yanıtlayamam, sizi klinik ekibe yönlendiriyorum" diyorsa, sistem bozulmuş olmuyor. Tam tersine, yapması gereken şeyi yapıyor.

Bunu Failure olarak modellersem, API tarafı bunu teknik bir hata gibi ele almaya başlar: 500 döner, frontend kırmızı bir hata gösterir, kullanıcı da sistem çöktü sanır. Ama ortada çöken bir şey yok; sistem güvenli davranıyor. Bu yüzden refusal da başarılı bir AssistantAnswer. API 200 döner. Frontend bunu hata gibi değil, kontrollü bir yönlendirme mesajı gibi gösterir. Kullanıcı da teknik bir hata görmek yerine doğru aksiyona yönlendirilir.

Bunun bedeli var mı? Var. Test yazarken biraz daha fazla nesne kuruyorsunuz. Bazen AssistantAnswer üretmek için RiskAssessment gibi bağlı modelleri de hazırlamanız gerekiyor; yani geliştiriciye küçük bir yük biniyor (çok şükür, artık bunları LLM'lere yazdırabiliyoruz).

Bu bedeli ödemeye razı olmalıyız. Çünkü karşılığında şunu kazanıyoruz: sistemde citation'sız ama cevap veriyormuş gibi görünen bir AssistantAnswer üretmek neredeyse imkânsız hâle geliyor (hiçbir şey %100 kesin değildir; en fazla %99,9'dur). Domain'in koruduğu sınır da tam olarak bu.

Bölüm 2Orchestrator: kısa görünen akışın gerçek hayattaki karşılığı

İlk yazıda orchestrator'ı özellikle sade göstermiştim: risk sınıflandır → ara → üret → audit. Kâğıt üzerinde sekiz satırlık bir akış gibi duruyor. Ama gerçek koda baktığınızda bu yapı yaklaşık 120 satıra çıkıyor.

Bu bir şişkinlik mi? Bence değil. Çünkü buradaki her ek satır, sistemin bir yerinde "burası böyle çalışmalı" dediğimiz sözleşmenin karşılığı. Kod uzamış gibi görünse de her blok bir sınır çiziyor: kimlik nereden alınır, risk ne zaman hesaplanır, model ne zaman çağrılır, hangi durumda cevap reddedilir, hangi karar audit'e yazılır.

C#
public async ValueTask<Result<AssistantAnswer>> HandleAsync(
    AssistantQuery request,
    CancellationToken ct)
{
    ArgumentNullException.ThrowIfNull(request);

    // Kimlik request body'sinden değil, IUserContextProvider'dan gelir.
    // Body'de userId/roles olsa bile retrieval filtresi buradan beslenir.
    request = request with
    {
        UserId = userContextProvider.UserId,
        Roles = userContextProvider.Roles,
        Location = userContextProvider.Location
    };

    var validation = await queryValidator.ValidateAsync(request, ct).ConfigureAwait(false);
    if (!validation.IsValid)
    {
        throw new InvalidAssistantQueryException(validation.ToString("; "));
    }

    var startTimestamp = Stopwatch.GetTimestamp();
    var mode = agentOptions.Value.Mode;
    metrics.RecordProviderMode(mode);

    var risk = await riskClassifier.ClassifyAsync(request, ct).ConfigureAwait(false);
    metrics.RecordRiskClass(risk.RiskClass, mode);

    var chunks = await knowledgeSearchService.SearchAsync(request, risk, ct).ConfigureAwait(false);
    metrics.RecordRetrievalCount(chunks.Count, mode);

    // REFUSAL #1 — Kaynak yoksa modele hiç gitme.
    if (chunks.Count is 0)
    {
        var refused = AssistantAnswer.RefusedAnswer(NoSourceRefusalReason, risk);
        await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
        return Result<AssistantAnswer>.Success(refused);
    }

    var template = await promptProvider.GetAsync(AnswerTemplateId, ct);
    var messages = BuildMessages(template, request, chunks);
    var chatResponse = await chatClient.GetResponseAsync(messages, cancellationToken: ct);

    // REFUSAL #2 — Model JSON şemasını bozduysa cevabı reddet.
    if (!ChatResponseParser.TryParse(chatResponse.Text, out var envelope) || envelope is null)
    {
        var refused = AssistantAnswer.RefusedAnswer(MalformedResponseReason, risk);
        await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
        return Result<AssistantAnswer>.Success(refused);
    }

    // REFUSAL #3 — Model kendisi "yapamam" diyorsa bunu structured olarak ilet.
    if (envelope.Refused)
    {
        var refused = AssistantAnswer.RefusedAnswer(envelope.RefusalReason ?? envelope.AnswerText, risk);
        await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
        return Result<AssistantAnswer>.Success(refused);
    }

    // REFUSAL #4 — Model uydurma citation ID döndüyse cevabı reddet.
    var citationValidation = CitationValidator.Validate(envelope.Citations, chunks);
    if (citationValidation.Outcome is not CitationValidationOutcome.Valid)
    {
        var refused = AssistantAnswer.RefusedAnswer(InvalidCitationReason, risk);
        await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
        return Result<AssistantAnswer>.Success(refused);
    }

    var chunkLookup = chunks.ToDictionary(chunk => chunk.ChunkId, StringComparer.Ordinal);
    var citations = envelope.Citations
        .Select(id => chunkLookup[id].ToCitation())
        .ToArray();

    var answer = AssistantAnswer.Grounded(
        envelope.AnswerText,
        citations,
        MapConfidenceLevel(envelope.Confidence),
        risk);

    answer.EnsureCitationInvariant();
    await WriteAuditAsync(request, answer, chunks.Count, startTimestamp, ct);
    return Result<AssistantAnswer>.Success(answer);
}

Şimdi bu kodu biraz irdeleyelim.

Sıralama tesadüf değil. IRiskClassifier, retrieval'dan önce çalışıyor; çünkü IKnowledgeSearchService.SearchAsync(query, risk, ct) metodu risk bilgisini parametre olarak alıyor. Bu şu anlama geliyor: soru düşük riskliyse daha az chunk getirebilirsiniz. Ama soru yüksek riskliyse daha fazla kaynak aramanız, daha sıkı filtrelemeniz ve cevabı daha kontrollü üretmeniz gerekir.

Yani risk → retrieval ilişkisi teknik bir detay değil, doğrudan iş kuralı. Bu yüzden orchestrator'ın içinde açıkça görünmesini istiyorum. Başka bir yere sakladığınızda, sistemin en kritik kararlarından biri okunamaz hâle geliyor.

Bir diğer önemli satır şu:

C#
request = request with
{
    UserId = userContextProvider.UserId,
    Roles = userContextProvider.Roles,
    Location = userContextProvider.Location
};

Bu satır küçük görünüyor ama güvenlik açısından çok kritik. Handler, request body'den gelmiş olabilecek userId, roles veya location değerlerine güvenmiyor; bunları IUserContextProvider üzerinden yeniden yazıyor. Yani kullanıcı API'ye şuna benzer bir body gönderse bile:

JSON
{
  "question": "...",
  "roles": ["supervisor"]
}

retrieval tarafı bu role bilgisini kullanmıyor. Sistem sadece authentication context'inden gelen role bilgisini dikkate alıyor.

"Body'de bu alanların gelmesine zaten izin vermiyoruz" diyebilirsiniz. Doğru: DTO tarafında JsonUnmappedMemberHandling.Disallow var ve böyle bir request 400 ile dönüyor. Ama buraya ikinci bir kalkan daha ekledik. JsonUnmappedMemberHandling.Disallow tam bir JSON schema validator değildir; sadece beklenmeyen alanları reddeder. Required alanlar, nullability, enum eşlemesi ve response parse davranışı ayrıca test edilmelidir.

Ben bunu defense-in-depth olarak görüyorum. İlk katman request modelinde, ikinci katman application akışında. Kimlikten türeyen veri body'den değil, sistemin güvenilir context'inden gelmeli.

Kodda dört ayrı refusal noktası var:

  1. Kaynak yoksa modele hiç gitmiyoruz. Modelin elinde kaynak yoksa vereceği cevap ya genel bilgi olacak ya da tahmin. Bu sistemde ikisi de istemediğimiz şeyler. O yüzden chunk bulunamadığında doğrudan no_source_refusal üretiyoruz.
  2. Model JSON formatını bozarsa cevabı yine reddediyoruz. Modelin metin üretmiş olması yeterli değil; ondan belirli bir şemaya uyan structured bir response bekliyoruz. Şema bozulduysa response güvenilir değildir.
  3. Model kendisi "bu soruyu yanıtlayamam" diyorsa bunu hata gibi değil, structured refusal olarak taşıyoruz. Modelin refusal kararını serbest metin içinde kaybetmek yerine sistemin anlayacağı bir RefusedAnswer nesnesine çeviriyoruz.
  4. Model citation uydurduysa cevabı reddediyoruz. Modelin döndürdüğü citation ID'leri gerçekten retrieval'dan gelen chunk'ların içinde yoksa, bu cevap dışarı çıkmamalı.

Bunların her biri ayrı bir RefusalReason ile audit'e yazılıyor. Bence bu çok önemli. Çünkü üretimde sadece "halüsinasyon oranımız şu kadar" demek pek bir şey ifade etmiyor. Onun yerine şunu görebilmek çok daha değerli:

Şekil 01 — Refusal sebepleri

Dört refusal, dört ayrı aksiyon

RefusalSebep koduNe olduModel çağrıldı mı?Artıyorsa şuna bak…
#1no_source_refusalBilgi tabanında kaynak yok.HayırBilgi tabanın eksik olabilir.
#2malformed_responseModel beklenen JSON şemasına uymadı.EvetPrompt template’e ya da response şemasına.
#3model_self_refusalModel cevap vermemeyi seçti.EvetRisk politikasını ya da prompt davranışını gözden geçir.
#4invalid_citationModel geçersiz bir citation döndürdü.EvetModel kaynak id’lerini doğru kullanmıyor olabilir.
Aynı belirti (“cevap dönmedi”), dört farklı sebep. Tek bir hata kovası hepsini gizler.

Bu ayrım aksiyonu değiştirir. Aynı "cevap dönmedi" durumu aslında dört farklı sebepten kaynaklanabilir. Bunları aynı hata kovasına atarsanız neyi düzelteceğinizi de bilemezsiniz. (Bu biraz da system design tarafına kayıyor; çok daha derin bir konu olduğu için bu kısma dalmıyorum.)

Bir diğer bilinçli tercih audit tarafında: audit her durumda yazılıyor. Sadece başarılı cevaplarda değil, refusal durumlarında da. Çünkü reddetmek de sistem açısından bir karardır; hatta bazı sektörlerde en çok açıklanması gereken karar bu olabilir.

Altı ay sonra biri çıkıp "Bu kullanıcıya neden cevap vermediniz?" diye sorduğunda sistemin şunu söyleyebilmesi gerekir: şu correlation ID ile gelen istekte şu risk sınıfı oluştu, retrieval şu kadar chunk döndürdü, model şu çıktıyı verdi ve sistem şu refusal sebebiyle cevabı reddetti. Bunu söyleyemiyorsanız refusal sadece ekranda görünen güvenli bir mesaj olarak kalır; denetlenebilir bir karar olmaz.

Kodda audit yazımı birkaç yerde tekrar ediyor; dikkatinizi çekmiştir. Bu küçük bir DRY ihlali mi? Evet. Bunu bir helper'a çıkarmak mümkün mü? Evet, denedim. Ama bu örnekte helper'a çıkardığımda orchestrator'ın okunabilirliği düştü. Her refusal yolunda ne olduğunu açıkça görmek benim için daha değerli. O yüzden burada küçük tekrarı bilinçli olarak kabul ediyorum. Biraz yoğurt yeme stilim diyebiliriz.

Bir de ConfigureAwait(false) tercihi var. Application katmanında her await sonrasında kullanıyorum; çünkü bu katman bir anlamda kütüphane gibi davranmalı ve hangi host tarafından çağrıldığını varsaymamalı. API katmanında ise bunu özellikle yazmıyorum; ASP.NET Core'un varsayılan davranışı bu ihtiyacı büyük ölçüde karşılıyor. Ama Application katmanı host katmanından bağımsız kalmalı.

Sizi çok fazla koda ve .NET'e boğmadan, konuyu kısa bir diyagramla özetleyeyim:

Şekil 02 — Orchestrator

Orchestrator: sorudan cevaba, dört refusal kapısından geçerek

  1. 01Soru gelir
  2. 02Kimlik UserContext’ten basılıruserId, roller, lokasyon. Asla request body’den değil.
  3. 03Validation geçti mi?400 · InvalidAssistantQueryException
  4. 04Risk sınıflandırmaRisk, aramaya parametre olarak gider
  5. 05Bilgi arama
  6. 06Chunk var mı?#1 · no_source_refusal
  7. 07IChatClient çağrısı
  8. 08JSON parse başarılı mı?#2 · malformed_response
  9. 09Model kendisi reddetti mi?#3 · model_self_refusal
  10. 10Citation’lar chunk’larda var mı?#4 · invalid_citation
  11. 11Grounded answerEnsureCitationInvariant()
  12. 12AuditHer yolda yazılır
  13. 13200 OK · AssistantAnswer
Sonuç
200 OK
Kaynaklı cevap
{
  "refused": false,
  "citations": ["CHK-001", "CHK-004"],
  "confidenceLevel": "High",
  "riskClass": "Low"
}

Bütün kontroller geçti; EnsureCitationInvariant() son sınırı bir kez daha kontrol etti.

Hangi yoldan çıkılırsa çıkılsın audit yazılır ve API 200 ile bir AssistantAnswer döner. Sadece hatalı istek 400 alır.

Her karar noktası, sistemin başka bir güvenlik sınırını temsil ediyor:

  1. Validation geçmezse bu artık bir asistan cevabı değildir; doğrudan 400 dönen hatalı bir request'tir.
  2. Kaynak yoksa modele gitmeyiz; no_source_refusal döneriz.
  3. Model beklenen JSON şemasına uymazsa malformed_response döneriz.
  4. Model kendisi cevap veremeyeceğini söylüyorsa bunu model_self_refusal olarak structured şekilde taşırız.
  5. Model citation uydurursa invalid_citation ile cevabı durdururuz.

Ancak bütün bu kontroller geçilirse grounded bir cevap üretiriz ve en sonda EnsureCitationInvariant() ile son sınırı tekrar kontrol ederiz. Hangi yoldan gelmiş olursak olalım, en sonda audit yazılır ve API 200 OK ile bir AssistantAnswer döner.

Burada özellikle şunu vurgulamak istiyorum: refusal, sistemin hata verdiği anlamına gelmiyor. Refusal da başarılı bir asistan sonucudur, çünkü sistem beklenen güvenlik davranışını göstermiştir. Hata olan şey; kaynak yokken cevap üretmek, citation uydurmak veya parse edilemeyen model çıktısını kullanıcıya göstermektir.

Bölüm 3Modelin sözü yetmez: citation sistem tarafından doğrulanmalı

Bir önceki yazıda şunu söylemiştim: citation'ı modelin dil becerisine bırakmamak gerekiyor. Citation, modelin "ben bunu şu kaynaktan aldım" demesiyle değil, sistemin bunu doğrulamasıyla geçerli sayılmalı.

Bu cümlenin .NET tarafındaki karşılığı aslında küçük bir sınıf: CitationValidator. Ama o sınıfa gelmeden önce bu kararın nedenini anlatmam lazım.

Bir önceki yazıdaki ilk denemede mock chat client şöyle çalışıyordu: model retrieve edilen chunk listesini alıyor, cevap metnine [1], [2] gibi işaretler koyuyordu. Ben de cevabı parse edip bu işaretleri listedeki index'lerle eşleştiriyordum. Test yazdım, geçti. İlk bakışta gayet mantıklı görünüyordu.

Sonra biraz ters bir senaryo denedim. Retrieve edilen chunk listesini iki elemana düşürdüm, ama mock'a üç chunk varmış gibi davranmasını söyledim. Model cevapta [3] yazdı. Benim parse kodum da doğal olarak chunks[2]'yi okumaya çalıştı ve IndexOutOfRangeException patladı.

Hata mesajını görünce asıl problemi fark ettim: modelin metin içinde yazdığı citation'a güvenmek, bir liste index'ine körlemesine güvenmek kadar kırılgan bir şey. Çünkü model gerçekten hangi kaynağa dayandığını bilmek zorunda değil; sadece doğru formatta konuşuyor gibi görünebilir. Üç chunk gönderirsiniz, dördüncüye atıf yapar. Ya da eğitim verisinde benzerini gördüğü için [Document_47] gibi hiç var olmayan bir kaynak ID'si uydurur.

O noktada text-marker yaklaşımını bıraktım ve structured bir citation alanına geçtim. Artık modelden cevap metninin içine [1], [2] gibi işaretler sıkıştırmasını beklemiyorum. Modelden ayrı bir citations listesi istiyorum. Sonra bu listedeki ID'lerin gerçekten retrieval'dan gelen chunk'lar içinde olup olmadığını sistem kontrol ediyor. Böylece kontrolsüz bir IndexOutOfRangeException yerine kontrollü bir sonuç elde ediyorum:

C#
public static CitationValidationResult Validate(
    IReadOnlyList<string> citations,
    IReadOnlyList<RetrievedChunk> retrievedChunks)
{
    if (citations.Count is 0)
    {
        return new CitationValidationResult(CitationValidationOutcome.Empty);
    }

    var whitelist = retrievedChunks
        .Select(chunk => chunk.ChunkId)
        .ToHashSet(StringComparer.Ordinal);

    var unknown = citations
        .Where(id => !whitelist.Contains(id))
        .ToArray();

    return unknown.Length > 0
        ? new CitationValidationResult(CitationValidationOutcome.Unknown, unknown)
        : new CitationValidationResult(CitationValidationOutcome.Valid);
}

Orchestrator da bunu gördüğünde cevabı dışarı çıkarmıyor, refusal'a düşürüyor.

Bu hikâye, bu makale için yazdığım PoC kodunda başıma geldi; ama bence RAG sistemi yazan herkesin bir noktada karşılaşacağı bir problem. Modelin citation üretmesiyle, o citation'ın gerçekten cevabı desteklemesi aynı şey değil. Wallat ve arkadaşlarının Correctness is not Faithfulness in RAG Attributions çalışması, doğru görünen bir citation'ın modelin gerçekten o belgeye dayandığı anlamına gelmediğini ve bazı durumlarda citation faithfulness eksiklerinin %57'ye kadar çıkabildiğini gösteriyor. Buchmann ve Gurevych ise citation failure problemini ayrıca tanımlayıp bu davranışı azaltmaya yönelik yöntemler öneriyor. Ortada citation var; ama citation gerçekten cevabın kaynağı mı, orası tartışmalı.

Bu arada modelden beklediğim şey düz metin değil, belirli bir JSON zarfı:

C#
internal sealed record AssistantAnswerEnvelope
{
    public required string AnswerText { get; init; }
    public required IReadOnlyList<string> Citations { get; init; }
    public string? Confidence { get; init; }
    public bool Refused { get; init; }
    public string? RefusalReason { get; init; }
}

Bu zarfa fazladan bir property gelirse parser bunu kabul etmiyor. Mesela model cevaba bonus_info diye ekstra bir alan ekledi. İlk bakışta masum görünebilir, ama benim açımdan bu iki ihtimalden birini gösterir: ya prompt beklediğim gibi çalışmadı ya da biri modeli başka bir formata yönlendirmeye çalıştı. İkisi de güvenli cevap üretmek için sıkıntılı. Bu yüzden JsonSerializerOptions.UnmappedMemberHandling = Disallow kullanıyorum. Model sadece bizim beklediğimiz alanları dönebilir; fazlası gelirse response parse edilmiş sayılmaz ve orchestrator bunu malformed_response refusal yoluna taşır.

CitationValidator'ın yaptığı şey aslında çok basit: modelin döndürdüğü envelope.Citations listesi, retrieval'dan gelen retrievedChunks.Select(c => c.ChunkId) listesinin içinde olmak zorunda. Bir tane bile yabancı ID varsa sonuç Unknown. Orchestrator da bunu gördüğünde cevabı kullanıcıya göstermiyor. Çünkü modelin verdiği cevap doğru görünebilir, dili düzgün olabilir, hatta kendinden emin olabilir. Ama citation ID'si sistemin gerçekten getirdiği chunk'lar arasında yoksa, o cevap benim için geçerli değildir.

Burada küçük ama önemli bir detay var: StringComparer.Ordinal. Chunk ID karşılaştırmasında kültüre duyarlı bir karşılaştırma kullanmak istemiyorum. CHK-001 ile chk-001 aynı şey değil. Bu ID'leri kullanıcı üretmiyor, sistem üretiyor; dolayısıyla büyük/küçük harf duyarlılığı burada bir tercih değil, zorunluluk.

Şekil 03 — Karşılaştırma

Text-marker vs. structured citation

SenaryoText-marker yaklaşımıStructured citation
1Model [5] yazar ama sadece 3 chunk vardırIndexOutOfRangeException ya da boş citationchunks[4] → IndexOutOfRangeExceptionUnknown sonucu → refusal[5] whitelist’te yok
2Model [1] yazar ama cevap aslında chunk 1 ile desteklenmezGörünürde kanıt var sanılırBu da yakalanmaz. Semantic kontrol ayrı bir problem.
3Model [DOC_NEW] gibi bir id uydururMarker pattern eşleşmeyebilir; cevap citation’sız dışarı çıkabilirWhitelist’te yok → refusal
4Model citation alanını boş döndürürDüz metin cevap güvenilir sanılabilirEmpty sonucu → refusalcitations: []
5Model JSON yerine düz metin döndürürParse mantığı bozulabilirJsonSerializer.Deserialize → exceptionTryParse false → malformed_response refusalTryParse(…) → false
Text-markerKırılgan, hataya açık. Sistemi çökertebilir, yanlış güven oluşturabilir.
Structured citationKontrollü, doğrulanabilir, denetlenebilir. Bir şey yanlışsa sistem doğru şekilde refusal döner.
2. satır bilerek duruyor: CitationValidator anlamı değil, yapıyı kontrol eder.

Bu tabloda özellikle ikinci satırı saklamıyorum: CitationValidator semantik doğruluğu çözmüyor. Yani "bu chunk gerçekten bu iddiayı destekliyor mu?" sorusunun cevabını vermiyor. Onun yaptığı şey daha temel ama çok kritik: modelin verdiği citation ID'leri gerçekten sistemin getirdiği chunk'lar içinde var mı, bunu kontrol ediyor.

Bu iki problemi birbirine karıştırmamak gerekiyor. Birincisi yapısal doğrulama: model var olmayan bir kaynağa atıf yapmasın. İkincisi semantik doğrulama: var olan kaynak gerçekten cevabı destekliyor mu? Bu yazıda anlattığım kısım birincisi.

Bir önceki yazıda önce sistemin sınırlarını kurmaya odaklanmıştım: hangi cevap dışarı çıkabilir, hangisi durdurulmalı, hangi durumda refusal dönülmeli? Semantik kalite, faithfulness skoru ve ayrı bir grounding değerlendirmesi bunun üzerine kurulacak ikinci katman. (Bu kısım biraz yapay zekânın derinlikleri; ileride motivasyonumu kaybetmezsem değiniriz.)

Özetle benim için sıralama şu: önce sistemin yapısı doğru olacak, sonra cevabın kalitesi daha derin metriklerle ölçülecek. Çünkü yapı yanlışsa kalite metriği de sizi kurtarmaz. Model var olmayan bir kaynağa atıf yapabiliyorsa, cevabın ne kadar akıcı olduğu artık ikinci planda kalır.

Bölüm 4Audit, kime ne sorulduğunu değil, sistemin ne karar verdiğini kaydeder

Yine biraz genel mimari tarafına gireceğiz ama önemli.

Audit log deyince akla bazen şu geliyor: "Her şeyi kaydedelim, lazım olursa sonra bakarız." Bence bu çok tehlikeli bir yaklaşım. Çünkü audit log sınırsız bir veri çöplüğü değildir. Özellikle kişisel veri içerebilecek sistemlerde, "belki lazım olur" diye ham kullanıcı sorusunu yıllarca saklamak doğru bir karar değil. Altı ay sonra biri çıkıp "Bu kullanıcının ham sorusunu neden hâlâ tutuyorsunuz?" diye sorduğunda, sadece teknik olarak değil, hukuki ve operasyonel olarak da cevap verebilmek gerekir.

Bizim bu soruya cevabımız şu: tutmuyoruz. Kod tarafında bunun karşılığı iki küçük metot:

C#
public static string ComputeQuestionHash(string question)
{
    ArgumentNullException.ThrowIfNull(question);
    var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(question));
    return Convert.ToHexString(bytes);
}

public static string BuildQuestionPreview(string question)
{
    ArgumentNullException.ThrowIfNull(question);
    var redacted = SensitiveNumberRedactor.Redact(question);
    return redacted.Length <= 80
        ? redacted
        : string.Concat(redacted.AsSpan(0, 80), "…");
}

Birinci metot sorunun SHA-256 hash'ini üretiyor. Sonuç 64 karakterlik, deterministik ve geri çevrilemeyen bir değer. Deterministik olması önemli: aynı soru tekrar gelirse aynı hash oluşur. Böylece audit tarafında "aynı soru kaç kez soruldu?" gibi bir soruya cevap verebilirsiniz; ama hash'ten geriye dönüp kullanıcının gerçek sorusunu okuyamazsınız.

İkinci metot sorunun kısa bir önizlemesini üretiyor. Ama bunu yapmadan önce SensitiveNumberRedactor devreye giriyor: TC kimlik numarası, kart numarası veya benzeri hassas örüntüler maskeleniyor. Sonra metin 80 karaktere indiriliyor. Yani audit kaydında elimizde iki şey oluyor:

  • QuestionHash → aynı soruyu takip edebilmek için,
  • QuestionPreview → sorunun kabaca neyle ilgili olduğunu anlamak için.

Ham soru ise audit'e yazılmıyor. Bu ayrım benim için kritik; çünkü audit uzun süre saklanabilir, örneğin 7 yıl. Ama ham kullanıcı sorusunu içeren request log'ları çok daha kısa süre tutulmalıdır, örneğin 24 saat veya 30 gün.

Şekil 04 — Saklama

Log vs. audit

Audit

Uzun süreli karar kaydı

  • QuestionHash · SHA-256
  • QuestionPreview · maskeli, 80 karakter
  • RiskClass · RefusalReason · RetrievalCount
  • Ham soru asla
Request log

Kısa süreli debugging desteği

  • Ham soru, request scope içinde
  • ör. Application Insights
  • Saklama süresi dolunca silinir
İkisini karıştırırsan audit sistemi zamanla kişisel veri arşivine dönüşür.

Bu ikisini karıştırırsanız audit sistemi zamanla bir kişisel veri arşivine dönüşür. O noktada da audit'in güvenlik faydası, veri saklama riskine dönüşmeye başlar.

Bir diğer önemli karar da audit yazımının nasıl ele alındığı. Kodda IAuditEventSink.WriteAsync çağrısı try/catch içinde çalışıyor:

C#
try
{
    await auditEventSink.WriteAsync(auditEvent, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
    throw;
}
catch (Exception ex)
{
    logger.LogWarning(ex, "Audit write failed for correlation {CorrelationId}; continuing.",
        auditEvent.CorrelationId);
    metrics.RecordAuditWriteFailed(mode);
}

Buradaki soru şu: audit yazılamazsa kullanıcıya cevap dönmeli miyiz? Mesela SQL veya Elastic geçici olarak çökmüş. Sistem cevabı üretmiş, refusal ya da grounded cevap kararını vermiş, ama audit event yazılamamış.

Bu durumda iki yaklaşım var. İlki audit-or-fail: audit yazılamıyorsa cevap da dönme; sistem kararını kayda alamıyorsa kullanıcıya 503 dön. Bu yaklaşım, özellikle regülasyonun çok ağır olduğu sistemlerde mantıklı olabilir; çünkü denetlenemeyen bir kararın dışarı çıkmasını istemezsiniz. İkincisi best-effort: audit yazılamasa bile kullanıcıya cevap dön, ama bu durumu sessiz geçme. Log'a warning yaz, metriği artır, observability tarafında alarm üret.

Audit'i bu şekilde tutmanın bir bedeli var: debugging zorlaşır. Çünkü audit'te ham soru yok. Bir kullanıcı "bot bana saçma bir cevap verdi" dediğinde audit tablosuna gidip kullanıcının tam sorusunu okuyamazsınız; sadece hash'i, maskelenmiş önizlemeyi, risk sınıfını, refusal sebebini, chunk sayısını ve correlation ID'yi görürsünüz. Bu bilinçli bir tercih.

Bunun çözümü audit'i kirletmek değil, kısa süreli log sistemini doğru tasarlamak. Ham soru gerekiyorsa request scope içinde kısa retention'lı bir log'a yazılabilir; mesela Application Insights tarafında 24 saat veya 30 gün saklanır, süre dolunca silinir. Audit ise uzun süre kalır ama ham soru taşımaz. Benim burada çizmeye çalıştığım çizgi şu:

Audit, kullanıcının tam olarak ne yazdığını saklamak için değil; sistemin hangi kararı neden verdiğini kanıtlamak için vardır.

Şekil 05 — Anatomi

Audit kaydı ne anlatır?

Dokuz alan, tek bir soru için sistemin verdiği kararın tüm izini oluşturur. Bir adımın üzerine gelin ya da dokunun.

Hangi alan hangi adımdan gelir

Audit kaydı · örnek

  • CorrelationId0HN4Q7K2B1M9RBu isteğe ait tüm log ve servis kayıtlarını birleştirir.
  • QuestionHash9F2C51…A7E1Sorunun SHA-256 hash’i. Aynı soru aynı hash’i üretir; tekrarları sayabilirsin.
  • QuestionPreview“TC’m ***********, randevum ne…”Maskelenmiş, 80 karaktere kısaltılmış. Konuya dair kaba bir fikir.
  • RiskClassMediumSistemin hangi risk politikasıyla hareket ettiği.
  • RetrievalCount0Retrieval’ın kaç chunk bulduğu.
  • RefusalReasonno_source_refusalCevap reddedildiyse, neden reddedildiği.
  • ProviderModeDevCloudÇağrının arkasında hangi sağlayıcının olduğu.
  • Latency412 msToplam yanıt süresi; performans ve SLA takibi için.
  • Timestamp2026-05-18T09:41:07ZKaydın ne zaman yazıldığı (UTC).
  • Model çağrıldı mı?RefusalReason = no_source ise hiç çağrılmadı.
  • Neden reddedildi?RefusalReason bunu açıkça söyler.
  • Audit yazımı başarılı mı?Değilse: metrics.RecordAuditWriteFailed() ve bir uyarı logu.
Kullanıcının ham verisini değil, sistemin verdiği kararı kaydeder. Değerler örnektir.

Bence güvenli yapay zekâ sistemlerinde audit'in görevi tam olarak bu olmalıdır: kullanıcıyı izlemek değil, sistemin kararını denetlenebilir hâle getirmek.

Bölüm 5Bir sözleşmenin gerçekten sözleşme olduğu nasıl anlaşılır?

Bence cevabı basit: ihlali test edilebiliyorsa sözleşmedir. Test edilemiyorsa, sadece bir niyet beyanıdır.

"Citation zorunlu olsun", "kaynak yoksa cevap dönmeyelim", "model citation uydurursa reddedelim" gibi cümleler tek başına mimari karar sayılmaz. Bunların gerçekten sistem davranışına dönüştüğünü ancak testlerle kanıtlayabilirsiniz.

Repoda şu an 70'in üzerinde test var. Hepsini buraya koymayacağım (seri ilerledikçe projeyi GitHub'da public yapacağım), ama bu yazının ana tezini doğrudan doğrulayan yedi testi özellikle göstermek istiyorum:

Şekil 06 — Testler

Yedi test, yazıdaki yedi cümle

Sözleşme testleri · 70+ testten 7’si
$ dotnet test
  1. HandleAsync_NoRetrievedChunks_ReturnsRefusedAnswer→ Kaynak yoksa cevap yok.
  2. Handler_ModelReturnsUnknownCitation_ReturnsRefusal→ Model hayali bir chunk ID uydursa bile sistem reddeder.
  3. Handler_DoesNotUseTextMarkerOnly_AsGroundingProof→ Cevap metnindeki [1] grounding kanıtı sayılmaz. Sadece structured citation dikkate alınır.
  4. Handler_PromptInjectionAttemptInUserMessage_DoesNotOverrideSystemPrompt→ Kullanıcı “önceki kuralları unut” dese bile sistem prompt’u korunur.
  5. AssistantAnswer_GroundedFactory_WithEmptyCitations_ThrowsUngroundedAnswerException→ Domain invariant compile-time’da değil, runtime’da garanti altına alınır.
  6. Audit_Question_StoredAsHashAndPreview_NotRaw→ Ham soru audit’e yazılmaz. Hash ve maskelenmiş preview yazılır.
  7. Audit_SqlOutage_IncrementsAuditWriteFailedMetric→ Best-effort audit gerçekten çalışır: SQL kesintisinde sayaç artar, cevap yine döner.
Başarılı: 7 · Başarısız: 0
İhlali test edilebiliyorsa sözleşmedir. Edilemiyorsa niyet beyanıdır.

Bu testlerin her biri aslında makaledeki bir cümleyi doğruluyor. İlk yazıda "refusal contract" ve "citation contract" derken kastettiğim şey tam olarak buydu. Bunlar sadece prompt'a yazılmış kurallar değil; kodda karşılıkları var. İhlal edildiklerinde sistemin ne yapacağı belli. Ve en önemlisi, bu davranış test edilebiliyor. Test geçmiyorsa sözleşme yok demektir.

Bölüm 6Sıradaki faz: Azure'a taşıma

Bu yazıda bilinçli olarak Azure tarafına girmedim. Çünkü bence iyi bir mimari şurada belli olur: context değiştiğinde sözleşmeler hâlâ çalışıyor mu?

Bir önceki yazıda sistemi mock tabanlı kurmuştum. IKnowledgeSearchService'in arkasında MockKnowledgeSearchService vardı ve bellekteki chunk'lar üzerinde basit bir keyword eşleştirmesi yapıyordu. Bu ilk bakışta oyuncak gibi görünebilir; ama amaç zaten Application ve Domain katmanlarındaki sözleşmeleri Azure'a, OpenAI'a ya da herhangi bir dış servise bağlanmadan test edebilmek.

Sonraki adımda bu interface'in arkasına AzureSearchKnowledgeService gelecek; yani bilgi arama tarafı Azure AI Search'e bağlanacak. Keyword search, vector search ve semantic ranker gibi production'a daha yakın retrieval mekanizmaları devreye girecek. MockChatClient'ın yerine de Azure OpenAI tarafındaki chat client register edilecek. Hedef aynı: vendor değişsin, handler değişmesin.

Çünkü Microsoft.Extensions.AI.IChatClient zaten bu ayrımı yapabilmek için anlamlı bir abstraction sunuyor. Application katmanı "hangi provider?" sorusuyla ilgilenmemeli. Provider OpenAI olmuş, Azure OpenAI olmuş, ileride başka bir model olmuş; Domain ve Application katmanlarındaki sözleşmeler bundan etkilenmemeli.

Kapanış

İlk yazıda bir kavram ortaya atmıştım: sistem sözleşmesi. Bu yazıda ise o sözleşmenin gerçekte nereye yazıldığını göstermeye çalıştım. Application katmanındaki orchestrator'ın 120 satırına yazdık. Domain katmanındaki tek bir invariant metoduna yazdık. CitationValidator içindeki 15 satırlık kontrole ve son olarak dört ayrı refusal noktasına yazdık.

Ama hepsinin ortak noktası aynı:

Sözleşme dediğiniz şey, ihlal edildiğinde sistemi durdurabilmeli. Bunu yapamıyorsa artık sözleşme değildir; en fazla iyi niyetli bir dilektir.

Air Canada'nın chatbot yüzünden ödediği tazminat, Mata v. Avianca davasındaki yaptırım, Anthropic tarafındaki mahkeme özrü… Bu örneklerin hepsinde ortak problem aslında çok benzerdi: model emin olmadığı yerde duramadı, bilmediğini söyleyemedi; ya da sistem, modelin ürettiği cevabı yeterince sorgulamadı.

Şekil 07 — Özet

Güvenli cevap akışı: model konuşur, sistem karar verir

01
Sınır koyModel konuşmadan önce
  • Kullanıcı sorusu alınırkimlik, bağlam, lokasyon
  • Validasyonteknik ve kural olarak uygun mu?
  • Risk sınıflandırmadüşük / orta / yüksek
  • Bilgi aramadoğru kaynaklarda arama
Refusal #1no_source_refusal
02
Kontrol etModel konuştuktan sonra
  • Model çağrısısistem prompt’u + soru + chunk’lar → JSON envelope
  • JSON parse başarılı mı?
  • Refusal #2malformed_response
  • Model “veremem” dedi mi?
  • Refusal #3model_self_refusal
  • Citation’ların hepsi retrieval’dan gelen chunk’larda mı?
  • Refusal #4invalid_citation
03
Cevap verSadece sözleşmeler geçerse
  • Grounded (kanıtlı)
  • Geçerli citation’larla
  • Confidence bilgisiyle
  • Audit kaydı yazılır

Sözleşme geçmezse, gerekçesiyle birlikte refusal döner.

Temel ilke
  1. Sınır koy Doğru soruyu modele gönder.
  2. Kontrol et Modelin cevabını sözleşmelere göre denetle.
  3. Cevap ver Sözleşmeler geçerse dışarı çıkar.

Kanıtlar ve kaynaklar

Yasal vakalar ve olaylar

Akademik çalışmalar

.NET ve Microsoft.Extensions.AI

Bu makaledeki fikirler, mimari yaklaşım ve teknik değerlendirmeler bana aittir. Görsel üretimi, yazım düzenleme, kodlama ve formatlama süreçlerinde yapay zekâ destekli araçlardan yararlanılmıştır.