DEV Community

Vadym Kazulkin for AWS Heroes

Posted on • Edited on

Spring AI with Amazon Bedrock - Part 1 Introduction and the sample application

Brief introduction into Spring AI and Amazon Bedrock

Spring AI is an application framework for AI engineering. Its goal is to apply to the AI domain Spring ecosystem design principles such as portability and modular design and promote using POJOs as the building blocks of an application to the AI domain.

Spring AI provides support for all major AI Model providers such as Anthropic, OpenAI, Microsoft, Amazon, Google, and Ollama. The list of its features is rich and new features are constantly added.

As I'm very active in the AWS community, I'll mostly cover Spring AI Amazon Bedrock support. Amazon Bedrock is a managed service that provides foundation models from various AI providers, available through a unified API. Following the Bedrock recommendations, Spring AI transitioned to using Amazon Bedrock’s Converse API for all Chat conversation implementations in Spring AI.

Spring AI supports the Embedding AI models available through Amazon Bedrock by implementing the Spring EmbeddingModel interface.

Additionally, Spring AI provides Spring Auto-Configurations and Boot Starters for all clients, making it easy to bootstrap and configure for the Bedrock models.

Sample Application

We'll Spring AI with Amazon Bedrock to develop a sample application for searching for the conferences.

Let's go to Spring Initializr to create our demo project. I added 2 dependencies:

  • Spring Web
  • Spring AI Amazon Bedrock Converse API

fill some other data (I selected Maven, Java 21 support and Java packaging):

Then I generated the artifact. You can find this sample application as spring-ai-with-amazon-bedrock-demo in my GitHub account. I use it to experiment with Spring AI with Amazon Bedrock feature in general, but we'll only focus on the conference tool in this article.

The relevant dependencies in the pom.xml are those:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-bedrock-converse</artifactId>
</dependency>

.....

<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>
  </dependencyManagement>
</dependencies>

Enter fullscreen mode Exit fullscreen mode

Global configuration is provided in the application.properties file:

spring.application.name=vkazulkin-demo
spring.ai.bedrock.aws.region=us-east-1
spring.ai.bedrock.aws.timeout=10m
spring.ai.bedrock.converse.chat.options.max-tokens=100
spring.ai.bedrock.converse.chat.options.model=amazon.nova-lite-v1:0
Enter fullscreen mode Exit fullscreen mode

Here we can provide some global configuration properties which will be valid for each communication with LLM. Here is the full list of the Amazon Bedrock Converse API chat properties. We can override them on the different levels, as we'll see later.
As I didn't find any public and free API for it I provided a static partially faked conference list as JSON file which contains only a few conferences (which is enough to experiment with Spring AI). Here is a short extract from:

{
  "conferences" : [ {
    "name" : "AWS Re:Invent",
    "topics" : [ "Serverless", "Gen AI", "AWS" ],
    "homepage" : "https://reinvent.awsevents.com",
    "startDate" : [ 2025, 12, 1 ],
    "endDate" : [ 2025, 12, 5 ],
    "city" : "Las Vegas",
    "linkToCallforPapers" : "https://reinvent.awsevents.com/cfp"
  },
....
  {
    "name" : "Voxxed Days Belgium",
    "topics" : [ "Spring", "AI", "JVM", "Java" ],
    "homepage" : "https://devoxx.be/",
    "startDate" : [ 2025, 10, 6 ],
    "endDate" : [ 2025, 10, 10 ],
    "city" : "Antwerpen",
    "linkToCallforPapers" : "https://devoxx.be/cfp"
  }]
}
Enter fullscreen mode Exit fullscreen mode

This will be enough to search in this list by the topic and by the date range. In this part of the series we'll develop a REST application which we'll run locally. In the next part of the series, we'll run Model Context Protocol (MCP) server with the defined tools and use Model Context Protocol Inspector and Amazon Q Developer plugin in the Visual Studio Code as MCP clients to list the available tools and to talk to our application using the natural language.

Here is the implementation of the Conference class which is a simple Java record containing some properties like conference name, topics, homepage, start and end dates, city where conference takes place and the link to the call for papers page:

public record Conference (String name, Set<String> topics, String homepage,
LocalDate startDate, LocalDate endDate, String city, String   linkToCallforPapers) {
}
Enter fullscreen mode Exit fullscreen mode

As our application is a REST application, let's implement the ConferenceSearchController.

