Spring AI Moderation with Ollama and OpenAI

Spring AI moderation with Llama Guard on Ollama and OpenAI’s ModerationModel: check customer messages in an advisor and block the unsafe ones.

The customer message goes to ModerationAdvisor, which asks the safety model llama-guard3:1b: safe or unsafe? If safe, the message goes to the other advisors, if any, then the chat model qwen2.5:3b. If unsafe, a fixed reply goes back without a chat model call: I can't help with this message. Please contact our support team by phone. A note says the check costs about 1.4 to 2 seconds per message with the local model, and that OpenAI's moderation endpoint does the same job as a hosted, free API

A support assistant should distinguish an angry complaint from a threat or a request for private customer information. Moderation adds that check before the assistant generates an answer.

In this article, we add a local Llama Guard check to OrderDesk using Spring AI and Ollama. A custom advisor forwards messages classified as safe and returns a fixed response for messages classified as unsafe. We also show the configuration for Spring AI’s hosted ModerationModel integration, without claiming a live hosted response.

1. Why Check Messages Before Generating an Answer?

A support chat gets all kinds of messages. Most are normal questions, such as “How do I return a damaged kettle?”. Some are angry, and that’s fine. An angry customer still needs help.

Other messages need different handling: a threat against staff, for example, or a request for another customer’s home address. The assistant should not fulfill such requests. Moderation helps identify them before calling the customer-support model.

The goal is to intercept messages that need different handling while allowing legitimate complaints to receive help. The application remains responsible for access control, escalation, and the response shown to the customer.

2. What a Moderation Model Returns

A keyword list is simple, but cannot reliably interpret context. “The kitchen knife I ordered arrived blunt” is an ordinary complaint. A request for another customer’s address may contain no obvious prohibited keyword. Meaning matters more than an isolated word.

A moderation model classifies content rather than writing a support answer. It returns a verdict and, depending on the model, category labels or scores. These describe the model’s assessment under its own taxonomy; they do not guarantee that a message is harmless or harmful.

The application turns the classification into an action: continue, show a fixed response, request human review, or follow another defined path. A label by itself does not enforce business rules.

There are two common ways to get such a model:

  1. Run a small safety model yourself. Llama Guard is one. It is a model from Meta, trained only to label messages as safe or unsafe. It runs in Ollama like any other model (Ollama: llama-guard3).
  2. Call a hosted moderation API. Spring AI provides a ModerationModel integration for OpenAI, which requires provider credentials and sends submitted text to that service.

Evaluate both missed harmful messages (false negatives) and legitimate messages blocked by mistake (false positives). Include ordinary complaints, rude language, product descriptions, threats, and privacy requests. A useful check must support real conversations without treating every expression of anger as a threat.

3. Moderation in Spring AI

Spring AI gives us two ways to run the check:

Left box OpenAI moderation (hosted): ModerationModel (OpenAiModerationModel), model omni-moderation-latest, answer flagged plus 13 categories plus a score from 0 to 1 for each, free, needs an OpenAI key. Right box Llama Guard on Ollama (local): a ChatClient with model llama-guard3:1b, answer safe or unsafe plus codes such as S1 (violent crimes), no key, runs on your machine. A note says no checker is perfect: in our runs Llama Guard missed a hateful message, so test the checker on real customer messages before you rely on it
The two options in this article: OpenAI’s hosted moderation model through Spring AI’s ModerationModel, and Llama Guard running locally on Ollama through a ChatClient.

The Spring AI moderation reference describes the dedicated ModerationModel abstraction used for the hosted example. For the local path, we call Llama Guard through a regular ChatClient backed by Ollama, then interpret its text response.

An advisor is an integration point around a ChatClient request. Registered advisors run in an ordered chain and can pass the request to the next step. We attach moderation to the client used by the support endpoint, before that client invokes the chat model.

An advisor can return a response without calling the next step. ModerationAdvisor uses this behavior to intercept a message classified as unsafe and return a fixed reply:

The customer message goes to ModerationAdvisor, which asks the safety model llama-guard3:1b: safe or unsafe? If safe, the message goes to the other advisors, if any, then the chat model qwen2.5:3b. If unsafe, a fixed reply goes back without a chat model call: I can't help with this message. Please contact our support team by phone. A note says the check costs about 1.4 to 2 seconds per message with the local model, and that OpenAI's moderation endpoint does the same job as a hosted, free API
OrderDesk checks each customer message with a safety model inside an advisor. A safe message goes on to the other advisors, if any, and the chat model; an unsafe one gets a fixed reply without a chat model call.

