☰
Spring Boot集成DeepSeek:ChatClient封装与SSE流式对话实战
2026/10/10 6:47:48 网站建设 项目流程

简介:面向Java后端开发者的Spring Boot与Spring AI实战代码包,基于DeepSeek大模型实现智能问答、文本生成与语义分析。项目采用前后端分离与模块化设计,后端通过Spring Boot组织业务逻辑、Spring AI完成模型调用,前端HTML负责交互展示,覆盖模型配置、服务封装、接口联调到页面渲染的完整链路,代码量精简但层次分明,适合作为企业级AI应用开发的入门范本。

资源共12个文件,以9个Java类为主,分别承担启动、配置、服务与控制职责;另有1个YAML配置模型参数、1个XML管理Maven依赖、1个HTML展示界面。压缩包约25KB,结构精简,便于快速通读与二次开发,文件类型覆盖后端、配置与前端,几乎无冗余资源。已有254人学习,可作为企业预研AI功能时的小型参考样例。

通过学习可拿到最小可运行工程,掌握DeepSeek接入Spring Boot的关键流程,包括依赖引入、消息构造、结果解析等细节,并理解前后端协作方式及集中配置模型参数的方法,也可在现有代码基础上替换模型或新增业务场景,为后续集成更多AI能力打下基础。

1. Spring Boot + Spring AI + DeepSeek:一套前后端完整的对话系统实战

先交代一个场景:某公司内部做了个“能聊天、能总结日报、能查内部知识库”的小工具,A同学没有引入重型RAG框架,也没有自己拿RestTemplate硬拼HTTP调用,而是用Spring Boot做后端、Spring AI作为模型对接层、DeepSeek作为底层大模型,一个下午把前后端完整链路跑通。这讲的就是这件事:依赖怎么配、ChatClient怎么封装、流式输出怎么推给前端、多轮对话怎么接,以及哪些环节最容易让新手翻车。适合两类人:一类是想把大模型能力快速塞进Java业务系统的后端开发,另一类是受够了前端调裸接口、想搞明白大模型应用前后端协作方式的从业者。整个过程没有玄学调参,但有几个参数和部署细节确实会坑人,我尽量把踩过的坑都标出来。

2. Spring AI集成DeepSeek:依赖配置与三个必调参数

2.1 为什么用Spring AI而不是自己封装HTTP

很多人第一反应是自己用HttpClient调DeepSeek的API。单轮对话确实简单,但一旦涉及流式SSE、多轮上下文、工具调用、动态切换模型,自己封装的代码很快就会变成一个不断打补丁的黑匣子。Spring AI做的事情是把你和具体模型提供方隔离开:它定义了一套统一的ChatClient、Prompt、Message抽象,底层可以接OpenAI、DeepSeek、通义等不同通道。实践里最直接的好处就一条:今天因为成本切到DeepSeek,明天因为效果想切回别的模型,业务代码不用动,改配置就行。

还有一层原因是生态。Spring AI的社区在快速膨胀,后续你想加向量数据库做知识库、加Function Calling调内部订单接口,这套框架都给你留好了扩展点。自己封装HTTP,这些全要手搓。我的建议很明确:除非你的需求只有一个“发一条消息拿回一句回复”的接口,否则别自己造HTTP轮子。这不是性能问题,是维护成本问题——大模型相关的HTTP调用细节远比普通REST接口多,超时、重试、流式解析、错误码分类,每一项都值得踩一遍坑。

2.2 最小依赖与application.yml配置

