Mert Özen Her Satırda Daha İleri
Yapay Zeka Claude API ile Uygulama Geliştirme — #02

Claude Messages API Derinlemesine: Roller, Content Block'ları, Stop Reason'lar ve Usage

Mert Özen 15 Kas 2026 15 dk 4 görüntülenme
Claude Messages API Derinlemesine: Roller, Content Block'ları, Stop Reason'lar ve Usage

Claude Messages API stateless olduğu için geçmişin tamamı her istekte gönderilir; cevap bir block listesidir ve her cevapta stop_reason ile usage okunmalıdır. Konuşma ortasında system mesajı, text/image/PDF block'ları, max_tokens'a dahil thinking, refusal kategorileri, count_tokens ve Bookstore destek botu ile fatura PDF'i analizi.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Anthropic C# SDK 13.8.0 ve Claude API version 2026-10-21 ile derlenmiş, birim testleri çalıştırılmıştır. Messages API davranışı, stop_reason değerleri, PDF sınırları ve token sayımı 15 Kasım 2026'da platform.claude.com belgelerinden kontrol edilmiştir. Bu yazıda resmî belgelerden yararlanılmıştır; yanıt örnekleri belgelerdeki örnek yanıtlardır.

Önce Tahmin Et

Müşteri hizmetlerinden Gül, Bookstore'un destek botu hakkında üç şikâyetle geliyor. Bazı cevaplar cümlenin ortasında kesiliyor. Bazı müşteriler boş bir balon görüyor. Ve bot, iki mesaj önce sipariş numarasını söyleyen müşteriye "sipariş numaranız nedir?" diye yeniden soruyor. Kod, sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için ilk yazıdaki gibi message.Content[0]'ı alıp text olarak gösteriyor ve her istekte yalnız son mesajı gönderiyor.

Tahminin ne? Üç şikâyetin üçü de aynı yerden mi geliyor? Bu yazıda Messages API'nin cevabının nasıl bir yapı olduğunu, konuşma geçmişinin neden her istekte gönderildiğini, stop_reason alanının her değerinde ne yapılacağını ve usage içindeki sayıların neyi anlattığını göreceğiz. Mesaj rollerinin ve system prompt'un sağlayıcıdan bağımsız mantığı LLM Temelleri serisinin beşinci yazısında, sohbet geçmişini saklamak .NET ile Yapay Zeka serisinin beşinci yazısında anlatıldı; burada Claude'a özgü kısımlara bakıyoruz.

Stateless Konuşma: Geçmişi Her İstekte Göndermek

Messages API stateless. "Using the Messages API" sayfası bunu açıkça söylüyor: konuşmanın tamamını her istekte sen gönderiyorsun. Botun sipariş numarasını unutmasının sebebi buydu; API önceki isteği hatırlamıyor, hatırlaması gereken her şey messages içinde olmalı. Belgeye göre geçmişteki assistant mesajlarının gerçekten Claude'dan gelmesi de gerekmiyor; kendi yazdığın sentetik bir assistant mesajı da koyabilirsin.

Rollerin Claude'daki karşılığı şöyle. system istek seviyesinde ayrı bir alan, messages içinde değil. messages içinde user ve assistant sırayla gidiyor. SDK 13'te bunlar rol başına bir class: UserMessageParam, AssistantMessageParam. Üçüncüsü yeni: SystemMessageParam, yani konuşmanın ortasında bir system mesajı. Belgeye göre bu mesaj üst seviyedeki system ile aynı yetkiye sahip ama geçmişin sonuna eklendiği için daha önce cache'lenmiş prefix'i bozmuyor. Kural: bir user turn'ünün hemen arkasından gelmeli, ardından ya messages bitmeli ya da bir assistant turn'ü gelmeli; messages'ın ilk elemanı olamaz. Bu özellik Opus 5.5, Sonnet 5.5, Haiku 5.5 ve Fable modellerinde çalışıyor. Bookstore'da bunu operatör notu için kullanacağız: Gül bir müşteri için "açık şikâyeti var, geri arama teklif et" notu düştüğünde not, müşterinin son mesajının hemen arkasına system mesajı olarak giriyor. Not müşteriden değil uygulamadan geldiği için modelin gözünde user mesajından daha yüksek önceliği var.