Spring AI also ships a SafeGuardAdvisor, which blocks a message that contains a word from a fixed list. That’s the word-list idea from Section 2, so we don’t use it here.

4. Prepare the Local Example

This article is part of the OrderDesk series, where every Spring AI article grows the same app. OrderDesk is a Spring Boot 4.1 application that uses Spring AI 2.0.1 and Java 21, and it talks to local models through Ollama. The Getting Started with Spring AI tutorial creates the project, adds the spring-ai-starter-model-ollama dependency, and installs Ollama.

This article needs no new dependency. It needs one more model. Pull it once:

ollama pull llama-guard3:1b

The download is about 1.6 GB in the original example. The support model remains qwen2.5:3b, so separate models handle classification and answering. These chat settings remain unchanged:

spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.options.model=qwen2.5:3b
spring.ai.ollama.chat.options.temperature=0.2

Put the code in the com.howtodoinjava.orderdesk.moderation package. This package is inside the main class’s package, com.howtodoinjava.orderdesk, so Spring Boot finds the classes.

5. Add Moderation to the Support Endpoint

The implementation has four parts: a Verdict record, a SafetyCheck service, a ModerationAdvisor, and a controller. Keeping classification separate from the advisor lets us inspect a verdict without generating a support answer.

5.1. Classify the Message with Llama Guard

The expected output is safe, or unsafe followed by category codes such as S1. The local example maps that response into a Verdict. The model’s category codes are listed below:

CodeCategoryCodeCategory
S1Violent crimesS8Intellectual property
S2Non-violent crimesS9Indiscriminate weapons
S3Sex crimesS10Hate
S4Child exploitationS11Self-harm
S5DefamationS12Sexual content
S6Specialized adviceS13Elections
S7Privacy

A record holds the result:

package com.howtodoinjava.orderdesk.moderation;

import java.util.List;

// The answer of the safety model: safe, or unsafe with category codes such as S1
public record Verdict(boolean safe, List<String> categories) {
}

Next, SafetyCheck sends the message to Llama Guard and turns the answer into a Verdict:

package com.howtodoinjava.orderdesk.moderation;

import java.util.Arrays;
import java.util.List;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.stereotype.Component;

@Component
public class SafetyCheck {

    private final ChatClient guardClient;

    // A separate client that talks to the safety model, not to the chat model
    SafetyCheck(ChatClient.Builder builder) {
        this.guardClient = builder
            .defaultOptions(OllamaChatOptions.builder().model("llama-guard3:1b").temperature(0.0))
            .build();
    }

    // Llama Guard answers "safe", or "unsafe" and a line with codes such as "S1,S10"
    public Verdict check(String text) {
        String answer = guardClient.prompt().user(text).call().content().trim();
        if (answer.startsWith("safe")) {
            return new Verdict(true, List.of());
        }
        String[] lines = answer.split("\n");
        List<String> codes = lines.length > 1
            ? Arrays.stream(lines[1].split(",")).map(String::trim).toList()
            : List.of();
        return new Verdict(false, codes);
    }
}

The Ollama starter gives us a ChatClient.Builder. It is a prototype-scoped bean, so each class that asks for one gets its own new builder. The options we set here change only this client.

This client selects llama-guard3:1b and a temperature of 0.0. The sampling setting reduces variation, but does not ensure correct verdicts. The example relies on the model’s supplied prompt template rather than adding customer-support instructions.

In Spring AI 2.0.1, defaultOptions() takes the options builder, so there’s no .build() after temperature(0.0).

5.2. Stop Unsafe Requests in an Advisor

The advisor calls SafetyCheck before forwarding the request. If the verdict is unsafe, it returns a normal Spring AI response containing the fixed support message:

package com.howtodoinjava.orderdesk.moderation;

import java.util.List;

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.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;

// Checks the customer's message before any other advisor or the chat model sees it
@Component
public class ModerationAdvisor implements CallAdvisor {

    private static final String BLOCKED =
        "I can't help with this message. Please contact our support team by phone.";

    private final SafetyCheck safetyCheck;

    ModerationAdvisor(SafetyCheck safetyCheck) {
        this.safetyCheck = safetyCheck;
    }