先加Maven依赖。这里有个容易被忽视的前提:Spring AI对Spring Boot版本有要求,新版要求Boot 3.x、JDK 17以上。如果你项目还在Boot 2.7打转,先升Boot版本,这是很多人第一步就卡住的点。

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>${spring-ai.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> </dependency> </dependencies>

这段配置里两个关键点:第一,spring-ai.version建议用一个你本地Maven仓库能拉到正式版,别用快照版,快照版API变动非常频繁,今天写好的代码过两周编译不过;第二,依赖名是spring-ai-openai-spring-boot-starter,不是deepseek-spring-boot-starter,因为DeepSeek走OpenAI兼容协议,Spring AI官方没有单独的DeepSeek包,这是新手最容易产生疑惑的地方。

接着写application.yml:

spring: ai: openai: base-url: https://api.deepseek.com api-key: ${DEEPSEEK_API_KEY} chat: options: model: deepseek-chat temperature: 0.7 max-tokens: 2048

base-url要指向DeepSeek的API根地址,不是OpenAI官方地址,这行写错直接导致所有请求都打到错误服务商。api-key不要硬编码在配置文件里,用环境变量注入,本地开发在IDEA的Environment variables里配,服务器部署在系统环境变量里配,否则代码一旦推到Git仓库,key就泄露了。model可以写deepseek-chat,这是通用对话模型;也可以写deepseek-reasoner,这是推理模型,响应结构不同,后面避坑章节会单独讲。

2.3 三个必调参数:temperature、max-tokens、top_p

temperature控制采样随机性,取值范围0到2。对话聊天场景设0.7比较均衡;如果是做代码生成或者结构化数据抽取,建议降到0.2甚至0.1,否则同一个输入每次输出差异很大,后续解析容易出问题。我做内部工具时,代码生成类任务固定0.1,聊天类任务固定0.7,业务代码里通过参数透传覆盖默认值。

max-tokens限制单次回复的最大token数量,默认值往往偏小,中文回答到一半就被截断,看起来像模型“说着说着停了”。很多新手误以为这是模型能力问题,其实只是token预算用完了。业务上做总结、写作类任务,我一般给2048以上;如果输出里包含大量代码或者长文本,直接给4096。

top_p是核采样,控制候选词的累计概率,和temperature是配合关系。官方文档明确建议不要同时大幅调整两者,一般是固定一个调另一个。我的习惯是保持top_p默认值,只动temperature。这三板斧调完之后基础对话基本稳定,接下来进入后端代码实现。

3. 后端核心代码:ChatClient封装与流式接口实现

3.1 配置类与ChatClient的两种创建方式

Spring AI里最核心的入口是ChatClient,它可以理解成“封装了所有对话细节的门面对象”。你不需要关心HTTP连接怎么建、消息怎么序列化、异常怎么处理,只需要给它一个用户消息,它返回模型回复。创建方式有两种:一种是直接注入ChatClient.Builder,在配置类里统一设置默认行为;另一种是每次调用时通过builder临时构建。

@Configuration public class AIConfig { @Bean public ChatClient chatClient(ChatClient.Builder builder) { return builder .defaultSystem("你是一名资深的Java技术顾问,回答要求准确、简洁、可执行。如果问题与编程无关,也要尽量给出结构化建议。") .defaultOptions( OpenAiChatOptions.builder() .withTemperature(0.7) .withMaxTokens(2048) .build() ) .build(); } }

逻辑说明:defaultSystem设置的是全局系统提示词,所有对话都会带上这个前提。defaultOptions设置默认的模型参数,业务侧如果没特殊要求,就统一走这套配置。这里有个设计取舍:把系统提示词写到代码里还是配置中心里。我的建议是,系统提示词这种“会频繁跟着业务变化”的内容,最好放到配置中心或者数据库,别焊死在代码里,否则每次改一句话都要重新发版。

3.2 非流式对话Service与Controller

先写一个最普通的同步对话接口,它负责接收用户消息、调用模型、一次性返回完整回复。这个接口适合问答、生成摘要、格式化输出等不需要实时展示的场景。前端拿到结果后统一渲染,逻辑最简单。

@Service public class ChatService { private final ChatClient chatClient; public ChatService(ChatClient chatClient) { this.chatClient = chatClient; } public String chat(String message) { return chatClient.prompt() .user(message) .call() .content(); } }

这段代码的逻辑:prompt()构建一个对话请求,user()设置用户消息,call()同步阻塞等待模型返回,content()取出回复文本。如果要带历史消息,可以在prompt前追加history(),但那种方式并不适合所有场景,后面第6章再展开。

Controller侧:

@RestController @RequestMapping("/api/chat") public class ChatController { private final ChatService chatService; public ChatController(ChatService chatService) { this.chatService = chatService; } @PostMapping public Map<String, String> chat(@RequestBody Map<String, String> body) { String message = body.get("message"); if (message == null || message.isBlank()) { return Map.of("error", "message不能为空"); } String reply = chatService.chat(message); return Map.of("reply", reply); } }

Controller层只做参数接收和响应封装,不写业务逻辑。这里用Map接收请求体是最轻量的做法,字段多了以后再换成DTO。很多人会在Controller里直接注入ChatClient,也能跑,但我习惯塞进Service,理由是测试好写——单测时Mock掉Service即可,不用去Mock ChatClient的复杂调用链。

3.3 流式对话接口:SSE与前端实时输出

同步接口有个体验问题:模型生成一段2000字的回复可能要等十几秒,前端一直转圈,用户以为程序死了。解决方案是流式输出,模型生成一个字就推给前端一个字,效果像打字机一样。后端用Spring Web的SSE(Server-Sent Events)来推流。

@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestBody Map<String, String> body) { return chatClient.prompt() .user(body.get("message")) .stream() .content(); }

