Agent Workflow Pattern'leri: Prompt Chaining, Routing, Parallelization, Orchestrator-Workers ve Evaluator-Optimizer
Beş workflow pattern'ini Bookstore üzerinde koşturuyoruz: prompt chaining ve orchestrator-workers düz C# ile, routing, fan-out/fan-in ve writer-critic döngüsü MAF WorkflowBuilder ile. Küçük modelin "must read" ifadesini onayladığı, orchestrator'ın bütçeyi aştığı gerçek koşular, kod kapıları ve workflow'u AsAIAgent ile agent gibi dışarı açmak.
Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.Extensions.AI 10.11.0, OllamaSharp 5.5.2 ve Microsoft.Agents.AI.Workflows 1.28.0 ile derlenip yerel Ollama 0.34.4 üzerindeki gemma3:4b ve qwen3:4b modelleriyle denenmiştir.
Önce Tahmin Et
Bookstore'un pazarlama ekibi bir kitap tanıtım metni için iki kontrol istiyor: yazarın adı geçmeli ve "best book ever", "must read" gibi abartılı ifadeler olmamalı. İki seçeneğin var. Birincisi, metni bir modele verip "abartılı ifade var mı, yazarın adı geçiyor mu" diye sormak. İkincisi, yazar adını string.Contains ile kontrol edip yalnızca üslup gibi gerçekten yoruma açık kısmı modele bırakmak.
Tahminin ne: 4 milyar parametrelik bir model, içinde "the best book ever written" ve "a must read" geçen bir metni "abartısız" diye onaylar mı? Bu yazıda beş workflow pattern'ini Bookstore üzerinde koşturacağız ve bu sorunun cevabı iki ayrı yerde karşımıza çıkacak.
Beş Pattern, Bir Bakışta
İlk yazıda Anthropic'in "Building effective agents" yazısındaki ayrımı kullandık: workflow'da yolu kod çizer. Aynı yazı, production'da en sık gördükleri beş workflow pattern'ini sayıyor. Bu yazıda her birini bir Bookstore işiyle eşleştirdik:

Prompt chaining işi sıralı adımlara böler ve adımlar arasına kodla bir kapı koyar. Routing girdiyi sınıflandırıp uzman bir yola gönderir. Parallelization aynı anda birden fazla bağımsız işi çalıştırır (sectioning) ya da aynı işi birkaç kez çalıştırıp oylar (voting). Orchestrator-workers'ta bir LLM (Large Language Model) işi çalışma anında parçalara ayırır ve worker'lara dağıtır. Evaluator-optimizer'da bir model üretir, diğeri değerlendirir ve döngü kabul edilene kadar sürer.
Bunların bir kısmını düz C# ile, bir kısmını Microsoft Agent Framework'ün (MAF) WorkflowBuilder'ıyla yazacağız. Ayrım bilinçli: graph'a, event'lere, checkpoint'e ihtiyacın yoksa framework'e taşımak sadece kod ekler.
Prompt Chaining: Düz C# ve Bir Kapı
"Winter in Kyoto" için önce üç satış noktası çıkarıp sonra onlardan tek cümlelik bir reklam yazıyoruz. İki adım arasında kod bir kapı koyuyor:
// Adımlar arasında bir kapıyla prompt chaining
public static async Task<string> ChainAsync(IChatClient small, Book book, string synopsis)
{
ChatResponse<SellingPoints> step1 = await small.GetResponseAsync<SellingPoints>(
$"List three selling points of this book. Synopsis: {synopsis}", new ChatOptions { Temperature = 0, Seed = 42 });
string[] points = step1.Result.Points;
// Kapı: zincirin devam edip etmeyeceğine kod karar verir
if (points.Length != 3 || points.Any(p => p.Contains('$')))
{
return $"stopped at gate: {points.Length} points";
}
ChatResponse step2 = await small.GetResponseAsync(
$"Write one sentence ad for '{book.Title}' by {book.Author} using: {string.Join("; ", points)}",
new ChatOptions { Temperature = 0, Seed = 42 });
return $"points: {string.Join(" | ", points)}{Environment.NewLine}ad: {step2.Text.Trim()}";
}
gemma3:4b ile gerçek çıktı (satış noktalarını kısalttım):
points: **Exploration of Family Relationships:** The core of the story revolves around a strained sibling bond ... | **Atmospheric Setting & Cultural Immersion:** Set in the beautiful and serene city of Kyoto ... | **Gentle & Reflective Pace:** The synopsis suggests a slow-burn narrative ...
ad: Lose yourself in “Winter in Kyoto” by Ken Sato – a poignant exploration of family relationships set against the breathtaking beauty of a snowy Kyoto, inviting you into a gentle, reflective story of forgiveness and cultural immersion through the quiet rhythm of letter writing.
small: model calls=2, input tokens=208, output tokens=196, time=8,6s
İki çağrı, 8,6 saniye. Kapı geçti, ama "kısa satış noktası" isteğine rağmen her madde markdown başlıklı uzun bir paragraf. Kapı tam bu tür şeyleri yakalamak için var: madde başına kelime sınırı eklemek bir satır kod. Bu pattern için framework'e gerek yok; iki await ve bir if.
Routing: WorkflowBuilder, Switch ve Ucuz Model
Destek mesajlarını üç yola ayırıyoruz. Sınıflandırmayı reasoning yapmayan, hızlı gemma3:4b yapıyor; tool gerektiren öneri işi qwen3:4b'ye gidiyor; sipariş durumu ve şikâyet hiç modele gitmiyor:
// Ucuz model yalnızca soruyu etiketliyor
ExecutorBinding classify = BindAsync<string, RoutedQuestion>("classify", async (question, ct) =>
{
ChatResponse<QuestionKind> label = await small.GetResponseAsync<QuestionKind>(
$"Label this Bookstore customer message: {question}",
new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
return new RoutedQuestion(label.Result, question);
});
// Sipariş durumu için model hiç gerekmiyor
ExecutorBinding orderStatus = Bind<RoutedQuestion, string>("order-status", q =>
{
Match m = Regex.Match(q.Text, @"\d{4}");
Order? order = data.Orders.FirstOrDefault(o => m.Success && o.Id == int.Parse(m.Value) && o.CustomerId == 7);
return order is null ? "I could not find that order." : $"Order {order.Id} is {order.Status}.";
});
// Açık uçlu istek tool'larıyla büyük modele gidiyor
ExecutorBinding recommend = BindAsync<RoutedQuestion, string>("recommend", async (q, ct) =>
{
ChatResponse r = await large.GetResponseAsync(q.Text, new ChatOptions
{
Temperature = 0,
Seed = 42,
Instructions = "Recommend one book from the Bookstore catalog in two sentences. Use search_books.",
Tools = [tools.ReadTools()[0]],
}, ct);
return r.Text.Trim();
});
// Şikâyetler modele değil insanlara gidiyor
ExecutorBinding complaint = Bind<RoutedQuestion, string>("complaint",
q => $"Ticket T-{Interlocked.Increment(ref ticketNo)} opened for the support team.");
return builder
.AddSwitch(classify, sw => sw
.AddCase<RoutedQuestion>(q => q!.Kind == QuestionKind.OrderStatus, orderStatus)
.AddCase<RoutedQuestion>(q => q!.Kind == QuestionKind.Recommendation, recommend)
.WithDefault(complaint))
.WithOutputFrom(orderStatus, recommend, complaint)
.WithName("BookstoreRouting")
.Build();
Bind ve BindAsync, bir lambda'yı BindAsExecutor(id) ile executor'a çeviren iki satırlık yardımcı method'lar. Executor, workflow graph'ının düğümü: bir tip alır, bir tip döndürür. Edge'ler düğümleri bağlar; AddSwitch ilk eşleşen case'e, hiçbiri eşleşmezse WithDefault'a gider. Build(), graph'ı çalıştırmadan önce doğrular (bağlı executor'ların tip uyumu, start executor'dan ulaşılabilirlik). WorkflowVisualizer.ToMermaidString(workflow) graph'ı Mermaid olarak verir; bu, kodun çizdiği yolun gerçek çıktısı:
flowchart TD
classify["classify (Start)"];
order_status["order-status"];
recommend["recommend"];
complaint["complaint"];
classify --> order_status;
classify --> recommend;
classify --> complaint;
Üç mesajın koşusu:
[classify] completed
[order-status] completed
output: Order 1043 is Shipped.
small: 1 call(s), large: 0 call(s), 0,9s
[classify] completed
[recommend] completed
output: The Quiet Orchard by Mira Holt is a thoughtful novel that matches your preference for quiet storytelling. It's currently priced at $16.50.
small: 1 call(s), large: 2 call(s), 437,9s
[classify] completed
[complaint] completed
output: Ticket T-1001 opened for the support team.
small: 1 call(s), large: 0 call(s), 0,8s
Üç mesajın ikisi bir saniyenin altında bitti. Öneri yolundaki 437 saniye, qwen3:4b'nin paylaşılan Ollama'da sıra beklemesi ve reasoning süresi; aynı makinede gemma3:4b'nin sınıflandırması 0,9 saniye. Routing'in asıl kazancı bu: pahalı yol yalnızca gerçekten gereken mesajlara uygulanıyor. Bedeli de sınıflandırıcının hatası; yanlış etiketlenen bir şikâyet öneri yoluna giderse müşteri kitap önerisi alır. Bu yüzden şikâyeti WithDefault'a koyduk: emin olunmayan her şey insana gidiyor.
Parallelization: Fan-out ve Fan-in
Bir tanıtım metnini üç bağımsız kontrolden geçiriyoruz: olgular (yazar adı var mı, fiyat yazılmış mı), uzunluk ve üslup. İlk ikisi kod, üçüncüsü gemma3:4b:
ExecutorBinding tone = ((Func<string, CancellationToken, ValueTask<CheckResult>>)(async (text, ct) =>
{
ChatResponse<StyleVerdict> v = await small.GetResponseAsync<StyleVerdict>(
$"Approve this book blurb only if it makes no exaggerated claims such as 'best ever' or 'must read': {text}",
new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
return new CheckResult("tone", v.Result.Approved, v.Result.Approved ? "ok" : v.Result.Feedback);
})).BindAsExecutor("tone");
ExecutorBinding report = new ReportExecutor(expected: 3);
return new WorkflowBuilder(start)
.AddFanOutEdge(start, [facts, length, tone])
.AddFanInBarrierEdge([facts, length, tone], report)
.WithOutputFrom(report)
.Build();
AddFanOutEdge aynı mesajı üç executor'a gönderir; AddFanInBarrierEdge üçü de bitene kadar bekler ve sonuçları ReportExecutor'a teslim eder. MAF workflow'ları Pregel'den türetilmiş bir BSP (Bulk Synchronous Parallel) modeliyle, superstep'ler halinde çalışır: aynı superstep'te mesaj alan executor'lar eşzamanlı koşar, ürettikleri mesajlar bir sonraki superstep'te teslim edilir ve superstep'ler arasında bir senkronizasyon bariyeri vardır. Test metni "A Short History of Maps by Lena Ford is the best book ever written about maps, a must read for every traveller." Sonuç:
PASS length: 22 words
PASS facts: author present, no price
PASS tone: ok
small: model calls=1, input tokens=58, output tokens=43, time=2,5s
Tahmin sorusunun ilk cevabı burada: model, içinde "best book ever written" ve "must read" geçen metni, tam olarak bu iki ifadeyi örnek verdiğimiz bir talimatla onayladı. Paralel çalıştırmak hızı artırdı, kaliteyi değil. Bir kural kodla kontrol edilebiliyorsa (yasaklı ifade listesi bir Regex'tir) onu modele bırakma; modeli yalnızca gerçekten yoruma açık kısım için kullan ve o kısmı da evaluation setiyle ölç.
Orchestrator-Workers: Plan Çalışma Anında
"Haritaları ve sakin romanları seven bir arkadaşa toplamı 60 doları geçmeyen bir hediye paketi" isteğinde kaç arama yapılacağı baştan belli değil. Orchestrator (qwen3:4b) aramaları planlıyor, worker'lar (gemma3:4b) paralel çalışıyor, sonucu kod birleştiriyor. Bunu da düz C# ile yazdık; Task.WhenAll fan-out için yeterli:
ChatResponse<GiftPlan> plan = await large.GetResponseAsync<GiftPlan>(
$"Plan catalog searches for this request. Catalog genres: software, novel, history, science. Request: {request}",
new ChatOptions { Temperature = 0, Seed = 42 });
Console.WriteLine($" plan: {string.Join(" | ", plan.Result.Searches)}");
Pick[] picks = await Task.WhenAll(plan.Result.Searches.Select(async search =>
{
object hits = tools.SearchBooks(search, maxResults: 3);
ChatResponse<Pick> pick = await small.GetResponseAsync<Pick>(
$"Pick the single best book for '{search}' from: {System.Text.Json.JsonSerializer.Serialize(hits)}",
new ChatOptions { Temperature = 0, Seed = 42 });
return pick.Result;
}));
List<Book> chosen = picks.Select(p => data.Books.First(b => b.Id == p.BookId)).Distinct().ToList();
decimal total = chosen.Sum(b => b.Price);
Gerçek çıktı:
plan: history catalog: map atlases (e.g., 'world atlas', 'ancient maps', 'thematic maps') - price range: $15-$25 | novel catalog: quiet novels (e.g., 'literary fiction', 'historical fiction', 'slow-paced novels', 'poetry') - price range: $10-$20 | science catalog: geographic maps (e.g., 'topographic maps', 'geological maps', 'natural history maps') - price range: $5-$15 (for map-focused items) | software catalog: skip (irrelevant for physical map/novel gifts under $60)
A Short History of Maps (history) 27,50
The Quiet Orchard (novel) 16,50
Coffee and Compilers (software) 35,00
total=79,00, within budget=False
large: model calls=1, input tokens=50, output tokens=1751, time=60,1s
small: model calls=4, input tokens=545, output tokens=238, time=29,4s
Bu koşu başarısız ve öğretici. Planın dördüncü maddesi "software catalog: skip" diyor, ama bizim kod her maddeyi bir arama sanıp worker'a verdi; worker da yazılım kitapları arasından bir seçim yaptı. Toplam 79 dolar ve bütçe kapısı within budget=False diyor. Orchestrator-workers'ın zayıf noktası tam burası: plan serbest metin olduğunda, worker'lara giden her şey modelin yorumuna bağlı. Çözüm planı daha sıkı tiplemek (örneğin Genre enum'u ve Skip alanı olan bir record), worker sayısını sınırlamak ve en sonda bütçe gibi kesin kuralları kodla kontrol etmek. Son kapı olmasaydı müşteriye 79 dolarlık paket "60 doların altında" diye sunulacaktı.
Evaluator-Optimizer: Writer ve Critic Döngüsü
Son pattern bir döngü. Writer bir tanıtım metni yazar, critic değerlendirir; reddederse revise executor'ı geri bildirimi yeni bir isteğe çevirip writer'a geri gönderir. Üçüncü denemede kabul edilmese de döngü biter:
ExecutorBinding critic = ((Func<Draft, CancellationToken, ValueTask<Review>>)(async (draft, ct) =>
{
int words = draft.Text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
if (words > 35)
{
return new Review(draft, false, $"It has {words} words; the limit is 35.");
}
// Kodla kontrol edilebilen kurallar modele gitmez
if (!draft.Text.Contains(draft.Book.Author))
{
return new Review(draft, false, $"Name the author, {draft.Book.Author}.");
}
ChatResponse<StyleVerdict> v = await small.GetResponseAsync<StyleVerdict>(
"You review book blurbs. Reject exaggerated claims ('best', 'must read', 'masterpiece') "
+ $"and blurbs that do not name the author. Blurb: {draft.Text}",
new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
return new Review(draft, v.Result.Approved, v.Result.Feedback);
})).BindAsExecutor("critic");
return new WorkflowBuilder(writer)
.AddEdge(writer, critic)
.AddSwitch(critic, sw => sw
.AddCase<Review>(r => r!.Approved || r.Draft.Attempt >= 3, publish)
.WithDefault(revise))
.AddEdge(revise, writer)
.WithOutputFrom(publish)
.Build();
Bu kodun üç sürümünü koşturduk. İlk sürümde yazar adı kontrolü yalnızca critic modelinin talimatındaydı. Writer'ın ilk metninde "Ken Sato" geçmiyordu ve gemma3:4b critic metni ilk denemede onayladı: tahmin sorusunun ikinci cevabı. Yazar adı kontrolünü koda taşıdık (yukarıdaki Contains) ve ikinci koşuda kod metni reddetti. Ama writer'a yalnızca "Name the author, Ken Sato." dediğimiz için önceki metni görmedi ve bu kez üç seçenekli, 97 kelimelik bir cevap döndürdü; döngü üçüncü denemede "gave up" ile bitti. Üçüncü sürümde writer'a önceki taslağını ve geri bildirimi birlikte verdik, cevabın yalnızca metin olmasını istedik:
[writer #1] Amidst the snow-dusted temples of Kyoto, a disillusioned American artist seeks solace and connection. But a haunting past and a mysterious local woman threaten to unravel his fragile peace.
[critic] rejected: Name the author, Ken Sato.
[writer #2] Ken Sato’s *Winter in Kyoto* follows a disillusioned American artist searching for peace amidst Kyoto’s snow-covered temples. A haunting past and a mysterious woman challenge his fragile tranquility.
approved after 2 attempt(s): Ken Sato’s *Winter in Kyoto* follows a disillusioned American artist searching for peace amidst Kyoto’s snow-covered temples. A haunting past and a mysterious woman challenge his fragile tranquility.
small: model calls=3, input tokens=222, output tokens=150, time=6,6s
Döngü çalışıyor: kod reddetti, writer düzeltti, critic onayladı. Yine de metne dikkatli bak: "disillusioned American artist" ve "mysterious woman" kitabın konusunda yok. Writer'a kitabın özetini vermedik, model konuyu uydurdu ve critic'in bunu bilmesinin yolu yoktu. Evaluator-optimizer, critic'in ölçebildiği kadar iyidir; olgusal doğruluk için writer'a kaynak (özet) verilmeli ve critic de aynı kaynağa göre kontrol etmeli.
Workflow'u Agent Olarak Dışarı Açmak
MAF'ta bir workflow, AsAIAgent() ile AIAgent arayüzünü alabilir; çağıran taraf onun bir workflow olduğunu bilmeden RunAsync der. Routing workflow'umuzu doğrudan sardığımızda gerçek sonuç şu oldu:
Unhandled exception. System.InvalidOperationException: Workflow does not support ChatProtocol: At least List<ChatMessage> and TurnToken must be supported as input.
Agent arayüzü mesaj listesiyle konuşuyor; workflow'un start executor'ı ise string bekliyordu. Çözüm, girişe chat protocol'ü konuşan bir executor koymak. ChatProtocolExecutor'dan türeyen bu class mesajları biriktiriyor, TurnToken geldiğinde son kullanıcı mesajını string olarak bir sonraki executor'a gönderiyor:
// Workflow'un AIAgent olarak çalışabilmesi için chat protocol'ü (mesajlar + TurnToken) kabul eder
public sealed class ChatEntryExecutor() : ChatProtocolExecutor("chat-in")
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
base.ConfigureProtocol(protocolBuilder).SendsMessage<string>();
protected override ValueTask TakeTurnAsync(List<ChatMessage> messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default) =>
context.SendMessageAsync(messages.Last(m => m.Role == ChatRole.User).Text, cancellationToken: cancellationToken);
}
ConfigureProtocol override'ı şart: ilk denemede yalnızca [SendsMessage(typeof(string))] attribute'u koyduk ve executor "cannot send messages of type 'System.String'" hatasıyla düştü. Düzeltmeden sonra:
Workflow workflow = RoutingWorkflow.Build(small, large, data, tools, chatEntry: args.Length > 1);
Microsoft.Agents.AI.AIAgent agent = workflow.AsAIAgent(id: "routing-agent", name: "BookstoreRouter");
Microsoft.Agents.AI.AgentResponse response = await agent.RunAsync("Where is my order 1044?");
1 message(s): Order 1044 is Processing.
Bunun pratik değeri büyük: workflow'u bir agent gibi başka bir workflow'a, bir multi-agent orkestrasyonuna ya da .NET ile Yapay Zeka serisinin 13. yazısındaki hosting endpoint'lerine verebilirsin. Dışarıdan bakan için içinde kaç model çağrısı, kaç kod adımı olduğu fark etmez.
Üretimde Dikkat
Superstep bariyerini hesaba kat. Microsoft Learn'deki yürütme modeli açıklamasına göre bir superstep'teki bütün executor'lar bitmeden sonraki superstep başlamaz. Fan-out'ta bir dal tek uzun bir model çağrısı, diğeri kısa adımlardan oluşan bir zincirse zincir her adımda uzun çağrıyı bekler. Bağımsız ilerlemesi gereken sıralı adımları tek bir executor'da birleştir.
Event'leri gözlemle. ExecutorInvokedEvent, ExecutorCompletedEvent, ExecutorFailedEvent ve WorkflowOutputEvent, her düğümün ne zaman başladığını ve bittiğini veriyor. Bunları log'a ya da OpenTelemetry'ye yazarsan bir workflow koşusunun trace'i agent loop'unkinden çok daha okunabilir olur, çünkü düğümlerin adı ve sırası baştan belli.
Executor'lar bir hata verdiğinde workflow durur. ExecutorFailedEvent geldiğinde ne olacağını (yeniden deneme, kullanıcıya mesaj, insana devretme) workflow'u yazarken karar ver. Uzun süren workflow'larda superstep sınırında checkpoint almak mümkün; bunu human-in-the-loop yazısında kullanacağız.
Pattern'in fazla geldiği yeri bil. Prompt chaining ve orchestrator-workers için düz C# yetti. WorkflowBuilder'a, graph'ı görselleştirmek, event'lerle izlemek, checkpoint almak ya da workflow'u agent olarak dışarı açmak istediğinde ihtiyaç duy. "Framework kullanalım" tek başına bir sebep değil.
Sık Düşülen Tuzaklar
Kodla kontrol edilebilen kuralı modele bırakmak. Bu yazıda iki kez gördük: üslup kontrolü "must read"i, critic yazar adı eksikliğini kaçırdı. Uzunluk, yasaklı ifade, zorunlu alan, bütçe kodda kontrol edilir.
Geri bildirim döngüsünde context'i kaybetmek. Writer'a yalnızca "şunu düzelt" demek, önceki metni göstermemek, modelin sıfırdan ve çoğu zaman alakasız bir şey yazmasına yol açtı. Döngüye taslak, geri bildirim ve kaynak birlikte girmeli.
Orchestrator'ın planına körü körüne güvenmek. Serbest metin plan, worker'lara "skip" gibi talimatları bile arama diye gönderebilir. Planı tiple, doğrula, sınırla.
Döngüye üst sınır koymamak. Evaluator-optimizer'da Attempt >= 3 case'i olmasaydı, critic'in hiç onaylamadığı bir metin için döngü sonsuza kadar sürerdi.
Routing'de default'u yanlış seçmek. Sınıflandırıcı hata yapacak; emin olunmayan girdinin en güvenli yola (çoğu zaman insana) gitmesini sağla.
Küçük Bir Deneme
★☆☆ Parallelization örneğine dördüncü bir kod kontrolü ekle: ["best", "must read", "masterpiece"] listesindeki ifadelerden biri geçiyorsa FAIL. Aynı metinle koştur; model ve kod kontrolleri ayrıştığında rapor nasıl görünüyor?
★★☆ GiftPlan'ı Genre enum'u ve MaxPrice alanı olan bir record listesine çevir, worker'a yalnızca o türde ve fiyatın altında arama yaptır. Bütçe kapısı bu kez geçiyor mu?
★★★ Writer'a kitabın özetini de ver ve critic'e "özette olmayan bir olay ya da karakter var mı" diye soran ikinci bir model kontrolü ekle. Kaç denemede kabul ediliyor, uydurma karakterler kayboluyor mu?
Bu yazıdaki her pattern'de tek bir model ya da birkaç bağımsız model çağrısı vardı. Bir sonraki yazıda birbiriyle konuşan agent'lara geçiyoruz: handoff, group chat, MAF'ın hazır orkestrasyonları ve başka bir serviste çalışan agent'a A2A (Agent2Agent) protokolüyle iş vermek.