Mert Özen Her Satırda Daha İleri
Yapay Zeka AI Agent'ları ve MCP — #07

Human-in-the-loop: Tool Onayı, Checkpoint ve Uzun Süren Agent'lar

Mert Özen 8 Kas 2026 10 dk 1 görüntülenme
Human-in-the-loop: Tool Onayı, Checkpoint ve Uzun Süren Agent'lar

Yazma yapan tool'ları ApprovalRequiredAIFunction ile onaya bağlıyor, onay saatler sürdüğünde agent session'ını JSON'a yazıp başka bir process'te devam ediyoruz. Gerekçeli ret, onay cevabındaki argümanı değiştirme denemesi (approval-response binding) ve RequestPort ile workflow'u checkpoint'ten sürdürmek gerçek koşularla.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2, Microsoft.Agents.AI 1.28.0 ve Microsoft.Agents.AI.Workflows 1.28.0 ile derlenip yerel Ollama 0.34.4 üzerindeki qwen3:4b modeliyle denenmiştir.

Önce Tahmin Et

Agent, müşterinin isteği üzerine cancel_order(orderId: 1044) çağırmak istiyor ve çağrı onaya düşüyor. Destek ekibinden biri ekranda "1044 iptal edilsin mi?" sorusunu görüp onaylıyor. Ama onay cevabı sunucuya giderken (bir bug, bir proxy ya da kötü niyetli bir istemci yüzünden) argüman 1043 olarak değişiyor; 1043 ise kargoya verilmiş bir sipariş.

Ne olur? Onay 1043 için de geçerli sayılır mı, framework argümanı yeniden kontrol eder mi, yoksa hiçbir şey olmaz mı? Bir de şunu düşün: onayı veren kişi iki saat sonra cevap verirse, agent'ın o iki saat boyunca bellekte beklemesi gerekir mi?

Hangi Aksiyonlar Onay İster?

Human-in-the-loop, agent'ın bir noktada durup bir insanın kararını beklemesi demek. Her tool'a onay koymak kolay, ama işe yaramaz: on onay penceresinden dokuzu "kitap ara" diyorsa kullanıcı onuncuyu okumadan onaylar. Buna approval fatigue (onay yorgunluğu) deniyor ve onayın güvenlik değerini sıfırlıyor.

Benim kullandığım ayrım şu. Okuma tool'ları (search_books, get_order_status) onaysız çalışır. Geri alınabilen, düşük etkili yazma işlemleri (add_to_cart) çoğu zaman onaysız çalışır ama loglanır. Şu üç sınıf ise her zaman onaya bağlanır: para hareketi (iade, ödeme, indirim), geri alınamayan silme ya da iptal ve dışarıya giden mesaj (müşteriye e-posta, tedarikçiye sipariş). Ortak özellikleri, yanlış çalıştıklarında modelin "pardon" demesinin bir şeyi düzeltmemesi.

Onayın yetkilendirmenin yerine geçmediğini de baştan söyleyelim. cancel_order yalnızca oturumdaki müşterinin siparişlerini iptal edebilmeli; bunu tool'un kendisi kontrol eder, onay ekranı değil. LLM (Large Language Model) Temelleri 12'de bu ayrımı "yetkiyi modelden değil session'dan al" diye konuşmuştuk.

ApprovalRequiredAIFunction ve ToolApprovalRequestContent

Onay gerektiren tool'u ApprovalRequiredAIFunction ile sarıyoruz. Bu sarmalayıcı tool'un davranışını değiştirmiyor; yalnızca function invocation katmanına "bunu çalıştırmadan önce sor" diyor:

public AITool[] All() =>
[
    AIFunctionFactory.Create(GetOrderStatus, "get_order_status"),
    // Veri değiştiriyor: her çağrıyı bir insan onaylamalı
    new ApprovalRequiredAIFunction(AIFunctionFactory.Create(CancelOrder, "cancel_order")),
];

LLM Temelleri 12'de aynı mekanizmayı düz IChatClient ile kullanmıştık, .NET ile Yapay Zeka serisinin 6. yazısı da onu bir HTTP (HyperText Transfer Protocol) API'sine (Application Programming Interface) taşıyor. Burada Microsoft Agent Framework'ün (MAF) ChatClientAgent'ı ve onun session'ıyla çalışıyoruz, çünkü asıl soru onayın uzun sürmesi:

AgentSession session = await agent.CreateSessionAsync();
AgentResponse response = await agent.RunAsync("Please cancel my order 1044.", session);
foreach (ToolApprovalRequestContent req in response.Messages.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>())
{
    FunctionCallContent call = (FunctionCallContent)req.ToolCall;
    // Onay ekranı argümanları birebir göstermeli
    Console.WriteLine($"approval needed: {call.Name}({JsonSerializer.Serialize(call.Arguments)}) request={req.RequestId}");
}

// Bu thread'de bekleyen kimse yok: session'ı sakla ve çık
JsonElement saved = await agent.SerializeSessionAsync(session);
await File.WriteAllTextAsync(SessionFile, saved.GetRawText());
Console.WriteLine($"session saved, {new FileInfo(SessionFile).Length} bytes");

Gerçek çıktı:

approval needed: cancel_order({"orderId":1044}) request=ficc_eb5be626
session saved, 12193 bytes

RunAsync bir cevapla döndü ama cevap metin değil: içinde bir ToolApprovalRequestContent var. Tool çalışmadı. RequestId (ficc_ önekli) bu isteğin kimliği, ToolCall da onaylanacak çağrının kendisi: ad ve argümanlar. Onay ekranı tam olarak bu ikisini göstermeli; "Siparişi iptal etmek istiyor musunuz?" gibi argümansız bir soru, kullanıcının neyi onayladığını bilmemesi demek.

Kullanıcı Saatler Sonra Cevap Verir: Session'ı Saklamak

Gerçek hayatta onay bir saniyede gelmez. Destek ekibindeki kişi öğle arasında olabilir, müşteri e-postadaki linke ertesi gün tıklayabilir. Bu sürede process'i ayakta tutup bir await'te bekletmek, deploy'da, restart'ta ya da ölçeklemede her şeyi kaybetmek demek. Bu yüzden yukarıdaki kod session'ı SerializeSessionAsync ile JSON (JavaScript Object Notation) olarak yazıp çıkıyor. Örnekte bir dosya; production'da bir veritabanı satırı ya da bir blob.

Onay geldiğinde, büyük ihtimalle başka bir process'te, session geri yükleniyor ve bekleyen istek history'den bulunuyor:

// Saatler sonra, belki başka bir sunucuda
JsonElement saved = JsonDocument.Parse(await File.ReadAllTextAsync(SessionFile)).RootElement;
AgentSession session = await agent.DeserializeSessionAsync(saved);
session.TryGetInMemoryChatHistory(out List<ChatMessage>? history);
ToolApprovalRequestContent pending = history!.SelectMany(m => m.Contents).OfType<ToolApprovalRequestContent>().Last();
FunctionCallContent call = (FunctionCallContent)pending.ToolCall;
Console.WriteLine($"restored: {history!.Count} messages, pending {call.Name}({JsonSerializer.Serialize(call.Arguments)})");

AIContent answer = mode switch
{
    "approve" => pending.CreateResponse(approved: true),
    "reject" => pending.CreateResponse(approved: false, reason: "Support must call the customer before cancelling. Do not retry."),
    // Kişiye gösterilenden farklı argümanlar taşıyan bir cevap
    _ => new ToolApprovalResponseContent(pending.RequestId, true,
        new FunctionCallContent(call.CallId, call.Name, new Dictionary<string, object?> { ["orderId"] = 1043 })),
};
AgentResponse response = await agent.RunAsync(new ChatMessage(ChatRole.User, [answer]), session);

Onaylanan koşunun çıktısı (yeni bir process'te):

restored: 2 messages, pending cancel_order({"orderId":1044})
  >>> CancelOrder EXECUTED for 1044
agent: Order 1044 has been cancelled.
orders: 1043=Shipped, 1044=Cancelled, 1040=Delivered

İlk process kapanmıştı; ikinci process session JSON'ından history'yi ve bekleyen isteği kurdu, onay cevabıyla RunAsync çağrıldı, tool ancak o zaman çalıştı ve model sonucu cümleye döktü. Arada geçen sürenin ne olduğu önemli değil; agent hiçbir şeyi bellekte tutmuyor.

Reddi Modele Anlamlı İletmek

Ret de bir cevap ve modelin onu doğru yorumlaması gerekiyor. CreateResponse(approved: false)'u gerekçesiz gönderirsen model nedenini bilmez; ya aynı çağrıyı tekrar dener ya da kullanıcıya uydurma bir sebep söyler (LLM Temelleri 12'deki denemede bunu gördük). MAF örnekleri de reddederken modele "tekrar deneme" demeyi öneriyor. Gerekçeli ret koşusu:

restored: 2 messages, pending cancel_order({"orderId":1044})
agent: Support must be contacted before cancelling order 1044. I cannot proceed with this request.
orders: 1043=Shipped, 1044=Processing, 1040=Delivered

CancelOrder hiç çalışmadı (EXECUTED satırı yok), sipariş hâlâ Processing ve model reddin gerekçesini kullanıcıya doğru aktardı. Gerekçeyi yazarken iki kitleyi düşün: model (ne yapmamalı) ve son kullanıcı (neden olmadı). Reddi veren kişinin iç notunu ("müşteri şüpheli") olduğu gibi gerekçeye koymak, onu modelin ağzından müşteriye söyletmek olabilir.

Onaylanan = Çalıştırılan

Şimdi tahmin sorusuna dönelim. Üçüncü koşuda bekleyen isteğin RequestId'siyle, ama argümanı 1043 olan bir ToolApprovalResponseContent gönderdik; kişiye 1044 gösterilmişti. Gerçek sonuç:

restored: 4 messages, pending cancel_order({"orderId":1044})
  >>> CancelOrder EXECUTED for 1044
agent: Your order 1044 has been successfully cancelled.
orders: 1043=Shipped, 1044=Cancelled, 1040=Delivered

CancelOrder çalıştı, ama 1043 için değil 1044 için. Cevaptaki değiştirilmiş argüman yok sayıldı ve session'da kayıtlı bekleyen çağrı, yani kişiye gösterilen çağrı çalıştırıldı. Microsoft Learn'deki tool approval sayfası bu davranışı approval-response binding olarak anlatıyor: varsayılan olarak bir onay cevabı yalnızca aynı AgentSession'da kayıtlı bekleyen bir istekle eşleşirse bir tool çağrısını yetkilendiriyor; MAF 1.28 paketinde bunun için ApprovalResponseBindingChatClient adlı bir katman var. Bu korumayı kapatmak için ayrı bir opt-out (DisableApprovalResponseBinding) bulunuyor; kapatırsan aynı korumayı kendin kurmalısın.

Kural basit: onaylanan şey ile çalıştırılan şey aynı olmalı. Bunu framework'e bırakmak yetmez, kendi sınırında da garanti et. Onay ekranına giden çağrının adını ve argümanlarını sunucuda sakla (ya da imzala), onay geldiğinde istemciden gelen argümanları değil, saklananları kullan. Onay isteğine bir son kullanma süresi koy: üç gün önce "evet" denmiş bir iade, bugün değişmiş bir tutarla çalışmamalı.

Workflow Checkpoint'leri: RequestPort ve Superstep Sınırı

Agent seviyesinde session'ı saklamak, tek bir sohbetin beklemesi için yeterli. Birden çok adımı olan bir süreçte (iade tutarını hesapla, yöneticiye sor, iadeyi yap) bekleme noktası workflow'un ortasında. Önceki yazıda gördüğümüz MAF workflow'larında bunun için RequestPort var: workflow o noktada durur, dışarıya bir istek yayar ve cevap gelene kadar ilerlemez.

public static Workflow Build(BookstoreData data)
{
    // Adım 1: iade tutarını kod hesaplar
    ExecutorBinding prepare = ((Func<int, RefundCase>)(orderId =>
    {
        Order order = data.Orders.Single(o => o.Id == orderId);
        decimal amount = order.BookIds.Sum(id => data.Books.Single(b => b.Id == id).Price);
        return new RefundCase(orderId, amount, "Damaged cover reported by customer");
    })).BindAsExecutor("prepare");

    // Adım 2: bir yönetici cevap verene kadar workflow burada durur
    RequestPort approval = RequestPort.Create<RefundCase, ManagerDecision>("manager-approval");

    // Adım 3: yalnızca yöneticinin cevabıyla çalışır
    ExecutorBinding execute = ((Func<ManagerDecision, string>)(d =>
        d.Approved ? $"Refund issued. Manager note: {d.Note}" : $"Refund declined. Manager note: {d.Note}")).BindAsExecutor("execute");

    return new WorkflowBuilder(prepare)
        .AddEdge(prepare, approval)
        .AddEdge(approval, execute)
        .WithOutputFrom(execute)
        .Build();
}