逻辑说明:produces指定了响应类型为TEXT_EVENT_STREAM_VALUE,这是SSE的标准内容类型。返回值从String变成了Flux ,Flux是响应式流,代表“多个元素异步到达”的数据管道,正好对应模型逐段生成的内容。前端通过fetch的ReadableStream逐段读取,页面上的文字一点点冒出来,体感比转圈好得多。

这里有一个关键区别:流式接口走的是响应式编程模型,如果你的项目是传统Servlet Web应用,Spring Boot 3.x默认支持,不用额外引入WebFlux依赖;但如果你用的是Spring MVC的阻塞线程模型,需要注意流式响应不会占用Tomcat线程太久,底层是异步写回。

@PostMapping(value = "/stream-sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamSse(@RequestBody Map<String, String> body) { SseEmitter emitter = new SseEmitter(0L); // 异步线程推送 executor.submit(() -> { try { chatClient.prompt() .user(body.get("message")) .stream() .content() .doOnNext(content -> { try { emitter.send(content); } catch (IOException e) { emitter.completeWithError(e); } }) .doOnComplete(emitter::complete) .subscribe(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }

SseEmitter是Spring MVC里传统的SSE推送方式,适合不想引入响应式思维的团队。两条路都能用,哪个顺手用哪个,不必纠结。我个人推荐Flux方案,代码量更少,也没有手动管理线程池的负担。

前端配合的调用代码下一章详细写,这里先把后端链路打通:Controller接收请求 → ChatClient交给Spring AI → Spring AI转发给DeepSeek → 模型逐段生成 → Flux流出 → SSE推给前端。

4. 前端页面与前后端联调:最快落地的Thymeleaf方案

4.1 为什么选Thymeleaf而不是Vue/React

后端同学做这种内部工具,最怕的是还要维护一个Node服务、一套Vue工程的构建链路。我的常用做法是使用Thymeleaf服务端渲染页面,Spring Boot一个进程跑完所有东西,没有CORS问题、没有两个服务联调问题、没有前端构建产物部署问题。对于团队内部工具、原型验证、管理后台,这套方案落地速度是最快的。

页面结构不复杂:templates/chat.html放聊天页面,static/js/chat.js放前端逻辑,static/css/chat.css放样式。Spring Boot自动装配会把resources下的静态资源映射到根路径,Thymeleaf模板引擎默认也配好了。

<!DOCTYPE html> <html lang="zh-CN" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>DeepSeek Chat Demo</title> <link rel="stylesheet" href="/css/chat.css"> </head> <body> <div id="chat-container"> <div id="messages"></div> <div class="input-area"> <textarea id="message-input" placeholder="输入你的问题..." rows="3"></textarea> <button id="send-btn">发送</button> </div> </div> <script src="/js/chat.js"></script> </body> </html>

聊天的核心交互就是两个元素:一个消息展示区,一个输入框加发送按钮。没有复杂状态管理,不需要框架,原生DOM操作完全够用。如果你后续要做多轮上下文切换、会话历史列表,再考虑引入前端框架也不迟,但那是后话。

4.2 前端调用流式接口:fetch + ReadableStream解析

调用SSE接口用fetch就行,浏览器原生支持流式响应读取。核心逻辑是拿到response.body的ReadableStream,不断读数据块、解码、按行解析SSE格式。

const sendBtn = document.getElementById('send-btn'); const messageInput = document.getElementById('message-input'); const messagesDiv = document.getElementById('messages'); sendBtn.addEventListener('click', async () => { const message = messageInput.value.trim(); if (!message) return; const userDiv = document.createElement('div'); userDiv.className = 'message user'; userDiv.textContent = message; messagesDiv.appendChild(userDiv); const aiDiv = document.createElement('div'); aiDiv.className = 'message ai'; messagesDiv.appendChild(aiDiv); messageInput.value = ''; try { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: message }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop(); for (const line of lines) { if (line.startsWith('data:')) { const content = line.slice(5).trim(); if (content && content !== '[DONE]') { aiDiv.textContent += content; messagesDiv.scrollTop = messagesDiv.scrollHeight; } } } } } catch (error) { aiDiv.textContent = '请求失败:' + error.message; } });

