[Architectural Analysis] Multi-Agent Design Patterns in C# LLM Workflows

This article provides a complete technical analysis and code walkthrough of a C# Large Language Model (LLM) multi-agent workflow utilizing LangChain.Providers. It deconstructs core agentic implementation architectures—starting with the foundational state contract and scaling up to Hub-and-Spoke routing and Generator-Evaluator loops.

1. The State Contract (Shared Memory)

In a multi-agent system, LLM API calls are inherently stateless. To maintain context across multiple agent interactions, we must establish a rigorous data contract. The AgentState class acts as the single source of truth, passed sequentially from node to node.

using LangChain.Providers;
using LangChain.Providers.OpenAI;
using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;

namespace OllamaAgenticWorkflow
{
    // ==========================================
    // STATE CONTRACT
    // ==========================================
    public class AgentState
    {
        [JsonPropertyName("original_request")]
        public string OriginalRequest { get; set; } = string.Empty;

        [JsonPropertyName("orchestrator_route")]
        public string OrchestratorRoute { get; set; } = string.Empty;

        [JsonPropertyName("generated_content")]
        public string GeneratedContent { get; set; } = string.Empty;

        [JsonPropertyName("evaluator_feedback")]
        public string EvaluatorFeedback { get; set; } = string.Empty;

        [JsonPropertyName("is_approved")]
        public bool IsApproved { get; set; } = false;

        [JsonPropertyName("iteration_count")]
        public int IterationCount { get; set; } = 0;

        public string ToJson()
        {
            var options = new JsonSerializerOptions
            {
                WriteIndented = true,
                Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping // Ensures Unicode/Chinese chars aren't escaped
            };
            return JsonSerializer.Serialize(this, options);
        }
    }

2. The Agent Blueprint (Template Method Pattern)

An “agent” is essentially an LLM wrapped in state management and output parsing. The AgentNodeBase uses the Template Method Pattern to define the standard execution sequence. It handles the API call and robust JSON extraction, forcing concrete subclasses to strictly define their Persona (System Prompt) and State Mutations.

// ==========================================
    // ABSTRACT BASE NODE (TEMPLATE METHOD PATTERN)
    // ==========================================
    public abstract class AgentNodeBase
    {
        protected readonly IChatModel _chatModel;

        protected AgentNodeBase(IChatModel chatModel)
        {
            _chatModel = chatModel;
        }

        // Abstract methods that each specific agent must implement
        protected abstract string NodeName { get; }
        protected abstract string GetSystemPrompt();
        protected abstract string GetHumanPrompt(AgentState state);
        protected abstract void ParseAndUpdateState(AgentState state, JsonElement jsonRoot);

        // The Template Method that runs the shared execution logic
        public async Task<AgentState> ExecuteAsync(AgentState state)
        {
            string iterationLog = NodeName == "Generator" ? $" Attempt {state.IterationCount + 1}..." : "...";
            Console.WriteLine($"\n[{NodeName}]{iterationLog}");

            var systemPrompt = GetSystemPrompt();
            var humanPrompt = GetHumanPrompt(state);

            var response = await _chatModel.GenerateAsync(new[]
            {
                new Message(systemPrompt, MessageRole.System),
                new Message(humanPrompt, MessageRole.Human)
            });

            var cleanJson = ExtractJsonFromLlmResponse(response.LastMessageContent ?? string.Empty);
            
            // Failsafe for models that completely fail to return JSON
            if (string.IsNullOrWhiteSpace(cleanJson) || (!cleanJson.StartsWith("{") && !cleanJson.StartsWith("[")))
            {
                throw new FormatException($"The LLM did not return valid JSON. Raw output: {response.LastMessageContent}");
            }

            using var jsonDoc = JsonDocument.Parse(cleanJson);
            ParseAndUpdateState(state, jsonDoc.RootElement);

            return state;
        }