Geçmişi her seferinde göndermenin bir maliyeti var: konuşma uzadıkça input token'ı da büyüyor. Bunu yedinci yazıda prompt caching ile düşüreceğiz. Belge, beta durumdaki message threads özelliğinin de konuşmayı sunucuda tutabildiğini söylüyor; bu seride geçmişi kendimiz saklıyoruz.

Content Block'ları: Text, Image ve Document

İstekte bir mesajın content'i tek bir string olabileceği gibi bir block listesi de olabilir. Text, image ve document block'ları en sık kullanılanlar. Image için base64, url ve file kaynakları var; desteklenen tipler image/jpeg, image/png, image/gif ve image/webp. file kaynağı Files API ile yüklenmiş bir dosyayı gösteriyor, onu sekizinci yazıda göreceğiz.

Cevap tarafında content her zaman bir block listesi. Text dışında thinking (modelin akıl yürütmesi), tool_use (modelin tool çağırma isteği) ve server tool sonuçları gibi block'lar gelebiliyor. Güncel modellerde adaptive thinking açık olduğu için cevabın ilk block'u çoğu zaman thinking oluyor. display varsayılan olarak omitted olduğunda bu block'un thinking alanı boş bir string. Gül'ün gördüğü boş balon buydu: kod, boş thinking block'unu text sanıp gösteriyordu.

Eskiden kullanılan bir teknik de artık yok: assistant prefill, yani son mesajı yarım bir assistant mesajı olarak gönderip cevabın başını sen yazmak. Belgeye göre Claude 4.6 ve sonraki modellerde prefill içeren istek 400 alıyor. Çıktının şeklini kontrol etmek istiyorsan altıncı yazıdaki structured outputs'u ya da system prompt'u kullanacaksın.

stop_reason: Her Değerde Ne Yapılır?

Her başarılı cevapta bir stop_reason var ve modelin neden durduğunu söylüyor. Bu bir hata değil; HTTP 200 dönen bir cevabın parçası. "Stop reasons and fallback" sayfasındaki listeyi Bookstore'un ihtiyacına göre şöyle okuyabiliriz:

DeğerNe zamanNe yapılır
end_turnModel cevabı doğal olarak bitirdiCevabı kullan
max_tokensmax_tokens sınırına ulaşıldıCevap yarım; sınırı artır ya da devam ettir
stop_sequencestop_sequences içindeki bir dizi üretildiHangisi olduğunu stop_sequence alanından oku
tool_useModel bir tool çağırmak istiyorTool'u çalıştır, sonucu dön (beşinci yazı)
pause_turnServer tool döngüsü iterasyon sınırına geldiCevabı olduğu gibi geri gönderip devam ettir
refusalModel isteği reddettistop_details'i oku, kullanıcıya sabit cevap ver
model_context_window_exceededCevap context window'u doldurduCevabı yarım say

Gül'ün ilk şikâyeti, yarım kalan cevaplar, büyük ihtimalle max_tokens'tı. Burada Claude'a özgü bir nokta var: thinking token'ları da max_tokens'a dahil. Thinking belgesi bunu açıkça yazıyor: thinking token'ları, metni sana dönmese bile output olarak faturalanıyor ve max_tokens içinde sayılıyor. İlk yazıda özet için verdiğimiz 400 token, düşünen bir model için kısa kalabilir. Destek botunda bu yüzden 2048 verdik.

Refusal'ın şekli dokümandaki örnek yanıtta şöyle:

{
  "id": "msg_01XFUDYJgAACzvnptvVoYEL",
  "type": "message",
  "role": "assistant",
  "model": "claude-fable-5",
  "content": [],
  "stop_reason": "refusal",
  "stop_details": {
    "type": "refusal",
    "category": "cyber",
    "explanation": "This request was declined because it could enable cyber harm."
  },
  "usage": {
    "input_tokens": 412,
    "output_tokens": 0
  }
}

