第一步:接入 Spring AI,跑通大模型对话

146 阅读4分钟

在本章中,我们将从零开始,通过引入 Spring AI 框架和 DeepSeek 大模型,快速实现一个简单的对话系统。我们将覆盖 Spring AI 的基本配置、全量输出与流式输出的实现,并最终搭建一个最小化的 Chat Demo,为后续的知识库功能打下基础。

一、引入 Spring AI & DeepSeek

1. 为什么选择 Spring AI?

Spring AI 是一个专为 Java 开发者设计的 AI 集成框架,类似于 Python 的 LangChain。它提供了与大语言模型(LLM)、向量数据库等 AI 组件的无缝对接,简化了配置和开发流程。Spring AI 的优势包括:

  • Spring 生态兼容:与 Spring Boot 无缝集成,配置简单。
  • 多模型支持:支持多种大模型(如 DeepSeek、OpenAI、阿里云 Qwen 等)。
  • 模块化设计:支持对话、向量嵌入、RAG 等功能,易于扩展。

2. DeepSeek 简介

DeepSeek 是一款高性能的大语言模型,适合中文场景,具备强大的语义理解和生成能力。本项目选择 DeepSeek 作为初始模型,因其中文友好,且 API 调用成本较低。

3. 环境准备

在开始之前,确保你的开发环境满足以下要求:

  • JDK:17 或以上。
  • Spring Boot:3.2.x 或以上(Spring AI 与 Spring Boot 高度集成)。
  • Maven/Gradle:用于依赖管理。
  • DeepSeek API Key:从 DeepSeek 官网申请,获取 API 密钥。
  • deepseek官网直达

4. 添加 Spring AI 依赖

在 Spring Boot 项目中,通过 Maven 添加 Spring AI 和 DeepSeek 相关依赖。以下是 pom.xml 的示例配置:

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.1.0-SNAPSHOT</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>
<dependencies>
    <!-- Spring AI DeepSeek Integration -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-deepseek</artifactId>
    </dependency>
</dependencies>

需要注意的是,由于 spring-ai 包可能未及时发布到 maven 库,所以添加以下配置,从 spring 官方直接引入

<repositories>
    <repository>
        <id>spring-snapshots</id>
        <name>Spring Snapshots</name>
        <url>https://repo.spring.io/snapshot</url>
        <releases>
            <enabled>false</enabled>
        </releases>
    </repository>
    <repository>
        <name>Central Portal Snapshots</name>
        <id>central-portal-snapshots</id>
        <url>https://central.sonatype.com/repository/maven-snapshots/</url>
        <releases>
            <enabled>false</enabled>
        </releases>
        <snapshots>
            <enabled>true</enabled>
        </snapshots>
    </repository>
</repositories>

5. 配置 DeepSeek API

在 application.yml 中配置 DeepSeek 的 API Key 和基础参数:

spring:
  ai:
    # DeepSeek AI 配置 chat
    deepseek:
      base-url: https://api.deepseek.com
      api-key: sk-xxx
      chat:
        options:
          model: deepseek-chat  # deepseek-reasoner

写一个 config 文件对该 ChatClient 配置,后面会有记忆存储的,所以我们自己配置这个 bean。

@Value("${spring.ai.deepseek.base-url}")
private String deepseekBaseUrl;
@Value("${spring.ai.deepseek.chat.options.model}")
private String deepseekModelName;
@Value("${spring.ai.deepseek.api-key}")
private String deepseekApiKey;

@Bean(name = "deepSeekApi")
public DeepSeekApi deepSeekApi() {
    return DeepSeekApi.builder()
            .apiKey(deepseekApiKey)
            .baseUrl(deepseekBaseUrl)
            .build();
}

