ai newsMi., 21. Okt.
EilmeldungMeta to create a new class of shares to fund AI push

Building an AI Shirt Agent with Java and Spring AI: Tools, ChatClient, and REST

Introduction Large language models are good at understanding requests, but they do not know your inventory, your prices, or your business rules. The usual answer is to give the model tools : ordinary Java methods it can call when it needs information. The model decides when to call a tool, your cod

dev.toMittwoch 21:47 · 21.10.8 Min. Lesezeit
Bild: dev.to

Large language models are good at understanding requests, but they do not know your inventory, your prices, or your business rules. The usual answer is to give the model tools: ordinary Java methods it can call when it needs information. The model decides when to call a tool, your code runs it, and the model uses the result to answer.

Spring AI brings this pattern into the Spring ecosystem. You define a tool with an annotation, hand it to a ChatClient, and ask for a typed response. In this article we build a small but complete example:

The code is written for Spring Boot 3 and Spring AI 1.x. The API has changed between milestones, so always check the reference documentation for the version you pin.

Client -- POST / api / shirts / recommend -- > ShirtController -- > ShirtService ( validation ) -- > ShirtAgent ( ChatClient + tools ) -- > LLM decides to call findShirtByColor -- > ShirtCatalogTool (@ Tool, in - memory catalog ) < -- Shirt (structured response) Enter fullscreen mode Exit fullscreen mode The controller knows nothing about the model. The service validates the business input. The agent owns the prompt and the tool wiring. The tool owns the data access. Each layer can be tested on its own.

Add the Spring AI BOM and the OpenAI starter. The BOM keeps all Spring AI artifacts on the same version.

<properties> <java.version> 21 </java.version> <spring-ai.version> 1.1.8 </spring-ai.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId> org.springframework.ai </groupId> <artifactId> spring-ai-bom </artifactId> <version> ${spring-ai.version} </version> <type> pom </type> <scope> import </scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId> org.springframework.boot </groupId> <artifactId> spring-boot-starter-web </artifactId> </dependency> <dependency> <groupId> org.springframework.ai </groupId> <artifactId> spring-ai-starter-model-openai </artifactId> </dependency> </dependencies> Enter fullscreen mode Exit fullscreen mode Configure the model in application.properties and read the key from the environment:

spring.ai.openai.api-key = ${OPENAI_API_KEY} spring.ai.openai.chat.options.model = gpt-4o-mini spring.ai.openai.chat.options.temperature = 0 Enter fullscreen mode Exit fullscreen mode Setting temperature to zero makes the tool selection and the output more predictable, which is what you want for a catalog lookup.

A Java record is a natural fit for the response. Spring AI converts the model's structured output into it.

package com.example.shirts; public record Shirt ( String color, String size, String model, double price ) { } Enter fullscreen mode Exit fullscreen mode Step 3: The Tool A tool is a method annotated with @Tool. The description matters: the model reads it to decide when the tool is relevant. Each parameter can carry a @ToolParam description as well.