stop_details.category alanı reddin hangi politika alanından geldiğini söylüyor: cyber, bio, frontier_llm, reasoning_extraction ya da general_harms; kategori eşleşmezse null. explanation metni sabit değil, belge onu parse etmeyip olduğu gibi göstermeni öneriyor. Faturalama da kategoriye bağlı: daha hiç çıktı üretilmeden gelen bir red, kategori bio, frontier_llm ya da reasoning_extraction ise faturalanıyor, diğerlerinde faturalanmıyor; stream ortasında gelen red ise o ana kadar akan çıktıyı da faturalıyor. Her durumda istek rate limit'e sayılıyor. reasoning_extraction ilginç bir kategori: modelden akıl yürütmesini cevap metnine yazmasını istersen (JSON'da reasoning alanı gibi) bu red gelebiliyor. Akıl yürütmeyi görmek istiyorsan thinking block'larını oku.

Belge, reddedilen bir isteği başka bir modele tekrar göndermeyi de anlatıyor. Claude API'de fallbacks: "default" ve server-side-fallback-2026-07-01 beta header'ı ile bu, tek istek içinde sunucuda yapılabiliyor. Bookstore'un kullandığı Haiku 5.5'in ise server-side fallback'i yok; belge onun için SDK middleware'i ya da kendi yazacağın retry'ı öneriyor. Destek botunda bunu yapmıyoruz: reddedilen bir destek talebi zaten bir insana gitmeli.

Cevabı Tek Yerde Okumak

Bu kararları her service'te tekrar yazmamak için cevabı okuyan küçük bir yardımcı yazdık:

using Anthropic.Models.Messages;

namespace Bookstore.Api.Ai;

public enum ReplyOutcome
{
    Complete,
    Truncated,
    Refused,
    NeedsTools,
    Paused,
    ContextWindowFull,
    Unknown,
}

public sealed record TokenUsage(long Input, long Output, long CacheRead, long CacheWrite, long Thinking);

public sealed record ClaudeReply(
    ReplyOutcome Outcome,
    string Text,
    string? RefusalCategory,
    TokenUsage Usage,
    IReadOnlyList<ContentBlockParam> AssistantContent);

public static class ClaudeReplyReader
{
    public static ClaudeReply Read(Message message)
    {
        ReplyOutcome outcome = message.StopReason?.Value() switch
        {
            StopReason.EndTurn or StopReason.StopSequence => ReplyOutcome.Complete,
            StopReason.MaxTokens => ReplyOutcome.Truncated,
            StopReason.Refusal => ReplyOutcome.Refused,
            StopReason.ToolUse => ReplyOutcome.NeedsTools,
            StopReason.PauseTurn => ReplyOutcome.Paused,
            StopReason.ModelContextWindowExceeded => ReplyOutcome.ContextWindowFull,
            _ => ReplyOutcome.Unknown,
        };

        // content[0]'ın text olduğunu varsayma: önce thinking ya da tool_use gelebilir.
        string text = string.Concat(message.Content
            .Select(block => block.TryPickText(out TextBlock? textBlock) ? textBlock.Text : string.Empty));

        Usage usage = message.Usage;
        TokenUsage tokens = new(
            usage.InputTokens,
            usage.OutputTokens,
            usage.CacheReadInputTokens ?? 0,
            usage.CacheCreationInputTokens ?? 0,
            usage.OutputTokensDetails?.ThinkingTokens ?? 0);

        // Assistant turn'ünü thinking block'larıyla birlikte değiştirmeden geri gönder.
        List<ContentBlockParam> assistantContent = message.Content
            .Select(block => new ContentBlockParam(block.Json))
            .ToList();

        return new ClaudeReply(outcome, text.Trim(), message.StopDetails?.Category?.Raw(), tokens, assistantContent);
    }
}