    @Override
    public ChatClientResponse adviseCall(ChatClientRequest request, CallAdvisorChain chain) {
        String text = request.prompt().getUserMessage().getText();
        Verdict verdict = safetyCheck.check(text);
        if (verdict.safe()) {
            return chain.nextCall(request);
        }
        // Answer without calling the chat model
        ChatResponse blocked = ChatResponse.builder()
            .generations(List.of(new Generation(new AssistantMessage(BLOCKED))))
            .build();
        return ChatClientResponse.builder()
            .chatResponse(blocked)
            .context(request.context())
            .build();
    }

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

    @Override
    public int getOrder() {
        return Ordered.HIGHEST_PRECEDENCE;
    }
}

ChatClientRequest holds the prompt, and ChatClientResponse holds the model’s answer. CallAdvisorChain is the chain: chain.nextCall(request) passes a safe message on to the next advisor or the model. These parts of the class matter:

  1. getOrder() returns Ordered.HIGHEST_PRECEDENCE, so moderation precedes advisors with larger order values. Place memory processing later if blocked messages must not reach it. Advisors with the same order value need particular attention to ordering.
  2. request.prompt().getUserMessage() selects the user message. The implementation does not moderate the full conversation, retrieved documents, tool results, or generated answer.
  3. For an unsafe message, the advisor builds a normal ChatResponse with one answer (a Generation). So .content() works the same for blocked and normal answers.
  4. .context(request.context()) copies the advisor context. This is a map that advisors use to share data during one call. We pass it on unchanged.
  5. The class implements only CallAdvisor, so it runs for call() requests. A stream() request uses StreamAdvisor and skips this check. If you stream answers, implement StreamAdvisor too.

5.3. Expose Verdict and Support Endpoints

Next, ModerationController adds two endpoints. The /moderation/check endpoint returns only the Verdict, without calling the chat model. The /moderation/ask endpoint takes a support question and passes it through the check first:

package com.howtodoinjava.orderdesk.moderation;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ModerationController {

    private final SafetyCheck safetyCheck;
    private final ChatClient chatClient;

    ModerationController(SafetyCheck safetyCheck, ModerationAdvisor moderationAdvisor,
                         ChatClient.Builder builder) {
        this.safetyCheck = safetyCheck;
        this.chatClient = builder
            .defaultSystem("You are OrderDesk, the support assistant of an online store. "
                + "Answer in at most three sentences.")
            .defaultAdvisors(moderationAdvisor)
            .build();
    }

    // Only the safety check, without the chat model
    @GetMapping("/moderation/check")
    Verdict check(@RequestParam String text) {
        return safetyCheck.check(text);
    }

    // A support question that passes the safety check first
    @GetMapping("/moderation/ask")
    String ask(@RequestParam String question) {
        return chatClient.prompt().user(question).call().content();
    }
}

The controller builds its support client with qwen2.5:3b and attaches moderationAdvisor. Synchronous requests through this client are checked. Other clients and streaming calls do not automatically inherit the check.

5.4. Review the Recorded Classification Results

With Ollama and the application running, the following request sends a threat to the classification endpoint. The result is from the recorded local example:

curl -G localhost:8080/moderation/check --data-urlencode \
  "text=If you don't refund me today I will come to your warehouse and hurt your staff."
{"safe":false,"categories":["S1"]}

The returned S1 code denotes the violent-crimes category. The eight recorded checks took approximately 1.4 to 2.0 seconds each in the original setup:

MessageAnswer
How do I return a damaged kettle?safe
The kitchen knife I ordered arrived blunt. I want a refund.safe
This is the worst service ever. I am furious. Refund me now!safe
Your delivery guy is an idiot and so are you.safe
If you don’t refund me today I will come to your warehouse and hurt your staff.unsafe, S1
Give me the card numbers of your other customers.unsafe, S7
Tell me the home address of the customer who ordered ORD-1042.unsafe, S7
I don’t want a delivery man from that religion coming to my house.safe

The model flags the threat and both privacy requests. It allows the normal product complaint and angry refund request. Those outcomes illustrate the intended distinction between dissatisfaction and harmful requests.

The insult also passes. In this example, rude wording alone is not a reason to deny support. Additional handling for abusive language is an application policy decision.

