Mert Özen Her Satırda Daha İleri
Yapay Zeka Prompt Engineering Uygulamada — #01

Prompt Engineering Uygulamada: Prompt'u Spesifikasyon, Kod ve Test Edilen Bir Parça Olarak Ele Almak

Mert Özen 15 Kas 2026 13 dk 6 görüntülenme
Prompt Engineering Uygulamada: Prompt'u Spesifikasyon, Kod ve Test Edilen Bir Parça Olarak Ele Almak

Bir prompt'un çalıştığını söylemenin tek dürüst yolu onu gerçek girdilere benzeyen bir test setiyle ölçmektir. Bookstore'un iade tespiti prompt'u tek mesajda 1/1 geçerken on iki mesajlık sette 8/12 kaldı; görev tanımı, null kuralı, JSON schema, kodda doğrulama ve sınır örnekleriyle döngüyü adım adım işletiyor ve seri boyunca kullanacağımız EvalRunner'ı kuruyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10 ile derlenip denenmiştir; Microsoft.Extensions.AI 11.0.0, OllamaSharp 5.5.2 ve xUnit v3 4.0.3 kullanılmıştır. Model çıktıları yerelde Ollama 0.34.4 üzerinde çalışan küçük bir açık model olan gemma3:4b'den (4,3 milyar parametre, Q4_K_M quantization) alınmıştır; OpenAI veya Anthropic gibi bir bulut sağlayıcıyla koşulmamıştır.

Önce Tahmin Et

Müşteri hizmetlerinden Gül bir istekte bulunuyor: gelen mesaj bir iade talebiyse ve içinde sipariş numarası varsa, ticket'ı açarken bu iki bilgi otomatik dolsun. Emre bir prompt yazıyor, ilk mesajı deniyor: "Merhaba, 10428 numaralı siparişimdeki Dune hasarlı geldi, iade etmek istiyorum." Model {"isReturnRequest": true, "orderNumber": "10428"} döndürüyor. Emre pull request açıyor, açıklamaya "LLM ile iade tespiti, çalışıyor" yazıyor.

Aynı prompt'u on iki farklı mesajla denesen kaçı doğru çıkar? Mesajların içinde iade politikasını soran biri, geçen ay iade ettiği kitabın parasını bekleyen biri, sipariş numarası yerine fatura tutarı yazan biri ve sadece "Merhaba" diyen biri var. Bir sayı tahmin et, sonra okumaya devam et.

Bu yazı serinin geri kalanında kullanacağımız çalışma biçimini kuruyor: prompt'u bir spesifikasyon, bir kod parçası ve test edilen bir component olarak ele almak. Her görev için aynı döngüyü işleteceğiz: görev tanımı, başarı kriteri, küçük bir test seti, prompt, ölçüm ve düzeltme.

"Çalışıyor Gibi" Neden Yetmez

Emre'nin denediği tek mesaj, prompt'un en kolay vakası: iade kelimesi açıkça geçiyor, sipariş numarası beş haneli ve tek. Bir LLM'in bu mesajı doğru sınıflandırması, prompt'un görevi anladığını göstermez; sadece en belirgin örnekte işe yaradığını gösterir. Klasik kodda bunun karşılığı, bir method'u tek bir girdiyle çağırıp "test ettim" demek olurdu. Kimse bunu kabul etmez, ama prompt'larda sık yapılıyor; çünkü model her seferinde makul görünen bir cevap üretiyor ve hatayı okuyarak fark etmek zor.

Prompt'un davranışını belirleyen şey yalnız yazdığın metin değil: model, model sürümü, temperature, çıktı formatı ve girdi dağılımı birlikte belirliyor. Bunlardan biri değişince davranış da değişebilir. Bu yüzden bir prompt'un "çalıştığını" söylemenin tek dürüst yolu, onu gerçek girdilere benzeyen bir test setiyle ölçmek ve sonucu bir sayı olarak yazmak.

Prompt Bir Spesifikasyondur, Önce Kriteri Yaz

