Agent Loop'unu Sıfırdan Yazmak: Plan, Tool Çağrısı, Gözlem ve Durma Koşulu
Bir agent loop'u framework'süz C# ile yazıyoruz: tool sonucunu ve hatasını modele geri vermek, adım/token/zaman bütçesi, tekrar eden çağrıyı engellemek ve her adımı trace'lemek. Gerçek koşuda token'ın turdan tura nasıl biriktiğini görüp aynı işi FunctionInvokingChatClient ve MAF ChatClientAgent ile yapıyor, neyin soyutlandığını karşılaştırıyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2 ve Microsoft.Agents.AI 1.28.0 ile derlenip yerel Ollama 0.34.4 üzerindeki qwen3:4b modeliyle denenmiştir.
Önce Tahmin Et
Müşteri şunu yazıyor: "Add The Last Lighthouse to my cart. If it is not available, add another novel by the same author." Katalogda The Last Lighthouse'un stoku 0, aynı yazarın stokta olan tek diğer romanı The Quiet Orchard. Agent'ın elinde dört tool var: search_books, check_stock, get_order_status ve add_to_cart.
İki şeyi tahmin et. Birincisi, bu iş kaç model çağrısında biter? İkincisi, her çağrıda modele giden token sayısı turdan tura sabit mi kalır, artar mı? Bu yazıda loop'u framework'süz yazıp her adımı loglayacağız; cevap trace'te.
Bir Agent Loop'unun Parçaları
Önceki yazıda agent'ı "modelin bir döngü içinde tool çağırıp sonucu gözlemlediği ve sonraki adımı kendisinin seçtiği sistem" diye tanımladık. Bu tanımın kodda karşılığı dört parça:

Plan: model, history'ye bakıp ne yapacağına karar verir. qwen3:4b gibi reasoning modellerinde bu karar ayrı bir reasoning metni olarak da gelir. Tool çağrısı: model bir veya daha fazla FunctionCallContent döndürür. Gözlem: senin kodun tool'u çalıştırır ve sonucu (hata dahil) tool rolünde bir mesajla history'ye ekler. Durma koşulu: model tool istemeden metin döndürdüğünde iş biter; ama bu tek koşul olamaz, çünkü model hiç durmayabilir.
Tek bir tool turunu LLM (Large Language Model) Temelleri serisinin 11. yazısında elle yazmıştık. Bu yazıdaki fark, turların sayısının belli olmaması ve bu belirsizliği güvenli hale getiren sınırlar.
Framework'süz Loop: AgentLoop
Bütün loop tek bir class. Girdi olarak bir IChatClient, tool listesi ve sınırlar alıyor:
public sealed record AgentLimits(int MaxSteps = 6, long MaxTokens = 20_000, TimeSpan? MaxDuration = null);
public sealed record AgentResult(string StopReason, string? Answer, int Steps, long Tokens);
public sealed class AgentLoop(IChatClient client, IReadOnlyList<AIFunction> tools, AgentLimits limits)
{
public async Task<AgentResult> RunAsync(string instructions, string goal, CancellationToken ct = default)
{
List<ChatMessage> history = [new(ChatRole.System, instructions), new(ChatRole.User, goal)];
ChatOptions options = new() { Temperature = 0, Seed = 42, Tools = [.. tools] };
Dictionary<string, int> seenCalls = [];
Stopwatch clock = Stopwatch.StartNew();
long tokens = 0;
for (int step = 1; step <= limits.MaxSteps; step++)
{
Stopwatch stepClock = Stopwatch.StartNew();
ChatResponse response = await client.GetResponseAsync(history, options, ct);
history.AddMessages(response);
tokens += response.Usage?.TotalTokenCount ?? 0;
List<FunctionCallContent> calls = response.Messages
.SelectMany(m => m.Contents).OfType<FunctionCallContent>().ToList();
Trace(step, "model", $"{calls.Count} tool call(s), {response.Usage?.TotalTokenCount} tokens", stepClock.Elapsed);
// Durma koşulu 1: model tool istemeden cevap verdi
if (calls.Count == 0)
{
return new AgentResult("done", response.Text, step, tokens);
}
foreach (FunctionCallContent call in calls)
{
Stopwatch toolClock = Stopwatch.StartNew();
object? result = await InvokeAsync(call, seenCalls, ct);
history.Add(new ChatMessage(ChatRole.Tool, [new FunctionResultContent(call.CallId, result)]));
Trace(step, call.Name, $"{JsonSerializer.Serialize(call.Arguments)} -> {JsonSerializer.Serialize(result)}", toolClock.Elapsed);
}
// Durma koşulu 2 ve 3: bütçeler
if (tokens > limits.MaxTokens)
{
return new AgentResult("token budget exceeded", null, step, tokens);
}
if (limits.MaxDuration is { } max && clock.Elapsed > max)
{
return new AgentResult("time budget exceeded", null, step, tokens);
}
}
return new AgentResult("max steps reached", null, limits.MaxSteps, tokens);
}
}
Trace, adım numarasını, adı, süreyi ve ayrıntıyı tek satırda yazan bir Console.WriteLine. Üç ayrıntıya dikkat. history.AddMessages(response) modelin cevabını (tool çağrıları dahil) history'ye ekliyor; bunu unutursan sonraki turda model kendi çağrısını görmez ve tool sonucunu neyle eşleştireceğini bilemez. Her sonuç call.CallId ile eşleşiyor; model aynı turda birden fazla tool isteyebilir ve hangi sonucun hangi çağrıya ait olduğu bu id'den anlaşılır. AgentResult da yalnızca cevabı değil, döngünün neden durduğunu taşıyor. "max steps reached" ile "done" aynı şey değil ve çağıran kodun bu farkı bilmesi gerekiyor: biri işi bitirdi, diğeri yarıda bıraktı.
Tool Sonucu ve Hatası Modele Geri Döner
Loop'un en kritik method'u tool çağrısını çalıştıran yer:
private async Task<object?> InvokeAsync(FunctionCallContent call, Dictionary<string, int> seenCalls, CancellationToken ct)
{
string key = $"{call.Name}:{JsonSerializer.Serialize(call.Arguments)}";
int count = seenCalls[key] = seenCalls.GetValueOrDefault(key) + 1;
if (count > 2)
{
return new { error = $"You already called {call.Name} with these arguments {count - 1} times. Use the earlier result or try something else." };
}
AIFunction? tool = tools.FirstOrDefault(t => t.Name == call.Name);
if (tool is null)
{
return new { error = $"Unknown tool '{call.Name}'. Available: {string.Join(", ", tools.Select(t => t.Name))}." };
}
try
{
return await tool.InvokeAsync(new AIFunctionArguments(call.Arguments), ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Model hatayı görmeli, yoksa başardığını sanabilir
return new { error = $"{call.Name} failed: {ex.Message}" };
}
}
Burada üç karar var. Birincisi, exception loop'u öldürmüyor; error field'lı bir object olarak modele dönüyor. Model hatayı görürse başka bir argümanla tekrar deneyebilir ya da kullanıcıya dürüstçe "yapamadım" diyebilir. Hatayı yutup null dönersen model çoğu zaman işin yapıldığını varsayar. İkincisi, bilinmeyen tool adı da bir hata sonucu; küçük modeller bazen var olmayan bir tool adı uydurur ve ona mevcut adların listesini vermek kendini düzeltmesine yetiyor. Üçüncüsü, aynı tool'un aynı argümanla üçüncü kez çağrılması engelleniyor. Tekrar eden çağrı, küçük modellerde en sık gördüğüm sonsuz döngü biçimi: sonuç değişmiyor, model aynı soruyu sormaya devam ediyor.
Bir uyarı: ex.Message'ı modele vermek, exception mesajının kullanıcıya kadar ulaşabileceği anlamına geliyor. Veritabanı bağlantı cümlesi ya da iç sunucu adı içeren bir mesajı modele verme; production'da tool'un kendisi anlamlı, güvenli bir hata mesajı üretmeli. Bunu üçüncü yazıda tool tasarımının parçası olarak konuşacağız.
Durma Koşulları: Cevap, Adım, Token, Zaman
Loop dört yoldan biter. Model tool istemeden metin döndürürse iş bitmiştir. MaxSteps aşılırsa loop zorla durur. Toplam token MaxTokens'ı geçerse durur; bu, bulut sağlayıcıda doğrudan para bütçesidir. MaxDuration geçerse durur; kullanıcı bir HTTP (HyperText Transfer Protocol) isteğinin ucunda bekliyorsa bu sınır gateway timeout'undan kısa olmalı.
Token sınırını neden adım sınırına ek olarak koyduğumuzu trace gösterecek. Bir de CancellationToken var: kullanıcı sayfayı kapatırsa model çağrısını ve tool'ları iptal edebilmek için her await'e aktarılıyor. Dikkat: OperationCanceledException'ı tool hatası gibi yakalayıp modele vermiyoruz, iptal iptaldir.
Loop'u çağıran kod kısa:
AgentLoop loop = new(ollama, toolSet, new AgentLimits(MaxSteps: 6, MaxTokens: 20_000, MaxDuration: TimeSpan.FromMinutes(4)));
AgentResult result = await loop.RunAsync(Instructions, Goal);
Console.WriteLine($"stop={result.StopReason}, steps={result.Steps}, tokens={result.Tokens}");
Console.WriteLine(result.Answer);
Trace: Her Adımı Görmek
Her adımın tool adı, argümanı, sonucu, süresi ve token sayısı loglanıyor. Gerçek koşunun çıktısı (search_books sonucundaki beş kitabın listesini kısalttım):
[step 1] model 100,8s 1 tool call(s), 2336 tokens
[step 1] search_books 0,0s {"query":"The Last Lighthouse"} -> [{"id":2,"title":"The Last Lighthouse","author":"Mira Holt","genre":"novel","price":18.90},{"id":7,"title":"The Quiet Orchard","author":"Mira Holt","genre":"novel","price":16.50},...]
[step 2] model 13,9s 1 tool call(s), 2746 tokens
[step 2] check_stock 0,0s {"bookId":2} -> {"id":2,"title":"The Last Lighthouse","inStock":0}
[step 3] model 17,6s 1 tool call(s), 3115 tokens
[step 3] check_stock 0,0s {"bookId":7} -> {"id":7,"title":"The Quiet Orchard","inStock":15}
[step 4] model 12,2s 1 tool call(s), 3414 tokens
[step 4] add_to_cart 0,0s {"bookId":7,"quantity":1} -> {"added":"The Quiet Orchard","quantity":1,"cartTotal":16.50}
[step 5] model 114,7s 0 tool call(s), 3758 tokens
stop=done, steps=5, tokens=15369
The book "The Quiet Orchard" by Mira Holt has been successfully added to your cart (1 copy). Your cart now totals $16.50.
cart: The Quiet Orchard x1
Tahmin sorusunun cevabı: beş model çağrısı, dört tool çağrısı. Model önce aradı, The Last Lighthouse'un stokunu kontrol etti, 0 görünce aynı yazarın diğer romanına geçti, onun stokunu kontrol etti ve ekledi. Mantıklı bir yol; ama kodda hiçbir yerde yazmıyor, model bu koşuda böyle seçti.
Token sütununa bak: 2336, 2746, 3115, 3414, 3758. Her tur bir öncekinden büyük, çünkü her model çağrısı bütün history'yi yeniden gönderiyor: system mesajı, kullanıcı mesajı, dört tool tanımı ve o ana kadarki bütün çağrılarla sonuçlar. Toplam 15.369 token'ın yarısından fazlası aynı içeriğin tekrar tekrar gönderilmesi. Adım sınırı tek başına bu yüzden yetmiyor: büyük tool sonuçları döndüren bir agent, beş adımda da bütçeyi patlatabilir. Dördüncü yazıdaki context engineering'in çıkış noktası tam bu tablo.
Süre sütunu da bir şey söylüyor: tool'lar milisaniyenin altında, zamanın tamamı modelde. Birinci ve beşinci adımdaki 100 saniyenin üzerindeki değerler bu makinedeki Ollama'yı başka işlerle paylaşmamızdan kaynaklanan bekleme; trace olmasa "agent yavaş" deyip geçerdik, trace ile yavaşlığın modelde, hatta model kuyruğunda olduğunu görüyoruz.
Aynı Loop, FunctionInvokingChatClient ile
Microsoft.Extensions.AI'daki FunctionInvokingChatClient bu loop'un production'a hazır hali. LLM Temelleri 12'de UseFunctionInvocation ile tanışmıştık; şimdi elle yazdığımız sınırların karşılıklarına bakalım:
IChatClient client = new ChatClientBuilder(ollama)
.UseFunctionInvocation(configure: f =>
{
f.MaximumIterationsPerRequest = 6;
f.MaximumConsecutiveErrorsPerRequest = 2;
})
.Build();
ChatOptions options = new() { Temperature = 0, Seed = 42, Instructions = Instructions, Tools = [.. toolSet] };
ChatResponse response = await client.GetResponseAsync(Goal, options);
Paketin XML (Extensible Markup Language) dokümantasyonuna göre varsayılanlar şöyle: MaximumIterationsPerRequest 40 (bizim MaxSteps), MaximumConsecutiveErrorsPerRequest 3 (art arda bu kadar tool hatasından sonra exception çağırana fırlatılır), IncludeDetailedErrors false (modele exception mesajı yerine genel bir hata metni gider; bizim loop'taki ex.Message sızıntısının güvenli varsayılanı), AllowConcurrentInvocation false (aynı turdaki birden fazla çağrı sırayla çalışır). Token ve zaman bütçesi yok; zamanı CancellationToken ile, token'ı da kendi yazacağın bir DelegatingChatClient ile sen eklersin. Aynı tool'u aynı argümanla tekrar çağırma kontrolü de yok.
Çıktı:
call search_books {"query":"The Last Lighthouse"}
result [{"id":2,"title":"The Last Lighthouse","author":"Mira Holt","genre":"novel","price":18.90},{"id":7,"title":"The Quiet Orchard","author":"Mira Holt","genre":"novel","price":16.50},...]
call check_stock {"bookId":2}
result {"id":2,"title":"The Last Lighthouse","inStock":0}
call check_stock {"bookId":7}
result {"id":7,"title":"The Quiet Orchard","inStock":15}
call add_to_cart {"bookId":7,"quantity":1}
result {"added":"The Quiet Orchard","quantity":1,"cartTotal":16.50}
messages=9, tokens=15729
The book "The Last Lighthouse" is currently out of stock. I've added **"The Quiet Orchard"** by Mira Holt (a novel) to your cart. Your cart now contains 1 copy of this book at $16.50. Let me know if you'd like to make any other changes!
cart: The Quiet Orchard x1
Aynı dört tool çağrısı, aynı sırayla. response.Messages tek bir model cevabı değil, döngünün ürettiği bütün mesajlar: dört tool çağrısı, dört sonuç ve son cevap, toplam dokuz mesaj. response.Usage da bütün turların toplamı (15.729 token); elle yazdığımız loop'taki 15.369 ile neredeyse aynı, fark ufak metin farklarından geliyor. Trace'i burada response.Messages'tan sonradan çıkardık; adım başına süreyi görmek istiyorsan ilk yazıdaki MeteringChatClient'ı UseFunctionInvocation'ın altına koyman gerekir.
Aynı Loop, ChatClientAgent ile
Microsoft Agent Framework (MAF) bir adım daha yukarı çıkıyor. AsAIAgent, bir IChatClient'ı talimatı, adı ve tool'larıyla birlikte bir ChatClientAgent'a çeviriyor; loop için içeride yine FunctionInvokingChatClient kullanılıyor:
AIAgent agent = ollama.AsAIAgent(
instructions: Instructions,
name: "ShoppingAssistant",
tools: [.. toolSet]);
AgentSession session = await agent.CreateSessionAsync();
AgentResponse first = await agent.RunAsync(Goal, session);
Console.WriteLine(first.Text);
AgentResponse second = await agent.RunAsync("What is my cart total now?", session);
Console.WriteLine(second.Text);
Your cart now includes **The Quiet Orchard** (1 copy) for a total of **$16.50**. Since "The Last Lighthouse" was unavailable, we added this alternative novel by Mira Holt instead! 😊 Let me know if you'd like to make any other changes.
Your cart total is currently **$16.50** (1 copy of *The Quiet Orchard*). Let me know if you'd like to adjust anything! 😊
cart: The Quiet Orchard x1
İlk cevap aynı işi yaptı. İkinci soruda ("What is my cart total now?") model hiçbir tool çağırmadan doğru tutarı söyledi, çünkü AgentSession önceki turun mesajlarını, tool sonuçları dahil, saklıyor ve ikinci RunAsync'te modele yeniden gönderiyor. Bu kullanışlı ama bir uyarı taşıyor: model sepetin güncel halini değil, history'deki eski tool sonucunu okudu. Sepet arada başka bir kanaldan değiştiyse cevap yanlış olurdu. Değişebilen veriyi history'den değil tool'dan okutmak istiyorsan bunu talimatta açıkça söylemen gerekir; context'te neyin tutulacağı konusu dördüncü yazının konusu.
Üç sürümü yan yana koyunca neyin soyutlandığı netleşiyor. FunctionInvokingChatClient loop'u, tool çağırmayı, hata yönetimini ve adım sınırını alıyor. ChatClientAgent bunlara ek olarak talimatı, history'yi (AgentSession), session'ın serialize edilmesini ve middleware/context provider noktalarını alıyor. Elinde kalan ve hiçbir katmanın senin yerine yapmadığı şeyler: token ve zaman bütçesi, tekrar eden çağrı kontrolü, iş kuralları ve trace'in içeriği. MAF'ın kendisini .NET ile Yapay Zeka serisinin 11. ve 12. yazıları anlatıyor; bu seride onu agent mühendisliğinin aracı olarak kullanacağız.
Üretimde Dikkat
Bütçeyi istek başına değil, iş başına düşün. Kullanıcının tek bir "sepete ekle" isteği beş model çağrısı ve 15 bin token sürdü. Günde bin istek alan bir mağazada bu, ortalama sayılar üzerinden aylık maliyetin ve kapasitenin hesaplanması demek. En kötü durum da önemli: MaxSteps 6 ise en kötü durum yaklaşık 6 x 4.000 token, yani bütçe sınırına yakın.
Trace'i yapılandırılmış logla. Buradaki Console.WriteLine anlatım için; gerçek uygulamada her adım bir log kaydı ya da OpenTelemetry span'i olmalı: adım numarası, tool adı, argümanların özeti, süre, token ve durma sebebi. Agent'ın davranışı koşudan koşuya değiştiği için hatayı ancak o koşunun trace'iyle yeniden kurabilirsin. Argümanları loglarken kişisel veriye dikkat et; adres ya da e-posta taşıyan argümanları maskele.
Durma sebebini kullanıcıya dürüst yansıt. "max steps reached" ile biten bir koşuda modelin son metni yoksa ya da yarım kalmışsa, kullanıcıya "işin bir kısmını yapamadım" demek, modelin son cümlesini olduğu gibi göstermekten iyidir.
Yan etkili tool'larda tekrar çağrıya özellikle dikkat et. add_to_cart iki kez çalışırsa sepette iki kitap olur. Tekrar kontrolü okuma tool'ları için bir optimizasyon, yazma tool'ları için bir doğruluk meselesi; idempotency'yi üçüncü yazıda tool tarafında ele alacağız.
Sık Düşülen Tuzaklar
Sonsuz döngü. Tek durma koşulu "model tool istemedi" olan bir loop, modelin her turda bir tool daha istediği koşuda hiç bitmez. Adım, token ve zaman sınırının üçü de olmalı.
Aynı tool'u aynı argümanla tekrar çağırma. Model sonucu beğenmediğinde ya da history'deki sonucu gözden kaçırdığında aynı çağrıyı tekrarlar. Sayaç tutmak ve üçüncü denemede açıklayıcı bir hata dönmek basit ve etkili.
Hatayı yutmak. Tool exception fırlattığında null ya da boş string dönen bir loop, modele "iş tamam" sinyali verir. Model de çoğu zaman kullanıcıya başardığını söyler. Hata her zaman modele okunabilir bir tool sonucu biçiminde dönmeli.
Tool çağrısını history'ye eklemeyi unutmak. Yalnızca tool sonucunu ekleyip modelin FunctionCallContent içeren mesajını eklemezsen bazı sağlayıcılar isteği reddeder, bazıları da sonucu hiçbir çağrıyla eşleştiremez.
Framework varsayılanını bilmemek. MaximumIterationsPerRequest'in varsayılanı 40. Küçük bir agent için bu çok yüksek bir tavan; senaryona göre bilinçli bir sayı seç.
Küçük Bir Deneme
★☆☆ AgentLimits'i MaxSteps: 2 ile çalıştır. StopReason ne oluyor, sepette ne var? Kullanıcıya gösterilecek mesajı bu durum için kendin yaz.
★★☆ CheckStock'a ilk çağrıda TimeoutException("warehouse service timed out") fırlatan bir sayaç ekle. Model hatayı gördükten sonra ne yapıyor: tekrar mı deniyor, başka bir yol mu seçiyor, kullanıcıya mı söylüyor? Aynı denemeyi FunctionInvokingChatClient ile IncludeDetailedErrors false ve true iken tekrarla.
★★★ Loop'a MaxTokens'a ek olarak "tool sonucu 2.000 karakterden uzunsa kes ve sonuna kaç karakter kesildiğini yaz" kuralı ekle. search_books'un maxResults'ını 50'ye çıkar ve token sütununun nasıl değiştiğini karşılaştır.
Loop'un kendisi basit; agent'ın kalitesini belirleyen şey modelin önüne koyduğun tool'lar. Bir sonraki yazıda tool tasarımına geçiyoruz: isim, açıklama, şema, hata mesajı ve tool sayısı; aynı isteği on iki CRUD (Create, Read, Update, Delete) tool'uyla ve iki iş odaklı tool'la çalıştırıp farkı ölçeceğiz.