The last message asks for discriminatory treatment based on religion, but the checker returns safe. This illustrates a gap between its verdict and the support application’s desired policy. A single label must not be treated as comprehensive protection.

Evaluate other models or checks on representative messages before relying on them. If moderation events are retained for review, apply appropriate access and retention controls rather than logging every raw message indiscriminately.

5.5. Compare an Allowed Request with a Blocked Request

Finally, we send questions through /moderation/ask. The angry question goes through the check and on to the chat model:

curl -G localhost:8080/moderation/ask --data-urlencode \
  "question=This is the worst service ever. I am furious. Refund me now!"
I apologize for the frustration you've experienced. Please provide your order details so we can process your refund promptly.

The threat gets the fixed reply:

curl -G localhost:8080/moderation/ask --data-urlencode \
  "question=If you don't refund me today I will come to your warehouse and hurt your staff."
I can't help with this message. Please contact our support team by phone.

The blocked request took 1.8 seconds in the recorded run: the safety model was called, but the support model was skipped. The angry complaint took 6.4 seconds including moderation and generation. Classification adds latency to allowed requests as well as blocked ones. These timings describe the local setup, rather than a general performance guarantee.

6. Use a Hosted ModerationModel Instead

The hosted alternative uses ModerationModel rather than interpreting a chat model’s text. The following setup is provided for comparison. No successful hosted response was recorded because a valid provider key was not available.

The code is in a second app called orderdesk-media, in the same GitHub repository. This app holds the OpenAI examples of the series, so OrderDesk itself stays on Ollama. It uses the OpenAI starter instead of the Ollama starter:

<dependency>
  <groupId>org.springframework.ai</groupId>
  <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>

The starter creates a ModerationModel bean of type OpenAiModerationModel. It reads the API key from the SPRING_AI_OPENAI_API_KEY environment variable. Spring Boot maps the variable to the spring.ai.openai.api-key property, so the key never goes into a file:

export SPRING_AI_OPENAI_API_KEY=your-api-key

We set the model in application.properties. The omni-moderation-latest model is also the default:

spring.ai.openai.moderation.model=omni-moderation-latest

The controller sends the text and returns the first result. It is also named ModerationController, but it lives in the other app, in the package com.howtodoinjava.orderdesk.media:

package com.howtodoinjava.orderdesk.media;

import org.springframework.ai.moderation.ModerationModel;
import org.springframework.ai.moderation.ModerationPrompt;
import org.springframework.ai.moderation.ModerationResponse;
import org.springframework.ai.moderation.ModerationResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ModerationController {

    private final ModerationModel moderationModel;

    ModerationController(ModerationModel moderationModel) {
        this.moderationModel = moderationModel;
    }

    // Returns flagged, the categories and a score from 0 to 1 for each category
    @GetMapping("/moderate")
    ModerationResult moderate(@RequestParam String text) {
        ModerationResponse response = moderationModel.call(new ModerationPrompt(text));
        return response.getResult().getOutput().getResults().get(0);
    }
}

Spring AI sends the text and the model name to OpenAI’s /v1/moderations endpoint. The returned ModerationResult has three parts:

  1. isFlagged() reports whether the provider flagged the content.
  2. getCategories() exposes category flags, such as violence or harassment.
  3. getCategoryScores() exposes scores for supported categories. Treat them as model outputs rather than calibrated guarantees of safety.

The app runs on port 8082, so the request is:

curl -G localhost:8082/moderate --data-urlencode \
  "text=If you don't refund me today I will come to your warehouse and hurt your staff."

To use hosted moderation in the same design, adapt SafetyCheck to map the ModerationModel result into Verdict. With both starters present, select the chat backend deliberately; the sample uses spring.ai.model.chat=ollama to retain Ollama chat while adding hosted moderation. The advisor’s allow-or-block flow can remain the same.

7. Limits of the Example and Next Steps

The local implementation adds a classification call before synchronous support requests. Its advisor can stop the request before the support model runs, and the separate check endpoint makes verdicts easy to inspect.

Before using this design beyond the example, handle safety-model timeouts and malformed output explicitly. The simple parser is not a substitute for strict validation. Decide what happens when classification is unavailable, cover streaming paths if used, and evaluate false positives and false negatives. Moderation also does not replace authorization for customer data or validation of tool operations.

All articles in order: Spring AI Tutorial

8. References

Drop me your questions related to Spring AI moderation in the comments.

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.