package com.example.shirts; import java.util.List; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; @Component public class ShirtCatalogTool { // In a real application this would be a repository or an inventory API. private static final List < Shirt > CATALOG = List. of ( new Shirt ( "red", "M", "Classic Crew", 299.0 ), new Shirt ( "red", "L", "Relaxed Fit", 349.0 ), new Shirt ( "blue", "M", "Classic Crew", 299.0 ), new Shirt ( "black", "L", "Essential Tee", 259.0 ) ); @Tool ( description = "Find a t-shirt (playera) available in the requested color. Returns the first matching shirt." ) public Shirt findShirtByColor ( @ToolParam ( description = "Color in English, for example red, blue or black" ) String color ) { return CATALOG. stream (). filter ( shirt -> shirt. color (). equalsIgnoreCase ( color. trim ())). findFirst (). orElseThrow (() -> new IllegalArgumentException ( "No shirt found for color: " + color )); } } Enter fullscreen mode Exit fullscreen mode Notice that the @ToolParam description asks for English color names. A user can still write roja, and the model translates it before calling the tool.

That is the model doing the language work, while your code keeps the catalog lookup deterministic.

The agent wraps a ChatClient. It sets the system instructions once, and each call supplies the user request and the tool.

package com.example.shirts; import org.springframework.ai.chat.client.ChatClient; import org.springframework.stereotype.Component; @Component public class ShirtAgent { private final ChatClient chatClient; private final ShirtCatalogTool shirtCatalogTool; public ShirtAgent ( ChatClient. Builder chatClientBuilder, ShirtCatalogTool shirtCatalogTool ) { this. chatClient = chatClientBuilder. defaultSystem ( """ You are a shopping assistant for a clothing store. To answer, call the findShirtByColor tool with the color in English.

Return only the shirt that the tool returns. Do not invent products. """ ). build (); this. shirtCatalogTool = shirtCatalogTool; } public Shirt recommend ( String item, String color ) { return chatClient. prompt (). user ( u -> u. text ( "Find a {item} in {color} color." ). param ( "item", item ). param ( "color", color )). tools ( shirtCatalogTool ). call (). entity ( Shirt. class ); } } Enter fullscreen mode Exit fullscreen mode Three details are worth noting:

package com.example.shirts; public record ShirtRequest ( String item, String color ) { } Enter fullscreen mode Exit fullscreen mode Step 6: The Service The service validates the input and calls the agent. Business rules belong here, not in the controller and not in the prompt.

package com.example.shirts; import java.util.Set; import org.springframework.stereotype.Service; @Service public class ShirtService { private static final Set < String > SUPPORTED_ITEMS = Set. of ( "playera", "shirt", "t-shirt" ); private final ShirtAgent shirtAgent; public ShirtService ( ShirtAgent shirtAgent ) { this. shirtAgent = shirtAgent; } public Shirt recommend ( ShirtRequest request ) { if ( request. item () == null ||! SUPPORTED_ITEMS. contains ( request. item (). trim (). toLowerCase ())) { throw new IllegalArgumentException ( "Unsupported item: " + request. item ()); } if ( request. color () == null || request. color (). isBlank ()) { throw new IllegalArgumentException ( "Color is required" ); } return shirtAgent. recommend ( request. item (). trim (), request. color (). trim ()); } } Enter fullscreen mode Exit fullscreen mode Step 7: The REST Controller package com.example.shirts; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping ( "/api/shirts" ) public class ShirtController { private final ShirtService shirtService; public ShirtController ( ShirtService shirtService ) { this. shirtService = shirtService; } @PostMapping ( "/recommend" ) public ResponseEntity < Shirt > recommend ( @RequestBody ShirtRequest request ) { return ResponseEntity. ok ( shirtService. recommend ( request )); } } Enter fullscreen mode Exit fullscreen mode Step 8: Calling the Endpoint curl -X POST http://localhost:8080/api/shirts/recommend \ -H "Content-Type: application/json" \ -d '{"item":"playera","color":"roja"}' Enter fullscreen mode Exit fullscreen mode A typical response looks like this. The exact output depends on the model and the catalog:

{ "color": "red", "size": "M", "model": "Classic Crew", "price": 299.0 } Enter fullscreen mode Exit fullscreen mode Step 9: Testing Without Calling a Model Two tests cover most of the logic without network calls.

The tool is plain Java, so test it directly:

class ShirtCatalogToolTest { @Test void findsRedShirt () { ShirtCatalogTool tool = new ShirtCatalogTool (); Shirt shirt = tool. findShirtByColor ( "red" ); assertEquals ( "red", shirt. color ()); } @Test void throwsWhenColorIsMissing () { ShirtCatalogTool tool = new ShirtCatalogTool (); assertThrows ( IllegalArgumentException. class, () -> tool. findShirtByColor ( "purple" )); } } Enter fullscreen mode Exit fullscreen mode The service validation can be tested with Mockito, since the agent is an ordinary bean:

class ShirtServiceTest { @Test void rejectsUnsupportedItem () { ShirtAgent agent = mock ( ShirtAgent. class ); ShirtService service = new ShirtService ( agent ); assertThrows ( IllegalArgumentException. class, () -> service. recommend ( new ShirtRequest ( "sofa", "red" ))); verifyNoInteractions ( agent ); } } Enter fullscreen mode Exit fullscreen mode For end-to-end checks against the real model, keep a small number of integration tests and run them separately, because they cost money and depend on network access.

When the tool throws, the model receives the error and may try again or explain the problem. For a REST API, however, you usually want a clear HTTP status. Map the exceptions in a @RestControllerAdvice:

@RestControllerAdvice public class ShirtExceptionHandler { @ExceptionHandler ( IllegalArgumentException. class ) public ResponseEntity < String > handleBadRequest ( IllegalArgumentException ex ) { return ResponseEntity. badRequest (). body ( ex. getMessage ()); } } Enter fullscreen mode Exit fullscreen mode Best Practices Describe tools precisely. The model chooses tools by their descriptions. Vague descriptions cause missed or wrong calls.

Keep data access in tools and services, not in prompts. Prices and stock come from your systems, not from text the model might misread. Validate input before the model.

Reject unsupported items in the service so you do not pay for a call that cannot succeed. Return typed objects. entity gives you a contract the rest of your code can rely on. Set temperature low for lookups.

Creative variation is rarely wanted when the answer comes from a catalog. Pin the Spring AI version. The API evolves quickly, and the BOM keeps the modules consistent.

Log tool calls in production. You need to know which tool ran, with which arguments, to debug wrong answers. Conclusion and Key Takeaways A Spring AI agent is a ChatClient with a system prompt, tools, and a typed response.

A @Tool method is ordinary Java. The model decides when to call it; your code controls what it does. Layering the example as controller, service, and agent keeps HTTP, validation, and AI concerns separate and testable.

Validate inputs in code, keep data in tools, and let the model handle only the language work. The same pattern scales from one catalog method to a full set of business tools. If you are building AI features into Java services, I would like to hear which tool patterns you found most useful and where the model surprised you.

Quelle: dev.to (dev.to). Original öffnen