Üç noktaya dikkat et. StopReason?.Value() SDK'nın tanımadığı bir değer gelirse exception fırlatmıyor, tanımsız bir enum değeri dönüyor; switch'in _ dalı bu yüzden var. Streaming belgesi de yeni event tiplerinin eklenebileceğini, kodun bilinmeyenleri sorunsuz geçmesi gerektiğini söylüyor; aynı mantık burada da geçerli. Text, block tipine bakılarak birleştiriliyor. Ve assistant turn'ü geçmişe eklenirken cevabın block'ları new ContentBlockParam(block.Json) ile olduğu gibi geri çevriliyor. Bu kalıp belgedeki C# örneğinden geliyor. Thinking belgesi, konuşma boyunca thinking block'larını değiştirmeden geri göndermeyi öneriyor; API hangilerinin gerektiğine kendisi karar veriyor ve yalnız modele gösterdiği block'lar için input token'ı faturalıyor.

Testler belgedeki örnek yanıtları ve onlardan türettiğimiz küçük fixture'ları kullanıyor; API'ye gitmeden okuyucunun her dalını deniyoruz:

using System.Text.Json;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;

namespace Bookstore.Api.Tests;

public sealed class ClaudeReplyReaderTests
{
    private static Message Parse(string json) => JsonSerializer.Deserialize<Message>(json)!;

    [Fact]
    public void Read_DocumentedRefusal_ReturnsRefusedWithCategory()
    {
        // "Refusals and fallback" sayfasındaki refusal örneği.
        Message message = Parse("""
            {"id":"msg_01XFUDYJgAACzvnptvVoYEL","type":"message","role":"assistant","model":"claude-fable-5","content":[],
             "stop_reason":"refusal",
             "stop_details":{"type":"refusal","category":"cyber","explanation":"This request was declined because it could enable cyber harm."},
             "usage":{"input_tokens":412,"output_tokens":0}}
            """);

        ClaudeReply reply = ClaudeReplyReader.Read(message);

        Assert.Equal(ReplyOutcome.Refused, reply.Outcome);
        Assert.Equal("cyber", reply.RefusalCategory);
        Assert.Equal(412, reply.Usage.Input);
        Assert.Empty(reply.Text);
    }

    [Fact]
    public void Read_ThinkingBeforeText_ReturnsOnlyTheText()
    {
        Message message = Parse("""
            {"id":"msg_test","type":"message","role":"assistant","model":"claude-haiku-5-5",
             "content":[{"type":"thinking","thinking":"","signature":"sig"},{"type":"text","text":"Your order ships tomorrow."}],
             "stop_reason":"end_turn","usage":{"input_tokens":120,"output_tokens":45,"output_tokens_details":{"thinking_tokens":30}}}
            """);

        ClaudeReply reply = ClaudeReplyReader.Read(message);

        Assert.Equal(ReplyOutcome.Complete, reply.Outcome);
        Assert.Equal("Your order ships tomorrow.", reply.Text);
        Assert.Equal(30, reply.Usage.Thinking);
        Assert.Equal(2, reply.AssistantContent.Count);
    }

    [Fact]
    public void Read_MaxTokens_ReturnsTruncated()
    {
        Message message = Parse("""
            {"id":"msg_test","type":"message","role":"assistant","model":"claude-haiku-5-5",
             "content":[{"type":"text","text":"Returns are accepted within"}],
             "stop_reason":"max_tokens","usage":{"input_tokens":200,"output_tokens":2048}}
            """);

        Assert.Equal(ReplyOutcome.Truncated, ClaudeReplyReader.Read(message).Outcome);
    }

    [Fact]
    public void Read_UnknownStopReason_ReturnsUnknown()
    {
        Message message = Parse("""
            {"id":"msg_test","type":"message","role":"assistant","model":"claude-haiku-5-5","content":[],
             "stop_reason":"a_future_reason","usage":{"input_tokens":1,"output_tokens":0}}
            """);

        Assert.Equal(ReplyOutcome.Unknown, ClaudeReplyReader.Read(message).Outcome);
    }
}

usage Alanları ve Token Saymak

usage faturanın kaynağı. SDK'daki Usage class'ında şu alanlar var:

AlanAnlamı
input_tokensCache'ten okunmayan ve cache'e yazılmayan input token'ları
output_tokensÜretilen token'lar; thinking dahil
cache_creation_input_tokensCache'e yazılan input token'ları (yedinci yazı)
cache_read_input_tokensCache'ten okunan input token'ları
output_tokens_details.thinking_tokensOutput token'larının ne kadarının thinking olduğu

Thinking için ayrı bir fiyat yok; thinking_tokens yalnız gözlem için bir kırılım. Bir isteğin maliyetini hesaplarken output fiyatını output_tokens ile çarpıyorsun. Fiyatları ve modelleri üçüncü yazıda ele alacağız.

İsteği göndermeden önce token sayısını öğrenmek için client.Messages.CountTokens var. Token counting sayfasına göre bu endpoint ücretsiz, Messages API'den bağımsız kendi rate limit'i var (tier'a göre dakikada 5.000 ile 20.000 istek) ve sonucu bir tahmin. Server tool'lar ve MCP (Model Context Protocol; modele dış tool ve veri kaynaklarını bağlayan protokol) connector içeren istekler ile url ya da file kaynaklı image ve document block'ları sayılamıyor; PDF'i saymak için base64 göndermen gerekiyor. Bir de tokenizer notu var: Claude Opus 4.7 ve sonraki modeller yeni bir tokenizer kullanıyor ve aynı metin eski modellere göre yaklaşık yüzde 30 daha fazla token tutuyor. Eski bir modelle ölçtüğün sayıları yeni modelin maliyetini tahmin etmek için kullanma. Türkçenin İngilizceden neden daha çok token tuttuğunu LLM Temelleri serisinin üçüncü yazısında görmüştük; aynı Bookstore metnini iki dilde CountTokens ile sayıp karşılaştırmak, bütçe planlarken işe yarar bir ölçüm.

Bookstore Destek Botu: SupportChatService

Geçmişi saklayan katman basit bir interface:

using System.Collections.Concurrent;
using Anthropic.Models.Messages;

namespace Bookstore.Api.Support;

public interface IConversationStore
{
    IReadOnlyList<MessageParam> Get(Guid conversationId);
    void Save(Guid conversationId, IReadOnlyList<MessageParam> messages);
}

// Messages API stateless: geçmişin tamamı her istekte gönderilir.
// Production'da veritabanında durur; burada bellekte.
public sealed class InMemoryConversationStore : IConversationStore
{
    private readonly ConcurrentDictionary<Guid, IReadOnlyList<MessageParam>> _conversations = new();

    public IReadOnlyList<MessageParam> Get(Guid conversationId) =>
        _conversations.TryGetValue(conversationId, out IReadOnlyList<MessageParam>? messages) ? messages : [];

    public void Save(Guid conversationId, IReadOnlyList<MessageParam> messages) =>
        _conversations[conversationId] = messages;
}

Service, müşterinin mesajını geçmişe ekliyor, varsa operatör notunu arkasına koyuyor, cevabı okuyup sonucuna göre geçmişi güncelliyor:

using Anthropic;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Support;

public sealed record SupportAnswer(string Text, ReplyOutcome Outcome, TokenUsage Usage);

public sealed class SupportChatService(
    IAnthropicClient claude,
    IConversationStore conversations,
    IOptions<ClaudeOptions> options,
    ILogger<SupportChatService> logger)
{
    public const string SystemPrompt = """
        You are the customer support assistant of Bookstore, an online bookstore.
        Answer in the customer's language. Keep answers under 120 words.
        You can explain orders, shipping, returns and payments in general terms.
        You cannot see order data yet; if the customer needs a specific order checked, say that a team member will follow up.
        Returns are accepted within 14 days of delivery for unused books.
        """;

    // Adaptive thinking ve 120 kelimelik cevap için yer; thinking max_tokens'a dahil.
    public const int MaxTokens = 2048;

    private const string RefusalText = "I can't help with this request here. A member of our support team will contact you.";

    public static MessageCreateParams BuildRequest(IReadOnlyList<MessageParam> history, ClaudeOptions settings) => new()
    {
        Model = settings.Model,
        MaxTokens = MaxTokens,
        System = SystemPrompt,
        Messages = history,
    };

    public async Task<SupportAnswer> SendAsync(
        Guid conversationId, string customerText, string? operatorNote, CancellationToken cancellationToken)
    {
        List<MessageParam> history = [.. conversations.Get(conversationId), new UserMessageParam { Content = customerText }];

        if (operatorNote is not null)
        {
            // User turn'ünün hemen arkasına; önceki turn'ler prompt cache için birebir aynı kalır.
            history.Add(new SystemMessageParam { Content = operatorNote });
        }

        Message message = await claude.Messages.Create(BuildRequest(history, options.Value), cancellationToken);
        ClaudeReply reply = ClaudeReplyReader.Read(message);

        logger.LogInformation(
            "Support {ConversationId}: outcome={Outcome} input={Input} output={Output} thinking={Thinking}",
            conversationId, reply.Outcome, reply.Usage.Input, reply.Usage.Output, reply.Usage.Thinking);

        switch (reply.Outcome)
        {
            case ReplyOutcome.Complete:
            case ReplyOutcome.Truncated:
                history.Add(new AssistantMessageParam { Content = reply.AssistantContent.ToList() });
                conversations.Save(conversationId, history);
                string text = reply.Outcome == ReplyOutcome.Truncated ? reply.Text + " [...]" : reply.Text;
                return new SupportAnswer(text, reply.Outcome, reply.Usage);

            case ReplyOutcome.Refused:
                // Reddedilen turn kaydedilmez; müşteri sabit bir cevap alır ve bir çalışan devralır.
                logger.LogWarning("Support {ConversationId} refused: category={Category}", conversationId, reply.RefusalCategory);
                return new SupportAnswer(RefusalText, reply.Outcome, reply.Usage);

            default:
                logger.LogError("Support {ConversationId}: unexpected outcome {Outcome}", conversationId, reply.Outcome);
                return new SupportAnswer(RefusalText, reply.Outcome, reply.Usage);
        }
    }
}

Burada üç karar var. Cevap tamamlandıysa ya da yarım kaldıysa assistant turn'ü geçmişe ekleniyor; yarım cevap kullanıcıya "[...]" işaretiyle gidiyor ve log'a düşüyor. Refusal'da geçmiş kaydedilmiyor; reddedilen soru bir sonraki isteğe taşınmasın diye. Beklenmeyen bir sonuçta (bu service tool tanımlamadığı için tool_use gelmemeli) müşteriye yine sabit bir cevap gidiyor ve hata log'a düşüyor.

Operatör notuyla giden isteğin gövdesi, RawBodyData'dan (system prompt alanını çıkararak) şöyle görünüyor:

{
  "model": "claude-haiku-5-5",
  "max_tokens": 2048,
  "messages": [
    {
      "role": "user",
      "content": "Where is my order?"
    },
    {
      "role": "assistant",
      "content": "Could you share the order number?"
    },
    {
      "role": "user",
      "content": "It is 1042."
    },
    {
      "role": "system",
      "content": "This customer has an open complaint. Offer a call back."
    }
  ]
}

System mesajı son user turn'ünün hemen arkasında. Bunu da bir testle sabitledik; test, gövdedeki rollerin sırasının user, assistant, user, system olduğunu kontrol ediyor.

Sipariş Faturası PDF'ini Okutmak

Gül'ün sık aldığı bir soru: "Faturada hangi kitaplar var, kargo ücreti ne kadar?" Faturanın PDF'ini bir document block olarak gönderiyoruz:

using Anthropic;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Support;

public sealed record InvoiceAnswer(string Text, ReplyOutcome Outcome, long CountedInputTokens, TokenUsage Usage);

public sealed class InvoiceAnalyzer(IAnthropicClient claude, IOptions<ClaudeOptions> options)
{
    public const long MaxInputTokens = 50_000;

    public static List<ContentBlockParam> BuildContent(byte[] pdf, string question) =>
    [
        // Belge, PDF'i text'ten önce koymayı öneriyor.
        new DocumentBlockParam { Source = new Base64PdfSource { Data = Convert.ToBase64String(pdf) } },
        new TextBlockParam($"This is a Bookstore order invoice. {question} Answer only from the invoice."),
    ];

    public async Task<InvoiceAnswer?> AnalyzeAsync(byte[] pdf, string question, CancellationToken cancellationToken)
    {
        ClaudeOptions settings = options.Value;
        List<ContentBlockParam> content = BuildContent(pdf, question);

        // count_tokens ücretsiz, kendi rate limit'i var; sonuç bir tahmin.
        MessageTokensCount count = await claude.Messages.CountTokens(new MessageCountTokensParams
        {
            Model = settings.Model,
            Messages = [new UserMessageParam { Content = content }],
        }, cancellationToken);

        if (count.InputTokens > MaxInputTokens)
        {
            return null;
        }

        Message message = await claude.Messages.Create(new MessageCreateParams
        {
            Model = settings.Model,
            MaxTokens = settings.MaxTokens,
            Messages = [new UserMessageParam { Content = content }],
        }, cancellationToken);

        ClaudeReply reply = ClaudeReplyReader.Read(message);
        return new InvoiceAnswer(reply.Text, reply.Outcome, count.InputTokens, reply.Usage);
    }
}

PDF support sayfasına göre (15 Kasım 2026) Claude API'de bir isteğin toplam boyutu en fazla 128 MB, sayfa sınırı 600; isteğin context window'u 1M token'ın altındaysa 100 sayfa. Şifreli PDF desteklenmiyor. Her sayfa bir resme çevriliyor ve sayfanın metni resimle birlikte veriliyor, bu yüzden tablo ve grafik içeren faturalar da okunabiliyor. Maliyet de buna göre: belge, sayfa başına tipik olarak 1.500 ile 3.000 text token'ı ve buna ek olarak resim token'ları olduğunu söylüyor. Biz bu yüzden önce CountTokens ile sayıp 50.000 token'ın üstündeki dosyaları reddediyoruz. Belgenin önerisine uyarak PDF'i soru metninden önce koyduk. Bir de API version notu: 2026-10-21'de document block'u bir assistant mesajına koymak 400 veriyor; belgeler her zaman user mesajında gitmeli.

Endpoint ve onu çağıran istek:

namespace Bookstore.Api.Support;

public static class SupportEndpoints
{
    public sealed record CustomerMessage(string Text, string? OperatorNote);

    public static IEndpointRouteBuilder MapSupportEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder support = app.MapGroup("/api/support");

        support.MapPost("/conversations/{conversationId:guid}/messages", async (
            Guid conversationId, CustomerMessage body, SupportChatService chat, CancellationToken ct) =>
            TypedResults.Ok(await chat.SendAsync(conversationId, body.Text, body.OperatorNote, ct)));

        support.MapPost("/invoices/analyze", async Task<IResult> (
            IFormFile invoice, string question, InvoiceAnalyzer analyzer, CancellationToken ct) =>
        {
            if (invoice.ContentType != "application/pdf")
            {
                return TypedResults.BadRequest("Only PDF invoices are accepted.");
            }

            using MemoryStream buffer = new();
            await invoice.CopyToAsync(buffer, ct);
            InvoiceAnswer? answer = await analyzer.AnalyzeAsync(buffer.ToArray(), question, ct);
            return answer is null
                ? TypedResults.Problem("The invoice is too large to analyze.", statusCode: StatusCodes.Status413PayloadTooLarge)
                : TypedResults.Ok(answer);
        }).DisableAntiforgery();

        return app;
    }
}
curl -X POST http://localhost:5231/api/support/invoices/analyze?question=Which%20books%20are%20on%20this%20invoice%3F \
  -F "invoice=@invoice-1042.pdf;type=application/pdf"