这段代码里最容易写错的是buffer那一行。后端SSE推送的数据是分行的,但TCP/HTTP分帧不保证每一帧恰好是一个完整行,可能一条data被拆成两半送过来。如果你不处理这个半行缓冲,解析出来的内容会随机丢字。这里的做法是:每次解码后先合并进buffer,按换行符拆成数组,最后一段可能是不完整的半行,保留在buffer里等下一次数据到达再拼。这是前端做SSE解析最容易踩的细节之一。

另一个细节是TextDecoder的stream: true参数。它在处理多字节UTF-8字符时至关重要——一个中文字符的编码可能被拆在两个chunk里,不传这个参数,第二个chunk解码时可能把半个字变成乱码。我见过很多在英文环境没问题换到中文就乱码的案例,根源都在这里。

4.3 联调时的三个排查点:CORS、端口映射、请求体格式

前后端联调出问题,90%集中在三个方面。第一个是CORS,如果你用了前后端分离部署,前端在8081端口调用后端8080端口,浏览器会拦跨域请求。排查方法:看浏览器控制台有没有CORS报错,在后端加全局CORS配置即可。但使用Thymeleaf方案不存在这个问题,页面和后端同源,天然绕开。

第二个是端口映射。Spring Boot默认8080端口,如果你改了端口,记得前端请求的路径要跟着变。很多人开发时用IDE直接跑后端,前端在Vite的5173端口,两边端口不一致就报网络错误。内部工具直接用同一个端口最省心。

第三个是请求体格式。JSON序列化和反序列化的坑,集中在后端接收对象上。比如前端传的字段叫message,后端DTO字段也叫message,没问题;但如果你前端传了JSON字符串而不是对象,或者Content-Type写成了text/plain,后端会直接报HttpMessageNotReadableException。联调时先看后端日志里有没有这句话,有的话就是请求体格式不对。

5. Spring AI + DeepSeek避坑清单:5个血泪教训

5.1 中文回答总像被腰斩

现象:模型回复到一半戛然而止,没有结尾句,看起来像“被强行打断”。开发群里的第一反应通常是“模型能力不行”或者“网络断了”。排查后才发现,答案里后半部分内容直接消失,和网络无关。

原因:max-tokens设置太小。中文不像英文按字母计数,一个中文字符在部分编码方案下会消耗掉多个token。你设了512的max-tokens,实际生成300个汉字后预算就耗尽,剩余内容直接被截断。这是最常见的翻车点,而且表现得很隐蔽——只有长回答才触发,短回答一切正常。

