Agentic Patterns with Spring AI
What even is an "AI Agent"?
Think of it this way: a normal LLM call is like asking someone a question and getting an answer. An agent is like hiring someone who can actually go do things β use tools, make decisions, loop until the job's done, and even hand off work to specialists.
| Aspect | Workflow | Agent |
|---|---|---|
| Control Flow | Predetermined, coded | LLM-directed, dynamic |
| Predictability | High | Medium |
| Flexibility | Low | High |
| Complexity | Simple to debug | Requires careful guardrails |
When should you reach for agents?
- Tasks where flexibility is needed at scale
- Complex problems with unpredictable paths
- Scenarios where cross-referencing different data sources helps
If your flow is predictable and sequential, you probably don't need an agent. A simple chain will do.
Agentic Design Patterns
Based on research from Anthropic's Building Effective Agents and Spring AI implementation patterns.
1. Chain Pattern
The simplest pattern. Tasks flow in a fixed sequence β each step's output feeds into the next.
[Input] β [Step 1] β [Step 2] β [Step 3] β [Output]
public String chainedExecution(String input) {
// Step 1: Categorize
String category = chatClient.prompt()
.user("Categorize this: " + input)
.call().content();
// Step 2: Analyze based on category
String analysis = chatClient.prompt()
.system("You are a " + category + " expert")
.user("Analyze: " + input)
.call().content();
// Step 3: Generate output
return chatClient.prompt()
.user("Summarize this analysis: " + analysis)
.call().content();
}
Trade-offs:
- β Simple to understand and debug
- β Predictable execution path
- β No parallelism
- β Rigid structure
2. Parallelization Pattern
Multiple independent tasks run concurrently, with results aggregated at the end.
ββ [Task A] ββ
[Input] ββββΌβ [Task B] ββΌβ [Aggregator] β [Output]
ββ [Task C] ββ
Two variants here:
- Sectioning β split task into independent subtasks
- Voting β same task, multiple perspectives, majority wins
public Map<String, String> parallelAnalysis(String input) {
CompletableFuture<String> security = CompletableFuture.supplyAsync(() ->
chatClient.prompt()
.system("You are a security expert")
.user("Analyze: " + input)
.call().content()
);
CompletableFuture<String> performance = CompletableFuture.supplyAsync(() ->
chatClient.prompt()
.system("You are a performance expert")
.user("Analyze: " + input)
.call().content()
);
return Map.of(
"security", security.join(),
"performance", performance.join()
);
}
Trade-offs:
- β Faster execution for independent tasks
- β Multiple perspectives
- β Higher API costs (multiple calls)
- β Aggregation complexity
3. Routing Pattern
An AI classifier routes input to the most appropriate specialized handler. This is the pattern I use the most.
ββ [Agent A: Incidents]
[Input] β [Router] ββΌβ [Agent B: Change Requests]
ββ [Agent C: Executive Summary]
@Component
public class AgentRouting {
public String route(String input, List<String> agentNames) {
RoutingResponse response = chatClient.prompt()
.system("""
You are a routing classifier. Analyze the user input and
select the most appropriate agent.
Available agents: """ + String.join(", ", agentNames))
.user(input)
.call()
.entity(RoutingResponse.class);
return response.selection();
}
}
record RoutingResponse(String reasoning, String selection) {}
Why routing works well:
- Tasks are predictable (incident vs CR vs combined)
- Each agent has focused, optimized prompts
- Specialized tools per agent reduce confusion
- Transfer capability allows cross-referencing
4. Orchestrator-Workers Pattern
A central orchestrator breaks tasks into subtasks and delegates to specialized workers. Think of it as "the manager pattern."
ββ [Worker A] ββ
[Input] β [Orchestrator] ββββββΌβ [Worker B] ββΌβ [Synthesizer] β [Output]
β ββ [Worker C] ββ
βββββ feedback loop βββββββββββββ
public String orchestrateTask(String complexTask) {
// Orchestrator plans the work
TaskPlan plan = chatClient.prompt()
.system("Break down this task into subtasks")
.user(complexTask)
.call()
.entity(TaskPlan.class);
// Workers execute subtasks
List<String> results = plan.subtasks().stream()
.map(subtask -> executeWorker(subtask))
.toList();
// Synthesize results
return chatClient.prompt()
.system("Synthesize these results into a cohesive response")
.user(String.join("\n", results))
.call().content();
}
5. Evaluator-Optimizer Pattern
Generate β Evaluate β Refine β Repeat. The loop runs until quality criteria are met.
[Input] β [Generator] β [Evaluator] ββ¬β [Output] (if pass)
β β
βββββ feedback βββββββββ (if fail)
public String generateWithQualityCheck(String input, int maxIterations) {
String output = "";
for (int i = 0; i < maxIterations; i++) {
output = chatClient.prompt()
.system("Generate a high-quality response")
.user(input + (i > 0 ? "\nPrevious attempt: " + output : ""))
.call().content();
QualityScore score = chatClient.prompt()
.system("Rate this output from 1-10")
.user(output)
.call()
.entity(QualityScore.class);
if (score.rating() >= 8) {
return output;
}
}
return output;
}
Real Implementation: Routing + Agent Transfer
Here's the architecture I used in a real project β routing pattern with agent transfer capability.
βββββββββββββββββββββββββββββββββββ
β AgentOrchestrator β
β - Routes requests to agents β
β - Manages agent execution β
β - Handles transfers β
ββββββββββββββ¬βββββββββββββββββββββ
β
ββββββββββββββββββββββββββββββββΌβββββββββββββββββββββββββββββββ
β β β
βΌ βΌ βΌ
βββββββββββββββββββββββββββ βββββββββββββββββββββββββββ βββββββββββββββββββββββββββ
β IncidentSummaryAgent β βChangeRequestSummaryAgentβ β ExecutiveSummaryAgent β
β β β β β β
β Tools: β β Tools: β β Tools: β
β - get_recent_incidents β β - get_recent_CRs β β - ALL incident tools β
β - get_by_application β β - get_by_application β β - ALL CR tools β
β - get_statistics β β - get_statistics β β β
β - get_critical β β - get_high_risk β β No transfer needed β
β β β - get_failed β β β
β Can transfer to: CR β β Can transfer to: Inc β β β
βββββββββββββββββββββββββββ βββββββββββββββββββββββββββ βββββββββββββββββββββββββββ
Core Components
| Component | Purpose |
|---|---|
Agent.java |
Record defining agent configuration |
AgentRouting.java |
Routes user input to appropriate agent |
AgentOrchestrator.java |
Central orchestrator managing all agents |
AgentTransferTool.java |
Enables inter-agent transfers |
AgentConfig.java |
Configures and registers all agents |
AgentService.java |
Business layer for agent operations |
AgentController.java |
REST endpoints for agent invocation |
Agent Transfer Mechanism
Agents can transfer control to other specialized agents when cross-referencing is beneficial.
- Detection: Agent identifies need for transfer (e.g., "incident after deployment")
- Tool Call: Agent calls
transferToAgent(agentName, reason) - Context Handoff: Transfer includes conversation context
- Execution: Target agent continues the task
- Response: Final response from the completing agent
@Tool(description = "Transfer to another specialized agent")
public String transferToAgent(
@ToolParam(description = "Target agent name") String agentName,
@ToolParam(description = "Reason for transfer") String reason
) {
this.transferRequested = true;
this.transferTarget = agentName;
return "Transfer initiated to " + agentName;
}
Example flow:
User: "Analyze the incidents from yesterday, especially any related to deployments"
1. Router β IncidentSummaryAgent (primary focus is incidents)
2. Agent finds incident: "Production outage after CRM deployment"
3. Agent calls: transferToAgent("ChangeRequestSummaryAgent", "Need CR details for correlation")
4. ChangeRequestSummaryAgent analyzes related changes
5. Combined insight returned to user
Best Practices
Keep Agent Prompts Focused
β Don't: Create one agent with broad, vague instructions
You are an AI that helps with IT operations. Do whatever the user asks.
β Do: Create specialized agents with clear scope
You are an expert IT Incident Analyst. Your task is to analyze
ServiceNow incidents and generate focused incident summary emails.
Limit Tools Per Agent
Don't give every agent access to every tool. It increases confusion, slows response times, and burns more tokens.
- IncidentAgent: 4 incident tools
- CRAgent: 5 CR tools
- ExecutiveAgent: All tools (it needs the combined view)
Use Structured Output
β Parsing free-form LLM responses with regex:
String response = chatClient.call().content();
// Brittle regex parsing...
β Using Spring AI's entity extraction:
RoutingResponse response = chatClient.prompt()
.call()
.entity(RoutingResponse.class);
Handle Transfer Loops
Prevent infinite transfer loops with:
- Maximum transfer count
- Transfer history tracking
- Clear termination conditions
Audit All Agent Actions
Log and persist: which agent handled the request, what tools were called, transfer history, and generated output.
API Reference
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/agents/incident-summary |
Generate incident-focused summary |
| POST | /api/v1/agents/cr-summary |
Generate CR-focused summary |
| POST | /api/v1/agents/executive-summary |
Generate combined executive summary |
| POST | /api/v1/agents/auto |
Auto-route to appropriate agent |
| POST | /api/v1/agents/{agentName}/with-transfer |
Execute with transfer capability |
# Auto-Route
curl -X POST "http://localhost:8080/api/v1/agents/auto" \
-H "Content-Type: text/plain" \
-d "What high-risk changes were deployed yesterday?"
# With Transfer
curl -X POST "http://localhost:8080/api/v1/agents/IncidentSummaryAgent/with-transfer" \
-H "Content-Type: text/plain" \
-d "Analyze incidents, check if any are related to recent deployments"