Mert Özen Her Satırda Daha İleri
Yapay Zeka LLM Temelleri — #09

LLM için Prompt Yazmak (2): Adım Adım Düşündürmek ve Reasoning Modelleri

Mert Özen 6 Kas 2026 11 dk 2 görüntülenme
LLM için Prompt Yazmak (2): Adım Adım Düşündürmek ve Reasoning Modelleri

Adım adım düşün demek doğru cevabı garanti eder mi? Tuzaklı bir sipariş hesabını gemma3:4b'ye üç biçimde soruyor, qwen3:4b'nin think parametresiyle düşünmeyi cevaptan ayırıyor, C#'ta ReasoningOptions kullanıyor ve düşünmenin token ile süre olarak bedelini ölçüyoruz.

Sürüm notu: Bu yazıdaki örnekler Ollama 0.34 ile yerel modellerde (gemma3:4b, qwen3:4b) denenmiştir; .NET 10 ve Node 22 kullanılmıştır.

Önce Tahmin Et

Modele küçük bir sipariş hesabı soruyorsun ve yanlış cevap alıyorsun. Prompt'un sonuna "adım adım düşün" ekliyorsun. Cevap düzelir mi? Peki düşünmek için eğitilmiş bir reasoning modeline geçersen, bu doğruluk sana kaç token'a ve kaç saniyeye mal olur?

Birinci sorunun cevabı "bazen" ve hangi durumda düzeldiğini birazdan göreceğiz. İkinci sorunun cevabı ise çoğu kişinin tahmininden çok daha büyük bir sayı.

Tek Adımda Cevaplanamayan Sorular

Bookstore'da bir müşteri 12,50 dolardan 3 kitap ve 1,75 dolardan 2 ayraç alıyor. %15'lik bir kupon yalnızca kitaplara uygulanıyor. Kargo 4,99 dolar, ama indirimden sonraki sipariş toplamı 35 doları geçerse ücretsiz. Doğru cevap: kitaplar 37,50, indirimle 31,875; ayraçlarla birlikte 35,375. Bu 35'i geçtiği için kargo ücretsiz, müşteri 35,38 dolar ödüyor. Sınırın hemen üstünde kalan, bilerek tuzaklı bir soru.

Model için bu sorunun zorluğu şurada: serinin ilk yazısındaki döngüyü hatırla, model her adımda yalnızca bir sonraki token'ı üretiyor. "Yalnızca tutarı yaz" dediğinde cevabın ilk token'ı, yani tutarın ilk rakamı, hiçbir ara hesap yapılmadan üretilmek zorunda. Modelin bir müsvedde kâğıdı yok; düşünmek için kullanabileceği tek yer ürettiği metnin kendisi. Ara adımları yazdırmak, ona o müsveddeyi vermek demek.

Adım Adım Düşündürmek

Aynı soruyu gemma3:4b'ye üç farklı biçimde soralım: doğrudan, "adım adım çöz" diyerek ve adımları bizim tarif ettiğimiz şekilde:

import ollama from 'ollama';

const problem = `A customer buys 3 books at $12.50 each and 2 bookmarks at $1.75 each.
A 15% coupon applies to the books only. Shipping costs $4.99, but it is free
when the order total after the discount is over $35.
What does the customer pay?`;

const variants: Record<string, string> = {
  direct: `${problem} Reply with the amount only.`,
  'step by step': `${problem} Work through it step by step, then give the final line as "Total: $X".`,
  'given steps': `${problem} Solve it in these steps:
1. Book subtotal, then apply the coupon to the books only.
2. Add the bookmarks to get the order total after the discount.
3. Compare that total with $35 to decide on shipping.
4. Give the final line as "Total: $X".`,
};