Aynı faturayı birçok soruda kullanacaksan her seferinde base64 göndermek yerine Files API ile bir kez yüklemek daha doğru; bunu sekizinci yazıda yapacağız.

Bütün testler:

dotnet test

  Bookstore.Api -> src/Bookstore.Api/bin/Debug/net10.0/Bookstore.Api.dll
  Bookstore.Api.Tests -> tests/Bookstore.Api.Tests/bin/Debug/net10.0/Bookstore.Api.Tests.dll

Passed!  - Failed:     0, Passed:     7, Skipped:     0, Total:     7, Duration: 35 ms - Bookstore.Api.Tests.dll (net10.0)

Üretimde Dikkat

Geçmişin büyümesi. Her turda geçmişin tamamı gidiyor, input token'ı her mesajla artıyor. Uzun konuşmalarda geçmişi sınırlamak (son N turn, ya da eski turn'lerin bir özeti) ve prompt caching'i açmak gerekiyor. usage.input_tokens değerini konuşma başına izlemek, hangi konuşmaların pahalılaştığını gösterir.

max_tokens seçimi. Sınır, cevabın değil thinking ile cevabın toplamının sınırı. Çok düşük tutarsan max_tokens ile kesilen cevaplar artar, çok yüksek tutarsan bir cevap beklediğinden uzun sürebilir. Belge büyük max_tokens değerlerinde streaming'i öneriyor; dördüncü yazının konusu bu.