Bekleme saatler sürebileceği için workflow'un durumunu diske yazıyoruz. MAF workflow'ları superstep sınırlarında checkpoint alır; FileSystemJsonCheckpointStore bunları JSON dosyası olarak bir klasöre yazar. İlk process isteği görünce çıkıyor, ikinci process en son checkpoint'ten devam ediyor:

DirectoryInfo dir = Directory.CreateDirectory("checkpoints");
using FileSystemJsonCheckpointStore store = new(dir);
CheckpointManager checkpoints = CheckpointManager.CreateJson(store);
Workflow workflow = RefundWorkflow.Build(data);
const string RunId = "refund-1040";

StreamingRun run = mode == "refund-start"
    ? await InProcessExecution.RunStreamingAsync(workflow, 1040, checkpoints, RunId)
    : await InProcessExecution.ResumeStreamingAsync(workflow,
        (await checkpoints.GetLatestCheckpointAsync(RunId))!, checkpoints);

await using (run)
{
    await foreach (WorkflowEvent evt in run.WatchStreamAsync())
    {
        switch (evt)
        {
            case SuperStepCompletedEvent step when step.CompletionInfo?.Checkpoint is { } cp:
                Console.WriteLine($"checkpoint saved: {cp.CheckpointId}");
                break;
            case RequestInfoEvent request when mode == "refund-start":
                RefundCase c = request.Request.Data.As<RefundCase>()!;
                Console.WriteLine($"waiting for manager: order {c.OrderId}, amount {c.Amount}, {c.Reason}");
                Console.WriteLine("process exits; nothing is kept in memory");
                return;
            case RequestInfoEvent request:
                Console.WriteLine($"resumed with pending request: {request.Request.PortInfo.PortId}");
                await run.SendResponseAsync(request.Request.CreateResponse(new ManagerDecision(true, "Approved, photo checked.")));
                break;
            case WorkflowOutputEvent output:
                Console.WriteLine($"output: {output.Data}");
                break;
        }
    }
}

İki ayrı process'te çalıştırınca:

$ dotnet run -- refund-start
checkpoint saved: 9d7bbacd7620435ea5feaa520cd6555e
waiting for manager: order 1040, amount 35,00, Damaged cover reported by customer
process exits; nothing is kept in memory

$ dotnet run -- refund-resume
resumed with pending request: manager-approval
checkpoint saved: dce0cdc33427489481fd3292ab5d13ae
output: Refund issued. Manager note: Approved, photo checked.
checkpoint saved: 092b424e34734d82958ac721b28bb3d4

İkinci process GetLatestCheckpointAsync("refund-1040") ile en son checkpoint'i buldu, workflow'u o noktadan kurdu ve bekleyen manager-approval isteğini yeniden yayınladı. Yöneticinin cevabı gönderildi, execute çalıştı. checkpoints klasöründe dört JSON dosyası ve bir index.jsonl oluştu; her kayıt parentCheckpointId ile bir öncekine bağlanıyor, yani bir koşunun geçmişi bir zincir olarak duruyor.

Checkpoint dosyasının içine bakınca önemli bir ayrıntı görülüyor: executor tipleri assembly adı ve sürümüyle yazılmış (Bookstore.Agents.RefundCase, p07, Version=1.0.0.0 gibi). Bekleyen bir workflow varken uygulamanın yeni bir sürümünü deploy edersen, eski checkpoint yeni koddaki tiplerle eşleşmeyebilir. Uzun bekleyen workflow'larda record'ları ve executor id'lerini bir API sözleşmesi gibi yönet.

Üretimde Dikkat

