# Spring AI Advisors Guide: Built-in and Custom Advisors Explained

> Learn how to use Spring AI advisors to manage ChatClient requests. This guide covers execution order, built-in options, and creating custom call advisors.

- Canonical URL: https://coreiten.com/en/article/spring-ai-advisors-guide-built-in-and-custom-chains
- Language: en
- Section: Tech Pedia
- Author: Sami
- Published: 2026-10-21T16:04:16+03:00
- Modified: 2026-10-21T16:04:16+03:00
- Publisher: CoreITen (https://coreiten.com)
- Keywords: Spring AI Advisors, ChatClient, SafeGuardAdvisor, MessageChatMemoryAdvisor, QuestionAnswerAdvisor, TokenUsageAdvisor

## Summary

Spring AI advisors intercept ChatClient requests to add capabilities like document retrieval, chat memory, and logging through a configurable execution chain.

- Requests flow through registered advisors ordered by their getOrder() value, with lower integer values running earlier in the chain.
- Built-in options include SafeGuardAdvisor to block restricted words and MessageChatMemoryAdvisor to maintain conversation history.
- The OrderDesk example application uses dependencies like spring-ai-starter-model-chat-memory-repository-jdbc and hsqldb for file-based database storage.
- The spring.ai.ollama.embedding.options.model property specifies the nomic-embed-text model for converting text into vectors.
- The MessageWindowChatMemory bean is configured to keep the last 10 messages of each conversation in the database table.

**Why it matters:** Understanding how Spring AI advisors and execution chains work helps developers seamlessly integrate memory, RAG, and custom guardrails into chat applications.

---

An advisor adds behavior around a ChatClient request, such as retrieving documents, adding conversation context, logging usage, or returning a response without calling the model. This Spring AI Advisors guide explores how the advisor chain works, how to configure built-in options, and how to write a custom call advisor using the OrderDesk example application.

### Understand the Advisor Chain and Execution Order

A request passes through its registered advisors before reaching model execution. An advisor can inspect or change the request, continue the chain, and process the returned response. It can also stop the chain by supplying its own response:

```java
ChatClientResponse response = chain.nextCall(request);   // the rest of the chain
```

For synchronous calls, logic before `nextCall()` runs before downstream processing, and logic after it runs as the response returns. The earliest advisor sees the request first and the downstream response last, unless an earlier step short-circuits execution. A ChatClient call passes through the advisors from the lowest order value to the highest, and the response comes back through them in reverse order.

Spring AI decides the order with `getOrder()`. Advisors implement Spring's `Ordered` interface, and an advisor with a lower order value runs earlier. `Ordered.HIGHEST_PRECEDENCE` is the smallest integer, so an advisor with it runs first. A CallAdvisor participates in synchronous calls, a StreamAdvisor in streaming calls, and an implementation can support both. The built-in advisors include:

| Advisor | Module | What it does | Default order |
| --- | --- | --- | --- |
| SafeGuardAdvisor | spring-ai-client-chat | Blocks a request that contains a listed word | 0 |
| MessageChatMemoryAdvisor | spring-ai-client-chat | Adds earlier messages of the conversation, saves the new ones | HIGHEST_PRECEDENCE + 200 |
| ToolCallingAdvisor | spring-ai-client-chat | Runs the tool-call loop; added automatically | HIGHEST_PRECEDENCE + 300 |
| QuestionAnswerAdvisor | spring-ai-vector-store-advisor | Adds matching documents from a vector store (RAG) | 0 |
| RetrievalAugmentationAdvisor | spring-ai-rag | Modular RAG: query rewrite, retrieval, augmentation | 0 |
| SimpleLoggerAdvisor | spring-ai-client-chat | Logs the request and the response at DEBUG level | 0 |

### The Example App Setup

The OrderDesk application requires specific dependencies and properties. **Add** these dependencies to `pom.xml` to enable chat memory and a vector store:

```xml
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId>
</dependency>
<dependency>
  <groupId>org.hsqldb</groupId>
  <artifactId>hsqldb</artifactId>
  <scope>runtime</scope>
</dependency>
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-markdown-document-reader</artifactId>
</dependency>
```

The `spring-ai-starter-model-chat-memory-repository-jdbc` dependency saves chat messages in a database table, while `hsqldb` provides the file-based database. The `spring-ai-vector-store-advisor` holds `QuestionAnswerAdvisor` and brings `SimpleVectorStore`, which keeps vectors in memory. Finally, `spring-ai-markdown-document-reader` reads policy files into the vector store.

**Add** these lines to `application.properties`:

```properties
# Chat memory is saved in a file database, so it survives a restart
spring.datasource.url=jdbc:hsqldb:file:./data/orderdesk
spring.ai.chat.memory.repository.jdbc.initialize-schema=always

# Embedding model for RAG (ollama pull nomic-embed-text)
spring.ai.ollama.embedding.options.model=nomic-embed-text

# Which vector store is active: simple (default, in memory), chroma, pgvector, redis or qdrant.
# The profiles application-chroma.properties and so on switch it.
spring.ai.vectorstore.type=simple
```

The `spring.datasource.url` points to the HSQLDB file, and `initialize-schema=always` creates the `SPRING_AI_CHAT_MEMORY` table at startup. The `spring.ai.ollama.embedding.options.model` names the model that turns text into vectors, and `spring.ai.vectorstore.type=simple` selects `SimpleVectorStore`. To see `SimpleLoggerAdvisor` output, add `logging.level.org.springframework.ai.chat.client.advisor=DEBUG`.

#### The Beans the Advisors Use

The advisors wrap `supportClient`, a ChatClient bean. Its system prompt holds the store's rules:

```java
package com.howtodoinjava.orderdesk.chat;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatClientConfig {

    static final String SUPPORT_RULES = """
        You are OrderDesk, the support assistant of an online store.
        Answer in at most three sentences.
        Delivery: Pune, Mumbai and Delhi in 2 days; every other city in India in 5 days.
        Delivery is free above INR 499; below that it costs INR 49.
        We do not deliver outside India.
        """;

    @Bean
    ChatClient supportClient(ChatClient.Builder builder) {
        return builder
            .defaultSystem(SUPPORT_RULES)
            .build();
    }

    @Bean
    ChatClient summaryClient(ChatClient.Builder builder) {
        return builder
            .defaultSystem("Summarize the customer's message in one line of at most 12 words.")
            .build();
    }
}
```

`MessageChatMemoryAdvisor` needs a ChatMemory bean. OrderDesk keeps the last 10 messages of each conversation in the JDBC table:

```java
package com.howtodoinjava.orderdesk.memory;

import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatMemoryConfig {

    // Keep the last 10 messages of each conversation (the default is 20)
    @Bean
    ChatMemory chatMemory(JdbcChatMemoryRepository repository) {
        return MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository)
            .maxMessages(10)
            .build();
    }
}
```

`QuestionAnswerAdvisor` needs a VectorStore bean holding the store's policies. After adding the code, load the policies once using `curl -X POST localhost:8080/ingest`.

### Build and Inspect the Advisor Chain

#### Step 1: Write a Custom Advisor

`TokenUsageAdvisor` logs the advisors in the chain, the token counts, and the time of every call:

```java
package com.howtodoinjava.orderdesk.advisors;

import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClientRequest;
import org.springframework.ai.chat.client.ChatClientResponse;
import org.springframework.ai.chat.client.advisor.api.CallAdvisor;
import org.springframework.ai.chat.client.advisor.api.CallAdvisorChain;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisor;
import org.springframework.ai.chat.client.advisor.api.StreamAdvisorChain;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;

import reactor.core.publisher.Flux;

// Logs the time and the tokens of every ChatClient call that it advises
public class TokenUsageAdvisor implements CallAdvisor, StreamAdvisor {

    private static final Logger log = LoggerFactory.getLogger(TokenUsageAdvisor.class);

    private final int order;

    public TokenUsageAdvisor(int order) {
        this.order = order;
    }

    @Override
    public String getName() {
        return "TokenUsageAdvisor";
    }

    @Override
    public int getOrder() {
        return order;
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        log.info("Chain: {}", chain.getCallAdvisors().stream()
            .map(a -> a.getName() + "(" + a.getOrder() + ")")
            .collect(Collectors.joining(" -> ")));
        long start = System.currentTimeMillis();

        ChatClientResponse response = chain.nextCall(request);   // the rest of the chain

        logUsage(response.chatResponse(), System.currentTimeMillis() - start);
        return response;
    }

    @Override
    public Flux<ChatClientResponse> adviseStream(ChatClientRequest request,
                                                 StreamAdvisorChain chain) {
        long start = System.currentTimeMillis();
        return chain.nextStream(request)
            .doOnNext(response -> {
                ChatResponse chatResponse = response.chatResponse();
                // only the last piece of a stream carries the token counts
                if (chatResponse != null && chatResponse.getMetadata().getUsage() != null
                        && chatResponse.getMetadata().getUsage().getTotalTokens() != null
                        && chatResponse.getMetadata().getUsage().getTotalTokens() > 0) {
                    logUsage(chatResponse, System.currentTimeMillis() - start);
                }
            });
    }

    private void logUsage(ChatResponse chatResponse, long millis) {
        Usage usage = chatResponse.getMetadata().getUsage();
        log.info("Tokens: prompt={}, completion={}, total={}, time={} ms",
            usage.getPromptTokens(), usage.getCompletionTokens(), usage.getTotalTokens(), millis);
    }
}
```

An advisor implements `CallAdvisor` for synchronous requests or `StreamAdvisor` for `stream()` requests. Both interfaces require `getName()` and `getOrder()`. The `chain.getCallAdvisors()` method returns the whole chain sorted by order. To change the request, build a new one with `request.mutate()` and pass it to `chain.nextCall()`.

#### Step 2: Chain the Built-in Advisors

Four built-in advisors are added next to the custom one in `AdvisorChatController`. The initial version left every built-in advisor at its default order:

```java
supportClient.prompt()
    .user(question)
    .advisors(safeGuardAdvisor, tokenUsageAdvisor, memoryAdvisor,
        questionAnswerAdvisor, new SimpleLoggerAdvisor())
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, customerId))
    .call()
    .content();
```

The `getOrder()` method determines execution priority rather than the order of arguments supplied to `advisors()`. Equal values do not establish a reliable ordering contract.

#### Step 3: Find the Two Problems

Two issues emerged with the default setup. First, when a customer shared a card number, `SafeGuardAdvisor` blocked the request, but the card number was already saved because the memory advisor ran before it. Second, a returning customer asked for her name and the return policy, but the model ignored the history. The default `QuestionAnswerAdvisor` template instructs the model to use only the provided context and not prior knowledge.

#### Step 4: Set the Order and the Template

The revised configuration assigns explicit priorities and changes the RAG template to permit both document context and conversation history:

```java
package com.howtodoinjava.orderdesk.advisors;

import java.util.List;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.SafeGuardAdvisor;
import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor;
import org.springframework.ai.chat.client.advisor.api.Advisor;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.prompt.PromptTemplate;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.Ordered;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import reactor.core.publisher.Flux;

@RestController
public class AdvisorChatController {

    // The default QuestionAnswerAdvisor template says "not prior knowledge",
    // so the model ignored the chat history; this one allows both
    static final PromptTemplate POLICY_AND_HISTORY = new PromptTemplate("""
        {query}

        Answer from the conversation so far and from the policy text below.
        ---------------------
        {question_answer_context}
        ---------------------
        If neither of them has the answer, say that you don't know.
        """);

    private final ChatClient supportClient;
    private final List<Advisor> advisors;
    private final TokenUsageAdvisor tokenUsageAdvisor =
        new TokenUsageAdvisor(Ordered.HIGHEST_PRECEDENCE + 100);

    AdvisorChatController(@Qualifier("supportClient") ChatClient supportClient,
                          ChatMemory chatMemory, VectorStore vectorStore) {
        this.supportClient = supportClient;
        this.advisors = List.of(
            // 1. block sensitive input before anything else sees or stores it
            new SafeGuardAdvisor(List.of("password", "card number", "CVV"),
                "Please don't share payment details or passwords in chat.",
                Ordered.HIGHEST_PRECEDENCE),
            // 2. measure the whole call, including memory and RAG
            tokenUsageAdvisor,
            // 3. add the conversation history (default order HIGHEST_PRECEDENCE + 200)
            MessageChatMemoryAdvisor.builder(chatMemory).build(),
            // 4. add the policy sections that match the question
            QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(3).similarityThreshold(0.5).build())
                .promptTemplate(POLICY_AND_HISTORY)
                .order(0)
                .build(),
            // 5. log the final prompt, just before the model call
            new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE - 1));
    }

    @GetMapping("/advisors/ask")
    String ask(@RequestParam String customerId, @RequestParam String question) {
        return supportClient.prompt()
            .user(question)
            .advisors(advisors)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, customerId))
            .call()
            .content();
    }

    @GetMapping("/advisors/stream")
    Flux<String> stream(@RequestParam String question) {
        return supportClient.prompt()
            .user(question)
            .advisors(tokenUsageAdvisor)
            .stream()
            .content();
    }
}
```

`SafeGuardAdvisor` now takes the blocked words, a custom message, and `Ordered.HIGHEST_PRECEDENCE`. The template keeps `{query}` and `{question_answer_context}` placeholders. `SearchRequest` returns at most 3 policy sections with a similarity score of at least 0.5. Moving the guard to the outermost position prevents blocked requests from reaching memory.

Testing with a new customer, Ravi, confirms the fixes:

```bash
curl -G localhost:8080/advisors/ask --data-urlencode "customerId=ravi" --data-urlencode "question=Hi, I am Ravi. How many days do I have to return a jacket?"
curl -G localhost:8080/advisors/ask --data-urlencode "customerId=ravi" --data-urlencode "question=What is my name, and how long did you say I have?"
curl -G localhost:8080/advisors/ask --data-urlencode "customerId=ravi" --data-urlencode "question=My card number is 4111 1111 1111 1111, can you save it?"
```

The first answer uses the policy section added by `QuestionAnswerAdvisor`. The second answer uses the history, as no policy section scored 0.5, and the new template allowed the model to use the conversation. The third request returns in 13 milliseconds because `SafeGuardAdvisor` returned before the memory advisor ran, leaving the card number unsaved.

#### Step 5: Advise a Streaming Call

The `stream()` method passes the `tokenUsageAdvisor` instance to a `stream()` call. Streaming produces multiple response fragments, and providers commonly report aggregate usage near completion. The custom advisor checks for nonzero totals before logging, as not every provider supplies the same usage fields.

### Common Traps

- **Equal Orders:** Advisors with the same order do not run in list order. Set an order on every advisor whose position matters.
- **Guards That Run Too Late:** Place checks before the processing they protect. Verify that sensitive data is not captured by earlier logging.
- **Blocking Code in a Stream:** Avoid blocking work on Reactor event-loop threads. If a custom streaming advisor calls a blocking database, use an appropriate scheduler.
- **Default Advisors Run Everywhere:** The `defaultAdvisors()` method adds advisors to every request made through that client. Add advisors per request unless every request needs them.
- **Templates That Fight Each Other:** Each advisor that adds text to the prompt also adds instructions. Read the final prompt in the `SimpleLoggerAdvisor` log when combining advisors.

### Advanced Advisor Concepts

While both advisors and Spring AOP aspects can wrap execution, ChatClient advisors work specifically with structured prompt, response, and advisor-context objects. Advisors pass data to each other through the advisor context, a Map on the request and the response. For example, `QuestionAnswerAdvisor` puts found documents under `QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS`, which a later advisor can read from `response.context()`.

An advisor can skip the model call entirely. If `adviseCall()` returns a response without calling `chain.nextCall()`, the model is never called, which is how `SafeGuardAdvisor` operates. To view the final prompt, place `SimpleLoggerAdvisor` at `Ordered.LOWEST_PRECEDENCE - 1` and enable DEBUG logging. Note that Spring AI 1.0 renamed `CallAroundAdvisor` and `StreamAroundAdvisor` to `CallAdvisor` and `StreamAdvisor`, and `AdvisedRequest` to `ChatClientRequest`.

### Managing the Advisor Chain

Registered advisors form an ordered processing chain for the execution modes they implement. Use explicit priorities where order matters, and check the final request when combining memory, retrieval, tools, guards, and logging. Because an advisor can short-circuit the chain, downstream code must not assume every request reaches the model.

## Sources

- [howtodoinjava.com](https://howtodoinjava.com/spring-ai/spring-ai-advisors/)