Refusal'ı ölçmek. Refusal'lar rate limit'e sayılıyor ve bazı kategorilerde faturalanıyor. Kategoriye göre bir sayaç tut; reasoning_extraction artıyorsa sorun prompt'ta, model değiştirerek çözülmez.

Kişisel veri. Fatura PDF'i müşterinin adını ve adresini içeriyor. LLM Uygulama Güvenliği serisinin üçüncü yazısında sağlayıcıya giden veriyi ele almıştık; PDF'i göndermeden önce gerçekten gerekli olup olmadığını düşün ve geçmiş saklarken bu veriyi uzun süre tutma.

Sık Düşülen Tuzaklar

content[0]'ı text sanmak. İlk block thinking, tool_use ya da başka bir tip olabilir. Her zaman tipine bakarak oku.

stop_reason'ı kontrol etmemek. max_tokens ile kesilen bir cevap düzgün bir cevap gibi gösterilir, refusal boş bir balon olarak görünür. Her cevapta stop_reason'a bak.

Thinking'i hesaba katmadan max_tokens vermek. 400 token iki cümle için yeterli görünür, ama düşünen bir model bu sınırın bir kısmını thinking'e harcar.

Yalnız son mesajı göndermek. API stateless; geçmiş gönderilmezse model bir önceki turn'ü bilmez.

