5 AI Agent Patterns – detailed explanation with Java examples
1. Overview
When you glue an LLM with a few lines of code to make it “run on its own,” you’ve got a rudimentary AI Agent — but that kind of agent usually just answers whatever you ask, doesn’t know how to fix its own mistakes, can’t call tools, and can’t break down work. This post explains 5 design patterns that make agents “smarter”: Reflection, Tool Use, Planning, Multi-Agent, and ReAct. Each pattern includes a short, runnable Java (Spring AI) code example with an explanation of when you should and shouldn’t use it. After reading, you’ll be able to pick the right pattern for your problem instead of cramming everything into one giant prompt.
Before diving into the details, you’ll need the Spring AI dependency for the examples below:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
Note: The code in this post illustrates pattern structure, not production-ready code — you’ll need to add retry, timeout, and logging when deploying for real.
2. Reflection Pattern: self-critique and revise answers
Reflection is the simplest pattern: the agent generates an answer, then uses the same LLM (or a different one) to score/critique that answer, and then revises it. This loop is like writing a draft and then reading it over yourself before sending.
2.1. Workflow
- Generate: the agent produces the initial answer.
- Critique: the agent (or a different agent) plays “reviewer,” pointing out errors.
- Revise: the agent revises based on the critique, potentially repeating steps 2–3 until quality is reached.
@Service
public class ReflectionAgent {
private final ChatClient chatClient;
public ReflectionAgent(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String answerWithReflection(String question, int maxRounds) {
String draft = chatClient.prompt()
.user("Answer this question concisely: " + question)
.call()
.content();
for (int round = 0; round < maxRounds; round++) {
String critique = chatClient.prompt()
.user("""
You are a demanding reviewer. Point out errors and omissions
in the answer below. If it's good, return "OK".
Question: %s
Answer: %s
""".formatted(question, draft))
.call()
.content();
if (critique.strip().equalsIgnoreCase("OK")) {
break;
}
draft = chatClient.prompt()
.user("Revise the answer based on this feedback: " + critique
+ "\nOld answer: " + draft)
.call()
.content();
}
return draft;
}
}
answerWithReflection generates an initial draft, then loops up to maxRounds times: calling the LLM to act as a reviewer and score, if the reviewer returns “OK” it stops, otherwise it generates a revised version. The result is an answer that tends to be more accurate than calling the LLM just once.
Note: Reflection costs 2–3 times as many LLM calls as usual. Only use it for tasks requiring high accuracy (code review, calculations, writing contracts), not for everyday chat since it’s slow and expensive.
3. Tool Use Pattern: letting agents call external tools
LLMs don’t know the current time, can’t access databases, and can’t calculate large numbers accurately. The Tool Use pattern lets agents “call functions” when needed — the LLM decides which tool to call, the code executes it, and returns the result for the LLM to continue reasoning.
@Component
public class OrderTools {
private final OrderRepository orderRepository;
public OrderTools(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Tool(description = "Look up order status by order ID")
public String getOrderStatus(String orderId) {
return orderRepository.findById(orderId)
.map(Order::getStatus)
.orElse("Order not found: " + orderId);
}
@Tool(description = "Calculate total order amount including 10% tax")
public double calculateTotal(double subtotal) {
return subtotal * 1.1;
}
}
@Service
public class CustomerSupportAgent {
private final ChatClient chatClient;
public CustomerSupportAgent(ChatClient.Builder builder, OrderTools orderTools) {
this.chatClient = builder
.defaultTools(orderTools)
.build();
}
public String handle(String userMessage) {
return chatClient.prompt()
.user(userMessage)
.call()
.content();
}
}
@Tool marks a method as a function that the LLM can call, and description helps the model understand when to use it. When defaultTools(orderTools) is registered, Spring AI automatically: sends the tool schema to the LLM, receives the tool call request (as JSON), executes the corresponding Java method, then returns the result for the LLM to complete the answer.
For example, if the user asks “Where is order ORD-001?”, the agent automatically decides to call getOrderStatus("ORD-001") without you having to write manual intent parsing logic.
User: Where is order ORD-001?
Agent: Order ORD-001 is currently in "Delivery in progress" status.
Test with JUnit 5 + Mockito to verify the tool works correctly without calling a real LLM:
class OrderToolsTest {
private final OrderRepository repository = mock(OrderRepository.class);
private final OrderTools tools = new OrderTools(repository);
@Test
void shouldReturnStatusWhenOrderExists() {
when(repository.findById("ORD-001"))
.thenReturn(Optional.of(new Order("ORD-001", "Delivery in progress")));
String result = tools.getOrderStatus("ORD-001");
assertThat(result).isEqualTo("Delivery in progress");
}
}
Note: Always validate input before the tool touches a database or calls a real API — the LLM might pass malformed parameters (like an empty orderId) if the prompt isn’t clear.
4. Planning Pattern: breaking big tasks into small steps
When you have a complex request (“write a report comparing 3 vendors, including pricing tables and recommendations”), asking the LLM to do it in one shot easily misses steps. The Planning pattern splits into two roles: Planner creates a list of steps, Executor runs each step in sequence.
public record PlanStep(int order, String description) {}
@Service
public class PlannerAgent {
private final ChatClient chatClient;
public PlannerAgent(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public List<PlanStep> createPlan(String goal) {
String json = chatClient.prompt()
.user("""
Break down the following goal into at most 5 execution steps,
return a JSON array like [{"order":1,"description":"..."}]:
%s
""".formatted(goal))
.call()
.content();
return parseSteps(json);
}
}
@Service
public class ExecutorAgent {
private final ChatClient chatClient;
public ExecutorAgent(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String runPlan(String goal, List<PlanStep> steps) {
StringBuilder context = new StringBuilder();
for (PlanStep step : steps) {
String result = chatClient.prompt()
.user("Goal: " + goal
+ "\nContext from prior steps: " + context
+ "\nExecute step: " + step.description())
.call()
.content();
context.append("Step ").append(step.order())
.append(": ").append(result).append("\n");
}
return context.toString();
}
}
PlannerAgent is only responsible for generating a step list as JSON, while ExecutorAgent runs each step and accumulates context so the next step knows what the previous one did. Splitting planner/executor makes debugging easier: if the result is wrong, check the plan first (is it reasonable?) before examining each execution step.
Plan for "write a report comparing 3 vendors":
1. Gather pricing and feature information from each vendor
2. Create a comparison table
3. Analyze pros and cons
4. Provide recommendations
5. Write the executive summary
Note: The Planning pattern shines when the task has multiple interdependent steps. For a single-step question (like “summarize this paragraph”), adding a planner just slows things down and wastes tokens unnecessarily.
5. Multi-Agent Pattern: specialized agents working together
Instead of one “know-it-all” agent, the Multi-Agent pattern divides work among specialized agents: a research agent, a writing agent, an editor agent… then an Orchestrator coordinates the flow between them. Like a small team where each person excels at one thing.
@Service
public class ResearchAgent {
private final ChatClient chatClient;
public ResearchAgent(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String research(String topic) {
return chatClient.prompt()
.user("List the 5 most important facts about: " + topic)
.call()
.content();
}
}
@Service
public class WriterAgent {
private final ChatClient chatClient;
public WriterAgent(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public String write(String topic, String researchNotes) {
return chatClient.prompt()
.user("Write a 200-word article about '" + topic
+ "' based on the data: " + researchNotes)
.call()
.content();
}
}
@Service
public class OrchestratorAgent {
private final ResearchAgent researchAgent;
private final WriterAgent writerAgent;
public OrchestratorAgent(ResearchAgent researchAgent, WriterAgent writerAgent) {
this.researchAgent = researchAgent;
this.writerAgent = writerAgent;
}
public String produceArticle(String topic) {
String notes = researchAgent.research(topic);
return writerAgent.write(topic, notes);
}
}
OrchestratorAgent doesn’t call the LLM itself, it just coordinates: calling ResearchAgent to get raw data, then passing it to WriterAgent to write a polished article. Each agent has its own prompt, optimized for exactly one task, so the quality of each part is usually better than cramming everything into one long prompt.
Test with mocks to verify the orchestration flow works correctly, independent of the LLM:
class OrchestratorAgentTest {
@Test
void shouldPassResearchNotesToWriter() {
ResearchAgent research = mock(ResearchAgent.class);
WriterAgent writer = mock(WriterAgent.class);
when(research.research("Spring AI")).thenReturn("note-A, note-B");
when(writer.write("Spring AI", "note-A, note-B")).thenReturn("Article completed");
OrchestratorAgent orchestrator = new OrchestratorAgent(research, writer);
String article = orchestrator.produceArticle("Spring AI");
assertThat(article).isEqualTo("Article completed");
verify(writer).write("Spring AI", "note-A, note-B");
}
}
Note: Multi-Agent increases latency (multiple sequential LLM calls) and system complexity. Only split agents when each truly has distinct, clear prompt/context differences — splitting too fine (one sentence per agent) just adds needless overhead.
6. ReAct Pattern: reasoning interleaved with action
ReAct (Reasoning + Acting) is a pattern combining Tool Use with a reasoning loop: the agent thinks (Thought) → acts (Action, like calling a tool) → observes the result (Observation) → repeats until it has enough info to answer. Unlike plain Tool Use (call 1 tool, then answer), ReAct lets the agent call multiple tools in flexible sequence, deciding the next step based on prior observations.
@Service
public class ReActAgent {
private final ChatClient chatClient;
private final List<Object> tools;
public ReActAgent(ChatClient.Builder builder, WeatherTool weatherTool, SearchTool searchTool) {
this.tools = List.of(weatherTool, searchTool);
this.chatClient = builder.defaultTools(tools.toArray()).build();
}
public String solve(String question) {
return chatClient.prompt()
.system("""
When you need real-world data, call the appropriate tool before answering.
Reason step-by-step: Thought -> Action -> Observation -> Thought...
Only provide the final answer when you have enough information.
""")
.user(question)
.call()
.content();
}
}
With the question “Should I bring a raincoat when going to Da Lat this weekend?”, the agent can reason on its own: call weatherTool.forecast("Đà Lạt") to get a forecast, observe the result “70% rain,” then answer — the entire Thought/Action/Observation sequence happens inside the framework without you having to write the loop manually, because Spring AI handles the tool-calling loop automatically until the model decides to stop.
Thought: I need to know Da Lat's weather this weekend.
Action: forecast("Đà Lạt")
Observation: 70% rain, 18-22°C
Thought: I should bring a raincoat.
Answer: You should bring a raincoat – the forecast shows 70% rain in Da Lat this weekend.
Note: ReAct can get stuck in an “infinite loop” if the agent keeps calling tools without converging on an answer. Always set a maximum tool call limit (
maxToolCallsor timeout) to avoid uncontrolled API spending.
7. Quick comparison of the 5 patterns
| Pattern | Solves what problem | Cost (number of LLM calls) | When to use |
|---|---|---|---|
| Reflection | Answer quality is low | High (2–3x) | Tasks requiring accuracy, can self-check |
| Tool Use | LLM can’t access real data/systems | Low–Medium | Need lookups, calculations, API calls |
| Planning | Multi-step tasks prone to missing steps | Medium | Complex, well-structured tasks |
| Multi-Agent | One agent isn’t specialized enough | High | Clear pipelines (research/write/review) |
| ReAct | Need tools but steps unknown in advance | Variable | Tasks requiring flexible info-seeking |
In practice, these patterns aren’t mutually exclusive — a customer support agent can use Tool Use to look up orders, Planning to handle complex requests, and Reflection to double-check sensitive answers before sending.
8. Conclusion
The 5 patterns above — Reflection, Tool Use, Planning, Multi-Agent, ReAct — are the building blocks for creating AI Agents that can self-correct, interact with real systems, handle complex tasks, and flexibly search for information. You don’t need to use all 5 for every problem; start with Tool Use (most agents need it), then add Planning or ReAct when tasks get more complex, and only use Reflection/Multi-Agent when the extra cost is worth the quality gain. Choosing the right pattern for your problem matters more than trying to cram everything into a single agent.