在本章中,我们将从零开始,通过引入 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 支持两种对话输出模式:
- 全量输出:模型一次性生成完整回答,适合短对话或简单场景。
- 流式输出:模型逐步返回回答内容,适合实时交互或长文本生成。
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 测试结果
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 测试结果为
三、一个最小 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),实现文档知识库的雏形,探索如何将文本向量化并存储,为智能问答铺平道路。