Sitemap

Spring AI Recipe: Adding a Loop to a Graph-Based Workflow

6 min readMay 8, 2026

--

What if your workflow could take a second pass at its own answer before showing it to the user? By adding a simple loop, you can turn a one-shot response into an iterative process that refines itself until it’s actually useful.

Press enter or click to view image in full size

In previous recipes, we built a graph-based agentic workflow using Spring AI Alibaba Graph and then enhanced it with Human-in-the-Loop (HITL) to allow a person to guide the workflow when needed.

This time, we’re going to take it a step further by adding a loop — giving the agent a chance to evaluate and improve its own response before returning it to the user.

NOTE: This recipe has been updated since it’s original publication to reflect changes in Spring AI 2.0.0.

Why Add a Loop?

So far, the workflow has been essentially linear:

Request -> Classify -> Support -> END

But real-world problem solving often isn’t one-and-done. Sometimes the first answer isn’t good enough.

By adding a loop, we enable the workflow to:

Generate -> Evaluate -> Revise -> Repeat (until good enough)

This is where the workflow shifts from a straight-through process to something adaptive.

Updated Workflow

This illustration shows the updated workflow:

Adding looping to the support workflow

The new workflow behaves like this:

  • Billing or Technical Support generates a response
  • A new Check Resolution node evaluates that response
  • If it’s good → END
  • If not → loop back and try again
  • After 3 failed attempts → escalate to a human

We’ll start where we left off in the Human-in-the-Loop recipe, adding a couple of nodes and a few edges to the graph.

Add a Resolution Evaluation Node

We start by introducing a new NodeAction that evaluates the support response:

public class CheckResolutionNode implements NodeAction {
private static final Logger logger = LoggerFactory.getLogger(CheckResolutionNode.class);
private final ChatClient chatClient;

public CheckResolutionNode(ChatClient chatClient) {
this.chatClient = chatClient;
}

@Override
public Map<String, Object> apply(OverAllState state) {
String userQuestion = state.value("user_question", "");
String response = state.value("response", "");
int attempts = state.value("attempts", 0);
logger.trace("Attempt {} to resolve {}", attempts + 1, userQuestion);
Evaluation evaluation = chatClient.prompt()
.system("""
You are evaluating a customer support response.
Determine whether the response adequately resolves
the customer's request.
A good response should:
- directly address the user's issue
- provide clear, actionable steps
- avoid vague or generic advice
If the response is insufficient, explain briefly what is missing.
Respond ONLY as JSON:
{
"resolved": true|false,
"feedback": "..."
}
""")
.user("""
Customer request:
%s
Support response:
%s
""".formatted(userQuestion, response))
.call()
.entity(Evaluation.class);
return Map.of(
"resolved", evaluation.resolved(),
"feedback", evaluation.feedback(),
"attempts", attempts + 1
);
}

public record Evaluation(boolean resolved, String feedback) {}
}

This node:

  • Evaluates the quality of the response
  • Adds a “resolved” entry to the workflow state indicating whether it’s acceptable or not
  • Provides feedback for improvement in a new “feedback” entry in workflow state
  • Tracks how many attempts have been made in the “attempts” entry of workflow state

Add an Escalation Node

If the workflow can’t resolve the issue after several attempts, we escalate to the user:

public class EscalateToHumanNode implements NodeAction {

@Override
public Map<String, Object> apply(OverAllState state) {
String userQuestion = state.value("user_question", "");
String category = state.value("category", "unknown");
String response = state.value("response", "");
String feedback = state.value("feedback", "");
int attempts = state.value("attempts", 0);
String finalResponse = """
I wasn't able to fully resolve this automatically, so this should be escalated
to a human support representative.
Category: %s
Original request:
%s
Last attempted response:
%s
Evaluation feedback:
%s
Automated attempts: %d
""".formatted(category, userQuestion, response, feedback, attempts);
return Map.of(
"escalated", true,
"finalResponse", finalResponse
);
}

}

This node is purposefully simple, only producing a final response to the user saying that it could not arrive at a satisfactory resolution. This keeps the example simple while demonstrating a real-world fallback pattern.

Register the New Nodes

Add the nodes to your graph in SupportGraphConfiguration:

.addNode(CHECK_RESOLUTION, node_async(new CheckResolutionNode(chatClient)))
.addNode(ESCALATE_TO_HUMAN, node_async(new EscalateToHumanNode()))

