SpringBoot + Vue 调用火山引擎 DeepSeek API 实现流式对话

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 的核心实现思路基于下面三个开源库:

  1. OkHttp:用于定义高效的 HTTP 客户端

    • 在 ArkService 中,OkHttp 通过多种拦截器增强了基础功能:

      • AuthenticationInterceptor:处理 API 密钥认证
      • ArkResourceStsAuthenticationInterceptor:处理 AK/SK 认证
      • RequestIdInterceptor:为每个请求添加唯一标识
      • RetryInterceptor:实现请求失败自动重试逻辑
      • BatchInterceptor:处理批量请求
    • 配置了连接池管理,默认维护 5 个空闲连接,超时 1 秒

    • 设置了读取超时、连接超时和调用超时,确保网络请求的可靠性

  2. Retrofit:将 HTTP API 优雅地转换为 Java 接口

    • 通过注解将 HTTP 请求映射到 Java 方法调用

    • 使用 JacksonConverterFactory 处理 JSON 序列化和反序列化

    • 结合 RxJava2CallAdapterFactory 支持响应式编程模型

    • 在 ArkService 中,可以看到如何通过 Retrofit 创建 ArkApi 接口的实现:

      ini 复制代码
      Retrofit retrofit = defaultRetrofit(client, mapper, baseUrl, callbackExecutor);this.api = retrofit.create(ArkApi.class);
  3. 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 是最理想的选择,原因很简单:

  1. 简洁直接:不需要复杂的事件标记,代码简洁易懂 - 就一根水管,没有复杂的阀门
  2. 性能高效:专为流式数据传输而设计,开销小 - 流量小,水压稳定
  3. 契合场景:AI 生成内容的特性与流式传输天然匹配 - 就像水流一样自然地一滴一滴流出
  4. 开发友好:几行代码就能实现,减少出错可能 - 不需要精通水利工程也能搞定

当 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();
                        }
                    });
            });
}

代码解析:

  1. StreamingResponseBody:Spring框架提供的接口,允许我们以异步方式将数据写入HTTP响应。
  2. blockingForEach:来自RxJava的方法,它会订阅可观察对象(Observable)并对每个发出的项目执行操作。在这里,它会处理每个从DeepSeek API返回的数据块。
  3. 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 是现代浏览器提供的用于网络请求的接口,它有以下特点:

  1. Promise 基础:基于 Promise,使异步代码更易于编写和管理
  2. 流式处理能力:支持通过 body.getReader() 方法获取 ReadableStream
  3. 可中断请求:通过 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 实现流式对话的全过程:

  1. 选择合适的API提供商:对比了腾讯云和火山引擎,选择了文档更清晰、示例更完善的火山引擎
  2. 后端流式响应实现:使用 SpringBoot 的 StreamingResponseBody 实现了流式响应
  3. 前端流式接收与显示:利用 Fetch API 和 Web Streams 实现了逐字符显示的流式接收

这种实现方式的优势在于:

  • 实时响应:用户无需等待完整回答,可以立即看到生成内容
  • 交互体验好:打字机效果增强了对话的真实感
  • 资源占用低:流式传输减少了服务器和客户端的内存占用
  • 可取消操作:用户可以随时取消正在进行的生成过程

未来可以考虑的改进方向:

  • 集成更多的 DeepSeek 高级功能(如思维链、知识库等)
  • 优化前端显示效果,支持 Markdown 渲染
  • 添加历史对话记忆功能
  • 实现多用户并发支持
相关推荐
Asthenia04126 分钟前
Seata:核心组件/工作流程/四大模式/事务传播/CAP与模式/三大组件/集成细节
后端
❆VE❆20 分钟前
【工具分享】vscode+deepseek的接入与使用
ide·vscode·编辑器·ai编程·工具·deepseek
kkk哥21 分钟前
基于springboot的教师工作量管理系统(031)
java·spring boot·后端
可了~22 分钟前
SpringBoot的配置文件了解
java·spring boot·后端
DevSecOps选型指南27 分钟前
SBOM情报预警 | 恶意NPM组件窃取Solana智能合约私钥
前端·npm·智能合约·软件供应链安全厂商·供应链安全情报
boring_student38 分钟前
CUL-CHMLFRP启动器 windows图形化客户端
前端·人工智能·python·5g·django·自动驾驶·restful
SailingCoder43 分钟前
递归陷阱:如何优雅地等待 props.parentRoute?
前端·javascript·面试
自由鬼1 小时前
人工智能:企业RAG方案
人工智能·python·机器学习·ai·deepseek
小丁爱养花1 小时前
MyBatis-Plus:告别手写 SQL 的高效之道
java·开发语言·后端·spring·mybatis
关山月1 小时前
React 中的 SSR 深度探讨
前端