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

Claude ile Streaming: CreateStreaming, Event'ler ve ASP.NET Core'da Server-Sent Events

Mert Özen 15 Kas 2026 13 dk 5 görüntülenme
Claude ile Streaming: CreateStreaming, Event'ler ve ASP.NET Core'da Server-Sent Events

Claude cevabını stream etmek için client.Messages.CreateStreaming'den gelen event'leri işlersin; message_delta'daki usage kümülatiftir ve CancellationToken SDK'ya geçirilmelidir. Event sırası, text/thinking/input_json delta'ları, stream içi hatalar, .NET 10 TypedResults.ServerSentEvents ile Bookstore asistanı, belgedeki event'lerle test ve eager_input_streaming'de kısmi JSON'u doğrulamak.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10), Anthropic C# SDK 13.8.0 ve Claude API version 2026-10-21 ile derlenmiş, birim testleri çalıştırılmıştır. Event sırası, delta tipleri ve hata davranışı 15 Kasım 2026'da platform.claude.com'daki streaming, thinking ve fine-grained tool streaming sayfalarından alınmıştır; yazıdaki Server-Sent Events çıktısı, belgedeki örnek event'lerin bizim mapper'ımızdan geçirilip yerel bir ASP.NET Core uygulamasında curl ile alınmış hâlidir. Bu yazıda resmî belgelerden yararlanılmıştır.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için Angular istemcisine bir alışveriş asistanı ekleniyor. İlk sürümde kullanıcı soruyu yazıyor ve cevap gelene kadar boş bir ekrana bakıyor; uzun önerilerde bu bekleme kullanıcının sayfayı yenileyeceği kadar uzuyor. Emre streaming'e geçiyor. Bu kez metin akıyor ama log'daki output token sayıları faturadakinin birkaç katı çıkıyor ve kullanıcı sekmeyi kapattığında sunucu tarafında Claude isteği sonuna kadar devam ediyor.

Tahminin ne? Token sayıları neden şişiyor, ve sekme kapanınca istek neden durmuyor? Bu yazıda Claude'un stream event'lerinin sırasını, SDK'nın CreateStreaming method'unu ve bu event'leri ASP.NET Core 10'un Server-Sent Events desteğiyle tarayıcıya taşımayı göreceğiz. Streaming'in genel mantığını LLM Temelleri serisinin yedinci yazısında, ASP.NET Core'da SSE'yi (Server-Sent Events; sunucunun açık tuttuğu tek bir HTTP cevabıyla istemciye event akıtması) .NET ile Yapay Zeka serisinin dördüncü yazısında anlattık; burada Claude'un event'lerine ve onların eşlenmesine odaklanıyoruz.

Neden Streaming?

İki sebep var. Birincisi algılanan hız: cevabın ilk kelimesi birkaç saniyede ekrana geliyor, gerisi yazıldıkça akıyor. İkincisi teknik bir zorunluluk. Streaming sayfası, büyük max_tokens değerlerinde SDK'nın HTTP timeout'larından kaçınmak için streaming istediğini söylüyor; Errors sayfası da 10 dakikayı aşabilecek isteklerde streaming'i ya da Message Batches'i öneriyor ve bazı ağların uzun süre boş kalan bağlantıyı kopardığını hatırlatıyor. Sonnet 5.5 için effort sayfası, agentic kodlamada max_tokens'u modelin üst sınırı olan 128.000'e çekip cevabı stream etmeyi öneriyor.

Event'leri tek tek işlemen gerekmiyorsa SDK bu işi senin yerine yapabiliyor; stream'i açıp bütün event'leri toplayarak normal bir Message dönüyor:

// Event'leri tek tek işlemek gerekmiyorsa: SDK stream'i toplayıp Message döner.
Message message = await claude.Messages.CreateStreaming(parameters, cancellationToken).Aggregate();

Bookstore'un toplu özet üretimi gibi arka plan işlerinde bu yeterli. Kullanıcının beklediği bir cevapta ise event'leri kendimiz işleyeceğiz.

Event Sırası

Streaming sayfasındaki temel örnekte, "Hello" isteğine gelen ham SSE akışı şöyle (dokümandaki örnek yanıt):

event: message_start
data: {"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-5-5", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}}

event: content_block_start
data: {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}

event: ping
data: {"type": "ping"}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}

