Skip to main content
Article

Multi-Agent Orchestration with Spring AI

What if instead of a single AI agent, you orchestrated several in parallel? In this article, we build a multi-agent workflow with Spring AI: specialized agents execute in parallel via Java Virtual Threads, then an evaluator qualifies the overall result.

9 min read
spring-aiiallmagentjavaspring-boot
spring-aiiallm

So far, our examples used a single ChatClient to interact with the LLM. But some complex tasks benefit from a multi-agent approach: multiple specialized agents work in parallel, each with their own instructions and tools, then their results are aggregated and evaluated.

In this article, we explore the agent/workflow module of the demo project, which implements a project draft generator orchestrating multiple AI agents in parallel.

A- Workflow Architecture

The workflow follows a simple sequence:

  1. The user submits a project description.
  2. The application selects the specialized agents to run.
  3. The agents execute in parallel through Virtual Threads.
  4. The generated results are aggregated.
  5. An evaluator agent measures the quality of the generated content.
  6. A final response is returned with both the result and the feedback.

B- The Agent Model

An agent is defined by a record that encapsulates all its properties:

public record Agent(
    String name,
    String systemInstruction,
    String input,
    List<String> tools,
    ChatClient.ChatClientRequestSpec chatClient
) {}

Each agent has:

  • An identifying name (e.g., "ProjectNamer")
  • System instructions detailing its mission and rules
  • An input template with placeholders (e.g., {projectDescription})
  • A list of tools it can call
  • A pre-configured ChatClientRequestSpec

C- The Agent Registry: AgentRegistry

The AgentRegistry centralizes the definition and construction of all agents:

@Component
public class AgentRegistry {
 
    private final Map<DraftSectionKey, Agent> AGENT_CACHE =
            new EnumMap<>(DraftSectionKey.class);
 
    private final ChatClient chatClient;
 
    public AgentRegistry(ChatClient.Builder chatClientBuilder) {
        this.chatClient = chatClientBuilder
                .defaultAdvisors(new SimpleLoggerAdvisor())
                .defaultOptions(
                    ChatOptions.builder().maxTokens(500).build())
                .build();
        initializeTemplates();
        initializeAgents();
    }
}

Specialized Agents

AgentKeyMissionTools
ProjectNamerPROJECT_NAMEGenerate a concise, memorable nameNone
ProjectSummarizerPROJECT_SUMMARYWrite an executive summarygetCountry
ProjectContextualizerPROJECT_CONTEXTAnalyze the project contextgetCountry, getCurrentDateTime
ExecutionPlannerEXECUTION_PLANCreate an execution plangetCurrentDateTime

Each agent has its own detailed system instructions:

AGENT_TEMPLATES.put(DraftSectionKey.PROJECT_NAME,
    new AgentTemplate("ProjectNamer", """
        You are an expert in project naming.
 
        ## Your mission
        Generate a concise, memorable, and descriptive name.
 
        ## Rules
        - The name must be short (5-15 words maximum)
        - It must reflect the essence of the project
        - It must be easy to remember
        - Avoid complex acronyms
 
        ## Output format
        Return only the project name.
        """,
    "Propose a name for this project: {projectDescription}",
    List.of(), 800));

Agent Tools

Agents can use tools declared as Spring beans:

@Configuration
public class FunctionAsTools {
 
    @Bean
    @Description("Get the current country")
    public Supplier<String> getCountry() {
        return () -> "South Africa - ZA";
    }
 
    @Bean
    @Description("Get the current date and time")
    public Supplier<String> getCurrentDateTime() {
        return () -> LocalDateTime.now()
                .format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
    }
}

D- Parallel Execution with Virtual Threads

The ParallelizationWorkflow orchestrates parallel execution of all agents using Java Virtual Threads:

@Component
public class ParallelizationlWorkflow {
 
    public List<AgentResult> executeWorkflow() {
        try (var executor =
                Executors.newVirtualThreadPerTaskExecutor()) {
 
            var futures = agents.stream()
                .map(agent -> executor.submit(() -> {
                    log.info("Executing agent: {}",
                            agent.name());
 
                    var systemInstruction =
                        replacePlaceholders(
                            agent.systemInstruction());
                    var userMessage =
                        replacePlaceholders(agent.input());
 
                    var result = agent.chatClient()
                            .system(systemInstruction)
                            .toolNames(agent.tools()
                                .toArray(new String[0]))
                            .user(userMessage)
                            .call()
                            .content();
 
                    return new AgentResult(
                        agent.name(), result);
                }))
                .toList();
 
            return futures.stream()
                .map(future -> {
                    try { return future.get(); }
                    catch (Exception e) {
                        throw new RuntimeException(
                            "Error executing agent", e);
                    }
                })
                .toList();
        }
    }
}