        // Shared utility to strip markdown backticks from LLM responses
        private string ExtractJsonFromLlmResponse(string text)
        {
            var start = text.IndexOf('{');
            var end = text.LastIndexOf('}');
            if (start != -1 && end != -1 && end > start)
            {
                return text.Substring(start, end - start + 1);
            }
            return text; 
        }
    }

3. The Worker Nodes (Generator, Evaluator, Translator)

These concrete classes inherit the blueprint.

The Generator-Evaluator Loop is visible here. The Generator produces content. If the Evaluator rejects it and provides feedback, the Orchestrator will route it back to the Generator. Notice how the GeneratorNode specifically injects state.EvaluatorFeedback into its next prompt to force a revision, and then explicitly wipes the feedback state so the process can restart cleanly.

// ==========================================
    // CONCRETE AGENT NODES
    // ==========================================
    public class GeneratorNode : AgentNodeBase
    {
        public GeneratorNode(IChatModel chatModel) : base(chatModel) { }

        protected override string NodeName => "Generator";

        protected override string GetSystemPrompt() => @"
            You are a Generator Agent. Execute the task provided by the user.
            
            Respond strictly in valid JSON format:
            { ""generated_result"": ""your content here"" }";

        protected override string GetHumanPrompt(AgentState state)
        {
            // Grab the feedback BEFORE we wipe it out
            var feedbackContext = !string.IsNullOrWhiteSpace(state.EvaluatorFeedback)
                ? $"\nPREVIOUS FEEDBACK TO FIX: {state.EvaluatorFeedback}"
                : "";

            return $"Task: {state.OriginalRequest}{feedbackContext}";
        }

        protected override void ParseAndUpdateState(AgentState state, JsonElement jsonRoot)
        {
            state.GeneratedContent = jsonRoot.GetProperty("generated_result").GetString() ?? "";
            state.IterationCount++;

            state.EvaluatorFeedback = "";
            state.IsApproved = false;

            Console.WriteLine($"[{NodeName} Content]:\n{state.GeneratedContent}");
        }
    }

    public class EvaluatorNode : AgentNodeBase
    {
        public EvaluatorNode(IChatModel chatModel) : base(chatModel) { }

        protected override string NodeName => "Evaluator";

        protected override string GetSystemPrompt() => @"
            You are an Evaluator Agent. 
            Review the original task and the generated content. Does the content satisfy the task perfectly? 
            
            Respond strictly in valid JSON format using a boolean:
            { ""is_approved"": true, ""feedback"": ""reasoning"" }";

        protected override string GetHumanPrompt(AgentState state) => $@"
            Original Task: {state.OriginalRequest}
            Generated Content: {state.GeneratedContent}";

        protected override void ParseAndUpdateState(AgentState state, JsonElement jsonRoot)
        {
            state.IsApproved = jsonRoot.GetProperty("is_approved").GetBoolean();
            state.EvaluatorFeedback = jsonRoot.GetProperty("feedback").GetString() ?? "";
            Console.WriteLine($"[{NodeName} Approved?]: {state.IsApproved}");
        }
    }

    public class TranslatorNode : AgentNodeBase
    {
        public TranslatorNode(IChatModel chatModel) : base(chatModel) { }

        protected override string NodeName => "Translator";

        protected override string GetSystemPrompt() => @"
            You are a Translator Agent. Translate the provided text accurately.
            
            Respond strictly in valid JSON format. Provide the translated text:
            { ""generated_result"": ""translated text here"" }";

        protected override string GetHumanPrompt(AgentState state) => 
            $"Task: {state.OriginalRequest}";

        protected override void ParseAndUpdateState(AgentState state, JsonElement jsonRoot)
        {
            state.GeneratedContent = jsonRoot.GetProperty("generated_result").GetString() ?? "";
            state.IsApproved = true; // Auto-approve translations
            Console.WriteLine($"[{NodeName} Output]:\n{state.GeneratedContent}");
        }
    }

4. The Hub Router (Orchestrator Node)

The Hub-and-Spoke Pattern relies on a central router. The OrchestratorNode acts as this hub. It evaluates the current AgentState variables to logically deduce the workflow status (NEEDS_GENERATION, NEEDS_EVALUATION, NEEDS_REVISION, or APPROVED). It then tells the execution runner which “spoke” to trigger next.

public class OrchestratorNode : AgentNodeBase
    {
        public OrchestratorNode(IChatModel chatModel) : base(chatModel) { }

        protected override string NodeName => "Orchestrator";