解决:把max-tokens调到2048以上,做长文总结或代码生成任务直接4096。同时在Service层做一个长度检测,如果模型返回的文本长度接近token上限的估算值,在日志里打一条warn,方便后续定位。

5.2 流式SSE在Nginx下一直转圈

现象:本地开发直连Spring Boot,流式输出一切正常。一旦部署到测试环境、前面挂了Nginx反代,页面开始一直转圈不出字,等很久之后一次性弹出全部内容。

原因:Nginx默认会对后端响应做缓冲,它会把整个SSE响应攒齐了再一次性返回给浏览器。而SSE协议本身要求响应实时逐段推给前端,Nginx的缓冲行为把实时流变成了非实时的大块传输,前端拿不到分段数据,体验就直接退化成了“转圈然后全部出现”。更严重的情况下,如果模型生成时间较长,Nginx的proxy_read_timeout到了,连接被切断,页面就彻底卡死。

解决:在这个流式接口的Nginx配置里关闭缓冲并调大超时时间:

location /api/chat/stream { proxy_pass http://backend-server; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_http_version 1.1; proxy_set_header Connection ""; chunked_transfer_encoding on; }

proxy_buffering off是核心,它告诉Nginx不要攒数据,收到后端字节就立刻转发给浏览器。proxy_read_timeout从默认60s调到300s,给长回答留够时间。这套配置是流式接口部署的标配,本地跑通不算完,代理层不配好上线就是事故现场。

5.3 一直报401但API Key明明没写错

现象:把DeepSeek控制台生成的API Key复制到配置文件里,启动项目,一调用就报401 Unauthorized。反复核对Key没写错,控制台复制、手工输入都试过,问题依旧。

原因:最常见是环境变量注入问题。配置文件里写的是${DEEPSEEK_API_KEY},但本地IDEA启动时没有配置同名环境变量,Spring启动的时候拿不到值,最后发出去的请求Authorization头是空或者null。另一个原因是复制Key时带上了空格或换行符,肉眼看不到但HTTP请求头发出去就报错。

解决:第一步先在启动类里临时打印一下配置是否注入成功——但这个打印上线前必须删掉,不然Key会进日志。更稳妥的做法是用配置中心的密钥管理功能,把Key存在配置中心而不是环境变量。我现在的习惯是:所有AI服务的Key统一走配置中心,本地开发用本地profile注入,杜绝这类黑匣子问题。

5.4 第一次请求慢到怀疑人生

现象:应用刚启动后第一个对话请求要等8到15秒才返回,之后的请求几百毫秒就完成。不是每次都慢,只有“冷启动”后第一次慢,重启后又复现。

原因:Spring AI第一次调用模型时,要完成多项初始化工作:HTTP连接池建立、SSL握手、Jackson序列化器初始化、Spring AI内部的模型元数据加载。这一整套冷启动开销叠加起来,就是十几秒。另外DeepSeek服务端对每个新建立的连接也有一定建立成本,多因素叠加造成“第一个请求必慢”的规律。

解决:在应用启动完成后做一个预热请求,用一个固定问题(比如“你好”)调用一次ChatClient,把连接池、序列化和模型端的连接都激活。常见做法是实现ApplicationRunner接口,在run方法里调用一次预热对话。注意预热请求不要算进业务指标,也不用管返回内容,纯粹是让链路热起来。

5.5 deepseek-reasoner返回内容字段一直为空

现象:把model配成deepseek-reasoner后,回复的content字段是空的,但是响应里多了一个reasoning_content字段,里面有完整的推理思考过程。很多开发走到这步会以为是Spring AI解析响应失败,开始怀疑是序列化问题,实际上模型本身的行为就是这样。

原因:deepseek-reasoner是推理模型,它的响应结构和deepseek-chat不同:推理过程的中间思考放进了reasoning_content,最终回复放content。但在某些场景下(比如回答“1+1等于几”这种秒答问题),模型可能只产生推理路径而不再重复生成最终回答,导致content为空。这是模型设计使然,不是Bug。

解决:明确使用场景。用reasoner做复杂推理、数学计算、逻辑分析,需要把reasoning_content透传给前端一起展示;用chat做普通对话、文案生成、功能调用。我的写法是配置里维护两个ChatClient实例,一个绑定deepseek-chat,一个绑定deepseek-reasoner,业务侧按需注入。别试图在一个实例上切换模型,Spring AI的配置覆盖链路容易产生预期外的行为。

6. 进阶:多轮对话与Function Calling的落地姿势

6.1 多轮上下文管理:Memory与业务侧持久化

目前的接口每次调用都是独立的,DeepSeek不记得上一轮说了什么。多轮对话要自己管理上下文。最常见的做法:把对话历史拼成一个消息列表,每轮把用户输入和模型回复都追加进去,再一起发给模型。Spring AI里可以用Message接口来组装这个列表。

public List<Message> buildMessages(String userMessage, List<Message> history) { List<Message> messages = new ArrayList<>(); messages.add(new SystemMessage("你是某公司的内部AI助手,回答基于给定知识库。")); messages.addAll(history); messages.add(new UserMessage(userMessage)); return messages; }

内存方案适合原型和单机Demo,正式项目要把历史会话存进Redis或者数据库,按sessionId维度存取。我见过很多团队先做内存方案跑通,然后用AOP统一切一层Redis持久化,这样业务侧基本无感。上下文的长度要控制,模型对上下文窗口有上限,超过限制会被截断,我的习惯是只保留最近10轮对话。

6.2 Function Calling:让模型去查数据库、调接口

Spring AI支持Function Calling,原理是定义一个“工具方法”,告诉模型有哪些能力,模型在需要的时候会生成一个调用请求,框架负责执行并把结果回传给模型继续生成回复。这是把DeepSeek从“聊天机器人”升级成“业务助手”的关键一步。

@Tool(description = "查询某个月份的销售总额,参数格式:yyyy-MM") public BigDecimal queryMonthlySales(String month) { return salesService.getMonthlyTotal(month); }

逻辑说明:@Tool注解把这个方法暴露给模型。用户问“上个月销售多少”,模型看到工具描述后,决定调用queryMonthlySales,Spring AI自动把参数month填成“2025-06”,执行方法拿结果,再组织成最终回答。整个过程代码层面你只需要写一个方法,剩下的交给框架。

这套机制需要后端接口允许跨域,因为模型可能主动调用你注册的多个Tool,组合出复杂结果。我给内部系统接Function Calling时,遇到最多的坑是工具方法的异常没有回传给模型——方法内部try-catch吞掉了异常,模型拿不到有效结果就只能编造。记住一个原则:工具方法的返回值一定要是“模型能直接用来组织回答的有效信息”,查不到就写“未查询到数据”,而不是抛异常。

6.3 最后一个习惯:把模型返回结构化

最后分享一个让我少加一个月班的心得:凡是模型输出要进入业务逻辑的,不要直接拿String给下游解析。让模型输出JSON,用Jackson或者Spring AI的实体映射能力直接反序列化成对象。做法是在System Prompt里声明输出格式,给一个示例,再用BeanOutputConverter指定目标类型。遇到模型偶尔输出非法JSON时,加一个重试机制,让模型看到“格式错误,请重新输出”的提示再生成一次。这个习惯救我太多次了——早期每次让模型返回一点结构化结果,下游解析代码就崩一次,后来统一走这条套路,稳定性直线上升。

这套组合打下来,一个能聊、能查数、能接业务的DeepSeek应用就立在Spring Boot上了。你踩过的坑要么是参数没配全,要么是代理缓冲没关,要么是模型选型不对,都不算硬伤,照着上面的路径走一遍,你也会发现大模型集成其实没那么玄。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询