Spring AI 对接 DeepSeek:实现推理过程流式输出
普通的大模型接口,一般只返回最终答案。
但 DeepSeek 的推理模型除了最终回答之外,还可以额外返回一段推理内容。
比如我们问:
一加一等于多少?
模型返回的数据,大致可以理解为:
推理过程:
用户询问一个简单数学问题,需要直接计算……
----------------
最终回答:
1 + 1 = 2
这一篇我们就来看看,在 Spring Boot + Spring AI 中,怎么把 DeepSeek 的推理过程也流式输出到浏览器。
一、实现思路
整个过程其实并不复杂。
DeepSeek 返回的数据里面,主要有两部分:
reasoning_content
表示模型的推理内容。
以及:
content
表示模型最终返回给用户的正式答案。
所以我们的核心逻辑就是:
用户发送问题
↓
Spring AI 调用 DeepSeek
↓
模型开始推理
↓
reasoning_content
↓
推理结束
↓
content
↓
最终答案
我们只需要在流式响应中把这两个字段分别取出来即可。
二、添加 commons-lang3
为了方便处理字符串,可以先引入 commons-lang3。
在 pom.xml 中添加:
<properties>
<commons-lang3.version>3.18.0</commons-lang3.version>
</properties>
然后添加依赖:
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>${commons-lang3.version}</version>
</dependency>
刷新 Maven 即可。
三、创建推理接口
新建一个控制器:
DeepSeekR1ChatController
完整代码如下:
package com.quanxiaoha.ai.robot.controller;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.deepseek.DeepSeekAssistantMessage;
import org.springframework.ai.deepseek.DeepSeekChatModel;
import org.springframework.ai.deepseek.DeepSeekChatOptions;
import org.springframework.ai.deepseek.api.DeepSeekApi;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicBoolean;
@RestController
@RequestMapping("/v1/ai")
public class DeepSeekR1ChatController {
@Resource
private DeepSeekChatModel chatModel;
@GetMapping(
value = "/generateStream",
produces = "text/html;charset=utf-8"
)
public Flux<String> generateStream(
@RequestParam(
value = "message",
defaultValue = "你是谁?"
) String message) {
// 1. 指定本次请求使用的模型
DeepSeekChatOptions chatOptions =
DeepSeekChatOptions.builder()
.model(
DeepSeekApi.ChatModel
.DEEPSEEK_V4_PRO
.getValue()
)
.build();
// 2. 创建 Prompt
Prompt prompt = new Prompt(message, chatOptions);
// 3. 标记是否已经输出过分割线
AtomicBoolean needSeparator =
new AtomicBoolean(true);
// 4. 流式调用 DeepSeek
return chatModel.stream(prompt)
.mapNotNull(response -> {
DeepSeekAssistantMessage output =
(DeepSeekAssistantMessage)
response
.getResult()
.getOutput();
// 推理内容
String reasoningContent =
output.getReasoningContent();
// 最终回答
String text =
output.getText();
boolean isAnswer = false;
String content;
if (Objects.isNull(text)) {
// 还在推理
content = reasoningContent;
} else {
// 开始输出最终答案
content = text;
isAnswer = true;
}
// 浏览器不能直接识别 \n,
// 所以替换成 <br>
if (StringUtils.isNotBlank(content)) {
content =
content.replace(
"\n",
"<br>"
);
}
// 第一次进入正式回答时,
// 插入分割线
if (isAnswer
&& needSeparator
.compareAndSet(
true,
false
)) {
content =
"<hr>" + content;
}
return content;
});
}
}
看起来代码不少,但真正重要的其实只有几个地方。
四、指定推理模型
首先:
DeepSeekChatOptions chatOptions =
DeepSeekChatOptions.builder()
.model(
DeepSeekApi.ChatModel
.DEEPSEEK_V4_PRO
.getValue()
)
.build();
这里创建了一个本次请求使用的配置。
然后:
Prompt prompt =
new Prompt(message, chatOptions);
把配置传给 Prompt。
这样做最大的好处是:
不会修改整个项目的默认模型。
比如项目默认使用:
deepseek-v4-flash
普通聊天继续使用 Flash。
而只有这个推理接口使用:
deepseek-v4-pro
相当于:
普通聊天
↓
V4-Flash
复杂推理
↓
V4-Pro
不同接口可以选择不同模型。
五、获取推理内容
最关键的一段代码是:
DeepSeekAssistantMessage output =
(DeepSeekAssistantMessage)
response
.getResult()
.getOutput();
为什么要转成:
DeepSeekAssistantMessage
?
因为普通的:
AssistantMessage
主要只能获取最终回答。
而 DeepSeek 还额外提供了:
reasoning_content
Spring AI 使用:
DeepSeekAssistantMessage
来接收这些 DeepSeek 专属字段。
所以我们就可以调用:
output.getReasoningContent();
获取推理内容:
String reasoningContent =
output.getReasoningContent();
同时通过:
String text =
output.getText();
获取最终答案。
可以简单理解成:
getReasoningContent()
负责拿:
模型正在想什么
而:
getText()
负责拿:
模型最后回答什么
六、区分推理和最终答案
DeepSeek 流式返回时,一般会经历两个阶段。
第一阶段:
reasoningContent 有内容
text 没内容
说明:
模型正在推理
第二阶段:
reasoningContent 没内容
text 有内容
说明:
模型开始输出最终答案
所以代码中进行了判断:
if (Objects.isNull(text)) {
content = reasoningContent;
} else {
content = text;
isAnswer = true;
}
逻辑非常简单:
text == null
↓
输出推理过程
text != null
↓
输出最终回答
七、为什么要把 \n 换成
?
模型返回的内容里面可能包含:
\n
例如:
第一步\n第二步\n第三步
但是浏览器直接渲染 HTML 时,并不会自动把:
\n
显示成换行。
所以:
content.replace("\n", "<br>");
把它转换成:
第一步<br>
第二步<br>
第三步
这样浏览器才能正确显示换行。
八、给推理过程和答案加一条分割线
如果直接输出:
推理内容
最终答案
用户不容易看出来哪里是推理,哪里是正式答案。
所以可以在正式回答开始的时候加:
<hr>
最终效果:
推理过程……
推理过程……
------------------------
最终答案……
但是这里有个问题。
因为是流式响应,最终答案也是一小段、一小段返回的。
比如:
答
然后:
案
然后:
是
如果我们每收到一次正式答案都加:
<hr>
最后就会变成:
----------------
答
----------------
案
----------------
是
显然不对。
因此定义一个:
AtomicBoolean needSeparator =
new AtomicBoolean(true);
第一次进入最终答案时执行:
needSeparator.compareAndSet(
true,
false
)
第一次执行:
true → false
返回:
true
于是插入:
<hr>
后面的正式回答再次执行时,因为已经是:
false
所以不会再次插入。
最终保证:
每次请求只添加一次分割线。
九、测试接口
启动 Spring Boot 项目。
浏览器访问:
http://localhost:8080/v1/ai/generateStream?message=一加一等于多少
此时浏览器就会看到模型内容一点一点输出。
整体效果类似:
用户询问一个简单的数学计算问题。
需要计算 1 + 1。
结果为 2。
--------------------------------
1 + 1 = 2。
这样,一个最基础的 DeepSeek 推理过程流式输出接口 就实现完成了。
十、核心代码再总结一下
整个功能其实只需要记住三个知识点。
第一,使用:
DeepSeekChatOptions
可以针对某一次请求单独指定模型:
DeepSeekChatOptions.builder()
.model(...)
.build();
第二,通过:
DeepSeekAssistantMessage
拿到 DeepSeek 特有的推理内容:
output.getReasoningContent();
以及最终答案:
output.getText();
第三,根据两者的状态判断当前模型是在推理还是在输出最终答案:
reasoningContent
↓
推理过程
text
↓
最终答案
完整的数据流就是:
浏览器提问
↓
Controller
↓
创建 Prompt
↓
DeepSeekChatModel.stream()
↓
DeepSeek
↓
reasoning_content
↓
页面流式显示推理内容
↓
<hr>
↓
content
↓
页面流式显示最终答案
到这里,我们就完成了:
Spring Boot + Spring AI + DeepSeek 推理模型的流式调用。
后续如果要做真正的 AI 聊天页面,还可以继续在这个基础上加入:
ChatClient
↓
ChatMemory
↓
多轮上下文
↓
Markdown 渲染
↓
Vue / React 聊天页面
↓
SSE 流式输出
这样就可以一步一步做出类似 ChatGPT、DeepSeek 官网的聊天效果。