In order to talk to LLM, we need to inject either ChatClient or ChatClient.Builder (this is what we do) in the constructor of the ConferenceSearchController. Below is the simplest way to create ChatClient using ChatClient.Builder with default options. By setting the options we can override the default configuration from the application.properties files my setting even a different LLM. We still set the same Amazon Nove Lite model, but can use any model supported via Amazon Bedrock on demand models the AWS region where we deploy our application. It's also important to ensure that the access to the Amazon Bedrock model in use is enabled. You can check it and enable access to the model on the Amazon Bedrock Model access page. We can also override system and user prompts, temperature, maximal number of tokens and so on.

public ConferenceSearchController(ChatClient.Builder builder, ChatMemory chatMemory) {
  var options = ToolCallingChatOptions.builder()
   .model("amazon.nova-lite-v1:0")
   .maxTokens(2000)
   .build();

  this.chatClient = builder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.defaultOptions(options).build();
Enter fullscreen mode Exit fullscreen mode

We also see here that we use defaultAdvisors of the ChatClient.Builder to configure the chat memory, as by default all conversations are stateless and you can't ask question about previously delivered content. My default InMemoryChatMemoryRepository is used which stores messages (by default the last 20, but you can set a different value) in memory using a ConcurrentHashMap. You can also use other chat memory providers instead, like JdbcChatMemoryRepository, CassandraChatMemoryRepository, Neo4j ChatMemoryRepository and others.

Spring AI also offers Advisors API, so you can use other advisors or build your own (both are beyond the scope of this article). The Spring AI Advisors API provides a flexible and powerful way to intercept, modify, and enhance AI-driven interactions in your Spring applications. By leveraging the Advisors API, developers can create more sophisticated, reusable, and maintainable AI components.

The key benefits include encapsulating recurring Generative AI patterns, transforming data sent to and from Large Language Models (LLMs), and providing portability across various models and use cases.

We'll implement 2 HTTP GET REST APIs/methods in our ConferenceSearchController:

  • conferenceSearch which takes a prompt as a parameter to do the search
  • conferenceSearchbyTopic which take the topic as a parameter (default value is "Java") and number_of_months (default value is 6). More on this later.

Let's first look at the implementation of the conferenceSearch method:

@GetMapping("/conference-search")
public Flux<String> conferenceSearch(@RequestParam(value = "prompt") String prompt) {
   return this.chatClient.prompt()
      .user(prompt)
      .tools(this.conferenceSearchTool)
      .stream()
      .content();
    }
Enter fullscreen mode Exit fullscreen mode

As you see, we need to learn some new concepts. Generally we use the user method of the ChatClient to pass the prompt. But then we call the tools method. What does it do? LLMs doesn't know anything about the conferences, they can only partially crawl the web. That's why we should provide them with the tool to get this information. This is exactly what the ConferenceSearchTool implementation does. It exposes tools (class methods annotated with the org.springframework.ai.tool.annotation.Tool annotation). For the conferenceSearch method showed above only this tool with the name All_Conference_Search_Tool is relevant:

@Tool(name="All_Conference_Search_Tool",
description = "Get the list of all conferences and answer questions about them")
  public Set<Conference> searchAllConferences() {
return this.conferences;
}
Enter fullscreen mode Exit fullscreen mode

We need to give the tool the name and description, so that LLM can understand when to use this tool. As you see in the class ConferenceSearchTool implementation, the conference list is loaded directly in the constructor by parsing our conference list JSON file :

private Set<Conference> conferences;

public ConferenceSearchTool(ObjectMapper objectMapper) {
  objectMapper.registerModule(new JavaTimeModule());
  this.conferences= this.getAllConferences(objectMapper).conferences();
}

private Conferences getAllConferences(ObjectMapper objectMapper) {
  try (InputStream inputStream = TypeReference.class.getResourceAsStream("/conferences.json")) {
    return objectMapper.readValue(inputStream, Conferences.class);
}
  catch(IOException ex) {
throw new RuntimeException("can't read conferences");
}
  }
Enter fullscreen mode Exit fullscreen mode

After we learned the concept of the tools, let's come back to the implementation of the conferenceSearch method:

@GetMapping("/conference-search")
public Flux<String> conferenceSearch(@RequestParam(value = "prompt") String prompt) {
   return this.chatClient.prompt()
      .user(prompt)
      .tools(this.conferenceSearchTool)
      .stream()
      .content();
    }
Enter fullscreen mode Exit fullscreen mode

As you see we pass a conferenceSearchTool object injected in the constructor on the ConferenceSearchController to the method tools. Then we invoke stream method to receive the results in the non-blocking fashion as soon as some are available (that's why the return type of the method is Flux) and then we invoke content method to stream the LLM response. You can also set other attributes on the ChatClientRequestSpec object (which is the result of chatClient.prompt() invocation), like advisors, including MemoryAdvisor introduced above. In such a case we override the global setting on the ChatClient (if set before) and define the setting for this specific invocation (in this case the scope is the chat with LLM in the conferenceSearch method only). See the whole list on what can be set for the ChatClientRequestSpec.

If you prefer the blocking implementation, this method should look like this:

@GetMapping("/conference-search")
public String conferenceSearch(@RequestParam(value = "prompt") String prompt) {
   return this.chatClient.prompt()
      .user(prompt)
      .tools(this.conferenceSearchTool)
      .call()
      .content();
    }
Enter fullscreen mode Exit fullscreen mode

The only differences are: the usage of the call instead of the stream method and the return type is String instead of Flux.

We'll try this out, but let's first look at the more sophisticated implementation of the conferenceSearchbyTopic method. This method is capable of searching for the specific conference topic but also the conference start date between now and a given number of months.

@GetMapping("/conference-search-by-topic")
public Flux<String> conferenceSearchbyTopic(@RequestParam(value = "topic", defaultValue = "Java") String topic,
@RequestParam(value = "number_of_months", defaultValue = "6") String numOfMonths) {

final String USER_PROMPT= """
   1. Provide me with the best suggestions to apply for the talk for the {topic} conferences.
   2. The provided conference start date attribute should be within the next {number_of_months} months starting from the current date and time.
   3. Please include the following conference info in the response only: name, topics, homepage, start and end date, city and call for papers link
   """;
    return this.chatClient.prompt()
        .system(s -> s.text(SYSTEM_PROMPT)
        .param("topic", topic))
        .user(u -> u.text(USER_PROMPT)
        .param("topic",topic)
        .param("number_of_months", numOfMonths))
        .tools(this.zonedDateTimeTool, this.conferenceSearchTool)
        .stream()
        .content();
    }

Enter fullscreen mode Exit fullscreen mode

First of all we set the parameterized system prompt (we could also set one for the conference search method previously):

.system(s -> s.text(SYSTEM_PROMPT).param("topic", topic))

System prompt which we defined as:

String SYSTEM_PROMPT="""
You are only able to answer questions about upcoming technical conferences.
If the provided search term {topic} is not a technical term or is not
connected to the conference for the software development, please respond in
the friendly manner that you're not able to provide this information.
""";
Enter fullscreen mode Exit fullscreen mode

is self-explaining. We'd like to prevent the controller method(s) from answering the question about topics other than technical conferences. We can also pass the information to the prompts (system and user) dynamically, like we did with the parameter topic, see it {topic} in the prompt.

Then we use the prompt .user(u -> u.text(USER_PROMPT).param("topic",topic).param("number_of_months", numOfMonths)).

As you see it also has the parameterized prompt. Then we pass the conference topic and number of months directly to the prompt. The part using the {number_of_months} is the most interesting part:

The provided conference start date attribute should be within the next
{number_of_months} months starting from the current date and time.
Enter fullscreen mode Exit fullscreen mode

How does LLM know the current start date and time to calculate the start conference date range? It doesn't, that's why we defined another tool, ZonedDateTimeTool injected in to ConferenceSearchController capable of providing this information:

@Component
public class ZonedDateTimeTool {

    @Tool(name="Current_Date_Time_Tool", description = "Provide the current date")
    String getZonedDateTime() {
        return LocalDateTime.now().toString();
    }

    @Tool(name="Current_Time_Zone", description = "Provide the current time zone")
        return TimeZone.getDefault().toString();
    }
}
Enter fullscreen mode Exit fullscreen mode

This class provides the tools to answer the questions about the current date and time and current time zone (to search later for the conference near to my zone). With that LLM can figure out the current date and add the defined number of months to determine the conference start date range to search for.

The missing piece is another defined tool in the ConferenceSearchTool capable of taking the conference topic and calculated (by LLM!) earliest start and end dates :

@Tool(name="Conference_Search_Tool_By_Topic", description = "Search for the conference list for exactly one topic provided")
public Set<Conference> search(
    @ToolParam(description = "conference topic") String topic,
    @ToolParam(description = " the conference earliest start date")
LocalDate earliestStartDate,
    @ToolParam(description = " the conference latest start date")
LocalDate latestStartDate) {

  Set<Conference> foundConferences= this.conferences.stream()
.filter(c -> c.topics().contains(topic))
.filter(c -> c.startDate().isAfter(earliestStartDate) && c.startDate().isBefore(latestStartDate))
.collect(Collectors.toSet());

return foundConferences;
}
Enter fullscreen mode Exit fullscreen mode

Then we pass both tools classes to the tools method .tools(this.zonedDateTimeTool, this.conferenceSearchTool). The LLM is capable of figuring out what tool (the method of the tool's class annotated with the @Tool annotation) exactly to invoke on its own.

We're done with the implementation, so let's now build the application with maven clean package and run it locally with java -jar target/spring-ai-with-amazon-bedrock-demo-0.0.1-SNAPSHOT.jar.

Let's first use the very generic conference-search API. Let's invoke
http://localhost:8080/conference-search?prompt='please provide the list of all conferences' for example in the browser. This is the response given by our application:

The answer is correct: The 'All_Conference_Search_Tool' tool was correctly picked and all 5 conferences have been listed. Your response may vary and sometimes the LLM doesn't pick the right or any tool at all and can't answer your question. In this case it's worth trying another model from the Amazon Nova family like Nova Pro or choose a totally different model provided via Amazon Bedrock Converse API like Anthropic Clause models (Sonnet) or others.

Now let's more specific question and use conference-search-by-topic API :

http://localhost:8080/conference-search-by-topic?topic='Java'. We are asking to provide a conference with a Java topic. Default start conference date is within 6 months from now. Here is the response :

Also looking into the application logs we see that the Current_Date_Time_Tool tool did its job first to identify the current date and time (August 7, 2025), which was identified by LLM as the earliest start date = 2025-08-07. Then LLM added 6 months to it and calculated the latest start date 2026-02-06 and then LLM invoked Conference_Search_Tool_By_Topic tool to pass the topic and the dates to it and then presented the correct response.

Now let's use the same API with the explicit number_of_months parameter.

http://localhost:8080/conference-search-by-topic?topic='Java'&number_of_months=10

Now, one more conference showed up taking place in April 2026 :

As we see in the logs, the earliest start date is equal to 2025-08-07. Then LLM added 6 months to it and calculated the latest start date 2026-06-06.

Now let's ask the application some generic questions, like: "I live in Bonn, Germany. Get the list of all conferences that are near my time zone?"

http://localhost:8080/conference-search?prompt='I leave in Bonn, Germany. Get the list of all conferences that are near my time zone?'

The response is ... LLM doesn't know.

Ok, even though we have a Current_Time_Zone tool in the ZonedDateTimeTool, conference-search API didn't pass it to the list of tools. Let's change it :

@GetMapping("/conference-search")
public String conferenceSearch(@RequestParam(value = "prompt") String prompt) {
   return this.chatClient.prompt()
      .user(prompt)
      .tools(this.conferenceSearchTool, this.zonedDateTimeTool)
      .call()
      .content();
    }
Enter fullscreen mode Exit fullscreen mode

Now let's ask the same questions again. Here is the response:

Even though LLM (Nova Lite) invoked the right tool and figured out that I'm based in the Europe/Berlin time zone, it can't know the time zone of the conferences, because the conference data contains only the information about the conference cities. Even though I might expect that LLM can figure this out on its own, for example from the information it has already been trained on, this model can't (I'll try with Claude Sonnet 4 by Anthropic later). I will have to implement another tool, and for example use Google Maps API to convert conference cities into the coordinates (latitude and longitude) and convert them back into the time zone. Even then, LLM can only provide the exact match of the time zone of me and the conference and I need some more logic to say what I mean by "near my time zone". So a lot of ideas on how to extend this application.

Your response may vary and sometimes the LLM doesn't pick the right or any tool at all and can't answer your question. And of course asking the same questions multiple times produces a slightly different answer.

That's all for this article. We've learned a lot!

Conclusion

In this first part of the series, we introduced Spring AI and its concepts by building the conference tool application using Amazon Bedrock Converse API and talked to it using the natural language.

In the next part of the series, we'll run Model Context Protocol (MCP) server with the defined tools and use Model Context Protocol Inspector and Amazon Q Developer plugin in the Visual Studio Code as MCP clients to list the available tools range and to talk to our application using the natural language and to search for the conferences by topic and start date range.

Top comments (0)