Assistant cevabını text'e indirip geri göndermek. Thinking block'larını atıp yalnız text'i saklamak, belgenin önerisine aykırı. Block'ları block.Json ile olduğu gibi geri gönder.

Prefill ile format zorlamak. Güncel modellerde 400 veriyor. Format için structured outputs kullan.

Küçük Bir Deneme

★☆☆ ClaudeReplyReaderTests'e stop_reason değeri pause_turn olan bir fixture ekle ve ReplyOutcome.Paused döndüğünü doğrula.

★★☆ InMemoryConversationStore'u geçmişin yalnız son 10 mesajını tutacak şekilde değiştir. Dikkat: kesilen geçmiş bir assistant mesajıyla başlamamalı ve bir system mesajı kendinden önceki user turn'ünden ayrılmamalı. Bunu bir testle göster.

★★★ Aynı Bookstore iade politikası metnini Türkçe ve İngilizce iki ayrı MessageCountTokensParams ile saydıracak bir endpoint yaz (/api/ai/count-tokens). Kendi key'inle iki dildeki input_tokens farkını ölç ve LLM Temelleri'ndeki yerel tokenizer sonuçlarıyla karşılaştır.

Bu yazıda modeli hep claude-haiku-5-5 olarak sabit tuttuk. Üçüncü yazıda model seçimine geçiyoruz: Opus 5.5, Sonnet 5.5, Haiku 5.5 ve Fable 5.2 arasındaki fiyat ve davranış farkları, output_config.effort ile kalite ve maliyet ayarı, adaptive thinking ve Bookstore'un iade taleplerini sınıflandıran bir görev için küçük bir değerlendirme düzeneği.