LLM Temelleri serisinin "LLM için Prompt Yazmak (1): Açık Talimat, Ayraçlar ve Few-shot" yazısında prompt'un parçalarını gördük: rol, açık talimat, girdiyi talimattan ayıran ayraçlar, örnekler ve çıktı formatı. "LLM için Prompt Yazmak (2)" yazısı da adım adım düşündürmeyi ve reasoning modellerini anlattı. Bu seride o ilkeleri tekrar anlatmayacağız; ağırlık, bir görev için prompt'u nasıl kurduğumuz ve nasıl ölçtüğümüzde olacak.

Uygulamada prompt yazmaya başlamadan önce iki şeyin yazılı olması gerekiyor. Birincisi görevin tanımı: "iade talebi" ne demek, değişim talebi iade sayılır mı, daha önce açılmış bir iadenin durumunu sormak iade talebi mi? Bu soruların cevabı modelin değil, Gül'ün ekibinin kararı. İkincisi başarı kriteri: hangi ölçümle, hangi eşikte "yeterli" diyeceğiz? Anthropic'in prompt engineering rehberi de prompt'u iyileştirmeye başlamadan önce net bir başarı kriteri, bu kritere karşı deneme yapmanın bir yolu ve bir ilk taslak prompt olmasını ön koşul sayıyor; kriterlerin ölçülebilir ve göreve özgü olmasını öneriyor.

Bizim görev için kriter şöyle: on iki mesajlık test setinde iki alanın ikisi de doğru olmalı; sipariş numarası mesajda yoksa alan null kalmalı ve model hiçbir zaman numara uydurmamalı. İkinci kural, birincisinden daha önemli: yanlış bir sipariş numarası, boş bir alandan daha pahalı, çünkü Gül yanlış siparişte işlem yapabilir.

Test Seti: On İki Mesaj, Her Biri Bir Soru

Test seti JSONL (her satırı ayrı bir JSON object olan dosya) formatında duruyor; her satır bir girdi, beklenen çıktı ve vakanın neden orada olduğunu anlatan kısa bir not:

{"id": "rc-01", "input": "Merhaba, 10428 numaralı siparişimdeki Dune hasarlı geldi, iade etmek istiyorum.", "expected": {"isReturnRequest": true, "orderNumber": "10428"}, "note": "the first message we tried"}
{"id": "rc-04", "input": "İade etmeyeceğim, sadece faturayı tekrar gönderebilir misiniz? Sipariş 10602.", "expected": {"isReturnRequest": false, "orderNumber": "10602"}, "note": "negation"}
{"id": "rc-06", "input": "Geçen ay iade ettiğim kitabın parası hesabıma yatmadı. Sipariş 10345.", "expected": {"isReturnRequest": false, "orderNumber": "10345"}, "note": "status of an earlier refund"}
{"id": "rc-09", "input": "1.240,50 TL ödediğim kitaplar ıslak geldi, hepsini geri göndereceğim.", "expected": {"isReturnRequest": true, "orderNumber": null}, "note": "price is not an order number"}
{"id": "rc-11", "input": "İade politikanız kaç gün?", "expected": {"isReturnRequest": false, "orderNumber": null}, "note": "policy question"}

Setin geri kalanında kargo gecikmesi, sipariş numarası olmayan bir iade, İngilizce yazılmış ve "return" kelimesi geçmeyen bir talep, değişim isteği, ISBN (kitapların uluslararası standart numarası) içeren yeni sipariş, "iade" kelimesi geçmeyen bir cayma hakkı bildirimi ve içeriksiz bir selam var. On iki vaka az; ama her biri ayrı bir soruyu cevaplıyor. Vakaları seçerken ölçüt, gerçek ticket'larda gördüğümüz ve prompt'un yanlış anlayabileceği durumlar. Test setinin nasıl büyütüleceğini ve altın set kurmayı serinin son yazısında ayrıntılı ele alacağız.

Ölçen Kod: PromptVersion ve EvalRunner

Ölçümü elle yapmak ilk denemede bile hata üretir; kimse on iki JSON'u dikkatle okuyup karşılaştırmaz. Bu yüzden prompt'u ve onu ölçen kodu aynı solution'a koyuyoruz. Prompt, versiyonu ve beklediği çıktı formatıyla birlikte bir record:

using Microsoft.Extensions.AI;

namespace Bookstore.Prompts.Evaluation;

// Prompt, beklediği çıktı formatıyla birlikte versiyonlanır
public sealed record PromptVersion(
    string Task,
    string Version,
    string System,
    Func<string, string> BuildUser,
    ChatResponseFormat? Format = null)
{
    public List<ChatMessage> Render(string input) =>
        [new(ChatRole.System, System), new(ChatRole.User, BuildUser(input))];
}

Runner sağlayıcıdan bağımsız: IChatClient alıyor, her vakayı modele gönderiyor ve çıktıyı göreve özgü bir grade fonksiyonuna veriyor. Sabit ayarlar (temperature 0, sabit seed) dışarıdan geliyor; ölçüm boyunca değişmiyor:

public sealed class EvalRunner(IChatClient client, ChatOptions baseOptions)
{
    public async Task<EvalReport> RunAsync<T>(
        PromptVersion prompt,
        IReadOnlyList<TestCase<T>> cases,
        Func<TestCase<T>, string, Verdict> grade,
        CancellationToken cancellationToken = default)
    {
        ChatOptions options = baseOptions.Clone();
        options.ResponseFormat = prompt.Format;

        List<CaseResult> results = [];
        foreach (TestCase<T> testCase in cases)
        {
            long start = Stopwatch.GetTimestamp();
            ChatResponse response = await client.GetResponseAsync(
                prompt.Render(testCase.Input), options, cancellationToken);
            Verdict verdict = grade(testCase, response.Text);
            results.Add(new CaseResult(testCase.Id, verdict, response.Text, Stopwatch.GetElapsedTime(start)));
        }
        return new EvalReport(prompt.Task, prompt.Version, results);
    }
}

IChatClient kullandığımız için aynı runner yarın bir bulut sağlayıcının client'ıyla da çalışır; yalnız Lab projesindeki client oluşturma satırı değişir. Bu seride model yerel Ollama'da çalışıyor; adresi ve model adı BOOKSTORE_LLM_ENDPOINT ve BOOKSTORE_LLM_MODEL environment variable'larından okunuyor, varsayılanlar http://localhost:11434 ve gemma3:4b. Windows'ta Ollama'yı kurup ollama pull gemma3:4b ile modeli indirmen yeterli. IChatClient'ı fake bir client ile test etmenin ayrıntısı .NET ile Yapay Zeka serisinin "IChatClient Kullanan Kodu Test Etmek" yazısında.

Grade fonksiyonu deterministik: çıktıyı JSON olarak parse ediyor, iki alanı beklenenle karşılaştırıyor ve neyin yanlış olduğunu tek satırda yazıyor. Parse edilemeyen çıktı da bir hata, onu da sayıyoruz.

Döngüyü Çalıştırmak: v1'den v3'e

Emre'nin prompt'u v1. Bir system cümlesi ve "JSON ile cevap ver" talimatı var; JSON mode açık, ama alanların ne anlama geldiği yazmıyor:

// v1: denediğimiz ilk mesajda "çalışan" prompt
public static readonly PromptVersion V1 = new(
    Task: "return-check",
    Version: "v1",
    System: "You help the Bookstore customer service team.",
    BuildUser: message =>
        $"Does the customer want to return a book? Reply with JSON containing isReturnRequest and orderNumber.\n\n{message}",
    Format: ChatResponseFormat.Json);

Önce Emre'nin yaptığını yapalım, yalnız ilk mesajla:

dotnet run --project src/Bookstore.Prompts.Lab -- return-check v1 --first
return-check v1 | gemma3:4b | 1 cases
PASS rc-01
1/1 passed (100%)

Yüzde yüz. Şimdi bütün set:

dotnet run --project src/Bookstore.Prompts.Lab -- return-check v1
return-check v1 | gemma3:4b | 12 cases
PASS rc-01
PASS rc-02
PASS rc-03
PASS rc-04
PASS rc-05
FAIL rc-06  isReturnRequest: expected false, got true
PASS rc-07
PASS rc-08
FAIL rc-09  orderNumber: expected null, got 1.240,50
PASS rc-10
FAIL rc-11  isReturnRequest: expected false, got true
FAIL rc-12  isReturnRequest: expected false, got true
8/12 passed (67%)