event: content_block_delta
data: {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}}

event: content_block_stop
data: {"type": "content_block_stop", "index": 0}

event: message_delta
data: {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null}, "usage": {"output_tokens": 15}}

event: message_stop
data: {"type": "message_stop"}

Akış her zaman aynı iskelete sahip. Önce message_start geliyor; içinde content'i boş bir Message ve input token sayısı var. Sonra her content block için bir content_block_start, sıfır ya da daha fazla content_block_delta ve bir content_block_stop. Her block'un bir index'i var ve bu index, son Message'ın content dizisindeki sırasına karşılık geliyor. Ardından bir ya da daha fazla message_delta geliyor; stop_reason ve güncel usage burada. En sonda message_stop. Arada istediği kadar ping olabilir.

Emre'nin şişen token sayılarının açıklaması message_delta'da. Belge açıkça uyarıyor: bu event'teki usage sayıları kümülatif. Her message_delta'daki output_tokens'ı toplarsan aynı token'ları birden fazla kez sayarsın; son değeri alman gerekiyor. Bu, ölçüm hatası değil, faturalanan sayının yanlış hesaplanması demek.

Delta tipleri block tipine göre değişiyor. Text block'larında text_delta, tool_use block'larında kısmi JSON taşıyan input_json_delta, thinking block'larında thinking_delta ve block kapanmadan hemen önce tek bir signature_delta. Güncel modellerde thinking'in display değeri varsayılan olarak omitted olduğu için thinking block'u açılıyor, boş bir thinking_delta geliyor, ardından imza ve block kapanıyor; metin akışı hemen sonra başlıyor. Bazı block'lar ise hiç delta almadan bütün olarak geliyor: redacted_thinking ve web search sonucu gibi server tool sonuçları.

İki durum daha var. Yoğunlukta stream'in içinde bir error event'i gelebiliyor; belgeye göre bu, stream dışında bir HTTP 529'a karşılık gelen overloaded_error olabiliyor. HTTP status zaten 200 dönmüş olduğu için bu hatayı status kodundan değil stream'in içinden yakalaman gerekiyor. Ve versioning politikası gereği yeni event tipleri eklenebilir; kodun tanımadığı bir event'te çökmemeli, onu atlamalı.

CreateStreaming ve RawMessageStreamEvent

SDK'da streaming method'unun adı Streaming ile bitiyor ve bir IAsyncEnumerable dönüyor. client.Messages.CreateStreaming her event için bir RawMessageStreamEvent veriyor. Bu bir union: TryPickStart, TryPickContentBlockDelta, TryPickDelta, TryPickStop gibi method'larla hangi event olduğunu seçiyorsun. Delta'nın kendisi de bir union: TryPickText, TryPickThinking, TryPickInputJson. Tarayıcıya giden payload'ı küçük tutuyoruz:

using System.Text.Json.Serialization;

namespace Bookstore.Api.Assistant;

// Tarayıcıya giden: her SSE event'i için küçük bir JSON object; null alanlar yazılmaz.
public sealed record AssistantChunk(
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Text = null,
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? StopReason = null,
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] long? InputTokens = null,
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] long? OutputTokens = null,
    [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Error = null);

Mapper, Claude event'lerini dört SSE event'ine indiriyor: text, ilk thinking delta'sında bir kez thinking, sonda done ve hata durumunda error.

using System.Net.ServerSentEvents;
using System.Runtime.CompilerServices;
using Anthropic.Exceptions;
using Anthropic.Models.Messages;

namespace Bookstore.Api.Assistant;

public static class ClaudeStreamMapper
{
    public const string TextEvent = "text";
    public const string ThinkingEvent = "thinking";
    public const string DoneEvent = "done";
    public const string ErrorEvent = "error";