Why Virtual Threads?

  • Lightweight: thousands of virtual threads can coexist without memory overhead
  • Simplicity: same API as classic threads (executor.submit())
  • True parallelism: each agent executes in its own thread, LLM calls are made simultaneously
  • Executors.newVirtualThreadPerTaskExecutor() creates one virtual thread per task.

E- Quality Evaluation: DraftGeneration

After agent execution, results are evaluated by an evaluator agent:

@Component
public class DraftGeneration {
 
    private static final String EVALUATION_SYSTEM_PROMPT = """
        You are a content quality evaluation expert.
 
        ## Your mission
        Evaluate quality according to three criteria (0-100%):
        - **Clarity**: Is the content easy to understand?
        - **Completeness**: Are important aspects covered?
        - **Relevance**: Is it adapted to the project context?
 
        ## Output format (raw JSON)
        {
            "clarte": "85",
            "completude": "70",
            "pertinence": "90",
            "feedback": "The content is of good quality..."
        }
        """;
 
    public DraftGenerationResult evaluateAndGenerateFeedback(
            String projectDescription,
            Map<String, String> sectionContent) {
 
        var response = chatClient.prompt()
                .system(EVALUATION_SYSTEM_PROMPT)
                .user(userMessage)
                .call()
                .entity(new MapOutputConverter());
 
        return DraftGenerationResult.of(
            aiFeedback, sectionContent,
            contentQualityAiFeedback);
    }
}

The Structured Result

public record DraftGenerationResult(
    String aiFeedback,
    Map<String, String> sectionContent,
    Map<String, String> contentQualityAiFeedback
) {}

The JSON response is parsed using Spring AI's MapOutputConverter, which automatically converts the LLM's structured output into a Map<String, Object>.

F- The Orchestrator Service

@Service
public class DemoService {
 
    public DraftGenerationResult generateDraft(String message) {
        // 1 - Select agents
        var agents = agentRegistry.getAgents(List.of(
                DraftSectionKey.PROJECT_NAME,
                DraftSectionKey.PROJECT_SUMMARY,
                DraftSectionKey.PROJECT_CONTEXT));
 
        // 2 - Register and execute in parallel
        workflow.registerAgents(agents);
        workflow.registerMetadata(
            Map.of("projectDescription", message));
        var result = workflow.executeWorkflow();
 
        // 3 - Aggregate
        var sectionContent = result.stream()
                .map(r -> Map.entry(r.name(), r.result()))
                .collect(Collectors.toMap(
                    Map.Entry::getKey, Map.Entry::getValue));
 
        // 4 - Quality evaluation
        return draftGeneration
            .evaluateAndGenerateFeedback(
                message, sectionContent);
    }
}

G- Testing the Workflow

curl "http://localhost:8080/draf-generator?message=Mobile ride-sharing app in South Africa"

The response will contain:

  • The project name generated by ProjectNamer
  • The summary from ProjectSummarizer
  • The context analysis from ProjectContextualizer
  • The quality feedback with clarity, completeness, and relevance scores

Conclusion

Multi-agent orchestration is a powerful pattern for decomposing complex tasks into specialized subtasks. With Spring AI, this orchestration becomes natural:

  • Specialized agents with dedicated instructions and tools
  • Parallel execution via Virtual Threads
  • Automated quality evaluation by an evaluator agent
  • Structured results thanks to output converters

In the next and final article, we will see the Model Context Protocol (MCP): how to expose and consume remote AI tools.

I hope you found this article useful. Thank you for reading.

To learn more:


"Spring AI in Action" Series

  1. Introduction to Spring AI
  2. ChatClient API: Getting Started with the API
  3. Chat Memory: Conversational Context
  4. RAG: Ingestion Pipeline
  5. RAG: From Naive to Advanced
  6. Function Calling
  7. Tools + Security
  8. Multi-Agent Orchestration
  9. Model Context Protocol (MCP)
ShareXLinkedIn