spring boot,

中文版

Integrating the DeepSeek LLM with Spring AI

qihaiyan qihaiyan Follow Aug 01, 2026 · 12 mins read

Spring AI is the official Spring framework for integrating large language models, connecting to LLMs of all kinds through a unified set of abstraction interfaces. DeepSeek is a popular open-source LLM in China. This post shows how to integrate DeepSeek with Spring AI to implement basic chat, streaming responses, reasoning-model chain of thought, and Function Calling.

The complete code is available in the example project https://github.com/qihaiyan/springcamp/tree/main/spring-ai-deepseek

1. Overview

Spring AI follows the usual design philosophy of the Spring ecosystem: through starter auto-configuration, LLMs from different vendors are uniformly wrapped behind the ChatClient and ChatModel abstractions. Developers only need to add the corresponding starter and configure an API key to call an LLM like any ordinary bean, without worrying about the underlying HTTP requests and protocol details.

DeepSeek provides an OpenAI-compatible API and offers both a standard chat model and a reasoning model (chain of thought). This example project is based on Spring Boot 4.1.0 and Spring AI 2.0.0, and demonstrates four typical usages with minimal code:

  • Basic synchronous chat
  • Streaming responses (SSE)
  • Chain-of-thought output from the reasoning model
  • Function Calling (tool calling)

2. Project Dependencies and Configuration

First, add the Spring AI DeepSeek starter; the whole module needs only two dependencies:

ext {
    set('springAiVersion', "2.0.0")
}

dependencies {
    implementation 'org.springframework.ai:spring-ai-starter-model-deepseek'
    implementation 'org.springframework.boot:spring-boot-starter-web'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.ai:spring-ai-bom:${springAiVersion}"
    }
}

The spring-ai-bom manages the versions of all Spring AI-related dependencies in one place. The starter auto-configures DeepSeekChatModel and ChatClient.Builder, which we can inject and use directly.

Next, configure the DeepSeek API key and model parameters in application.properties:

server.port=8080
spring.main.banner-mode=off
logging.level.root=INFO

# DeepSeek configuration
spring.ai.deepseek.api-key=your-api-key
spring.ai.deepseek.chat.model=deepseek-chat
spring.ai.deepseek.chat.temperature=0.8