    public static async IAsyncEnumerable<SseItem<AssistantChunk>> MapAsync(
        IAsyncEnumerable<RawMessageStreamEvent> events,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        long inputTokens = 0;
        long outputTokens = 0;
        string? stopReason = null;
        bool thinkingAnnounced = false;

        await using IAsyncEnumerator<RawMessageStreamEvent> enumerator = events.GetAsyncEnumerator(cancellationToken);
        while (true)
        {
            bool hasNext;
            bool interrupted = false;
            try
            {
                hasNext = await enumerator.MoveNextAsync();
            }
            catch (Exception ex) when (ex is AnthropicSseException or AnthropicIOException)
            {
                // HTTP status 200'dü; hata stream'in içinde geldi (overloaded_error) ya da bağlantı koptu.
                hasNext = false;
                interrupted = true;
            }

            if (interrupted)
            {
                yield return new SseItem<AssistantChunk>(new AssistantChunk(Error: "The answer was interrupted."), ErrorEvent);
                yield break;
            }

            if (!hasNext)
            {
                break;
            }

            RawMessageStreamEvent current = enumerator.Current;
            if (current.TryPickStart(out RawMessageStartEvent? start))
            {
                inputTokens = start.Message.Usage.InputTokens;
            }
            else if (current.TryPickContentBlockDelta(out RawContentBlockDeltaEvent? delta))
            {
                if (delta.Delta.TryPickText(out TextDelta? text))
                {
                    yield return new SseItem<AssistantChunk>(new AssistantChunk(Text: text.Text), TextEvent);
                }
                else if (delta.Delta.TryPickThinking(out ThinkingDelta? _) && !thinkingAnnounced)
                {
                    // Thinking metni iletilmez; UI yalnız bir kez "düşünüyor" göstergesi gösterir.
                    thinkingAnnounced = true;
                    yield return new SseItem<AssistantChunk>(new AssistantChunk(), ThinkingEvent);
                }
            }
            else if (current.TryPickDelta(out RawMessageDeltaEvent? messageDelta))
            {
                // message_delta'daki usage kümülatif: son değeri tut, toplama.
                outputTokens = messageDelta.Usage.OutputTokens;
                stopReason = messageDelta.Delta.StopReason?.Raw();
            }
            else if (current.TryPickStop(out RawMessageStopEvent? _))
            {
                yield return new SseItem<AssistantChunk>(
                    new AssistantChunk(StopReason: stopReason, InputTokens: inputTokens, OutputTokens: outputTokens), DoneEvent);
            }

            // ping ve bu SDK sürümünün tanımadığı event tipleri atlanır.
        }
    }
}

Bu kodda dikkat edilecek üç yer var. Hata yakalama MoveNextAsync çağrısının çevresinde, çünkü C#'ta catch içinden yield return yapılamıyor; hatayı bir bayrağa yazıp döngünün dışında error event'ini gönderiyoruz. C# SDK sayfasına göre AnthropicSseException, HTTP cevabı başarıyla başladıktan sonra SSE akışında oluşan hataları temsil ediyor; ağ kopmaları ise AnthropicIOException olarak geliyor. OperationCanceledException bilerek yakalanmıyor: tarayıcı gittiyse yapılacak bir şey yok. İkinci nokta message_delta: output token sayısını topluyor değil, üzerine yazıyoruz. Üçüncüsü thinking: thinking metnini tarayıcıya göndermiyoruz, yalnız bir kez "düşünüyor" göstergesi için event üretiyoruz.

04-stream-akisi-tr.png

Bookstore.Api'de /api/assistant/stream

Asistan, katalogdaki kitapları system prompt'a koyup soruyu stream ediyor:

using System.Net.ServerSentEvents;
using Anthropic;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Bookstore.Api.Catalog;
using Microsoft.Extensions.Options;

namespace Bookstore.Api.Assistant;

public sealed class ShoppingAssistant(IAnthropicClient claude, IBookRepository books, IOptions<ClaudeOptions> options)
{
    public MessageCreateParams BuildRequest(string question) => new()
    {
        Model = options.Value.Model,
        MaxTokens = 4096,
        System = $"""
            You are the shopping assistant of Bookstore, an online bookstore. Answer in the customer's language.
            Recommend only books from this catalog, with their prices in TRY:
            {string.Join("\n", books.Search(string.Empty, null).Select(b => $"- {b.Title} by {b.Author}, {b.Price} TRY"))}
            """,
        Messages = [new UserMessageParam { Content = question }],
    };

    public IAsyncEnumerable<SseItem<AssistantChunk>> StreamAsync(string question, CancellationToken cancellationToken) =>
        ClaudeStreamMapper.MapAsync(claude.Messages.CreateStreaming(BuildRequest(question), cancellationToken), cancellationToken);
}

Endpoint, .NET 10'un TypedResults.ServerSentEvents sonucunu kullanıyor:

namespace Bookstore.Api.Assistant;

public static class AssistantEndpoints
{
    public sealed record AssistantQuestion(string Question);

    public static IEndpointRouteBuilder MapAssistantEndpoints(this IEndpointRouteBuilder app)
    {
        // Tarayıcı bağlantıyı kapatınca isteğin CancellationToken'ı tetiklenir; Claude stream'i de durur.
        app.MapPost("/api/assistant/stream", (AssistantQuestion body, ShoppingAssistant assistant, CancellationToken ct) =>
            TypedResults.ServerSentEvents(assistant.StreamAsync(body.Question, ct)));

        return app;
    }
}

CancellationToken iki işi birden yapıyor. Minimal API'de bu parametre isteğin RequestAborted token'ına bağlanıyor; tarayıcı bağlantıyı kapatınca tetikleniyor. Aynı token CreateStreaming'e de geçtiği için Claude'a giden HTTP isteği de iptal ediliyor. Emre'nin ikinci sorunu buydu: token'ı SDK'ya geçirmeyen bir kod, kullanıcı gittikten sonra da üretilen ve faturalanan token'ları okumaya devam eder.

Endpoint'i POST yaptık, çünkü soruyu URL'e koymak istemiyoruz; URL'ler access log'larına düşüyor. Bunun bir sonucu var: tarayıcının EventSource API'si yalnız GET destekliyor, bu yüzden Angular tarafında fetch ile gelen akışı okumak gerekiyor. Angular tarafının ayrıntıları ilerideki Angular ile AI Arayüzleri serisinin konusu.

Akışı API'ye Gitmeden Denemek

Mapper'ı belgedeki örnek event'lerle test ediyoruz. Event'ler JSON'dan RawMessageStreamEvent'e deserialize ediliyor ve sahte bir IAsyncEnumerable üzerinden mapper'a veriliyor:

using System.Net.ServerSentEvents;
using System.Text.Json;
using Anthropic.Exceptions;
using Anthropic.Models.Messages;
using Bookstore.Api.Assistant;

namespace Bookstore.Api.Tests;

public sealed class ClaudeStreamMapperTests
{
    // Streaming sayfasındaki "Basic streaming request" cevabı (ping hariç).
    private static readonly string[] DocumentedEvents =
    [
        """{"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-opus-5-5", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 25, "output_tokens": 1}}}""",
        """{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}""",
        """{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "Hello"}}""",
        """{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "!"}}""",
        """{"type": "content_block_stop", "index": 0}""",
        """{"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence":null}, "usage": {"output_tokens": 15}}""",
        """{"type": "message_stop"}""",
    ];

    private static async IAsyncEnumerable<RawMessageStreamEvent> Replay(IEnumerable<string> lines, Exception? failAtEnd = null)
    {
        foreach (string line in lines)
        {
            await Task.Yield();
            yield return JsonSerializer.Deserialize<RawMessageStreamEvent>(line)!;
        }

        if (failAtEnd is not null)
        {
            throw failAtEnd;
        }
    }

    [Fact]
    public async Task MapAsync_DocumentedStream_YieldsTextThenDone()
    {
        List<SseItem<AssistantChunk>> items = [];
        await foreach (SseItem<AssistantChunk> item in ClaudeStreamMapper.MapAsync(Replay(DocumentedEvents)))
        {
            items.Add(item);
        }

        Assert.Equal(["text", "text", "done"], items.Select(i => i.EventType));
        Assert.Equal("Hello!", string.Concat(items.Select(i => i.Data.Text)));
        AssistantChunk done = items[^1].Data;
        Assert.Equal("end_turn", done.StopReason);
        Assert.Equal(25, done.InputTokens);
        Assert.Equal(15, done.OutputTokens);
    }

    [Fact]
    public async Task MapAsync_ErrorInsideTheStream_YieldsAnErrorEvent()
    {
        List<SseItem<AssistantChunk>> items = [];
        await foreach (SseItem<AssistantChunk> item in ClaudeStreamMapper.MapAsync(
            Replay(DocumentedEvents[..3], new AnthropicSseException("overloaded_error"))))
        {
            items.Add(item);
        }

        Assert.Equal(["text", "error"], items.Select(i => i.EventType));
    }
}
Passed!  - Failed:     0, Passed:    18, Skipped:     0, Total:    18, Duration: 44 ms - Bookstore.Api.Tests.dll (net10.0)