@Bean(name = "deepseekV3ClientNoMemory")
public ChatClient deepseekV3ClientNoMemory(@Qualifier("deepSeekApi") DeepSeekApi deepSeekApi) {

    DeepSeekChatModel deepSeekChatModel = DeepSeekChatModel.builder()
            .deepSeekApi(deepSeekApi)
            .defaultOptions(DeepSeekChatOptions.builder()
                    .model(deepseekModelName)
                    .build())
            .build();

    return ChatClient.builder(deepSeekChatModel)
            .defaultAdvisors(new SimpleLoggerAdvisor())
            .build();
}

二、全量输出与流式输出

Spring AI 支持两种对话输出模式:

  • 全量输出:模型一次性生成完整回答,适合短对话或简单场景。
  • 流式输出:模型逐步返回回答内容,适合实时交互或长文本生成。

image.png

1. 全量输出实现

创建一个简单的控制器,用于接收用户输入并调用 DeepSeek 模型生成回答。

@RestController
public class RagDocumentController {

    private final ChatClient chatClient;

    public RagDocumentController(@Qualifier("deepseekV3ClientNoMemory") ChatClient chatClient) {
        this.chatClient = chatClient;
    }

    @GetMapping(value = "/chat", produces = "application/json")
    public String chat(@RequestParam String prompt) {
        return chatClient.prompt(prompt).call().content();
    }
}

apifox 测试结果

image.png

2. 流式输出实现

在流式输出场景下,我们需要基于 Spring AI 的 StreamingChatClient 接口,让模型逐步返回生成的文本内容。由于这里采用的是 Server-Sent Events (SSE) 协议,而非传统的 JSON 响应,因此控制器方法应声明 produces = "text/event-stream"修改控制器如下:

@GetMapping(value = "/chat/stream", produces = "text/event-stream")
public Flux<String> streamChat(@RequestParam String prompt) {
    return chatClient.prompt(prompt).stream().content();
}

apifox 测试结果为

image.png

三、一个最小 Chat Demo

这里是一个简单的前端vue页面,实现流式效果 index.ts

/** 简单聊天请求参数 */
export interface SimpleChatRequest {
  prompt: string
}
/** 简单流式聊天接口 - 使用后端提供的新接口 */
export function simpleStreamChat(prompt: string, signal?: AbortSignal): Promise<ReadableStream> {
  // 获取基础URL和token
  const baseURL = import.meta.env.VITE_BASE_API || ""
  const token = getToken()

  return fetch(`${baseURL}/chat/stream?prompt=${encodeURIComponent(prompt)}`, {
    method: "GET",
    headers: {
      Accept: "text/event-stream",
      Authorization: token ? `Bearer ${token}` : ""
    },
    signal
  }).then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    if (!response.body) {
      throw new Error("Response body is null")
    }
    return response.body
  })
}

index.vue

<template>
  <div class="stream-chat-container">
    <!-- 消息列表 -->
    <div class="message-list" ref="messageListRef">
      <div v-for="(message, index) in messages" :key="index" class="message-item">
        <div class="message-role">{{ message.role === "user" ? "用户" : "AI" }}</div>
        <div
          class="message-content"
          v-html="message.isStreaming ? formatStreamingMessage(message.content) : formatMessage(message.content)"
        />
      </div>
    </div>

    <!-- 输入区域 -->
    <div class="input-area">
      <el-input
        v-model="inputMessage"
        type="textarea"
        :rows="3"
        placeholder="请输入消息..."
        :disabled="isLoading"
        @keydown.ctrl.enter="sendMessage"
      />
      <el-button
        type="primary"
        :loading="isLoading && !isStreaming"
        :disabled="!inputMessage.trim() || (isLoading && !isStreaming)"
        @click="isStreaming ? stopStreaming() : sendMessage()"
      >
        {{ isStreaming ? "停止" : "发送" }}
      </el-button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, nextTick } from "vue"
import { ElMessage } from "element-plus"
import { simpleStreamChat } from "@/api/chat/index"

interface Message {
  role: "user" | "assistant"
  content: string
  isStreaming?: boolean
  timestamp?: Date
}