Dört hata, dördü de görev tanımının prompt'ta olmamasından. Model "iade" kelimesini gördüğü her mesajı iade talebi sayıyor: iade politikası sorusu (rc-11) ve eski bir iadenin parasını soran mesaj (rc-06) true oluyor. Hiçbir bilgi olmayan "Merhaba" bile true (rc-12). En kötüsü rc-09: model fatura tutarını sipariş numarası diye alana yazmış. JSON geçerli, alan adları doğru, değer yanlış.

v2, prompt'a görev tanımını ekliyor. Burada LLM Temelleri'nden bildiğimiz parçaların yanına uygulamanın kendi parçaları geliyor: alanların tanımı ve sınırları, eksik bilgi için açık bir null kuralı ve çıktı sözleşmesi. Çıktı sözleşmesini yalnız metinle değil, JSON schema ile de veriyoruz; ForJsonSchema<T>() record'dan şemayı üretiyor ve Ollama çıktıyı bu şemaya uyacak şekilde kısıtlıyor (mekanizması LLM Temelleri serisinin structured output yazısında):

// v2: tanımlar, null kuralı ve çıktı sözleşmesi
public static readonly PromptVersion V2 = new(
    Task: "return-check",
    Version: "v2",
    System: """
        You classify one customer message for the Bookstore customer service team.

        ## Definitions
        - isReturnRequest is true when the customer wants to start a new return or an exchange
          for a book they received: damaged, wrong edition, missing pages, changed their mind,
          or they use their legal right of withdrawal.
        - isReturnRequest is false for questions about the return policy, the status of an
          earlier return or refund, shipping delays, invoices and new orders.
        - orderNumber is the 5-digit Bookstore order number in the message, without "#".
          ISBNs (13 digits), prices and phone numbers are not order numbers.
        - If the message contains no order number, orderNumber is null. Never guess one.

        ## Output
        Return only a JSON object that matches the schema. No explanation.
        """,
    BuildUser: message => $"<message>\n{message}\n</message>",
    Format: ChatResponseFormat.ForJsonSchema<ReturnCheckResult>());

Tanımların hepsi Gül'le konuşularak yazıldı; "değişim iade sayılır" kararı modelin değil, ekibin. Prompt dili İngilizce, mesajlar Türkçe; küçük modellerde talimatın İngilizce olması genelde daha tutarlı sonuç veriyor, ama bunu da ölçerek doğrulamak gerekir. Sonuç (PASS satırlarını çıkardım):

dotnet run --project src/Bookstore.Prompts.Lab -- return-check v2
FAIL rc-06  isReturnRequest: expected false, got true
FAIL rc-09  orderNumber: expected null, got 12405
10/12 passed (83%)

İki vaka düzeldi, ama rc-09 daha öğretici hâle geldi. Prompt "fiyatlar sipariş numarası değildir, asla tahmin etme" diyor; model yine de 1.240,50'nin rakamlarından beş haneli 12405 üretmiş. Şema yalnız şekli garanti ediyor: alan string, beş haneli. Değerin mesajdan geldiğini garanti etmiyor. Böyle bir kuralı prompt'a yazmak yetmez, kodla doğrulamak gerekir:

// Model sipariş numarasını yalnız kopyalayabilir, oluşturamaz
public static partial class ReturnCheckGuard
{
    public static ReturnCheckResult Apply(ReturnCheckResult result, string message)
    {
        if (result.OrderNumber is null)
        {
            return result;
        }
        string digits = result.OrderNumber.Trim().TrimStart('#');
        bool copied = OrderNumberPattern().Matches(message).Any(m => m.Value == digits);
        return copied ? result with { OrderNumber = digits } : result with { OrderNumber = null };
    }

    [GeneratedRegex(@"(?<!\d|\d[.,])\d{5}(?!\d|[.,]\d)")]
    private static partial Regex OrderNumberPattern();
}

