Let's get started with a Microservice Architecture with Spring Cloud:
Safeguarding LLM Output in Spring AI
Last updated: October 18, 2026
1. Overview
When we expose a large language model through our own API, our first instinct is to filter the prompts that users send. Spring AI ships a SafeGuardAdvisor for exactly that job. It blocks any prompt containing a forbidden term before the prompt reaches the model.
That covers only half of the problem. A perfectly innocent-looking prompt can still produce a response that we’d rather not return. The model answers from its own knowledge. The user never has to name the thing we’re trying to hide.
In this tutorial, we’ll close that gap with a custom advisor that inspects the model’s response before ChatClient hands it back. We’ll set up the input guard first, find the hole in it, then stack an output guard on top.
2. Setting Up the Project
A chat starter is the only dependency we need. Let’s add the Google GenAI one:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-google-genai</artifactId>
<version>1.1.2</version>
</dependency>
The latest version of spring-ai-starter-model-google-genai is available in the Maven Repository. Advisors are model-agnostic, so any other chat starter works here just as well.
Next, we point the starter at our Gemini API key and pick a model:
spring:
ai:
google:
genai:
api-key: ${GEMINI_API_KEY}
chat:
options:
model: gemini-3.5-flash
Throughout the tutorial, we’ll build a Java coding assistant with one rule: it must never recommend the legacy date API over java.time.
3. Blocking Sensitive Prompts
SafeGuardAdvisor is Spring AI’s built-in answer to unwanted input. It scans the prompt for a list of sensitive words. When it finds one, it short-circuits the advisor chain. The model is never called, and the caller gets a canned response instead.
Let’s register one as a default advisor on our ChatClient, next to a system prompt that sets the assistant’s role:
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
SafeGuardAdvisor inputGuard = SafeGuardAdvisor.builder()
.sensitiveWords(List.of("SimpleDateFormat", "java.util.Date"))
.failureResponse("Use java.time instead.")
.build();
return builder.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(inputGuard)
.build();
}
The advisor compares each word against the prompt with a plain substring check, so the match is case-sensitive and exact. It also scans the whole prompt, system message included. That has a catch. If our system prompt spelled out SimpleDateFormat, the guard would reject every request.
The guard now stops any question that names the legacy API before it reaches the model:
private static final String FAILURE_RESPONSE = "Use java.time instead.";
@Test
void givenLegacyApiPrompt_whenCallingTheModel_thenItIsBlocked() {
String answer = chatClient.prompt()
.user("How do I use SimpleDateFormat?")
.call()
.content();
assertThat(answer).isEqualTo(FAILURE_RESPONSE);
}
The model is never invoked at all, which makes this the cheapest guardrail we can put in place.
4. Why Input Filtering Isn’t Enough
SafeGuardAdvisor only ever sees the prompt. A developer can ask about a class without ever typing its name. Asking “How did we parse date strings in Java 7?” matches nothing on our list, so it reaches the model. Since java.time didn’t exist before Java 8, it has only one honest answer:
In Java 7, date strings were parsed using the java.text.SimpleDateFormat
class by defining a format pattern to convert the string into a
java.util.Date object.
The guard held, and the legacy API came back anyway. Input filtering only matches what the user typed, and the model is under no obligation to stay inside that vocabulary. To catch this, we have to look at what comes back.
5. Creating the Output Advisor
Spring AI has no built-in advisor that filters response content, so we’ll write our own. It takes the same three settings as SafeGuardAdvisor, and we’ll build it in two steps: the class and its builder, then the filtering logic.
5.1. Skeleton and Builder
An advisor that inspects responses only needs the CallAdvisor interface, which Spring AI uses for non-streaming calls. Our class holds the three values the builder configures, and exposes two of them through the Advisor contract:
public class OutputSafeGuardAdvisor implements CallAdvisor {
private List<String> sensitiveWords;
private String failureResponse;
private int order;
private OutputSafeGuardAdvisor(
List<String> sensitiveWords, String failureResponse, int order) {
this.sensitiveWords = sensitiveWords;
this.failureResponse = failureResponse;
this.order = order;
}
public static Builder builder() {
return new Builder();
}
@Override
public String getName() {
return getClass().getSimpleName();
}
@Override
public int getOrder() {
return order;
}
}
The builder mirrors the one on SafeGuardAdvisor, so both advisors read the same way at the call site:
public static class Builder {
private List<String> sensitiveWords = List.of();
private String failureResponse
= "Response contained forbidden content.";
private int order = Ordered.LOWEST_PRECEDENCE - 1;
public Builder sensitiveWords(String... sensitiveWords) {
this.sensitiveWords = Arrays.asList(sensitiveWords);
return this;
}
public Builder sensitiveWords(List<String> sensitiveWords) {
this.sensitiveWords = sensitiveWords;
return this;
}
public Builder failureResponse(String failureResponse) {
this.failureResponse = failureResponse;
return this;
}
public Builder order(int order) {
this.order = order;
return this;
}
public OutputSafeGuardAdvisor build() {
return new OutputSafeGuardAdvisor(
sensitiveWords, failureResponse, order);
}
}
The varargs overload of sensitiveWords() is one SafeGuardAdvisor doesn’t offer. The default order deserves a closer look. Spring AI terminates the chain with an internal ChatModelCallAdvisor whose order is exactly Ordered.LOWEST_PRECEDENCE. An advisor that shares that value can be sorted behind the terminal one. The terminal advisor never delegates any further. Ours would silently never run.
Defaulting to Ordered.LOWEST_PRECEDENCE – 1 puts us last among the advisors that do execute. Spring AI’s own StructuredOutputValidationAdvisor leaves the same gap, defaulting to Ordered.LOWEST_PRECEDENCE – 2000.
5.2. Implementing adviseCall()
Unlike an input guard, an output advisor starts by letting the call through. We invoke the rest of the chain first, and only then inspect what came back:
@Override
public ChatClientResponse adviseCall(
ChatClientRequest request, CallAdvisorChain chain) {
ChatClientResponse response = chain.nextCall(request);
ChatResponse chatResponse = response.chatResponse();
if (chatResponse == null || chatResponse.getResults().isEmpty()) {
return response;
}
List<Generation> results = chatResponse.getResults();
List<Generation> filteredResults = results.stream()
.map(this::filterGeneration)
.toList();
if (filteredResults.equals(results)) {
return response;
}
ChatResponse filtered = new ChatResponse(
filteredResults, chatResponse.getMetadata());
return response.mutate()
.chatResponse(filtered)
.build();
}
A response with no ChatResponse or generations gives us nothing to inspect, so we hand it straight back. Otherwise, we map over every Generation and compare the filtered list against the original. Generation implements equals(), so that’s a value comparison, not an identity check, and when nothing changed we return the original response.
The replacement swaps the message but keeps the generation’s metadata, so the finish reason survives:
private Generation filterGeneration(Generation generation) {
if (!containsSensitiveWord(generation.getOutput().getText())) {
return generation;
}
return new Generation(
new AssistantMessage(failureResponse), generation.getMetadata());
}
private boolean containsSensitiveWord(String text) {
if (!StringUtils.hasText(text)) {
return false;
}
return sensitiveWords.stream()
.anyMatch(text::contains);
}
6. Registering the Advisor
Registering the output guard means adding it to the same defaultAdvisors() call:
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
SafeGuardAdvisor inputGuard = SafeGuardAdvisor.builder()
.sensitiveWords(List.of("SimpleDateFormat", "java.util.Date"))
.failureResponse("Use java.time instead.")
.build();
OutputSafeGuardAdvisor outputGuard = OutputSafeGuardAdvisor.builder()
.sensitiveWords("DateFormat", "java.util.Date",
"Calendar", "Timestamp")
.failureResponse("Use java.time instead.")
.build();
return builder.defaultSystem(SYSTEM_PROMPT)
.defaultAdvisors(inputGuard, outputGuard)
.build();
}
The two guards deliberately watch different word lists. The output guard watches DateFormat rather than SimpleDateFormat, which also catches the parent class, and adds Calendar and Timestamp, the neighboring legacy types a model reaches for. Adding those to the input guard would be counterproductive: a developer asking about Calendar isn’t the problem, an answer that recommends it is.
We don’t set an order: SafeGuardAdvisor defaults to 0 and runs first, while our default puts the output guard last.
7. Testing the Guardrail
The advisor is a plain class, so we can exercise it without a model. Let’s start with the fixture:
private static final String FAILURE_RESPONSE = "Use java.time instead.";
private static final String LEAKED_ANSWER = "Java 7 used SimpleDateFormat.";
private static final String CLEAN_ANSWER = "LocalDate.now() returns today.";
private OutputSafeGuardAdvisor advisor = OutputSafeGuardAdvisor.builder()
.sensitiveWords("SimpleDateFormat", "java.util.Date")
.failureResponse(FAILURE_RESPONSE)
.build();
Now we stub the chain with Mockito and feed it a generation that leaks:
@Test
void givenAnswerNamingLegacyApi_whenAdvisingCall_thenAnswerIsReplaced() {
CallAdvisorChain chain = chainReturning(generation(LEAKED_ANSWER));
ChatClientResponse response = advisor.adviseCall(request(), chain);
assertThat(textOf(response)).isEqualTo(FAILURE_RESPONSE);
}
A clean answer takes the early return, so we get the very same instance back:
@Test
void givenCleanAnswer_whenAdvisingCall_thenItIsReturnedUntouched() {
ChatClientResponse original = responseOf(generation(CLEAN_ANSWER));
CallAdvisorChain chain = chainReturning(original);
ChatClientResponse response = advisor.adviseCall(request(), chain);
assertThat(response).isSameAs(original);
}
End to end, the question that defeated the input guard now comes back filtered:
@Test
void givenJava7Question_whenCallingTheModel_thenAnswerIsBlocked() {
String answer = chatClient.prompt()
.user("How did we parse date strings in Java 7?")
.call()
.content();
assertThat(answer).isEqualTo(FAILURE_RESPONSE);
}
8. Limitations
Substring matching is blunt in both directions. It’s case-sensitive, so a stray capitalization slips through. It also has no notion of word boundaries. That’s why our list spells out java.util.Date rather than a bare Date. The shorter term would also reject a good answer built on LocalDate. Paraphrases defeat it outright.
It’s also a call-only guard. OutputSafeGuardAdvisor implements CallAdvisor but not StreamAdvisor, so a streamed response bypasses it. Filtering a stream is genuinely harder, because a sensitive word can straddle two chunks and the earlier ones have already reached the client.
For stronger guarantees, a moderation model is a better tool than a word list.
9. Conclusion
In this article, we built a custom Spring AI advisor that inspects the model’s response and replaces it when it contains something we’d rather not return. Pairing it with SafeGuardAdvisor covers both directions: one guard rejects the prompt, the other rejects the answer. Neither is complete on its own, but together they close the obvious gaps with very little code.
As always, the full source code is available over on GitHub.
