Tarayıcının ne alacağını görmek için de küçük bir yerel uygulama hazırladık. Aynı event'leri 50 ms arayla mapper'a veriyor ve sonucu gerçek TypedResults.ServerSentEvents ile, 127.0.0.1 üzerinde yayınlıyor:

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

// Belgedeki "Basic streaming request" event'lerini ClaudeStreamMapper'dan geçirir; API çağrısı yok.
string[] lines = File.ReadAllLines(Path.Combine(AppContext.BaseDirectory, "events.jsonl"));
WebApplication app = WebApplication.CreateBuilder(args).Build();
app.MapPost("/api/assistant/stream", (CancellationToken ct) =>
    TypedResults.ServerSentEvents(ClaudeStreamMapper.MapAsync(Replay(lines), ct)));
app.Run("http://127.0.0.1:52304");

static async IAsyncEnumerable<RawMessageStreamEvent> Replay(string[] lines)
{
    foreach (string line in lines)
    {
        await Task.Delay(50);
        yield return JsonSerializer.Deserialize<RawMessageStreamEvent>(line)!;
    }
}
curl -N -i -X POST http://127.0.0.1:52304/api/assistant/stream
HTTP/1.1 200 OK
Content-Type: text/event-stream
Server: Kestrel
Cache-Control: no-cache,no-store
Content-Encoding: identity
Pragma: no-cache
Transfer-Encoding: chunked

event: text
data: {"text":"Hello"}

event: text
data: {"text":"!"}

event: done
data: {"stopReason":"end_turn","inputTokens":25,"outputTokens":15}

Çıktıda iki şey işimize yarıyor. Payload'lar küçük: null alanlar JsonIgnore sayesinde yazılmıyor. Ve ASP.NET Core'un SSE sonucu kendi header'larını ekliyor: Content-Type: text/event-stream, Cache-Control: no-cache,no-store ve Content-Encoding: identity. Son header, response compression middleware'inin bu cevabı sıkıştırmaya çalışmasını engelliyor.

Tool Use ile Streaming: eager_input_streaming

Beşinci yazıda tool'ları ekleyeceğiz; streaming tarafında bilmen gereken bir ayrıntı var. Tool çağrısının input'u input_json_delta event'leriyle, kısmi JSON string'leri olarak geliyor. Belgeye göre varsayılan davranışta API her key ve value'yu tamamlanana kadar tamponluyor ve doğruluyor, sonra parçalar hâlinde gönderiyor. Bir tool tanımında eager_input_streaming: true (SDK'da Tool.EagerInputStreaming) verirsen fine-grained tool streaming açılıyor: parçalar model yazdıkça geliyor, ama API onları tamponlamıyor ve doğrulamıyor. Belge bu durumda geçersiz JSON alabileceğini, max_tokens ile kesilen bir cevabın parametreyi ortasından bölebileceğini açıkça yazıyor.

Kural basit: parçaları block index'ine göre biriktir, content_block_stop gelince parse et, parse edilemiyorsa tool'u çalıştırma.

using System.Text;
using System.Text.Json;

namespace Bookstore.Api.Assistant;

// input_json_delta parçalarını content block index'ine göre toplar, block bitince parse eder.
public sealed class ToolInputAccumulator
{
    private readonly Dictionary<long, StringBuilder> _buffers = [];

    public void Append(long index, string partialJson)
    {
        if (!_buffers.TryGetValue(index, out StringBuilder? buffer))
        {
            buffer = new StringBuilder();
            _buffers[index] = buffer;
        }

        buffer.Append(partialJson);
    }

    // Parse edilemeyen input'la tool çalıştırma: eager_input_streaming'de API input'u doğrulamaz.
    public bool TryComplete(long index, out JsonElement input)
    {
        input = default;
        if (!_buffers.Remove(index, out StringBuilder? buffer))
        {
            return false;
        }

        try
        {
            using JsonDocument document = JsonDocument.Parse(buffer.ToString());
            input = document.RootElement.Clone();
            return input.ValueKind == JsonValueKind.Object;
        }
        catch (JsonException)
        {
            return false;
        }
    }
}