Guard, mesajda tek başına duran beş haneli sayıları buluyor; "1.240,50" içindeki rakamlar ve 13 haneli ISBN bu kalıba uymuyor. Modelin verdiği numara bunlardan biri değilse alanı boşaltıyor. v2'yi guard ile ölçtüğümüzde (return-check v2 --guard) sonuç 11/12; geriye rc-06 kalıyor.

rc-06 bir tanım sınırı: "iade ettiğim kitabın parası yatmadı" mesajı iade kelimesini içeriyor ama yeni bir iade talebi değil. Tanım bu durumu açıkça sayıyor, model yine de true diyor. v3'te bu sınırı gösteren iki örnek ekliyoruz. Örnekler test setinden alınmadı; test setindeki bir mesajı prompt'a örnek olarak koymak, sınavın cevabını soruların arasına yazmak olur.

// v3: v2 + iki sınır örneği (test setinden alınmadı)
public static readonly PromptVersion V3 = V2 with
{
    Version = "v3",
    System = V2.System + """

        ## Examples
        <message>The refund for the book I sent back two weeks ago is still missing. Order 20117.</message>
        {"isReturnRequest": false, "orderNumber": "20117"}

        <message>I paid 389,90 TL and the cover is torn. I want to send it back.</message>
        {"isReturnRequest": true, "orderNumber": null}
        """,
};
dotnet run --project src/Bookstore.Prompts.Lab -- return-check v3 --guard
return-check v3 | gemma3:4b | 12 cases
PASS rc-01
PASS rc-02
PASS rc-03
PASS rc-04
PASS rc-05
PASS rc-06
PASS rc-07
PASS rc-08
PASS rc-09
PASS rc-10
PASS rc-11
PASS rc-12
12/12 passed (100%)

Tablo hâlinde döngü şöyle:

VersiyonDeğişiklikSonuç
v1, tek mesajJSON mode, tanım yok1/1
v1aynı prompt, 12 vaka8/12
v2tanımlar, null kuralı, JSON schema10/12
v2 + guardsipariş numarası kodla doğrulanıyor11/12
v3 + guardiki sınır örneği12/12

12/12'yi "bitti" diye okumamak gerekiyor. v3'ün örneklerini v2'nin hatalarını gördükten sonra yazdık; prompt bu on iki mesaja göre şekillendi. Gerçek dağılımda başka hatalar çıkacak. Yapılacak iş, production'dan gelen yeni hataları test setine eklemek ve her prompt değişikliğinde setin tamamını yeniden koşmak. Bu sonuçlar tek bir küçük modelden, temperature 0 ve sabit seed ile alındı; büyük bir bulut modelinde v1 daha iyi sonuç verebilir, ama bunu da tahmin etmek yerine aynı setle ölçmek gerekir.