Those two lines rely on these constants also being added to SupportGraphConfiguration:

static final String CHECK_RESOLUTION = "check-resolution";
static final String ESCALATE_TO_HUMAN = "escalate-to-human";

Route Support Nodes to Evaluation

Previously, both the billing and technical support nodes transitioned to the. END node:

.addEdge(BILLING, END)
.addEdge(TECHNICAL, END)

Now, they will need to transition to the check resolution node:

.addEdge(BILLING, CHECK_RESOLUTION)
.addEdge(TECHNICAL, CHECK_RESOLUTION)

Add the Looping Logic

Depending on the outcome of the check resolution node, the flow could transition to END, to the escalation node, or back to the billing or technical nodes. The conditional edge for the check resolution node is where the loop actually happens:

.addConditionalEdges(
CHECK_RESOLUTION,
edge_async(state -> {
boolean resolved = state.value("resolved", false);
int attempts = state.value("attempts", 0);
String category = state.value("category", TECHNICAL);

if (resolved) {
return END;
}
if (attempts >= 3) {
return ESCALATE_TO_HUMAN;
}
return category;
}),
Map.of(
END, END,
BILLING, BILLING,
TECHNICAL, TECHNICAL,
ESCALATE_TO_HUMAN, ESCALATE_TO_HUMAN
)
)

Using Feedback on Retries

To make retries meaningful, we need to modify the billing and support nodes to use the feedback (if any) when retrying to create a resolution. To do that, we’ll need to pull the feedback from workflow state and add it to the user message sent in those nodes. The changes to BillingSupportNode and TechnicalSupportNode are similar.

For BillingSupportNode, the apply() method should be updated to look like this:

@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
logger.info("Handling billing question.");
String question = state.value("user_question", String.class)
.orElseThrow();
String feedback = state.value("feedback", "");

String userMessage = """
Customer request:
%s
""".formatted(question);

if (!feedback.isBlank()) {
userMessage += """

Previous feedback on your last response:
%s

Revise your answer to address this feedback.
""".formatted(feedback);
}

var response = chatClient.prompt()
.system("""
You are a billing support assistant.
Write a brief, helpful response to the customer.
Do not promise refunds. Say that the billing team will review the account.
""")
.user(userMessage)
.call()
.content();

return Map.of("response", response);
}

The same pattern applies to TechnicalSupportNode:

@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
logger.info("Handling technical question.");
String question = state.value("user_question", String.class)
.orElseThrow();
String feedback = state.value("feedback", "");

String userMessage = """
Customer request:
%s
""".formatted(question);

if (!feedback.isBlank()) {
userMessage += """

Previous feedback on your last response:
%s

Revise your answer to address this feedback.
""".formatted(feedback);
}

String response = chatClient.prompt()
.system("""
You are a technical support assistant.
Write a brief, helpful response to the customer.
Suggest one or two practical troubleshooting steps or
ask if they have tried turning it off and then back on again.
""")
.user(userMessage)
.call()
.content();

return Map.of("response", response);
}

This ensures each iteration improves instead of repeating the same answer.

Try It Out

Start the app and try prompts that tend to produce weak first responses:

  • Billing:
    "I was charged twice this month. What should I do?"
  • Technical:
    "I can't log into my account"

You’ll often see the agent refine its answer over multiple iterations.

To observe the loop in action, enable trace logging:

logger.trace("Attempt {} to resolve {}", attempts+1, userQuestion);

A Note on LLM Behavior

Because LLMs are nondeterministic:

  • Sometimes the first answer is good
  • Sometimes it improves after a few loops
  • Sometimes it still fails and escalates

That variability is expected — and actually reinforces why loops are valuable.

Wrapping Up

With just a few additions, we transformed a linear workflow into something far more powerful:

Generate -> Evaluate -> Revise -> Repeat

This pattern is foundational for many modern agentic systems — and now you’ve built it yourself with Spring AI and Spring AI Alibaba Graph.

You can find the code for this recipe and my other Spring AI recipes at https://github.com/habuma/spring-ai-recipes.

If you’re exploring Spring AI, I’ve written a book (Spring AI in Action) that covers the core foundations — and I’ll continue sharing newer patterns like this here as the ecosystem evolves. You can find my book on the publisher’s website, at Amazon, or anywhere you buy technical books.

--

--

Craig Walls
Craig Walls

Written by Craig Walls

Author Spring AI in Action, Spring in Action, and Build Talking Apps for Alexa