for (const [name, prompt] of Object.entries(variants)) {
  const res = await ollama.chat({
    model: 'gemma3:4b',
    messages: [{ role: 'user', content: prompt }],
    options: { temperature: 0, seed: 42 },
  });
  console.log(`--- ${name}: ${res.eval_count} output tokens, ${(res.total_duration / 1e6).toFixed(0)} ms`);
  // Çıktı kısa kalsın diye yalnızca tutar içeren satırlar
  for (const line of res.message.content.split('\n')) {
    if (line.includes('$')) console.log(line.trim());
  }
}
--- direct: 8 output tokens, 287 ms
$30.75
--- step by step: 206 output tokens, 4664 ms
1. **Cost of books:** 3 books * $12.50/book = $37.50
2. **Cost of bookmarks:** 2 bookmarks * $1.75/bookmark = $3.50
3. **Total cost before discount:** $37.50 + $3.50 = $41.00
4. **Discount amount:** 15% of $41.00 = 0.15 * $41.00 = $6.15
5. **Total cost after discount:** $41.00 - $6.15 = $34.85
6. **Shipping cost:** $4.99
7. **Final cost:** $34.85 + $4.99 = $39.84
Total: $39.84
--- given steps: 325 output tokens, 7490 ms
*   Cost per book: $12.50
*   Book subtotal: $12.50 * 3 = $37.50
*   Coupon discount (15% of $37.50): $37.50 * 0.15 = $5.625
*   Book total after coupon: $37.50 - $5.625 = $31.875
*   Cost per bookmark: $1.75
*   Bookmark subtotal: $1.75 * 2 = $3.50
*   Order total after discount: $31.875 + $3.50 = $35.375
**3. Compare Total with $35 to Decide on Shipping:**
*   Order total after discount: $35.375
*   Shipping cost: $4.99
*   Since $35.375 > $35, shipping is free.
Total: $35.375
Total: $35.38

Doğrudan cevap 8 token'da ve 287 ms'de geldi: 30,75 dolar. Yanlış, ama kendinden emin. "Adım adım çöz" dediğimizde model 206 token harcadı ve yine yanlış buldu: 39,84. Fark şu ki bu kez hatanın nerede olduğunu görebiliyoruz. Dördüncü adımda kuponu 41 dolarlık toplamın tamamına uyguladı; "yalnızca kitaplara" kısıtını kaçırdı. Ondan sonraki her adım bu hatanın üzerine doğru bir hesap kurdu.

Üçüncü varyantta adımları biz verdik: önce kitap ara toplamı ve kupon, sonra ayraçlar, sonra 35 dolar karşılaştırması. Model 325 token'da doğru sonuca ulaştı. Bu tekniğin adı chain-of-thought prompting; Wei ve arkadaşlarının 2022 tarihli makalesiyle yaygınlaştı. Bizim deneyimizden çıkan ders ise daha somut: "adım adım düşün" demek modele müsvedde verir, ama hangi adımları atacağını bilmiyorsa yanlış yolda adım adım ilerler. Problemi senin bölmen, küçük bir modelde genelde daha güvenilir.

Bir de bedeli var: doğru cevaba ulaşan varyant, doğrudan cevaptan 40 kat fazla token üretti ve 26 kat uzun sürdü.

Reasoning Modelleri

Chain-of-thought'u prompt'la istemek yerine, modeli cevaptan önce düşünmesi için eğitmek de mümkün. OpenAI'ın o1'i ve DeepSeek-R1 gibi reasoning modelleri bu fikrin üzerine kurulu. Bu modeller önce uzun bir düşünme metni üretiyor, sonra cevabı yazıyor.

Ollama bunu think parametresiyle yönetiyor ve düşünme metnini cevaptan ayrı bir alanda, message.thinking içinde döndürüyor. Her model bunu desteklemiyor: /api/show cevabındaki capabilities listesinde thinking olmalı. gemma3:4b'de bu yok ve think: true gönderdiğinde Ollama doğrudan hata dönüyor: "gemma3:4b" does not support thinking. Bu yazıda qwen3:4b kullanıyoruz:

import ollama from 'ollama';

const info = await ollama.show({ model: 'qwen3:4b' });
const meta = info.model_info as unknown as Record<string, unknown>;

console.log('capabilities:', info.capabilities);
console.log('finetune:', meta['general.finetune'], '| version:', meta['general.version']);
// Chat template'inin son satırları: cevap başlamadan hemen önce yazılan kısım
console.log(info.template.trimEnd().split('\n').slice(-4).join('\n'));
capabilities: [ 'completion', 'tools', 'thinking' ]
finetune: Thinking | version: 2507
{{- if and (ne .Role "assistant") $last }}<|im_start|>assistant
<think>
{{ end }}
{{- end }}