The API key must be applied for on the DeepSeek open platform (https://platform.deepseek.com). Hard-coding it in the configuration file is for demonstration only; in production it should be injected through environment variables or a configuration center to avoid leakage.

3. Configuring the ChatClient

The spring-ai-deepseek starter automatically provides a ChatClient.Builder; we only need to build the ChatClient bean in the application class:

@SpringBootApplication
public class Application {

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

    @Bean
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

ChatClient is the model-agnostic high-level facade provided by Spring AI; the chat, streaming responses, and tool calls that follow are all built on it.

4. Basic Chat and Streaming Responses

Basic chat is the simplest way to call an LLM and takes one line of code with the ChatClient fluent API:

@GetMapping(value = "/chat")
public String chat(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    return chatClient.prompt().user(message).call().content();
}

Calling the /ai/chat?message=讲个笑话 endpoint waits synchronously for the LLM to return the complete result before responding.

In real applications, to improve the user experience we usually adopt streaming responses, letting the frontend render a typewriter-like, character-by-character output:

@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_PLAIN_VALUE + ";charset=UTF-8")
public Flux<String> stream(@RequestParam(value = "message", defaultValue = "Tell me a joke") String message) {
    return chatClient.prompt().user(message).stream().content();
}

Simply replace call() with stream() and change the return type from String to Flux<String>. Spring then pushes each token generated by the LLM to the client via SSE (Server-Sent Events). Here text/plain;charset=UTF-8 is set explicitly to avoid garbled Chinese characters.

5. Reasoning Model Chain of Thought

Before giving the answer, DeepSeek’s reasoning model first outputs a thinking process — the chain of thought — which is the key capability that distinguishes reasoning models from standard chat models.

ChatClient is a model-agnostic generic interface and cannot directly expose DeepSeek-specific thinking content, so here we bypass ChatClient and inject the underlying DeepSeekChatModel directly:

@GetMapping("/reasoning")
public Map<String, String> reasoning(@RequestParam(value = "message", defaultValue = "9.11 和 9.8 哪个大?") String message) {
    ChatResponse response = chatModel.call(new Prompt(message));
    DeepSeekAssistantMessage output = (DeepSeekAssistantMessage) Objects.requireNonNull(response.getResult()).getOutput();
    return Map.of(
            "reasoning", output.getReasoningContent() != null ? output.getReasoningContent() : "",
            "answer", output.getText() != null ? output.getText() : ""
    );
}

After casting the result to DeepSeekAssistantMessage, we can get the thinking process via getReasoningContent() and the final answer via getText(). The code null-checks both fields because only the reasoning model returns reasoningContent; for the standard chat model this field is null.

Note that actually obtaining the chain of thought requires a model with thinking capability. Both of DeepSeek’s current models (deepseek-v4-flash and deepseek-v4-pro) have thinking capability, while standard chat models do not return a thinking process. The default example question “Which is bigger, 9.11 or 9.8?” is a classic test of a model’s reasoning ability.

6. Function Calling

Function Calling lets an LLM call external Java methods, so it can fetch real-time data or perform concrete actions. Spring AI declares an ordinary Java method as a tool callable by the model through the @Tool annotation, with no need to hand-write a JSON schema.

Define a mock weather query service:

@Service
public class WeatherService {

    private static final Map<Integer, String> CONDITIONS = Map.of(
            0, "晴", 1, "多云", 2, "小雨", 3, "小雪"
    );

    @Tool(description = "查询指定城市的当前天气情况,返回温度和天气状况")
    public String getCurrentWeather(String city) {
        int temp = ThreadLocalRandom.current().nextInt(-5, 35);
        String condition = CONDITIONS.get(ThreadLocalRandom.current().nextInt(CONDITIONS.size()));
        return String.format("%s 当前天气:%s,气温 %d°C", city, condition, temp);
    }
}

When calling the ChatClient, pass this service to the model through the tools() method:

@GetMapping(value = "/tool")
public String tool(@RequestParam(value = "message", defaultValue = "北京和上海今天天气怎么样?") String message) {
    return chatClient.prompt().user(message).tools(weatherService).call().content();
}

When the user asks “What’s the weather like in Beijing and Shanghai today?”, the model decides on its own that it needs weather data, calls the getCurrentWeather method twice — once for Beijing and once for Shanghai — and then organizes the results into a natural-language response. The model decides whether to call a tool and how many times; that is the core capability of Function Calling.

7. Inspecting the Function Calling Process

The /ai/tool endpoint from the previous section returns only the model’s final answer. From the result alone we cannot tell how many times the model actually called a tool behind the scenes, what arguments it passed each time, or what the tools returned. In real development and debugging, exposing this call process helps us observe the model’s behavior and check whether it called the tools as expected.

We can define a request-scoped ToolCallRecorder to record the tool calls within a single request:

@Component
@RequestScope
public class ToolCallRecorder {

    private final List<ToolInvocation> invocations = new ArrayList<>();

    public record ToolInvocation(String tool, String arguments, String result) {
    }

    public void record(String tool, String arguments, String result) {
        invocations.add(new ToolInvocation(tool, arguments, result));
    }

    public List<ToolInvocation> get() {
        return Collections.unmodifiableList(invocations);
    }
}

Here @RequestScope limits the scope to a single HTTP request: each request gets its own instance, which is destroyed when the request ends. With this request-level isolation, the call records of different requests do not interfere with each other — no ThreadLocal needed — and it is also friendly to virtual threads. Each call record is wrapped in a record with three fields: the tool name, the arguments, and the return value.

Next, inject the ToolCallRecorder into WeatherService and record every call inside the tool method:

@Service
public class WeatherService {

    private static final Map<Integer, String> CONDITIONS = Map.of(
            0, "晴", 1, "多云", 2, "小雨", 3, "小雪"
    );

    private final ToolCallRecorder recorder;

    public WeatherService(ToolCallRecorder recorder) {
        this.recorder = recorder;
    }

    @Tool(description = "查询指定城市的当前天气情况,返回温度和天气状况")
    public String getCurrentWeather(String city) {
        int temp = ThreadLocalRandom.current().nextInt(-5, 35);
        String condition = CONDITIONS.get(ThreadLocalRandom.current().nextInt(CONDITIONS.size()));
        String result = String.format("%s 当前天气:%s,气温 %d°C", city, condition, temp);
        recorder.record("getCurrentWeather", city, result);
        return result;
    }
}

Compared with the previous section, the only change is one extra line, recorder.record(...), before the method returns, recording the tool name, the city argument, and the return value.

Finally, rework the /ai/tool endpoint to change the return type from String to Map, returning both the model’s final answer and the complete tool call process:

@GetMapping(value = "/tool")
public Map<String, Object> tool(@RequestParam(value = "message", defaultValue = "北京和上海今天天气怎么样?") String message) {
    String answer = chatClient.prompt().user(message).tools(weatherService).call().content();
    return Map.of(
            "answer", answer != null ? answer : "",
            "toolCalls", toolCallRecorder.get()
    );
}

Calling /ai/tool?message=北京和上海今天天气怎么样? again, the response now contains a toolCalls array in addition to answer, recording the model’s complete tool call process:

{
  "answer": "北京当前多云,气温20°C;上海当前小雨,气温25°C。",
  "toolCalls": [
    {
      "tool": "getCurrentWeather",
      "arguments": "北京",
      "result": "北京 当前天气:多云,气温 20°C"
    },
    {
      "tool": "getCurrentWeather",
      "arguments": "上海",
      "result": "上海 当前天气:小雨,气温 25°C"
    }
  ]
}

From toolCalls we can clearly see that the model called getCurrentWeather once for Beijing and once for Shanghai, with the arguments and return values of each call fully recorded. This makes it easy to observe how the model uses tools.

qihaiyan
Written by qihaiyan
业精于勤而荒于嬉,行成于思而毁于随