const inputMessage = ref("")
const isLoading = ref(false)
const isStreaming = ref(false)
const streamController = ref<AbortController | null>(null)
const messageListRef = ref<HTMLElement>()
const messages = ref<Message[]>([])
const textBuffer = ref("")

// 格式化流式消息 - 简化版,用于实时显示
const formatStreamingMessage = (content: string) => {
  if (!content) return ""
  return content
    .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
    .replace(/\*(.*?)\*/g, "<em>$1</em>")
    .replace(/`(.*?)`/g, "<code>$1</code>")
    .replace(/^###\s+(.*?)$/gm, "<h3>$1</h3>")
    .replace(/^##\s+(.*?)$/gm, "<h2>$1</h2>")
    .replace(/^#\s+(.*?)$/gm, "<h1>$1</h1>")
    .replace(/\r\n|\n|\r/g, "<br>")
}

// 完整的消息格式化 - 用于最终显示
const formatMessage = (content: string) => {
  if (!content) return ""

  let formattedContent = content

  // 处理基本文本格式化
  formattedContent = formattedContent
    .replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>")
    .replace(/\*(.*?)\*/g, "<em>$1</em>")
    .replace(/`(.*?)`/g, "<code>$1</code>")
    .replace(/^###\s+(.*?)$/gm, "<h3>$1</h3>")
    .replace(/^##\s+(.*?)$/gm, "<h2>$1</h2>")
    .replace(/^#\s+(.*?)$/gm, "<h1>$1</h1>")
    .replace(/\[(#\d+)\]/g, '<sup class="reference-mark">$1</sup>')
    .replace(/\r\n|\n|\r/g, "<br>")
    .replace(/(<br\s*\/?>){3,}/g, "<br><br>")

  return formattedContent
}

// 滚动到底部
const scrollToBottom = () => {
  nextTick(() => {
    if (messageListRef.value) {
      messageListRef.value.scrollTop = messageListRef.value.scrollHeight
    }
  })
}

// 发送消息
const sendMessage = async () => {
  if (!inputMessage.value.trim() || (isLoading.value && !isStreaming.value)) return

  isLoading.value = true
  isStreaming.value = true
  streamController.value = new AbortController()

  // 保存当前的消息
  const messageText = inputMessage.value.trim()

  // 添加用户消息
  const userMessage: Message = {
    role: "user",
    content: messageText,
    timestamp: new Date()
  }
  messages.value.push(userMessage)

  // 清空输入
  inputMessage.value = ""
  scrollToBottom()

  // 创建AI回复消息
  const aiMessage: Message = {
    role: "assistant",
    content: "",
    timestamp: new Date(),
    isStreaming: true
  }
  messages.value.push(aiMessage)
  scrollToBottom()

  try {
    // 重置文本缓冲区
    textBuffer.value = ""

    // 发送请求并获取流式响应
    const responseBody = await simpleStreamChat(messageText, streamController.value.signal)
    const reader = responseBody.getReader()
    const decoder = new TextDecoder()
    let buffer = ""
    const aiMessageIndex = messages.value.length - 1
    let isStreamComplete = false

    // 处理流式数据
    while (true) {
      const { done, value } = await reader.read()
      if (done) break

      const chunk = decoder.decode(value, { stream: true })
      buffer += chunk
      const lines = buffer.split("\n")
      buffer = lines.pop() || ""

      for (const line of lines) {
        const trimmedLine = line.trim()
        if (!trimmedLine || !trimmedLine.startsWith("data:")) continue
        const jsonStr = trimmedLine.substring(5).trim()
        if (!jsonStr) continue

        try {
          const data = JSON.parse(jsonStr)
          // 根据后端返回的实际格式获取内容
          let textContent = ""
          let finishReason = ""

          if (data.generations && data.generations.length > 0) {
            textContent = data.generations[0].assistantMessage.textContent || ""
            finishReason = data.generations[0].chatGenerationMetadata.finishReason || ""
          }

          const currentMessage = messages.value[aiMessageIndex]
          if (!currentMessage || currentMessage.role !== "assistant" || !currentMessage.isStreaming) continue

          if (textContent) {
            // 累积内容
            textBuffer.value += textContent
            currentMessage.content = formatStreamingMessage(textBuffer.value)
            scrollToBottom()
          }

          if (finishReason === "STOP") {
            currentMessage.isStreaming = false
            isStreamComplete = true

            // 流式完成后,应用完整格式化
            currentMessage.content = formatMessage(textBuffer.value)
            textBuffer.value = ""
            break
          }
        } catch (e) {
          console.warn("解析流式数据失败:", e, "Line:", trimmedLine)
        }
      }
    }

    // 处理流式结束
    const currentMessage = messages.value[aiMessageIndex]
    if (currentMessage && currentMessage.role === "assistant" && !isStreamComplete) {
      currentMessage.isStreaming = false
      if (!currentMessage.content.trim()) {
        currentMessage.content = "抱歉,服务暂时不可用,请稍后再试。"
      } else {
        // 应用完整格式化
        currentMessage.content = formatMessage(textBuffer.value || currentMessage.content)
      }
      textBuffer.value = ""
    }
  } catch (error: any) {
    if (error.name === "AbortError") {
      return
    }
    console.error("流式聊天失败:", error)
    const currentMessage = messages.value[messages.value.length - 1]
    if (currentMessage && currentMessage.role === "assistant") {
      currentMessage.content = "抱歉,服务暂时不可用,请稍后再试。"
      currentMessage.isStreaming = false
    }
    ElMessage.error("发送消息失败,请稍后再试")
  } finally {
    isLoading.value = false
    isStreaming.value = false
    streamController.value = null
    textBuffer.value = ""
    scrollToBottom()
  }
}

// 停止流式输出
const stopStreaming = () => {
  if (streamController.value && isStreaming.value) {
    streamController.value.abort()
    isStreaming.value = false

    const lastMessage = messages.value[messages.value.length - 1]
    if (lastMessage && lastMessage.role === "assistant" && lastMessage.isStreaming) {
      lastMessage.isStreaming = false

      // 如果内容为空,添加提示信息
      if (!lastMessage.content.trim()) {
        lastMessage.content = "回复已被中断。"
      } else {
        // 应用完整格式化
        lastMessage.content = formatMessage(textBuffer.value || lastMessage.content)
      }
    }

    textBuffer.value = ""
    isLoading.value = false
  }
}
</script>

<style scoped>
.stream-chat-container {
  display: flex;
  flex-direction: column;
  height: 100%;
}

.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 10px;
}

.message-item {
  margin-bottom: 12px;
}

.message-role {
  font-weight: bold;
  margin-bottom: 4px;
}

.message-content {
  background: #f6f6f6;
  padding: 8px 12px;
  border-radius: 6px;
  white-space: pre-wrap;
  word-break: break-word;
}

.message-content :deep(code) {
  background-color: rgba(0, 0, 0, 0.05);
  padding: 2px 4px;
  border-radius: 3px;
  font-family: monospace;
}

.message-content :deep(h1),
.message-content :deep(h2),
.message-content :deep(h3) {
  margin: 8px 0;
}

.input-area {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
  border-top: 1px solid #ddd;
}
</style>

四、总结与下一步

通过本章,我们成功接入了 Spring AI 和 DeepSeek,实现了全量输出与流式输出的简单对话系统,并搭建了一个最小化的 Chat Demo。这个 Demo 是后续知识库功能的基础,为向量化和 RAG(检索增强生成)提供了模型调用能力。

下一步:我们将引入向量数据库(如 Redis-Stack),实现文档知识库的雏形,探索如何将文本向量化并存储,为智能问答铺平道路。