Spring AI's advisors are its interception layer: ordered components that wrap every ChatClient call, can rewrite the request on the way in and the response on the way out, and can answer without calling the model at all. Chat memory, retrieval (see Spring AI Vector Stores), tool calling, structured-output validation and logging are all advisors. If you have used servlet filters or Spring AOP around advice, the shape is familiar.
This article reads the advisor API from the Spring AI 2.0.0 jars: the interfaces, how the chain is ordered, the default orders of the shipped advisors, how request context flows, what changes for streaming, and how to write one. It also answers the question an ADK Java reader should ask first. ADK's Spring AI model adapter, com.google.adk.models.springai.SpringAI, is built from a ChatModel or StreamingChatModel and calls ChatModel.call directly. It never goes through ChatClient (checked against the adapter source on the adk-java main branch, October 2026), so advisors do not run inside an ADK agent. Inside ADK the equivalent layer is the plugin and callback system; advisors matter when the same service also uses ChatClient directly, which Spring applications often do. For wiring ADK into Spring in the first place, see ADK Java + Spring.
The API
Every advisor implements Advisor, which extends Spring's Ordered and adds getName(). Two sub-interfaces carry the work: CallAdvisor.adviseCall(ChatClientRequest, CallAdvisorChain) for blocking calls and StreamAdvisor.adviseStream(ChatClientRequest, StreamAdvisorChain) for streaming. An around-advisor does its work, calls chain.nextCall(request) to hand on, and post-processes what comes back; not calling the chain answers the request itself.
The values passed along are records. ChatClientRequest holds a Prompt and a Map<String, Object> context; ChatClientResponse holds a ChatResponse and a context map. Both have mutate() builders, which is how you produce a changed copy. The context map is the channel between advisors and between your call site and the chain: memory reads its conversation id from it, retrieval writes the documents it found into it.
For the common before/after shape there is BaseAdvisor, which implements both call and stream for you. You write before(request, chain) and after(response, chain); its adviseCall runs before, then nextCall, then after.
Ordering: who sees what
The chain sorts advisors with Spring's OrderComparator: a lower order value runs earlier on the way in and later on the way out, so it is the outer layer. The chain always ends with ChatModelCallAdvisor, named "call", at Integer.MAX_VALUE; it is the advisor that actually calls the model. The orders in 2.0.0 that you need to know:
| Advisor | Default order | Notes |
|---|---|---|
MessageChatMemoryAdvisor | MIN_VALUE + 200 | adds stored history to the prompt; requires a conversation id |
ToolCallingAdvisor | MIN_VALUE + 300 | runs the tool loop; auto-registered by ChatClient unless disabled |
SimpleLoggerAdvisor | 0 | logs request and response at DEBUG |
SafeGuardAdvisor | 0 | blocks prompts containing listed words |
StructuredOutputValidationAdvisor | MAX_VALUE - 2000 | validates JSON output, retries up to 3 times; call only |
ChatModelCallAdvisor | MAX_VALUE | terminal; calls the model |
Two consequences are easy to miss. Equal orders keep registration order, because the sort is stable, so two advisors at 0 run in the order you added them. And anything at order 0 sees the request after memory has prepended history, because memory sits at MIN_VALUE + 200. That second point produces the bug in the worked example below.
Request context and the conversation id
Per-call parameters go in through the advisor spec and arrive in the request context:
String answer = chatClient.prompt()
.user(question)
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)
.param("tenant", tenantId))
.call()
.content();ChatMemory.CONVERSATION_ID is the string "chat_memory_conversation_id". In 2.0.0 the memory advisor asserts that it is present and throws IllegalArgumentException with conversationId cannot be null when it is not, so a call path that forgets the parameter fails loudly rather than mixing users into a shared default conversation. Treat the context map as an internal API: define your keys as constants, document which advisor reads and writes each, and never put secrets in it, because logging advisors may print it.
Writing advisors
Two advisors cover most custom needs. The first rewrites: it masks account numbers in the outgoing user text and records what it did in the response context.
public final class AccountMaskingAdvisor implements BaseAdvisor {
private static final Pattern ACCOUNT = Pattern.compile("\\b\\d{8,12}\\b");
private final int order;
public AccountMaskingAdvisor(int order) { this.order = order; }
@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain chain) {
Prompt masked = request.prompt().augmentUserMessage(
u -> u.mutate().text(ACCOUNT.matcher(u.getText()).replaceAll("[account]")).build());
return request.mutate().prompt(masked).context("masking.applied", true).build();
}
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
return response;
}
@Override public int getOrder() { return order; }
}The second answers: a cache that returns a stored response and never calls the chain.
public final class ExactCacheAdvisor implements CallAdvisor {
private final Cache<String, ChatResponse> cache; // e.g. a Caffeine cache with a TTL
public ExactCacheAdvisor(Cache<String, ChatResponse> cache) { this.cache = cache; }
@Override
public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
String key = request.prompt().getContents(); // all message text, in order
ChatResponse hit = cache.getIfPresent(key);
if (hit != null) {
return ChatClientResponse.builder().chatResponse(hit)
.context(Map.copyOf(request.context())).build();
}
ChatClientResponse fresh = chain.nextCall(request);
cache.put(key, fresh.chatResponse());
return fresh;
}
@Override public String getName() { return "exact-cache"; }
@Override public int getOrder() { return 100; }
}Where you place each one is the design decision. Prompt.augmentUserMessage rewrites only the last user message, so the masking advisor must run outside memory, with an order below MIN_VALUE + 200: memory then stores the masked text, and history is masked on every later turn. Placed inside memory it would mask the new message while the raw one was already stored. The cache must run after anything that makes the prompt differ per user, and before the tool advisor if cached answers should skip tools. augmentUserMessage and the record builders are the 2.0.0 shapes; check them against the version you use.
Streaming
Streaming changes the timing of after-processing. BaseAdvisor.adviseStream runs before on the advisor's scheduler, streams the chain, and calls after only on the chunk that carries a finish reason. So an after-hook that inspects the full answer text sees one chunk, not the whole answer. If you need the assembled answer while streaming, implement StreamAdvisor directly and aggregate the chunks yourself, accepting that you cannot block or rewrite text that has already gone to the client. StructuredOutputValidationAdvisor refuses streaming for exactly this reason: it cannot validate JSON it has not finished receiving.
Worked example: the conversation that refuses everything
A support service registers MessageChatMemoryAdvisor and a SafeGuardAdvisor with the word list ["password"]. On turn one a user writes, "I reset my password yesterday and now the export fails." The safeguard blocks it with its default failure message, which is the intended behaviour. Two weaknesses show up at once. Matching is a case-sensitive String.contains, so "Password" at the start of a sentence passes while a harmless log line containing password_policy=strict is blocked, because the word is a substring.
The real surprise comes from ordering. The safeguard checks prompt.getContents(), which concatenates the text of every message in the prompt, and at order 0 it runs after memory has added history. Once any stored message contains a listed word, for example an assistant reply that said "never share your password", every later turn in that conversation is blocked. Users see a conversation that refuses everything from one point on.
The fix is order and scope. Register the safeguard with an order below MIN_VALUE + 200 so it sees only the new message, use the three-argument constructor to set that order, and match on normalised, lower-cased text with word boundaries in your own advisor if you need anything stronger than a substring list.
Testing advisors
Advisors are easy to test without a model, because the chain ends in an advisor you can replace. Build a ChatClient on a stub ChatModel that records the prompt it receives and returns a fixed response, register the advisors in production order, and assert on what reached the model:
class RecordingModel implements ChatModel {
final List<Prompt> seen = new CopyOnWriteArrayList<>();
@Override public ChatResponse call(Prompt prompt) {
seen.add(prompt);
return new ChatResponse(List.of(new Generation(new AssistantMessage("ok"))));
}
}
@Test
void maskingRunsBeforeMemoryStoresTheTurn() {
RecordingModel model = new RecordingModel();
ChatMemory memory = MessageWindowChatMemory.builder().build();
ChatClient client = ChatClient.builder(model)
.defaultAdvisors(new AccountMaskingAdvisor(Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER - 10),
MessageChatMemoryAdvisor.builder(memory).build())
.build();
client.prompt().user("move funds from 123456789")
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "c-1")).call().content();
assertThat(model.seen.get(0).getContents()).doesNotContain("123456789");
assertThat(memory.get("c-1").toString()).doesNotContain("123456789");
}Write one such test per ordering assumption: that filters see only the new message, that the cache does not cross tenants, that a missing conversation id fails. The second assertion, on what memory stored, is the one that catches a masking advisor placed on the wrong side of memory. Constructor shapes for ChatResponse and MessageWindowChatMemory vary between releases; adjust them to yours.
Operating advisors alongside ADK
Advisors are observed through Micrometer: the chain opens an observation per advisor, tagged with low-cardinality keys including ADVISOR_NAME and SPRING_AI_KIND, so with tracing enabled each advisor appears as its own span and its time is visible. That makes getName() operationally significant: give every custom advisor a stable, unique name.
Logging needs one setting people miss: SimpleLoggerAdvisor logs at DEBUG through commons-logging, so nothing appears until you set logging.level.org.springframework.ai.chat.client.advisor=DEBUG. It prints the full request and response, so keep it out of production or give it custom formatting functions through its three-argument constructor.
If the same service runs ADK agents and plain ChatClient calls, decide where each policy lives. A masking rule written only as an advisor does not protect the ADK path; a rule written only as an ADK plugin does not protect the ChatClient path. Put the rule itself in a plain class and call it from a thin advisor and a thin plugin, and test both paths. How plugins and callbacks compose on the ADK side is covered in Agent Hooks and the Middleware Pattern, and layered policy in ADK Java Guardrails.
Failure modes
- Assuming advisors cover ADK. They do not; ADK's adapter calls
ChatModeldirectly. - Wrong side of memory. Filters at order 0 see the whole history and can block or rewrite past turns.
- Missing conversation id. The memory advisor throws on every call path that forgets the parameter.
- Stream after-hooks. Code that inspects
after()output in streaming mode sees only the final chunk. - Silent logger.
SimpleLoggerAdvisoradded, DEBUG not enabled, nothing logged. - Cache keyed too loosely. A cache keyed on the user text alone serves one user's answer to another; key on the full prompt plus tenant.
Trade-offs
| Choice | Gain | Cost |
|---|---|---|
BaseAdvisor | one class covers call and stream | after() sees only the final stream chunk |
Raw CallAdvisor/StreamAdvisor | full control, can short-circuit | two implementations to keep consistent |
| Low order (outer) | sees raw input, wraps everything | misses what inner advisors add |
| High order (inner) | sees the final prompt | sees history and retrieved text too |
| Advisor vs ADK plugin | fits Spring apps | does not apply to ADK agents |
What to do next
- List every advisor on each
ChatClientwith its effective order, including auto-registered tool calling. - Move input filters below
MIN_VALUE + 200unless they must see history. - Add the conversation id at every call site, and a test that a missing id fails.
- Give each custom advisor a unique
getName()and check its span in a trace. - Decide for each policy whether it must also cover the ADK path, and share the rule.
- Review
SimpleLoggerAdvisoruse: off in production or with redacting formatters.