Bu çıktıyı tuzaklar bölümünde tekrar konuşacağız; şimdilik iki satıra dikkat et. finetune: Thinking ve version: 2507, bu makinedeki qwen3:4b etiketinin Qwen3'ün düşünmeye ayrılmış 2507 sürümü olduğunu söylüyor. Template'in son satırları da cevabın her zaman bir <think> etiketiyle başlatıldığını gösteriyor.

Düşünmeyi Cevaptan Ayırmak

Aynı sipariş sorusunu şimdi qwen3:4b'ye soralım. Streaming kullanıyoruz, çünkü düşünme uzun sürecek ve gerçek bir arayüzde kullanıcıya bu sürede bir şey göstermek isteyeceksin:

import ollama from 'ollama';

const problem = `A customer buys 3 books at $12.50 each and 2 bookmarks at $1.75 each.
A 15% coupon applies to the books only. Shipping costs $4.99, but it is free
when the order total after the discount is over $35.
What does the customer pay? Reply with the amount only.`;

const started = performance.now();
let thinking = '';
let answer = '';
let firstAnswerMs = -1;

const stream = await ollama.chat({
  model: 'qwen3:4b',
  messages: [{ role: 'user', content: problem }],
  think: true,
  options: { temperature: 0, seed: 42 },
  stream: true,
});

for await (const chunk of stream) {
  // Düşünme ve cevap aynı parçanın ayrı alanlarında gelir
  thinking += chunk.message.thinking ?? '';
  if (chunk.message.content) {
    if (firstAnswerMs < 0) firstAnswerMs = performance.now() - started;
    answer += chunk.message.content;
  }
  if (chunk.done) {
    console.log(`thinking: ${thinking.length} chars, starts: ${JSON.stringify(thinking.slice(0, 70))}`);
    console.log(`answer starts after ${(firstAnswerMs / 1000).toFixed(1)} s`);
    console.log(`answer: ${JSON.stringify(answer.trim().slice(-60))}`);
    console.log(`output tokens: ${chunk.eval_count}, total: ${(chunk.total_duration / 1e9).toFixed(1)} s`);
  }
}
thinking: 7573 chars, starts: "Okay, let's try to figure out how much the customer pays. Hmm, first, "
answer starts after 68.3 s
answer: "rest cent:\n- $35.375$ rounds to $35.38$\n\n$$\n\\boxed{35.38}\n$$"
output tokens: 2984, total: 76.7 s

Model doğru cevabı buldu: 35,38. Ama oraya varmak için 7573 karakterlik bir düşünme metni yazdı ve toplam 2984 token üretti. Cevabın ilk karakteri 68 saniye sonra geldi; bu süre boyunca kullanıcıya gösterilecek tek şey thinking alanında akan metin. Sohbet arayüzlerindeki "düşünüyor..." göstergesi tam olarak bu alanın varlığına dayanıyor.

İkinci gözlem: "yalnızca tutarı yaz" dememize rağmen cevap bir hesap özeti ve LaTeX'te bir \boxed{35.38} ile bitti. Doğru düşünen bir model de biçim talimatını esnetebiliyor; doğruluk ile itaat ayrı şeyler. Cevabı programla kullanacaksan, bir sonraki yazının konusu olan structured output'a ihtiyacın var.

C#'ta Reasoning: ReasoningOptions

Microsoft.Extensions.AI'de düşünme, sağlayıcıdan bağımsız bir seçenekle isteniyor: ChatOptions.Reasoning. Aşağıda aynı soruyu iki modele soruyoruz; soru bilerek kolay:

using System.Diagnostics;
using Microsoft.Extensions.AI;
using OllamaSharp;

Uri ollama = new("http://localhost:11434");
string question = "Which is larger, 9.11 or 9.9? Reply with the number only.";

// Normal bir model: doğrudan cevap verir
IChatClient gemma = new OllamaApiClient(ollama, "gemma3:4b");
await Ask(gemma, new ChatOptions { Temperature = 0, Seed = 42 });

