Spring AI with DeepSeek: Setup and Thinking Mode

Spring AI DeepSeek setup in Spring Boot: the starter, the API key, two ChatClients for DeepSeek and a local model, and reasoning mode in Spring AI 2.0.

SupportController sends a question to one of two ChatClients with the same API. One is backed by DeepSeekChatModel for DeepSeek, the other by OllamaChatModel with qwen2.5:3b on localhost.

Spring AI can connect the same Spring Boot application to DeepSeek and a local Ollama model. Each backend gets its own ChatClient, and a request parameter chooses which one answers a question.

This example covers the DeepSeek starter, API-key configuration, response metadata, and thinking mode. The recorded answers use Ollama. DeepSeek configuration, a rejected placeholder key, and captured requests illustrate the hosted integration without claiming a successful DeepSeek response.

1. How DeepSeek and Ollama Fit into Spring AI

Both backends accept messages and generate a response, but their deployment, features, latency, and costs differ. Answer quality depends on the model and task; hosting alone does not determine it.

  • Ollama runs inference on your machine. The local setup avoids a hosted API charge and does not send prompts to DeepSeek, but uses local compute and memory.
  • DeepSeek runs inference on its service. Requests need a valid API key, send prompts to the provider, and are subject to service limits and pricing.

Keeping both clients available lets you evaluate them behind the same endpoint. Here, the caller chooses the backend; the application does not route questions automatically.

Spring AI’s ChatModel interface represents the backend, with implementations such as OllamaChatModel and DeepSeekChatModel. ChatClient supplies the fluent API used by the controller. One client per model lets the endpoint use either backend without constructing provider-specific HTTP requests:

SupportController with /ask?model= has two arrows: hosted goes to ChatClient (hostedClient) and then to DeepSeekChatModel with deepseek-v4-flash, which calls api.deepseek.com; local goes to ChatClient (localClient) and then to OllamaChatModel with qwen2.5:3b at localhost:11434. A note says both clients have the same API, prompt(), user(), call(), and only the ChatModel bean behind each one is different
The same SupportController uses two ChatClients with the same API; one is backed by DeepSeekChatModel (deepseek-v4-flash at api.deepseek.com), the other by OllamaChatModel (qwen2.5:3b in local Ollama).

No valid DeepSeek account key was available. The local output demonstrates Ollama execution. Captured request bodies show request construction, while authentication errors show failed access; neither verifies a successful DeepSeek answer.

2. Configure the Spring Boot Application

This article is part of the OrderDesk series. Its code is a separate module called orderdesk-deepseek. It uses Spring Boot 4.1.1, Spring AI 2.0.1, and Java 21, with all classes in the com.howtodoinjava.orderdesk.deepseek package.

If you need help installing Ollama, downloading the qwen2.5:3b model, and creating the Spring Boot project, follow the Getting Started with Spring AI tutorial.

2.1. Dependencies

Here is the complete pom.xml. It uses the Spring Boot 4.1.1 parent and imports the Spring AI BOM (bill of materials), which sets the version of every Spring AI module:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.1.1</version>
    <relativePath/>
  </parent>

  <groupId>com.howtodoinjava</groupId>
  <artifactId>orderdesk-deepseek</artifactId>
  <version>1.0.0</version>

  <properties>
    <java.version>21</java.version>
  </properties>

  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-bom</artifactId>
        <version>2.0.1</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>

  <dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-starter-model-ollama</artifactId>
    </dependency>
    <dependency>
      <groupId>org.springframework.ai</groupId>
      <artifactId>spring-ai-starter-model-deepseek</artifactId>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
      </plugin>
    </plugins>
  </build>
</project>

The spring-boot-starter-webmvc dependency is the Spring Boot 4 name of the web starter. The new dependency is spring-ai-starter-model-deepseek, which adds the DeepSeekChatModel bean. The Spring AI DeepSeek page documents this starter and all its properties.

2.2. Configure the DeepSeek API Key

Create a key on the DeepSeek Platform. DeepSeek bills each call by tokens, so check the pricing in the DeepSeek API docs first.

The key is a secret, so keep it out of your code and your Git repository. Put it in an environment variable instead:

export SPRING_AI_DEEPSEEK_API_KEY=your-deepseek-key

On Windows PowerShell, use $env:SPRING_AI_DEEPSEEK_API_KEY=”your-deepseek-key”. Spring Boot reads this variable as the property spring.ai.deepseek.api-key.

If the key is missing, the app does not start. The DeepSeek module checks the key when it creates the DeepSeekChatModel bean:

Caused by: java.lang.IllegalArgumentException: DeepSeek API key must be set

Both model beans are configured at startup, so this sample needs a nonempty DeepSeek key property even for local-only use. A placeholder lets the local demonstration start, but cannot authenticate a DeepSeek request. This is a consequence of the sample configuration, rather than an Ollama requirement.

With a placeholder or wrong key, the first call to DeepSeek fails:

org.springframework.ai.retry.NonTransientAiException: HTTP 401 - {"error":{"message":"Authentication Fails, Your api key: ****lder is invalid ...

The recorded response is an authentication failure. Spring AI reports a NonTransientAiException: retrying unchanged credentials will not resolve it. Supply a valid key and confirm account access before expecting a hosted response.

2.3. Select the Model and Response Options

Here is the full application.properties:

spring.application.name=orderdesk-deepseek
server.port=8085

# Two chat models in one app, so we build the ChatClients ourselves
spring.ai.chat.client.enabled=false

# The local model, as in the other OrderDesk articles
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.model=qwen2.5:3b
spring.ai.ollama.chat.temperature=0.2

# The key comes from the environment variable SPRING_AI_DEEPSEEK_API_KEY
spring.ai.deepseek.chat.model=deepseek-v4-flash
spring.ai.deepseek.chat.max-tokens=500
spring.ai.deepseek.chat.temperature=0.2

The properties explicitly select deepseek-v4-flash and set an output limit of 500 tokens. Keeping the model in configuration makes the choice visible. Check available IDs in the DeepSeek API documentation before selecting another model; temperature support can also depend on the model and mode.

The application disables the auto-configured builder with spring.ai.chat.client.enabled=false and constructs two clients explicitly. Each is then bound to its intended model. The Spring AI ChatClient reference explains other multiple-model arrangements.

3. Create and Select the Two ChatClients

ChatConfig creates one client per model. Both clients get the same system prompt, an instruction the model reads before every user question:

package com.howtodoinjava.orderdesk.deepseek;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ChatConfig {

    static final String SUPPORT = "You are OrderDesk, the support assistant of an online store. "
        + "Answer in at most three sentences.";

    // The hosted model: for questions that need a stronger model
    @Bean
    ChatClient hostedClient(DeepSeekChatModel chatModel) {
        return ChatClient.builder(chatModel).defaultSystem(SUPPORT).build();
    }

    // The local model in Ollama: free, for simple questions
    @Bean
    ChatClient localClient(OllamaChatModel chatModel) {
        return ChatClient.builder(chatModel).defaultSystem(SUPPORT).build();
    }
}

Injecting DeepSeekChatModel and OllamaChatModel by concrete type identifies the backend for each client. Each ChatClient.builder(chatModel) call therefore receives the intended model without an ambiguous choice between two ChatModel beans.

The controller picks a client from the model request parameter. It returns the answer, the model name and the token counts. These values are different for each provider, so we put them in a small record:

package com.howtodoinjava.orderdesk.deepseek;

// The answer plus the facts that differ between models
public record Reply(String model, String answer, Integer inputTokens, Integer outputTokens) {
}
package com.howtodoinjava.orderdesk.deepseek;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.metadata.ChatResponseMetadata;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SupportController {

    private final ChatClient hostedClient;
    private final ChatClient localClient;

    SupportController(@Qualifier("hostedClient") ChatClient hostedClient,
                      @Qualifier("localClient") ChatClient localClient) {
        this.hostedClient = hostedClient;
        this.localClient = localClient;
    }

    // model=hosted (default) or model=local
    @GetMapping("/ask")
    Reply ask(@RequestParam String question,
              @RequestParam(defaultValue = "hosted") String model) {
        ChatClient client = "local".equals(model) ? localClient : hostedClient;
        ChatResponse response = client.prompt().user(question).call().chatResponse();
        ChatResponseMetadata metadata = response.getMetadata();
        return new Reply(metadata.getModel(), response.getResult().getOutput().getText(),
            metadata.getUsage().getPromptTokens(), metadata.getUsage().getCompletionTokens());
    }
}

@Qualifier tells Spring which bean to inject when multiple beans match the required type. In this example, there are two ChatClient beans, and it identifies the one we want by the name of the @Bean method. There is no automatic routing: the caller chooses with model=local, and DeepSeek is the default.

The chatResponse() method returns the whole response instead of only the text. This lets us read getMetadata(), which holds the model that answered and the Usage with the token counts.

The main class is a plain Spring Boot application:

package com.howtodoinjava.orderdesk.deepseek;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DeepSeekApplication {

    public static void main(String[] args) {
        SpringApplication.run(DeepSeekApplication.class, args);
    }
}

4. Call the Endpoint and Inspect the Request

Start Ollama, set the key, and start the app with mvn spring-boot:run. It listens on port 8085. Ask the local model first:

curl -G localhost:8085/ask --data-urlencode "question=Can I return a kettle after 5 days?" \
  --data-urlencode model=local
{"model":"qwen2.5:3b","answer":"Yes, you can return a kettle within 5 days if the item is still in its original condition and packaging. Please contact customer service for the specific return process and any return shipping instructions.","inputTokens":43,"outputTokens":39}

This recorded answer came from Ollama’s qwen2.5:3b and took 13.5 seconds in the original setup. The placeholder DeepSeek key does not affect that local call. Omitting model=local selects DeepSeek, which rejects the placeholder with the authentication error shown earlier.

The following body was captured by pointing spring.ai.deepseek.base-url at a recording server. It shows the request sent to POST /chat/completions, rather than a response generated by DeepSeek:

{"messages":[{"content":"You are OrderDesk, the support assistant of an online store. Answer in at most three sentences.","role":"system"},{"content":"Can I return a kettle after 5 days?","role":"user"}],"model":"deepseek-v4-flash","max_tokens":500,"stream":false,"temperature":0.2}

According to the DeepSeek API docs, this request has the same format as the OpenAI API. It has a model, a max_tokens limit, and a messages list. The system prompt is the first message, with the role system. The model, the token limit, and the temperature come straight from application.properties.

The prompt does not include OrderDesk’s ten-day return policy. Neither a local nor a hosted model can reliably apply a store-specific rule it has not received. Supply those rules through instructions, retrieval, or tools before using the response as a support decision.

5. Enable Thinking Mode for One Request

A return decision can require several checks, including the time since delivery and product exclusions. Thinking mode gives a supported model additional reasoning work before its final response. It does not replace missing policies or guarantee a correct conclusion.

The application needs to distinguish the final answer from any reasoning content exposed by the provider. Handle them as separate fields rather than combining everything into the customer’s reply.

The Spring AI DeepSeek reference describes the thinking options and DeepSeekAssistantMessage.getReasoningContent(). Availability and behavior depend on the selected model.

In Spring AI, thinking is a chat option. We turn it on for a single request with DeepSeekChatOptions:

package com.howtodoinjava.orderdesk.deepseek;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
import org.springframework.ai.deepseek.DeepSeekChatOptions;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ReasoningController {

    private final ChatClient hostedClient;

    ReasoningController(@Qualifier("hostedClient") ChatClient hostedClient) {
        this.hostedClient = hostedClient;
    }

    // The model thinks first; the reasoning text comes back next to the answer
    @GetMapping("/ask/reason")
    Reasoned reason(@RequestParam String question) {
        ChatResponse response = hostedClient.prompt()
            .user(question)
            .options(DeepSeekChatOptions.builder().enableThinking())
            .call()
            .chatResponse();
        DeepSeekAssistantMessage message =
            (DeepSeekAssistantMessage) response.getResult().getOutput();
        return new Reasoned(message.getReasoningContent(), message.getText());
    }

    record Reasoned(String reasoning, String answer) {
    }
}

Three parts of this code matter.

  1. enableThinking() is a short way to set the Thinking option to ENABLED. The options(…) call applies it to this request only, so the /ask endpoint isn’t affected.
  2. In Spring AI 2.0, options(…) takes the options builder, so there is no build() call (Spring AI upgrade notes).
  3. The output of a DeepSeek call is a DeepSeekAssistantMessage, a subclass of the usual AssistantMessage. We cast to it because it has one extra method, getReasoningContent(), which returns the reasoning. The getText() method returns the final answer, as for any other message.

The provider-specific cast exposes reasoning content in addition to final text. This response-handling path has not been verified against a successful DeepSeek call in the example.

You call the endpoint like this:

curl -G localhost:8085/ask/reason --data-urlencode "question=Can I return a kettle after 5 days?"

The endpoint is designed to return separate reasoning and answer fields. No successful hosted response is included. The recording server captured this outgoing request:

{"messages":[{"content":"You are OrderDesk, the support assistant of an online store. Answer in at most three sentences.","role":"system"},{"content":"Can I return a kettle after 5 days?","role":"user"}],"model":"deepseek-v4-flash","max_tokens":500,"stream":false,"temperature":0.2,"thinking":{"type":"enabled"}}

Compared with the ordinary request, the body adds “thinking”:{“type”:”enabled”}. This confirms serialization of the option; it does not verify provider acceptance or answer quality.

The app also logged this warning from DeepSeekChatModel:

WARN ... o.s.ai.deepseek.DeepSeekChatModel        : Thinking mode does not support the temperature parameter(s). Please note that, for compatibility with existing software, setting these parameters will not trigger an error but will also have no effect.

The warning says that the selected model ignores the configured temperature. It was also recorded for the ordinary /ask request. Do not assume temperature=0.2 controls every DeepSeek mode.

To turn thinking on or off for the whole app instead of one request, set the property spring.ai.deepseek.chat.thinking.type to enabled or disabled. When thinking is disabled, the model produces no reasoning content.

Follow the selected model’s current requirements for conversation history. Keep final text distinct from provider-specific reasoning fields, and decide deliberately whether auxiliary content needs to be retained. Do not automatically display reasoning as part of the customer reply.

6. Deployment Considerations

A few things change when real customers start using the hosted model:

  1. Monitor token usage and request volume against model pricing. Bound output and enable thinking where its benefit justifies the latency and cost.
  2. Set the key in every environment where the app runs. Without it, the app doesn’t start, even for the local model.
  3. Handle invalid keys, rate limits, timeouts, and service failures. The sample does not implement automatic fallback; decide explicitly whether an Ollama response is appropriate after a hosted failure.
  4. Customer messages leave your machine. Check that you are allowed to send this data. Send only what the model needs.

All articles in order: Spring AI Tutorial

Drop me your questions related to Spring AI with DeepSeek 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.