1. 背景介绍
最近 "万物皆可 DeepSeek",为了跟风,我也想在跟导师做的项目中尝试接入 DeepSeek,希望能帮助到后续的合同验收以及奖项评选。本文将详细介绍从选择合适的云服务商到前后端实现的完整过程,希望能为同样想要简单接入 DeepSeek 的开发者提供参考。
先贴出效果图:
2. 调用方式的选择
2.1. 接入方式对比
对于 DeepSeek 的接入,主要有以下几种方式:
- API 调用(最简单快速)
- 本地部署(资源要求高)
考虑到只是简单接入,显然选择 API 的方式是最为合适的。
2.2. 云服务商的选择
本地部署对于我的项目来说不太现实,因此需要选择合适的云服务商。我的选择过程如下:
腾讯云
由于本科时期我曾租用过腾讯云的服务器,对于其云服务比较熟悉,因此首先考虑了腾讯云提供的 DeepSeek API 接口。然而,在实际使用过程中遇到了一些问题:
- 文档不够清晰:腾讯云提供的文档缺乏详细的使用说明,而且逻辑有点乱,读起来不是很顺畅。
- SDK 示例不足:只给出了 LkeapClient、请求响应等类的实现,并没有给出具体的使用方式,封装程度不足,想要用还得自己专门去看代码。
- 响应速度慢:经过长时间调试后,一个请求需要等待 40 多秒才能得到响应。
火山引擎
在尝试腾讯云失败后,我想到学校最近宣传的接入 DeepSeek 的平台中有一个是基于火山引擎 API 的,于是决定尝试一下。尝试之后发现火山引擎的使用体验比腾讯云知识引擎好多,优势在于:
- 文档更加清晰:明确列出了 REST API、OPENAI SDK 和火山引擎 SDK 三种方式。总而言之就是很好上手。
- 接入点粒度更细:模型申请细化到接入点的粒度。
- SDK 示例完善:提供了可直接使用的 Demo。
综合考虑后,最终选择了火山引擎 DeepSeek API。由于火山引擎文档非常清晰,跟着操作即可,非常简便,这里省略接入过程。
3. 后端实现
3.1 SDK 学习
首先需要学习火山引擎 SDK 的实现,重点关注了两个核心文件:
- ArkService:服务层实现
- ArkApi:API 接口定义
SDK 的核心实现思路基于下面三个开源库:
-
OkHttp:用于定义高效的 HTTP 客户端
-
在 ArkService 中,OkHttp 通过多种拦截器增强了基础功能:
AuthenticationInterceptor
:处理 API 密钥认证ArkResourceStsAuthenticationInterceptor
:处理 AK/SK 认证RequestIdInterceptor
:为每个请求添加唯一标识RetryInterceptor
:实现请求失败自动重试逻辑BatchInterceptor
:处理批量请求
-
配置了连接池管理,默认维护 5 个空闲连接,超时 1 秒
-
设置了读取超时、连接超时和调用超时,确保网络请求的可靠性
-
-
Retrofit:将 HTTP API 优雅地转换为 Java 接口
-
通过注解将 HTTP 请求映射到 Java 方法调用
-
使用
JacksonConverterFactory
处理 JSON 序列化和反序列化 -
结合
RxJava2CallAdapterFactory
支持响应式编程模型 -
在 ArkService 中,可以看到如何通过 Retrofit 创建 ArkApi 接口的实现:
iniRetrofit retrofit = defaultRetrofit(client, mapper, baseUrl, callbackExecutor);this.api = retrofit.create(ArkApi.class);
-
-
RxJava:提供强大的异步编程和数据流处理能力
- 使用
Flowable
处理可能的背压问题(当生产数据速度快于消费速度时) - 提供
stream()
方法将 HTTP 响应体转换为响应式数据流 - 支持通过
blockingForEach
方法按块处理流数据 - 实现了
Single
类型用于处理单一响应的异步操作
- 使用
通过分析 ArkService 类的实现,我们可以看到它提供了丰富的功能:
java
public class ArkService extends ArkBaseService implements ArkBaseServiceImpl {
// 初始化各种客户端和映射器
private static final ObjectMapper mapper = defaultObjectMapper();
private final ArkApi api;
private final ExecutorService executorService;
// 各种构造方法...
// 关键方法:流式聊天完成接口
public Flowable<ChatCompletionChunk> streamChatCompletion(ChatCompletionRequest request) {
request.setStream(true);
return stream(api.createChatCompletionStream(request, request.getModel(), new HashMap<>()),
ChatCompletionChunk.class);
}
// 流处理帮助方法
public static Flowable<SSE> stream(Call<ResponseBody> apiCall) {
return stream(apiCall, false);
}
public static <T> Flowable<T> stream(Call<ResponseBody> apiCall, Class<T> cl) {
return stream(apiCall).map(sse -> mapper.readValue(sse.getData(), cl));
}
// 其他帮助方法...
}
ArkService 采用了 Builder 模式进行实例化,支持多种认证方式(API Key、AK/SK),并提供了丰富的配置选项(超时时间、重试次数、代理等)。最重要的是,它通过 RxJava 的 Flowable 实现了流式数据处理,这正是我们实现流式对话的核心。
对我们的项目而言,最关键的方法是 streamChatCompletion()
,它将请求设置为流式模式,并返回一个 Flowable 对象,允许我们以异步方式处理 DeepSeek API 返回的数据块。
3.2 Spring 流式响应方案选择
想象一下前后端之间的数据传输就像不同的水流系统。在 Spring 框架中,实现异步流式接口主要有三种"水流方式":
StreamingResponseBody:就像一根直通的水管,水流(数据)顺畅地从源头流向目的地,没有复杂的阀门和分叉。你打开水龙头,水就源源不断地流出来,就像 AI 生成的文本一个字一个字地呈现。
SseEmitter:相当于一个智能水流系统,不仅能传输水,还能发送各种信号和事件。它会定期告诉你"嘿,这是热水"、"注意,水压有变化"。功能强大,但对于单纯想喝水的人来说,可能有些"过度设计"。
ResponseBodyEmitter:像一个通用的配送系统,既能送水,也能送牛奶、果汁等各种液体。灵活性很高,但需要更多的配置和处理。
对于流式对话场景,StreamingResponseBody 是最理想的选择,原因很简单:
- 简洁直接:不需要复杂的事件标记,代码简洁易懂 - 就一根水管,没有复杂的阀门
- 性能高效:专为流式数据传输而设计,开销小 - 流量小,水压稳定
- 契合场景:AI 生成内容的特性与流式传输天然匹配 - 就像水流一样自然地一滴一滴流出
- 开发友好:几行代码就能实现,减少出错可能 - 不需要精通水利工程也能搞定
当 DeepSeek API 返回一小块一小块的文本时,StreamingResponseBody 能够立即将这些文本推送到前端,实现打字机效果,让用户体验更加自然流畅。就像看着水流从水龙头缓缓流出,而不是等待整桶水一次性倒出。
3.3 后端代码实现
scss
static String apiKey = "XXX"; // 替换成你的 API KEY
static ArkService service = ArkService.builder()
.timeout(Duration.ofSeconds(60))
.baseUrl("https://ark.cn-beijing.volces.com/api/v3")
.apiKey(apiKey)
.build();
/**
* 流式聊天代理接口 - 简化版
*/
@PostMapping(value = "/v1/free-chat")
public ResponseEntity<StreamingResponseBody> freeChat(@RequestParam("question") String question) {
// 1. 准备请求消息
final List<ChatMessage> messages = new ArrayList<>();
messages.add(ChatMessage.builder()
.role(ChatMessageRole.USER)
.content(question)
.build());
// 2. 创建请求
ChatCompletionRequest chatRequest = ChatCompletionRequest.builder()
.model("ep-XXXXXXXXXXXXXXXXXX") // 替换为你的模型接入点ID
.messages(messages)
.build();
// 3. 发起请求与返回响应
return ResponseEntity
.ok()
.header("Content-Type", "text/plain;charset=utf-8")
.body(outputStream -> {
service.streamChatCompletion(chatRequest)
.blockingForEach(delta -> {
String content = null;
if (!delta.getChoices().isEmpty()) {
if (StringUtils.isNotEmpty(delta.getChoices().get(0).getMessage().getReasoningContent())) {
content = (String) delta.getChoices().get(0).getMessage().getReasoningContent();
} else {
content = (String) delta.getChoices().get(0).getMessage().getContent();
}
System.out.print(content);
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
}
});
});
}
代码解析:
- StreamingResponseBody:Spring框架提供的接口,允许我们以异步方式将数据写入HTTP响应。
- blockingForEach:来自RxJava的方法,它会订阅可观察对象(Observable)并对每个发出的项目执行操作。在这里,它会处理每个从DeepSeek API返回的数据块。
- outputStream :HTTP响应的输出流,我们可以直接向其写入字节数据。通过调用
write()
写入内容,flush()
确保数据立即发送到客户端。
这种实现方式的优点是:
- 简单直接,代码量少
- 支持长时间运行的请求
- 客户端可以立即看到部分响应,而不必等待完整响应
4. 前端实现
前端使用Vue实现流式接收和显示,关键在于利用Fetch API和Web Streams来处理流式数据。
4.1 Vue组件实现
xml
<script setup>
import { nextTick, onUnmounted, ref } from 'vue'
const question = ref('')
const answer = ref('')
const loading = ref(false)
const abortController = ref(null)
// 自动滚动到底部
function scrollToBottom() {
nextTick(() => {
const container = document.querySelector('.answer-container')
if (container) {
container.scrollTop = container.scrollHeight
}
})
}
// 流式聊天 - 使用原生 fetch API 实现字符逐个显示
async function streamChat() {
if (!question.value.trim()) {
return
}
loading.value = true
answer.value = '' // 清空之前的回答
// 创建新的 AbortController 用于取消请求
abortController.value = new AbortController()
try {
const response = await fetch('/proxy/ds/v1/free-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'token': 'eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjIsImlhdCI6MTc0MjQ3OTcwOCwiZXhwIjoxNzc4NDc5NzA4fQ.iK-BaoGE_xjhvrkqOOOlwXR9uOh18j3qq8jxb4V2gAY', // 只是一个 Demo,为了简便起见就直接在头部写入 token 了,不要在意。。。
},
body: new URLSearchParams({ question: question.value }),
signal: abortController.value.signal,
})
if (!response.ok) {
throw new Error(`服务器响应错误: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
// 逐字符显示实现
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
// 接收到的文本块
const chunk = decoder.decode(value, { stream: true })
// 模拟逐字符显示
for (let i = 0; i < chunk.length; i++) {
// 一次添加一个字符
answer.value += chunk[i]
scrollToBottom()
// 添加小延迟以实现动画效果 (可以调整或移除此延迟)
await new Promise(resolve => setTimeout(resolve, 15))
}
}
}
catch (error) {
if (error.name !== 'AbortError') {
console.error('请求发生错误:', error)
answer.value = `请求过程中发生错误: ${error.message}`
}
else {
answer.value += '\n\n[请求已取消]'
}
}
finally {
loading.value = false
}
}
// 取消请求
function cancelRequest() {
if (abortController.value) {
abortController.value.abort()
}
}
// 组件卸载时清理资源
onUnmounted(() => {
cancelRequest()
})
// 处理按键事件
function handleKeydown(e) {
// 按Ctrl+Enter发送消息
if (e.ctrlKey && e.key === 'Enter') {
streamChat()
}
}
// 清空输入和回答
function clearContent() {
question.value = ''
answer.value = ''
}
</script>
<template>
<div class="analysis-container">
<h1>智能分析</h1>
<div class="question-container">
<a-textarea
v-model="question"
placeholder="请输入您的问题,按Ctrl+Enter发送"
:rows="4"
:disabled="loading"
@keydown="handleKeydown"
/>
<div class="button-group">
<a-button
type="primary"
:loading="loading"
:disabled="!question.trim()"
@click="streamChat"
>
{{ loading ? '生成中...' : '获取分析' }}
</a-button>
<a-button
v-if="loading"
danger
@click="cancelRequest"
>
取消
</a-button>
<a-button
v-if="question.trim() || answer"
@click="clearContent"
>
清空
</a-button>
</div>
</div>
<div v-if="answer || loading" class="answer-container">
<div class="answer-content">
{{ answer }}<span v-if="loading" class="cursor">|</span>
</div>
</div>
</div>
</template>
<style scoped>
.analysis-container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.question-container {
margin: 20px 0;
}
.button-group {
margin-top: 12px;
display: flex;
gap: 10px;
}
.answer-container {
margin-top: 20px;
border: 1px solid #e6e6e6;
border-radius: 6px;
padding: 16px;
background-color: #f9f9f9;
min-height: 200px;
max-height: 500px;
overflow-y: auto;
}
.answer-content {
white-space: pre-wrap;
line-height: 1.6;
font-size: 14px;
}
.cursor {
display: inline-block;
width: 2px;
height: 18px;
background-color: #000;
animation: blink 1s step-end infinite;
vertical-align: middle;
}
@keyframes blink {
50% { opacity: 0; }
}
</style>
4.2 Fetch API 与 Web Streams API 解析
详细可以参考我之前的这篇博客: Fetch API 及其与 Web Streams API 的结合使用
Fetch API 是现代浏览器提供的用于网络请求的接口,它有以下特点:
- Promise 基础:基于 Promise,使异步代码更易于编写和管理
- 流式处理能力:支持通过 body.getReader() 方法获取 ReadableStream
- 可中断请求:通过 AbortController 支持取消进行中的请求
在我们的实现中,关键点在于:
(1) 获取响应流
ini
const response = await fetch('/proxy/ds/v1/free-chat', {...})
const reader = response.body.getReader()
这里我们通过 response.body.getReader()
获取了一个 ReadableStreamDefaultReader
对象,它允许我们以块的形式读取响应数据。
(2) 解码字节数据
ini
const decoder = new TextDecoder('utf-8')
const chunk = decoder.decode(value, { stream: true })
TextDecoder
用于将接收到的二进制数据转换为文本。{ stream: true }
参数告诉解码器我们正在处理流数据,可能会出现未完成的 UTF-8 序列。
(3) 逐字符显示
ini
for (let i = 0; i < chunk.length; i++) {
answer.value += chunk[i]
scrollToBottom()
await new Promise(resolve => setTimeout(resolve, 15))
}
为了实现打字机效果,我们逐字符添加文本并添加少量延迟。这种方式比直接显示整个块更加生动,提升了用户体验。
(4) 请求取消
arduino
abortController.value = new AbortController()
// 在请求配置中添加
signal: abortController.value.signal
// 取消请求
abortController.value.abort()
使用 AbortController
可以优雅地取消进行中的请求,提高用户体验。当用户点击取消按钮或组件卸载时,可以立即终止请求。
5. 总结与展望
通过本文,我们完成了使用 SpringBoot 和 Vue 调用 DeepSeek API 实现流式对话的全过程:
- 选择合适的API提供商:对比了腾讯云和火山引擎,选择了文档更清晰、示例更完善的火山引擎
- 后端流式响应实现:使用 SpringBoot 的 StreamingResponseBody 实现了流式响应
- 前端流式接收与显示:利用 Fetch API 和 Web Streams 实现了逐字符显示的流式接收
这种实现方式的优势在于:
- 实时响应:用户无需等待完整回答,可以立即看到生成内容
- 交互体验好:打字机效果增强了对话的真实感
- 资源占用低:流式传输减少了服务器和客户端的内存占用
- 可取消操作:用户可以随时取消正在进行的生成过程
未来可以考虑的改进方向:
- 集成更多的 DeepSeek 高级功能(如思维链、知识库等)
- 优化前端显示效果,支持 Markdown 渲染
- 添加历史对话记忆功能
- 实现多用户并发支持