// Bir reasoning modeli: önce düşünür, sonra cevap verir
IChatClient qwen = new OllamaApiClient(ollama, "qwen3:4b");
await Ask(qwen, new ChatOptions
{
    Temperature = 0,
    Seed = 42,
    Reasoning = new ReasoningOptions { Effort = ReasoningEffort.Medium },
});

async Task Ask(IChatClient client, ChatOptions options)
{
    Stopwatch sw = Stopwatch.StartNew();
    ChatResponse response = await client.GetResponseAsync(question, options);

    // Düşünme metni response.Text içinde değil, ayrı bir içerik tipi olarak gelir
    string thinking = string.Concat(response.Messages
        .SelectMany(m => m.Contents)
        .OfType<TextReasoningContent>()
        .Select(c => c.Text));

    Console.WriteLine($"--- {response.ModelId}");
    Console.WriteLine($"answer: {response.Text.Trim()}");
    Console.WriteLine($"thinking: {thinking.Length} chars");
    Console.WriteLine($"output tokens: {response.Usage?.OutputTokenCount}, time: {sw.ElapsedMilliseconds} ms");
}
--- gemma3:4b
answer: 9.9
thinking: 0 chars
output tokens: 5, time: 296 ms
--- qwen3:4b
answer: 9.9
thinking: 4680 chars
output tokens: 1566, time: 42025 ms

Giden isteği bir DelegatingHandler ile yakaladığımda OllamaSharp'ın ReasoningEffort.Medium'u Ollama'ya "think": "medium" olarak gönderdiğini gördüm. Ollama think için true/false dışında "low", "medium", "high" düzeylerini de kabul ediyor; ama bu modelde "medium" ile true aynı soruda birebir aynı düşünmeyi, 4680 karakteri üretti. Düzeylere ne kadar uyulacağı modele bağlı. Düşünme metni de response.Text'e karışmıyor, ayrı bir TextReasoningContent olarak geliyor; geçmişe ya da log'a ne yazacağına sen karar veriyorsun.

Bedeli: Token ve Süre

Bu yazının ölçümlerini yan yana koyalım. Sipariş sorusunda gemma3:4b doğrudan cevap verdi: 8 token, 0,3 saniye, yanlış. Adımları biz verdiğimizde 325 token, 7,5 saniye, doğru. qwen3:4b düşünerek 2984 token, 77 saniye, doğru. "9.11 mi 9.9 mu?" sorusunda ise iki model de doğru cevap verdi; gemma3:4b 5 token ve 296 ms ile, qwen3:4b 1566 token ve 42 saniye ile. Kolay bir soruda düşünmek, cevabı iyileştirmeden 300 kat token harcadı.

Bu oran bulutta doğrudan faturaya yansır, çünkü düşünme token'ları da üretilen token'lardır. Yerelde ölçtüğümüz eval_count düşünmeyi de içeriyor; bulut sağlayıcıları da düşünme token'larını genellikle çıktı token'ı olarak ücretlendiriyor, ayrıntı için sağlayıcının fiyat sayfasına bak. Gecikme tarafında ise kullanıcının cevabı görmeden önce beklediği süre, düşünme metninin uzunluğuyla doğru orantılı.

Benim kullandığım pratik ayrım şu. Sınıflandırma, bilgi çıkarma, özet, çeviri gibi tek adımlı işlerde reasoning gereksiz; hızlı bir model ve iyi bir prompt yeter. Birden fazla kısıtın etkileştiği hesaplar, çok adımlı planlama ve "bu kodda hata nerede" türü analizlerde reasoning gerçekten fark yaratabilir. Ama para gibi kesin cevap isteyen hesaplarda en güvenilir yol, hesabı hiç modele bırakmamak: modelin işi kuralları anlamak ve doğru fonksiyonu çağırmak, toplamı ise kod hesaplar. Bunu tool calling yazılarında yapacağız.

Sık Düşülen Tuzaklar

İlk tuzak, think: false'un düşünmeyi her modelde kapattığını sanmak. Yukarıdaki show.ts çıktısını hatırla: bu model düşünmeye ayrılmış bir sürüm ve template'i cevabı her zaman <think> ile başlatıyor. Bakalım think: false ne yapıyor:

import ollama from 'ollama';