Belgedeki örnek parçalarla ({"location": "San Fra ve ncisco, CA"}) yazdığımız test birleşen input'u doğruluyor, yarım kalan input'ta TryComplete false dönüyor. Kısmi JSON'u ekranda göstermek bir şey, onunla bir sipariş iptal eden tool'u çalıştırmak başka bir şey.

Üretimde Dikkat

Ara katmanlardaki tamponlama. Stream'in tarayıcıya parça parça ulaşması için aradaki her katmanın cevabı tamponlamadan geçirmesi gerekiyor. ASP.NET Core'un SSE sonucu kendi tarafında bunu sağlıyor; önünde bir reverse proxy varsa onun ayarına da bakmak gerekiyor (nginx'te proxy_buffering ya da X-Accel-Buffering: no header'ı, IIS (Windows üzerinde çalışan Microsoft web sunucusu) ve Azure tarafında response buffering ve dinamik sıkıştırma ayarları). Belirti hep aynı: geliştirme ortamında akan metin, production'da tek parça gelir.

Yarıda kesilen stream. Ağ koparsa ya da error event'i gelirse elinde yarım bir cevap var. Streaming sayfası Claude 4.6 ve sonraki modeller için yolu tarif ediyor: alınan kısmı sakla, yeni bir user mesajında bu kısmı verip modelden kaldığı yerden devam etmesini iste. Tool use ve thinking block'ları kısmen kurtarılamıyor; yalnız son text block'undan devam edilebiliyor. Bookstore asistanında bunu yapmıyoruz: kullanıcıya "cevap kesildi" gösterip tekrar sormasına izin veriyoruz.

Stream ortasında refusal. Streaming refusal sayfasına göre stream stop_reason: "refusal" ile bitiyor ve stop_details message_delta ile geliyor. Bu noktaya kadar akmış metin, refusals sayfasının tarifine göre eksik sayılıp atılmalı; ayrıca o ana kadar akan çıktı faturalanıyor. Bizim done event'imiz stopReason'ı taşıyor; istemci refusal görürse ekrandaki yarım metni kaldırmalı.

Usage'ı sonda oku. Doğru output token sayısı ancak son message_delta geldiğinde belli. Thinking token kırılımı da belgeye göre yalnız son message_delta event'inde var. Maliyet log'unu done anında yaz, delta'larda değil.

Sık Düşülen Tuzaklar

message_delta usage'ını toplamak. Sayılar kümülatif; toplarsan faturadan büyük bir sayı loglarsın.

Her delta'da arayüzü yeniden çizmek. Model saniyede çok sayıda küçük delta gönderebiliyor. İstemcide metni bir buffer'a ekleyip ekranı belli aralıklarla güncellemek, her delta'da bütün sohbeti yeniden render etmekten çok daha ucuz.

Stream bitmeden usage okumak. message_start'taki output sayısı başlangıç değeri, gerçek değil.

CancellationToken'ı SDK'ya geçirmemek. Kullanıcı gittiği hâlde Claude cevabı üretmeye ve sen ödemeye devam edersin.

Bilinmeyen event'te exception fırlatmak. Yeni event tipleri eklenebilir; tanımadığını atla.

Kısmi tool input'unu doğrulamadan kullanmak. eager_input_streaming açıkken JSON geçersiz olabilir; parse edilmeden hiçbir tool çalışmamalı.

Küçük Bir Deneme

★☆☆ Testlere belgedeki thinking delta örneğini ("type": "thinking_delta") içeren bir event ekle ve mapper'ın tam olarak bir tane thinking event'i ürettiğini doğrula.

★★☆ Mapper'a küçük text delta'larını birleştirip en fazla 100 ms'de bir gönderen bir seçenek ekle. Replay uygulamasında event'ler arasındaki gecikmeyi 10 ms'ye düşürüp çıktıdaki event sayısının nasıl değiştiğini gör.

★★★ done event'ine stopReason == "refusal" olduğunda stop_details.category'yi de ekle (RawMessageDeltaEvent.Delta.StopDetails). Belgedeki refusal örneğinden bir stream fixture'ı hazırlayıp testini yaz.

Asistan şimdilik yalnız system prompt'taki dört kitabı biliyor ve hiçbir siparişi göremiyor. Beşinci yazıda tool use'a geçiyoruz: search_books ve get_order_status tool'ları, tool loop'u, strict mode, parallel tool çağrıları ve web search gibi server tool'lar.