        protected override string GetSystemPrompt() => @"
            You are the Lead Orchestrator Agent. Your job is to route the workflow based strictly on the 'Workflow Status' provided.
            
            Routing Rules:
            - If Workflow Status is 'NEEDS_GENERATION', route to: 'generator'
            - If Workflow Status is 'NEEDS_EVALUATION', route to: 'evaluator'
            - If Workflow Status is 'NEEDS_REVISION', route to: 'generator'
            - If Workflow Status is 'APPROVED', route to: 'finish'
            - If the user explicitly asks for a language translation and the status is NEEDS_GENERATION, route to: 'translator'
            
            Respond strictly in valid JSON format:
            { ""route_to"": ""chosen_route"", ""reasoning"": ""brief explanation of why"" }";

        protected override string GetHumanPrompt(AgentState state)
        {
            // Calculate a strict, foolproof status string for the LLM
            string workflowStatus = "NEEDS_GENERATION";

            if (state.IsApproved)
            {
                workflowStatus = "APPROVED";
            }
            else if (!string.IsNullOrWhiteSpace(state.GeneratedContent))
            {
                if (string.IsNullOrWhiteSpace(state.EvaluatorFeedback))
                {
                    // Content exists, but no feedback yet -> Must Evaluate
                    workflowStatus = "NEEDS_EVALUATION";
                }
                else
                {
                    // Content exists, and we have feedback -> Must Revise
                    workflowStatus = "NEEDS_REVISION";
                }
            }

            string genContent = string.IsNullOrWhiteSpace(state.GeneratedContent) ? "None" : state.GeneratedContent;
            string evalFeedback = string.IsNullOrWhiteSpace(state.EvaluatorFeedback) ? "None" : state.EvaluatorFeedback;

            return $@"
            Workflow Status: {workflowStatus}
            Original Request: {state.OriginalRequest}
            Generated Content: {genContent}
            Evaluator Feedback: {evalFeedback}";
        }

        protected override void ParseAndUpdateState(AgentState state, JsonElement jsonRoot)
        {
            state.OrchestratorRoute = jsonRoot.GetProperty("route_to").GetString()?.ToLower() ?? "unknown";
            string reasoning = jsonRoot.GetProperty("reasoning").GetString() ?? "";

            Console.WriteLine($"[{NodeName} Decision]: Route to '{state.OrchestratorRoute}'");
            Console.WriteLine($"[{NodeName} Reasoning]: {reasoning}");
        }
    }

5. The State Machine Runner

The AgentGraphRunner executes the actual Hub-and-Spoke loop. It ensures that every single step passes through the Orchestrator first. Crucially, it implements a maxLoops failsafe to prevent the agents from getting stuck in an infinite loop of generation and rejection.

// ==========================================
    // WORKFLOW RUNNER (HUB-AND-SPOKE STATE MACHINE)
    // ==========================================
    public class AgentGraphRunner
    {
        private readonly OrchestratorNode _orchestrator;
        private readonly TranslatorNode _translator;
        private readonly GeneratorNode _generator;
        private readonly EvaluatorNode _evaluator;

        public AgentGraphRunner(IChatModel chatModel)
        {
            _orchestrator = new OrchestratorNode(chatModel);
            _translator = new TranslatorNode(chatModel);
            _generator = new GeneratorNode(chatModel);
            _evaluator = new EvaluatorNode(chatModel);
        }