const res = await ollama.chat({
  model: 'qwen3:4b',
  messages: [{ role: 'user', content: 'Which is larger, 9.11 or 9.9? Reply with the number only.' }],
  think: false,
  options: { temperature: 0, seed: 42 },
});

const content = res.message.content;
console.log('thinking field:', res.message.thinking);
console.log('content length:', content.length);
console.log('contains </think>:', content.includes('</think>'));
console.log('after </think>:', JSON.stringify(content.split('</think>').pop()!.trim()));
console.log('output tokens:', res.eval_count);
thinking field: undefined
content length: 4693
contains </think>: true
after </think>: "9.9"
output tokens: 1566

thinking alanı boş geldi, ama model düşünmeyi bırakmadı: yine 1566 token üretti ve düşünme metni </think> etiketiyle birlikte content'in içine karıştı. Bu modelde think: false düşünmeyi değil, düşünmenin ayrıştırılmasını kapattı. Ekrana content basan bir uygulama kullanıcıya dört bin karakterlik bir iç ses gösterir. Modeli değiştirdiğinde ya da bir etiket yeni bir sürüme işaret etmeye başladığında /api/show ile neyle çalıştığını kontrol et.

İkincisi, çıktı sınırını düşünmeyi hesaba katmadan koymak. Düşünme token'ları da num_predict bütçesinden yiyor:

import ollama from 'ollama';

const res = await ollama.chat({
  model: 'qwen3:4b',
  messages: [{ role: 'user', content: 'Which is larger, 9.11 or 9.9? Reply with the number only.' }],
  think: true,
  options: { temperature: 0, seed: 42, num_predict: 200 },
});

console.log('done_reason:', res.done_reason, '| output tokens:', res.eval_count);
console.log('thinking chars:', res.message.thinking?.length ?? 0);
console.log('answer:', JSON.stringify(res.message.content));
done_reason: length | output tokens: 200
thinking chars: 569
answer: ""

200 token'lık sınır düşünmenin ortasında doldu ve cevap boş string olarak geldi. Reasoning modelinde sınırı, düşünme ve cevabın toplamına yetecek şekilde koy ve done_reason'ı mutlaka kontrol et.

Üçüncüsü, chain-of-thought çıktısında ilk bulduğun "Total:" satırını almak. Üçüncü varyantın çıktısında iki tane var: önce 35,375, sonra yuvarlanmış 35,38. Serbest metinden sonuç çekiyorsan son satırı al; daha iyisi, sonucu ayrı bir alanda isteyen structured output kullan.

Dördüncüsü, düşünme metnini doğruluk kanıtı sanmak. Düşünme, modelin cevaba nasıl ulaştığına dair faydalı bir iz, ama ikinci varyantta gördüğümüz gibi düzgün görünen adımlar yanlış bir varsayımın üzerine kurulabilir. Düşünme metnini hata ayıklamak için oku, cevabı ise ayrıca doğrula.

Küçük Bir Deneme

★☆☆ cot.ts'teki sipariş sorusunda kargo sınırını 35 yerine 36 dolar yap. Doğru cevap ne olmalı? Üç varyantın hangileri yeni sınırı doğru uyguluyor?

★★☆ think-stream.ts'i "What is the capital of France?" gibi kolay bir soruyla çalıştır. qwen3:4b kaç token düşündü, cevap kaç saniye sonra başladı? Aynı soruyu gemma3:4b'ye düşünmesiz sorup iki sonucu karşılaştır.

★★★ C# örneğine bir Stopwatch daha ekleyip GetStreamingResponseAsync ile düşünmeyi akış olarak oku: TextReasoningContent parçalarını gri, cevabı normal renkte yazdır ve ilk cevap karakterinin ne zaman geldiğini ölç.

Bu yazıda modelin cevabını iki kez programla okumaya çalıştık ve iki kez de serbest metne takıldık: iki "Total:" satırı ve bir \boxed{}. Bir sonraki yazıda bu sorunu kökünden çözüyoruz: structured output ile modelden bir JSON schema'ya uyan cevap isteyeceğiz, C#'ta GetResponseAsync<T> ile cevabı doğrudan bir record'a alacağız ve schema'nın neyi garanti edip neyi etmediğini göreceğiz.