Spring AI Recipe: Building a Graph-Based Agentic Workflow
Completely autonomous agents are like unplanned road trips — flexible and full of adventure, but unpredictable. Graph-based workflows provide the roadmap to follow, while still allowing decisions along the way.
In a previous recipe, we explored dynamic agentic behavior where an LLM decides which tools to use and what steps to take in order to achieve a goal.
But not every workflow should be so flexible and open-ended. For some circumstances deterministic workflows are better than autonomous planning. Sometimes you want:
- predictable execution
- explicit branching logic
- controlled flow between steps
- or deterministic handling of specific scenarios
That’s where graph-based workflows come in. Rather than allowing the agent to freely decide every step, a graph workflow defines an explicit flow of nodes and transitions between those nodes. The LLM still plays an important role, but the overall process is guided by a predefined structure.
In this recipe, we’ll use Spring AI Alibaba Graph to build a simple, but meaningful, graph-based customer support workflow.
Heads up: Defining a graph-based workflow involves a bit of work, so this recipe is going to be longer then earlier Spring AI recipes. You might want to refresh your drink of choice before getting started.
NOTE: This recipe has been updated since it’s original publication to reflect changes in Spring AI 2.0.0.
Starting the Project
Start with a basic Spring AI project. The example from the chat loop recipe is a good starting point.
One important note: At the time of writing, Spring AI Alibaba Graph appears to target Spring AI 1.1.x. However, simple testing with Spring AI 2.0.0-M5 also appears to work just fine.
Adding Dependencies
Add Spring AI Alibaba Graph:
implementation 'com.alibaba.cloud.ai:spring-ai-alibaba-graph-core'This also requires the Alibaba BOMs in dependency management:
dependencyManagement {
imports {
mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
mavenBom "com.alibaba.cloud.ai:spring-ai-alibaba-bom:${springAiAlibabaVersion}"
mavenBom "com.alibaba.cloud.ai:spring-ai-alibaba-extensions-bom:${springAiAlibabaVersion}"
}
}And then define the versions:
ext {
set('springAiVersion', "2.0.0-M5")
set('springAiAlibabaVersion', '1.1.2.2')
}The Workflow
The workflow we’ll build handles customer support requests. The graph itself is intentionally simple:
- Classify the request
- Route to either billing support or technical support
In illustrated form, the workflow looks something like this:
Even though the workflow is small, it demonstrates several important graph concepts:
- nodes
- edges
- conditional transitions and branching
- workflow state
Defining Workflow Nodes
In Spring AI Alibaba Graph, workflow nodes implement the NodeAction interface.
Each node:
- receives the workflow state
- performs some work
- returns additional state to merge into the workflow
Classifying the Request
The first node determines whether the customer question is billing-related or technical:
public class ClassifySupportRequestNode implements NodeAction {
private static final Logger logger =
LoggerFactory.getLogger(ClassifySupportRequestNode.class);
private final ChatClient chatClient;
public ClassifySupportRequestNode(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
logger.info("Classifying user question");
var question = state.value("user_question", String.class)
.orElseThrow();
var category = chatClient.prompt()
.system("""
Classify the customer support request as exactly one of:
billing
technical
Respond with only the category name.
""")
.user(question)
.call()
.content()
.trim()
.toLowerCase();
if (!category.equals("billing")
&& !category.equals("technical")) {
logger.info(
"Question category({}) unknown. Defaulting to technical.",
category);
category = "technical";
}
return Map.of("category", category);
}
}A few important things are happening here:
- The node pulls
"user_question"from workflow state - It uses an LLM to classify the question
- The result is added back into workflow state as
"category"
Notice that the node itself does not decide where execution flows next. It only contributes state. The graph definition will decide how that state affects workflow routing.
Billing Support Node
Next, define the billing support handler:
public class BillingSupportNode implements NodeAction {
private static final Logger logger =
LoggerFactory.getLogger(BillingSupportNode.class);
private final ChatClient chatClient;
public BillingSupportNode(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public Map<String, Object> apply(OverAllState state)
throws Exception {
logger.info("Handling billing question.");
String question = state.value("user_question", String.class)
.orElseThrow();
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(question)
.call()
.content();
return Map.of("response", response);
}
}This node:
- retrieves the question
- generates a billing-specific response
- stores the response into workflow state
Technical Support Node
The technical support node works almost exactly the same way:
public class TechnicalSupportNode implements NodeAction {
private static final Logger logger =
LoggerFactory.getLogger(TechnicalSupportNode.class);
private final ChatClient chatClient;
public TechnicalSupportNode(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public Map<String, Object> apply(OverAllState state)
throws Exception {
logger.info("Handling technical question.");
String message = state.value("user_question", String.class)
.orElseThrow();
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(message)
.call()
.content();
return Map.of("response", response);
}
}The only meaningful difference is the system prompt that guides the LLM’s behavior.
Building the Graph
Now that all nodes are defined, it’s time to connect them into a workflow. That happens by defining a CompiledGraph bean:
@Configuration
public class SupportGraphConfiguration {
static final String CLASSIFY = "classify";
static final String BILLING = "billing";
static final String TECHNICAL = "technical";
@Bean
CompiledGraph supportGraph(ChatClient chatClient)
throws GraphStateException {
return new StateGraph("support-triage", this::stateStrategies)
// Nodes
.addNode(CLASSIFY,
node_async(new ClassifySupportRequestNode(chatClient)))
.addNode(BILLING,
node_async(new BillingSupportNode(chatClient)))
.addNode(TECHNICAL,
node_async(new TechnicalSupportNode(chatClient)))
// Edges
.addEdge(START, CLASSIFY)
.addConditionalEdges(
CLASSIFY,
edge_async(state -> {
String category =
state.value("category", String.class)
.orElse(TECHNICAL);
return switch (category) {
case BILLING -> BILLING;
case TECHNICAL -> TECHNICAL;
default -> TECHNICAL;
};
}),
Map.of(
BILLING, BILLING,
TECHNICAL, TECHNICAL
))
.addEdge(BILLING, END)
.addEdge(TECHNICAL, END)
.compile();
}
private Map<String, KeyStrategy> stateStrategies() {
return Map.of(
"user_question", new ReplaceStrategy(),
"category", new ReplaceStrategy(),
"response", new ReplaceStrategy()
);
}
}This is where the graph workflow truly comes together. A few important concepts appear here.
Nodes
Each node represents a unit of work. They are added to the graph by calling addNode().
Edges
Edges define transitions between nodes. They are added to the graph by calling addEdge(from, to).
START and END
There are two special nodes:
STARTEND
Execution begins at START and terminates at END.
Conditional Routing
The most interesting part is:
.addConditionalEdges(...)This allows workflow execution to branch dynamically based on workflow state. In this case:
"billing"routes to the billing node"technical"routes to the technical node
This is the core idea behind graph-based workflows:
- structured flow
- dynamic branching
Understanding Workflow State
Workflow state is essentially a shared map passed between nodes. Each node can: read state, add state, or replace state. The stateStrategies() method determines how updates from each node are merged into the workflow’s state.
Here we use ReplaceStrategy, meaning that new values overwrite previous values. Other options include:
AppendStrategy— Appends new values to any existing values, essentially forming a list of values collected as the workflow progresses.MergeStrategy— A more advanced strategy in which complex values are intelligently merged with existing values. AMap, for example, would have it’s entries merged with any entries in an existingMap.
Running the Workflow
Finally, wire the graph into the chat loop:
@Bean
ApplicationRunner go(CompiledGraph compiledGraph) {
return args -> {
System.out.println("How can I help?\n");
try (Scanner scanner = new Scanner(System.in)) {
while (true) {
System.out.print("> ");
Map<String, Object> initialState = new HashMap<>();
initialState.put(
"user_question",
scanner.nextLine());
var workflowResult =
compiledGraph.invoke(initialState)
.orElseThrow();
System.out.println(
"\n - "
+ workflowResult.data().get("response"));
}
}
};
}Unlike previous recipes that invoked ChatClient directly, this version invokes the graph itself.
The graph:
- receives initial workflow state
- executes nodes
- transitions between nodes
- returns final workflow state
Trying It Out
Run the application and try questions such as:
How can I help?
> I was overcharged $50Or:
How can I help?
> My computer screen is frozenYou should:
- receive an appropriate response
- and see logging proving the workflow moved through classification…
- then billing or technical support
Key Takeaway
Graph workflows occupy an interesting middle ground between:
- rigid procedural logic
- and completely autonomous agents
They provide:
- structure
- predictability
- branching behavior
- and controlled agentic execution
This makes them ideal for:
- support workflows
- approval flows
- orchestrated multi-step systems
- and any situation where you want explicit control over execution paths
There’s much more that you can do with Spring AI Alibaba Graph, such as looping and handling human-in-the-loop interactions. I’ll cover some of those things in future recipes.
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.