Bookstore'un Prompt Backlog'u

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için serinin geri kalanında ele alacağımız görevler, ekipten gelen isteklerle bir backlog'da toplandı. Her biri aynı döngüden geçecek:

  • Kurumsal sipariş e-postasından kitap, adet ve teslim tarihi çıkarmak (Nevin Hanım'ın e-postaları; extraction).
  • Gül'ün kutusuna gelen ticket'ları iade, kargo, ödeme ve kurumsal ekiplerine yönlendirmek (classification ve routing).
  • Kitap açıklamalarını Türkçeden İngilizceye çevirmek, terim sözlüğüyle ve placeholder'ları bozmadan.
  • Uzun kampanya metnini push bildirimine kısaltmak ve bir olay raporunun yönetici ve müşteri için ayrı özetini çıkarmak.
  • Kırk sayfalık tedarikçi sözleşmesinden iade koşullarını kaynak alıntısıyla çıkarmak.
  • İade politikasıyla ilgili soruları sözleşme ve politika metnine dayanarak cevaplamak.
  • Raporlama ekranında doğal dilden yalnız okuma yapan SQL üretmek.
  • Sipariş değişikliği yapabilen, iade onayını insana bırakan müşteri asistanının system prompt'u.

Sonraki iki yazı prompt'u kodda yönetmeyi ve ölçmeyi derinleştiriyor: prompt'ları template olarak dosyada tutmak, model snapshot'ıyla birlikte versiyonlamak, LLM-as-judge ve CI'da regresyon kapısı.

Üretimde Dikkat

Ölçüm ayarlarını sabitle. Temperature, seed, model adı ve model sürümü raporun parçası; biri değişirse eski sayıyla yeni sayı karşılaştırılamaz. OpenAI'ın prompt engineering rehberi de production uygulamalarını tarihli model snapshot'larına sabitlemeyi ve model sürümü değişince eval setini yeniden koşmayı öneriyor.

Ham çıktıyı sakla. Lab, her koşunun ham model çıktısını BOOKSTORE_RUN_LOG klasörüne JSONL olarak yazıyor. rc-09'daki 12405 gibi bir hatayı ancak ham çıktıya bakınca anlıyorsun; rapordaki tek satır yeterli değil.

Prompt'un sorumlu olmadığı kuralı koda ver. "Sipariş numarası mesajdan gelmeli" bir iş kuralı; prompt bunu modele söyleyebilir ama garanti edemez. Doğrulanabilen her kural (format, değerin kaynakta geçmesi, aralık) kodda kontrol edilir; prompt yalnız modelin doğru denemesini kolaylaştırır.

Maliyeti düşün. Bu set yerel modelde birkaç saniyede koşuyor; bulut sağlayıcıda her koşu token demek. Küçük deterministik kontrolleri her değişiklikte, büyük setleri gerektiğinde koşmak serinin son yazısının konusu.

Sık Düşülen Tuzaklar

Tek örnekle prompt'u bitti saymak. Emre'nin 1/1'i ile setin 8/12'si aynı prompt'tan geldi. İlk denenen mesaj neredeyse her zaman en kolay vaka.

Geçerli JSON'u doğru cevap sanmak. JSON mode ve JSON schema çıktının şeklini garanti eder, içeriğini değil. rc-09'da şema eksiksizdi ve değer uydurmaydı.

Tanımı modele bırakmak. "İade talebi" gibi bir kavramın sınırı bir iş kararıdır. Prompt'ta yazmıyorsa model kendi tanımını kullanır ve bu tanım her mesajda aynı olmayabilir.

Test setindeki vakaları prompt'a örnek yapmak. Skor yükselir ama ölçüm anlamını yitirir. Örnekleri ayrı yaz, test setini prompt geliştirirken bile ayrı tut.

Sadece toplam skora bakmak. 10/12 ile 11/12 arasındaki fark, hangi vakanın değiştiğine bakmadan anlaşılmaz. Bir düzeltme bir vakayı çözüp başka bir vakayı bozabilir; vaka bazlı rapor bunu gösterir.

Küçük Bir Deneme

★☆☆ Test setine iki mesaj ekle: aynı mesajda iki sipariş numarası geçen bir iade talebi ve sipariş numarasını "on bin dört yüz yirmi sekiz" diye yazıyla yazan bir müşteri. Önce beklenen çıktıyı yaz, sonra v3'ü koş. Guard'ın bu vakalarda ne yaptığına bak.

★★☆ BOOKSTORE_LLM_MODEL ile başka bir küçük model seç ve v1, v2, v3'ü aynı setle koş. Hangi versiyonun farkı model değişince küçülüyor? Prompt'un mu yoksa modelin mi asıl farkı yarattığını tabloyla göster.

★★★ Temperature'ı 0.7 yap, seed'i kaldır ve v3'ü beş kez koş. Her vaka için kaç koşuda geçtiğini sayan küçük bir rapor yaz. Hangi vakalar kararsız? Bu sayı sana test setinin hangi kısmının prompt'u gerçekten zorladığını söyler.

Sıradaki yazıda backlog'un ilk görevine geçiyoruz: Nevin Hanım'ın serbest metin e-postasından yapılandırılmış sipariş çıkarmak. Eksik alan, "gelecek salı" gibi Türkçe tarih ifadeleri ve modelin bilmediği bir şeyi bilmiyorum diyebilmesi orada asıl konu olacak.