        public async Task RunWorkflowAsync(string userRequest)
        {
            var state = new AgentState { OriginalRequest = userRequest };

            int maxLoops = 10; // Failsafe to prevent endless LLM arguments
            int currentLoop = 0;

            Console.WriteLine("\n=== Starting Hub-and-Spoke Agentic Workflow ===");

            while (currentLoop < maxLoops)
            {
                // 1. The Orchestrator acts as the central router for EVERY step
                state = await _orchestrator.ExecuteAsync(state);

                // 2. Execute the route determined by the Orchestrator
                if (state.OrchestratorRoute == "finish")
                {
                    Console.WriteLine("\n[System] Orchestrator determined the task is complete.");
                    break;
                }
                else if (state.OrchestratorRoute == "translator")
                {
                    state = await _translator.ExecuteAsync(state);
                }
                else if (state.OrchestratorRoute == "generator")
                {
                    state = await _generator.ExecuteAsync(state);
                }
                else if (state.OrchestratorRoute == "evaluator")
                {
                    state = await _evaluator.ExecuteAsync(state);
                }
                else
                {
                    Console.WriteLine($"\n[System] Unknown route '{state.OrchestratorRoute}'. Ending workflow.");
                    break;
                }

                currentLoop++;
            }

            if (currentLoop >= maxLoops)
            {
                Console.WriteLine($"\n[System] Workflow forced to stop after {maxLoops} iterations.");
            }

            Console.WriteLine("\n=== Workflow Complete ===");
            Console.WriteLine(state.ToJson());
        }
    }

6. Application Initialization

Finally, the Main execution initializes the model provider and launches the interactive loop.

Critical Architecture Note: The current implementation utilizes an identical local model (gemma3:4b) for both generation and evaluation. Operating the same model for generation and validation introduces structural bias and validation echo chambers. In a production environment, decoupling model providers is mandatory. The EvaluatorNode should ideally be initialized with a secondary, more advanced model architecture to ensure objective review and mathematical correctness.

class Program
    {
        static async Task Main(string[] args)
        {
            // 1. Force UTF-8 Encoding for Traditional Chinese / Unicode support
            Console.OutputEncoding = System.Text.Encoding.UTF8;
            Console.InputEncoding = System.Text.Encoding.UTF8;

            // 2. Initialize Provider and Model (OpenAI compatibility mode for Ollama)
            // Note: Endpoints must be secured/masked in production.
            var provider = new OpenAiProvider(
                apiKey: "ollama",
                customEndpoint: "http://[REDACTED_INTERNAL_IP]:11434/v1" 
            );
            
            // To prevent structural bias, consider instantiating a second model 
            // (e.g., GPT-4o or Claude 3.5 Sonnet) specifically for the Evaluator node.
            var chatModel = new OpenAiChatModel(provider, id: "gemma3:4b");

            // 3. Instantiate the Runner
            var runner = new AgentGraphRunner(chatModel);

            Console.WriteLine("=================================================");
            Console.WriteLine("🤖 OOP Multi-Agent Workflow Initialized");
            Console.WriteLine("Type 'exit' to quit.");
            Console.WriteLine("=================================================\n");

            // 4. Interactive User Loop
            while (true)
            {
                Console.Write("👤 You: ");
                string? userRequest = Console.ReadLine();

                if (string.IsNullOrWhiteSpace(userRequest)) continue;
                if (userRequest.Trim().ToLower() is "exit" or "quit") break;

                try
                {
                    await runner.RunWorkflowAsync(userRequest);
                }
                catch (Exception ex)
                {
                    Console.WriteLine($"\n[Error] The workflow encountered an issue: {ex.Message}");
                }

                Console.WriteLine("\n------------------------------------------\n");
            }
        }
    }
}

Test Result

References

  1. Ratnesh Yadav. (2025). Bot-to-Bot: Centralized (Hub-and-Spoke) Multi-Agent Topology. Medium. https://medium.com/@ratneshyadav_26063/bot-to-bot-centralized-hub-and-spoke-multi-agent-topology-part-2-87b46ec7e1bc

  2. Yuval Mehta. (2026). How Multi-Agent Self-Verification Actually Works. Towards AI. https://pub.towardsai.net/how-multi-agent-self-verification-actually-works-and-why-it-changes-everything-for-production-ai-71923df63d01

  3. arXiv preprint. (2025). MAR: Multi-Agent Reflexion Improves Reasoning Abilities in LLMs. arXiv. https://arxiv.org/html/2512.20845v1

About C.H. Ling 266 Articles
a .net / Java developer from Hong Kong and currently located in United Kingdom. Thanks for Google because it solve many technical problems so I build this blog as return. Besides coding and trying advance technology, hiking and traveling is other favorite to me, so I will write down something what I see and what I feel during it. Happy reading!!!

Be the first to comment

Leave a Reply

Your email address will not be published.


*


This site uses Akismet to reduce spam. Learn how your comment data is processed.