Dosya sistemi örnek için. Birden çok instance'ın aynı checkpoint'e erişebilmesi için veritabanı ya da blob storage tabanlı bir ICheckpointStore kullan; agent session JSON'ı için de aynısı geçerli. İkisinde de müşteri verisi var: şifreleme, saklama süresi ve silme politikası gerekiyor.

Resume işlemini idempotent yap. Onay cevabı iki kez gelebilir (çift tıklama, retry). İkinci cevap aynı checkpoint'ten ikinci bir koşu başlatmamalı; bekleyen isteği tüketildi diye işaretle ve tool'un kendisini de idempotent tasarla (aynı sipariş iki kez iptal edilmez, aynı iade iki kez yapılmaz).

Audit log tut. Kim, ne zaman, hangi argümanlarla neyi onayladı ya da reddetti; ret gerekçesi neydi. Bir agent'ın yaptığı her yan etkili işlemin arkasında bir insan kararı varsa, o kararın izi de olmalı.

Onay isteğinin bir son kullanma süresi olsun ve süresi dolan istekler otomatik reddedilsin. Bekleyen istekleri listeleyen bir ekran, unutulan iadelerin ve iptallerin sessizce birikmesini engeller.

MAF 1.28'de ToolApprovalAgent ve harness agent "her zaman onayla" gibi kalıcı kurallar ve otomatik onay kuralları ekleyebiliyor. Microsoft Learn'deki tool approval sayfası, Python tarafı için bu kuralların session state'te kaldığını ve aynı adı taşıyan başka bir tool'la da eşleşebileceğini, bu yüzden tool seti değişince kaydedilen kuralların gözden geçirilmesi gerektiğini yazıyor. Kalıcı onayı rahatlık için açıyorsan bu notu oku.

Sık Düşülen Tuzaklar

Onay ekranında argümanları göstermemek. "İşlemi onaylıyor musunuz?" sorusu, kullanıcının neyi onayladığını bilmemesi demek. Tool adı, bütün argümanlar ve mümkünse etkisi (iade tutarı, iptal edilecek siparişin içeriği) gösterilmeli.

Onay sonrası argümanın değişmesi. İstemciden gelen onay cevabındaki argümanlara güvenmek, onay mekanizmasını delmek demek. Onaylanan çağrı sunucuda saklanır ve aynen çalıştırılır.

Her şeyi onaya bağlamak. Okuma tool'larını da onaya bağlarsan kullanıcı her şeyi okumadan onaylamaya başlar ve asıl önemli onay da değersizleşir.

Bekleyen işi bellekte tutmak. Console.ReadLine ya da bir TaskCompletionSource ile saatlerce beklemek demo'da çalışır, production'da ilk deploy'da kaybolur. Session'ı ya da checkpoint'i dışarıya yaz.

Gerekçesiz ret. Model nedenini bilmezse tekrar dener ya da uydurur. Ret her zaman modele ne yapmaması gerektiğini söyleyen bir gerekçeyle gider.

Küçük Bir Deneme

★☆☆ "Please cancel my orders 1043 and 1044." isteğiyle başlat. Kaç onay isteği geliyor? Birini onaylayıp diğerini reddet ve orders satırını kontrol et; 1043 zaten Shipped olduğu için tool ne döndürüyor?

★★☆ Session JSON'ını onay isteği oluştuktan sonra kaydet, sonra bekleyen istek için iki kez onay gönder (iki ayrı process'te). CancelOrder kaç kez çalışıyor? Çalışıyorsa bunu engelleyen bir "consumed requests" tablosu ekle.

★★★ RefundCase record'una yeni bir property ekle, uygulamayı yeniden derle ve eski checkpoint'ten refund-resume çalıştır. Ne oluyor? Ardından executor id'sini değiştirip aynı denemeyi tekrarla ve hangisinin daha kırılgan olduğunu not et.

Blok A burada bitiyor: agent loop'u, tool tasarımı, context yönetimi, workflow ve multi-agent pattern'leri ve insan onayı. Bir sonraki yazıda, şimdiye kadar C# method'u olarak yazdığımız tool'ları her host'un kullanabileceği standart bir biçime taşıyan protokole geçiyoruz: MCP (Model Context Protocol) nedir, host, client ve server rolleri ve 2026-07-28 spec'i.