1. 这个Demo到底解决什么问题
先说一下我为什么写这个项目。上个月我在做一个内部知识库问答工具,需要把大模型的能力接进SpringBoot服务里,翻遍了网上各种教程,要么是只贴一段RestTemplate调用完事,要么是流式输出写到一半就没下文了。好不容易找到一个能跑的demo,历史记录又是用内存List存的,服务一重启全没了。折腾了大概一周,我决定自己从头撸一个干净利落的版本。
这个项目的定位很明确:给你一个基于SpringBoot的DeepSeek-demo,自带流式输出和历史记录能力,拿过来就能跑,跑通之后你能照着改成自己业务里的模样。
适合谁看?
- 想在SpringBoot项目里接入DeepSeek或其他OpenAI兼容接口的后端开发
- 已经跑通过普通接口调用,但不知道怎么处理流式输出的朋友
- 需要一个带历史记录参考实现的AI对话后端做毕业设计或项目原型
我在设计时做了几个关键取舍,先把结论放前面:
- 用
WebFlux的Flux<String>做流式响应,而不是传统的Servlet异步或SSE手写推送 - 历史记录存
H2内存数据库,零配置开箱即用,同时把DAO层抽象出来,你想换MySQL就改个依赖加个配置 - 前端只用一个
HTML页面,用fetch加ReadableStream解析流式数据,不引入任何前端框架
下面我从头开始拆。
2. 为什么选SpringBoot WebFlux处理流式输出
2.1 流式输出的本质:HTTP分块传输
先搞清楚一件事:大模型接口的流式输出,说白了就是服务器一边生成一边把内容推给客户端,而不是等全部生成完了再一次性返回。DeepSeek的API兼容OpenAI格式,当你设置stream=true时,服务端会通过text/event-stream(SSE协议)按行返回数据片段,每一行是一个data: {...}的JSON。
这里有一个容易被忽略的核心点:SSE本身是HTTP协议的分块传输(Transfer-Encoding: chunked)在事件流场景下的应用,它要求服务端不能缓存整个响应体,必须边生成边写。
在SpringBoot里实现这种能力,传统方式是SseEmitter,它需要你自己管理线程池、处理超时、处理客户端断开。而WebFlux天然就是响应式编程模型,Flux就是流,Flux<String>往客户端一丢,框架自动处理背压和异步,代码少写一大半。
我最初试过SseEmitter,用起来其实也能跑通,但有两个痛点:一是超时时间要手动设置,客户端一断线线程池就堆积;二是拼接SSE事件格式的代码很啰嗦。WebFlux把这些都封装掉了,代码量大概能少30%。
2.2 SpringBoot版本选择的坑
这里必须单独提醒一下版本问题。现在网上很多SpringBoot教程还在用2.x,但你如果要接DeepSeek这种比较新的服务,建议直接上SpringBoot 3.x,原因有三个:
3.x基于Spring Framework 6,内置了更好的HTTP接口客户端RestClient,比RestTemplate和WebClient都更适合这种API对接场景3.x的WebFlux对响应式流式处理的支持更完善- JDK 17是SpringBoot 3的最低要求,如果你还在用JDK 8,很多新特性用不了
我这个demo用的是SpringBoot 3.2.4+JDK 17。如果你公司项目还在SpringBoot 2.x,也没关系,核心思路一样,把jakarta.*的包导回javax.*就行。
2.3 RestClient还是WebClient
这是第二个容易纠结的点。对接大模型API,发送请求的方式有三种选择:RestTemplate、WebClient、RestClient。我的建议是:
| 客户端 | 异步支持 | 流式支持 | 代码简洁度 | 推荐场景 |
|---|---|---|---|---|
| RestTemplate | 不支持 | 不友好 | 中 | 普通同步调用,老项目 |
| WebClient | 支持 | 支持但API繁琐 | 中 | Spring 5时代的响应式项目 |
| RestClient | 支持 | 支持且API简洁 | 高 | SpringBoot 3.x新项目 |
RestClient在SpringBoot 3.2中正式可用,它的API设计和RestTemplate很像,但底层用的是响应式HTTP客户端,天然支持流式。我最终选了RestClient配合Flux,既保持了代码可读性,又拿到了流式能力。
3. 项目骨架:一个能直接跑的工程长什么样
3.1 目录结构设计
先说目录结构,这决定了你后面加功能的时候会不会乱。我按照常见的分层架构来做,但比教科书上的更精简:
deepseek-demo/ ├── pom.xml └── src/ └── main/ ├── java/ │ └── com/example/deepseekdemo/ │ ├── DeepseekDemoApplication.java │ ├── controller/ │ │ └── ChatController.java │ ├── service/ │ │ ├── DeepSeekService.java │ │ └── ChatHistoryService.java │ ├── config/ │ │ └── DeepSeekConfig.java │ ├── model/ │ │ ├── ChatRequest.java │ │ ├── ChatResponse.java │ │ ├── Message.java │ │ └── HistoryRecord.java │ └── repository/ │ └── HistoryRepository.java └── resources/ ├── application.yml └── static/ └── index.html几个设计要点:
controller层尽量薄,只做参数接收和响应装配,业务逻辑全在service层model里的Message直接对齐DeepSeek API的请求体结构,方便后续扩展repository只管历史记录,不掺和业务逻辑static/index.html是测试页面,正式集成到你自己的前端时可以直接扔掉
3.2 pom.xml关键依赖
pom.xml里最核心的依赖只有这几个:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.4</version> <relativePath/> </parent> <dependencies> <!-- WebFlux:提供响应式编程模型和Flux支持 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <!-- H2:内存数据库,存储历史记录 --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <!-- Spring Data JPA:简化数据库操作 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Lombok:减少样板代码 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>注意一个小细节:我用了spring-boot-starter-webflux,没加spring-boot-starter-web。这两个starter同时存在会有冲突,WebMVC会覆盖WebFlux的自动配置,导致响应式特性失效。如果你的项目里必须同时用,需要在application.yml里显式配置spring.webflux.pathmatch之类的参数,但一般来说没必要共存。
3.3 application.yml配置
server: port: 8080 spring: application: name: deepseek-demo h2: console: enabled: true path: /h2-console datasource: url: jdbc:h2:mem:chatdb driver-class-name: org.h2.Driver username: sa password: jpa: hibernate: ddl-auto: update show-sql: true deepseek: api-key: ${DEEPSEEK_API_KEY:your-api-key-here} base-url: https://api.deepseek.com model: deepseek-chat max-tokens: 2048 temperature: 0.7这里把api-key做成环境变量注入,是我坚持的一个习惯。直接把key写死在配置文件里然后推到GitHub,等于把密钥送人了。用${DEEPSEEK_API_KEY:your-api-key-here}这种写法,本地没有环境变量时用默认值兜底,部署时只要在环境变量里设一个DEEPSEEK_API_KEY就行。
4. 流式输出:从请求构造到Flux响应
4.1 DeepSeek API的请求格式
DeepSeek的接口兼容OpenAI格式,POST到/chat/completions,请求体长这样:
{ "model": "deepseek-chat", "messages": [ {"role": "system", "content": "你是一个有帮助的助手"}, {"role": "user", "content": "你好"} ], "stream": true, "max_tokens": 2048, "temperature": 0.7 }这里的关键字段是stream,设为true后,响应不再是单个JSON,而是一连串data:开头的行,每一行是一个增量片段,最后有一个data: [DONE]标记结束。
我构造请求体的时候,用了Map来组装JSON,这样最灵活,也最贴近实际业务中各种动态参数的需求:
Map<String, Object> requestBody = new HashMap<>(); requestBody.put("model", "deepseek-chat"); requestBody.put("messages", messages); requestBody.put("stream", true); requestBody.put("max_tokens", 2048); requestBody.put("temperature", 0.7);如果你喜欢强类型,也可以定义ChatRequest和Message的POJO来传参。但在这个demo里,直接操作Map更便于你看清楚整个请求结构,后续要加stop、top_p之类的参数也直观。
4.2 核心Service实现拆解
流式输出的核心就一段代码,我直接贴出来,然后逐行解释为什么这么写:
@Service public class DeepSeekService { private final RestClient restClient; private final DeepSeekConfig config; public DeepSeekService(RestClient.Builder builder, DeepSeekConfig config) { this.config = config; this.restClient = builder .baseUrl(config.getBaseUrl()) .defaultHeader("Authorization", "Bearer " + config.getApiKey()) .defaultHeader("Content-Type", "application/json") .build(); } public Flux<String> chatStream(List<Message> messages) { Map<String, Object> requestBody = new HashMap<>(); requestBody.put("model", config.getModel()); requestBody.put("messages", messages); requestBody.put("stream", true); requestBody.put("max_tokens", config.getMaxTokens()); requestBody.put("temperature", config.getTemperature()); return restClient.post() .uri("/chat/completions") .body(requestBody) .retrieve() .bodyToFlux(String.class) .map(this::parseSSELine) .filter(Objects::nonNull); } private String parseSSELine(String line) { if (line == null || !line.startsWith("data:")) { return null; } String data = line.substring(5).trim(); if ("[DONE]".equals(data)) { return null; } try { JsonNode node = new ObjectMapper().readTree(data); return node.path("choices").path(0).path("delta").path("content").asText(null); } catch (JsonProcessingException e) { return null; } } }几个写代码时容易踩的坑,我一个个说:
坑一:流式响应里每个data:行的内容是JSON片段,不是完整JSON。我第一次写的时候,天真地以为整个流是一个完整的JSON数组,直接readTree整个流,结果解析报错。正确的做法是一行一行解析,每一行单独readTree,取choices[0].delta.content字段。
坑二:RestClient的bodyToFlux(String.class)返回的每个元素实际是SSE消息的一部分,可能是多行拼一起的。实测你会发现,有时候一个元素就是完整的一行data: {...},有时候是两行(空行分隔)。所以parseSSELine要兼容这种情况。我在代码里只处理了以data:开头的行,空行直接忽略,这样最稳。
坑三:delta.content可能是null。流式返回的第一个片段和最后一个片段,content字段经常是空字符串或null。所以解析完要filter(Objects::nonNull),不然前端会收到一堆undefined。
4.3 Controller层怎么把Flux交给前端
Controller层更简单,只需要返回Flux<String>,SpringBoot WebFlux会自动用text/event-stream格式输出到客户端:
@RestController @RequestMapping("/api/chat") public class ChatController { private final DeepSeekService deepSeekService; private final ChatHistoryService chatHistoryService; public ChatController(DeepSeekService deepSeekService, ChatHistoryService chatHistoryService) { this.deepSeekService = deepSeekService; this.chatHistoryService = chatHistoryService; } @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestBody ChatRequest request) { // 保存用户消息 chatHistoryService.saveMessage(request.getSessionId(), "user", request.getLastMessage()); // 构建完整消息列表:历史 + 当前 List<Message> messages = chatHistoryService.buildMessageList(request.getSessionId(), request.getLastMessage()); // 流式返回,同时在结束时把助手回复存库 return deepSeekService.chatStream(messages) .doOnComplete(() -> { // 这里有个问题,流式输出过程中不好累积内容 }); } }produces = MediaType.TEXT_EVENT_STREAM_VALUE这一步是关键,它告诉Spring这个接口会返回SSE流。没有这个声明,浏览器端的EventSource或fetch的流式解析可能拿不到正确的Content-Type,导致解析失败。
4.4 一个没被讲透的问题:流式过程中如何保存完整回复
上面的Controller代码里,我留了一个问题:流式输出的过程中,回复内容是分批到前端的,那后端怎么在结束时把完整的助手回复存进历史记录?
这里有几种方案,我逐一分析:
方案一:在整个流中累积字符串。用一个StringBuilder,每收到一个片段就append,在doOnComplete里把累积的内容保存到数据库。这是最简单直接的做法,但有个微不足道的性能代价,就是每次都要拼接字符串。考虑到对话场景的回复长度一般几千字,这个代价可以忽略。
方案二:把累积逻辑放到Service层,返回一个带完整内容的包装对象。比如服务端先累积完整回复,再返回Flux<String>给Controller,同时把完整回复存库。但这样会牺牲实时性,因为必须等Flux完全结束才能知道完整内容。
方案三:前端负责把流式片段拼起来,在结束时单独调一个接口保存历史。这种方案把存储压力交给前端,后端多一个POST /api/chat/saveHistory接口。优点是后端逻辑简单,缺点是多了一次网络往返,而且如果前端异常退出,历史记录就丢了。
我最终采用的是方案一的思想,但在实现上做了个小优化——除了StringBuilder累积,还额外用了一个AtomicInteger做计数器,避免并发场景下doOnComplete回调里访问累积变量时出问题。完整实现我放在后面第6章里讲。
5. 历史记录:不只是存个List那么简单
5.1 需求分析:历史记录要解决什么问题
很多人做demo的时候,历史记录就是List<Message> history = new ArrayList<>(),请求来了就往里add,看似能用,实际上有三个问题:
- 服务重启,历史记录全丢
- 多个用户共用一份历史,互相串数据
- 没有会话概念,所有对话挤在一个列表里
我这版设计从一开始就加入sessionId的概念。每个前端会话对应一个sessionId,后端的chatHistoryService根据sessionId拉取对应会话的历史消息,拼进请求上下发给DeepSeek。这样DeepSeek才能"记住"之前的对话内容,实现多轮对话的连贯性。
5.2 数据模型设计
历史记录表的结构不要设计得太复杂,够用就行:
@Entity @Table(name = "chat_history") public class HistoryRecord { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String sessionId; private String role; @Column(length = 4096) private String content; private LocalDateTime createTime; }这里有个细节:content字段我限定了length = 4096。大模型的回复动辄上千字,如果字段长度不够,JPA在H2里会自动建表,但如果换到MySQL,varchar(255)的默认长度会直接导致插入失败。我踩过一次这个坑,后来统一用@Column(length = 4096),H2里对应VARCHAR(4096),MySQL里对应VARCHAR(4096),都不会截断。
5.3 Repository层:Spring Data JPA一句话搞定
public interface HistoryRepository extends JpaRepository<HistoryRecord, Long> { List<HistoryRecord> findBySessionIdOrderByCreateTimeDesc(String sessionId); void deleteBySessionId(String sessionId); }就这两个方法够用了。findBySessionIdOrderByCreateTimeDesc用于加载历史消息,倒序排列方便后面拼装;deleteBySessionId用于清理会话。
5.4 如何把历史记录拼进请求:消息列表与上下文长度控制
这一步是整个历史记录功能里最容易被低估的部分。直接把所有历史消息一股脑拼进请求,会发生两个问题:
问题一:请求体超过DeepSeek的token限制。DeepSeek的上下文长度有上限(目前deepseek-chat是64K),你把几十轮对话全塞进去,迟早爆掉。
问题二:早期消息稀释了当前问题的相关性。模型会更关注最近的上下文,历史太长反而可能答非所问。
所以我在buildMessageList做了个简单的截断策略:
public List<Message> buildMessageList(String sessionId, String newUserMessage) { List<HistoryRecord> records = repository.findBySessionIdOrderByCreateTimeDesc(sessionId); // 最多取最近20条历史 List<HistoryRecord> recent = records.stream() .limit(20) .collect(Collectors.toList()); List<Message> messages = new ArrayList<>(); messages.add(new Message("system", "你是一个乐于助人的AI助手,请用简洁清晰的中文回答问题。")); // 倒序重新排回正序 Collections.reverse(recent); for (HistoryRecord record : recent) { messages.add(new Message(record.getRole(), record.getContent())); } messages.add(new Message("user", newUserMessage)); return messages; }limit(20)是我经过几次实测后拍的数。20轮对话按每轮平均500字算,大概1万token,在64K的上下文里占不到四分之一,既不会立刻爆长度,又足够让模型理解对话脉络。如果你的场景里单条消息特别长,建议把这个数调小到10甚至5,按字符数估算更准确。
5.5 清空会话:删除历史记录的时机
前端需要一个"清空会话"按钮,对应的后端接口是:
@DeleteMapping("/history/{sessionId}") public ResponseEntity<Void> clearHistory(@PathVariable String sessionId) { chatHistoryService.clearHistory(sessionId); return ResponseEntity.ok().build(); }这里没什么技术含量,但要注意一个业务决策:清空历史之后,前端应该同时重置页面上的聊天列表,并且下一次请求不要带sessionId对应的历史。我实测中遇到过一个尴尬情况:前端清了UI但没调后端接口,重新发消息后旧历史又回来了,模型还在"记得"前面聊过什么,很出戏。
6. 完整实现:把流式输出和历史记录缝在一起
前面分开了讲,这一章给一个完整的、能直接跑通的缝合版本。先说明这一版的取舍:为了兼容历史记录保存,我把流式的累积逻辑放到了Service层,Controller只负责组装和响应。
6.1 DeepSeekService完整版
@Service public class DeepSeekService { private final RestClient restClient; private final DeepSeekConfig config; public DeepSeekService(RestClient.Builder builder, DeepSeekConfig config) { this.config = config; this.restClient = builder .baseUrl(config.getBaseUrl()) .defaultHeader("Authorization", "Bearer " + config.getApiKey()) .defaultHeader("Content-Type", "application/json") .build(); } public Flux<String> chatStream(List<Message> messages) { Map<String, Object> requestBody = new HashMap<>(); requestBody.put("model", config.getModel()); requestBody.put("messages", messages); requestBody.put("stream", true); requestBody.put("max_tokens", config.getMaxTokens()); requestBody.put("temperature", config.getTemperature()); return restClient.post() .uri("/chat/completions") .body(requestBody) .retrieve() .bodyToFlux(String.class) .map(this::parseSSELine) .filter(Objects::nonNull); } private String parseSSELine(String line) { if (line == null || line.isBlank() || !line.startsWith("data:")) { return null; } String data = line.substring(5).trim(); if ("[DONE]".equals(data)) { return null; } try { JsonNode node = new ObjectMapper().readTree(data); String content = node.path("choices").path(0).path("delta").path("content").asText(null); return content == null || content.isEmpty() ? null : content; } catch (JsonProcessingException e) { return null; } } }注意parseSSELine里多了一个line.isBlank()的判断。原因是RestClient在解析SSE流时,每个元素可能包含行尾的空行,这些空行不是有效业务数据,过滤掉能避免前端收到一大串换行符。
6.2 保存完整回复到历史记录
为了能在流式结束时保存完整的助手回复,我加了一个包装类型ChatStreamResult,包含两部分:流式片段Flux<String>和完整回复的Mono<String>:
public class ChatStreamResult { private final Flux<String> stream; private final Mono<String> fullContent; public ChatStreamResult(Flux<String> stream, Mono<String> fullContent) { this.stream = stream; this.fullContent = fullContent; } public Flux<String> getStream() { return stream; } public Mono<String> getFullContent() { return fullContent; } }然后在Service里:
public ChatStreamResult chatStreamWithHistory(String sessionId, List<Message> messages) { // 用StringBuilder累积完整回复 StringBuilder fullContent = new StringBuilder(); AtomicBoolean firstChunk = new AtomicBoolean(true); Flux<String> stream = restClient.post() .uri("/chat/completions") .body(messages) .retrieve() .bodyToFlux(String.class) .map(this::parseSSELine) .filter(Objects::nonNull) .doOnNext(content -> { fullContent.append(content); firstChunk.set(false); }); Mono<String> fullMono = stream.reduce("", (acc, item) -> acc + item); return new ChatStreamResult(stream, fullMono); }这里有个小坑:doOnNext里的fullContent.append是在响应式线程池里跑的,虽然StringBuilder不是线程安全的,但在Flux的串行处理中,doOnNext默认是同一个线程顺序执行,所以实测没问题。如果你不放心,可以用StringBuffer或者把累积逻辑放到reduce里。
Controller层完整版:
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestBody ChatRequest request) { String sessionId = request.getSessionId(); String lastMessage = request.getLastMessage(); chatHistoryService.saveMessage(sessionId, "user", lastMessage); List<Message> messages = chatHistoryService.buildMessageList(sessionId, lastMessage); return deepSeekService.chatStream(messages) .doOnComplete(() -> { // 把完整回复存库 // 注意:这里拿不到累积的内容,在实际项目中需要把累积值传出来 // 更优雅的方式是用 Mono.zip,让保存动作等流结束 }); }说实话,上面这个版本还有瑕疵,doOnComplete里拿不到完整回复内容。更优雅的写法是用Mono.zip或doOnNext配合外部变量,我实际项目里是这样处理的:
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> stream(@RequestBody ChatRequest request) { String sessionId = request.getSessionId(); String lastMessage = request.getLastMessage(); chatHistoryService.saveMessage(sessionId, "user", lastMessage); List<Message> messages = chatHistoryService.buildMessageList(sessionId, lastMessage); StringBuilder fullReply = new StringBuilder(); return deepSeekService.chatStream(messages) .doOnNext(fullReply::append) .doOnComplete(() -> { chatHistoryService.saveMessage(sessionId, "assistant", fullReply.toString()); }); }这段代码虽然看起来简单,但有一个前置条件:Spring Boot默认对SSE返回的Flux,doOnNext和doOnComplete的调度器和生成流的线程必须是同一个,否则StringBuilder会有并发问题。实测下来,如果不调subscribeOn和publishOn,默认就是串行的,没问题。如果你加了并发操作符,记得把累积逻辑单独抽到一个线程安全的结构里。
6.3 前端页面:用fetch的ReadableStream解析SSE
后端流式接口写好了,前端如果还用axios等待完整响应,体验就废了。我这里用原生fetch,配合ReadableStream手动解析SSE数据:
async function sendMessage() { const userInput = document.getElementById('userInput').value; if (!userInput.trim()) return; appendMessage('user', userInput); const response = await fetch('/api/chat/stream', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ sessionId: currentSessionId, lastMessage: userInput }) }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let assistantMessage = ''; appendMessage('assistant', ''); while (true) { const {done, value} = await reader.read(); if (done) break; const text = decoder.decode(value, {stream: true}); const lines = text.split('\n'); for (const line of lines) { if (!line.startsWith('data:')) continue; const data = line.substring(5).trim(); if (data === '[DONE]') continue; try { const json = JSON.parse(data); const content = json.choices[0].delta.content; if (content) { assistantMessage += content; updateLastMessage(assistantMessage); } } catch (e) { // 解析失败的行直接跳过 } } } }这段代码有个处理细节要注意:SSE数据流在fetch的reader.read()里,一次可能返回多个data:行,也可能一个data:行被拆成两次read()。所以不能依赖"一次read就是一行",需要用split('\n')按行拆,而且要处理跨read()的断行(我上面的简化版没处理断行,实际项目里需要一个缓冲变量累积不完整的行)。
简化版已经能覆盖90%的场景,但如果你的API偶尔返回很长的行,建议加一个buffer变量:
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) { // 处理完整的行 } }7. 测试与排查:我实际跑过的几种异常场景
7.1 跑通标准流程
启动项目后,访问http://localhost:8080,在输入框里输入"你好",你可以看到:
- 页面顶部立刻出现空白的assistant气泡
- 约1~2秒后,文字开始一个字一个词地往外蹦
- 结束后,打开H2控制台(
http://localhost:8080/h2-console),能看到历史表里多了user和assistant两条记录
这里有个体验优化的点:如果内容迟迟不出现,大部分原因是DeepSeek的API响应延迟,尤其是高峰期,首次token可能要等5秒以上。前端最好加一个"等待中"的状态提示,我在demo里用setTimeout做了个2秒未出字就显示"正在思考"的兜底。
7.2 网络异常:项目启动报SSL或连接超时
这个我实测踩过一次。公司内网访问外网API经常要走代理,DeepSeek的接口走HTTPS,如果代理证书不被信任,RestClient会报sun.security.validator.ValidatorException: PKIX path building failed。
解决办法有两个:
方案一(推荐):走系统代理并信任证书。在启动参数里加:
-Dhttps.proxyHost=your-proxy -Dhttps.proxyPort=8080方案二(本地测试临时用):跳过证书校验。给RestClient配置一个不校验证书的ClientHttpRequestFactory。这个方案只适合本地联调,千万别带到生产环境。
// 临时方案,生产禁用 SSLContext sslContext = SSLContextBuilder.create() .loadTrustMaterial(null, (cert, authType) -> true) .build();7.3 返回空内容:过滤条件太狠了
有朋友反馈"接口通了但前端一个字都不显示",排查下来是parseSSELine里过滤了太多内容。delta.content有时是一个空格,有时是\n,这些看起来是"空"的内容其实是有意义的格式字符。
我在parseSSELine里只过滤null和isEmpty(),但保留空白字符。如果你发现回复里丢换行,大概率是这里过滤规则太严格。改成:
String content = node.path("choices").path(0).path("delta").path("content").asText(null); return content == null ? null : content; // 不过滤空字符串和空白这样前端拼出来的内容才和官方API返回的一致。
7.4 SpringBoot版本太高导致的AutoConfiguration问题
网上很多老教程还在用spring.factories来注册自动配置,但SpringBoot 3.x已经改为AutoConfiguration.imports。如果你搜到老博客,照着配自定义starter的时候,大概率会报ClassNotFoundException或Failed to load auto-configuration。
这个demo里没涉及自定义starter,但如果你的项目要扩展,记住:SpringBoot 3.x用META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports,别再写spring.factories了。
7.5 流式接口后面挂网关超时
SSE是长连接,如果你们公司有Nginx或Spring Cloud Gateway做统一入口,默认超时时间可能只有60秒。DeepSeek生成一篇长文可能要两三分钟,网关超时就会把连接掐断,前端表现为"回复到一半突然断了"。
我处理过Nginx的配置:
proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_buffering off;记住最后一行proxy_buffering off很关键。Nginx默认会缓冲响应,导致SSE的流式效果失效——前端要等整个响应结束才能看到内容,等于流式白做了。
8. 再往前一步:这个demo还能怎么扩展
8.1 换成MySQL持久化
H2只在本地开发方便,线上肯定要换MySQL。操作分两步:
第一步,在pom.xml把H2依赖换成MySQL驱动:
<dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>第二步,改application.yml:
spring: datasource: url: jdbc:mysql://localhost:3306/deepseek_demo?useSSL=false&serverTimezone=Asia/Shanghai driver-class-name: com.mysql.cj.jdbc.Driver username: root password: your-password jpa: hibernate: ddl-auto: update因为Repository层用的Spring Data JPA,SQL方言的切换都由框架处理,业务代码一行不用动。这算是当初抽象Repository层换来的一大便利。
8.2 支持多轮记忆的本地向量检索
现在buildMessageList里是盲目取最近20条记录,如果用户聊了50轮,更早的关键信息可能被挤掉了。进阶方案是引入向量检索,把每条历史记录embedding化,每次请求时按相关性召回,而不是按时间截断。
实现思路:
- 用
spring-ai或langchain4j的embedding能力,把每条历史消息转成向量 - 存在专门的向量表里(生产建议用
pgvector,本地demo用H2凑合) - 每次构建请求时,用当前问题做向量相似度检索,召回topK条历史
这个方案能显著提升长对话场景下的回答质量,但工程量会翻几倍。如果项目周期紧,我建议先做基于时间的截断,后续再演进。
8.3 前端做成一个完整的聊天Web应用
目前index.html只是单文件demo,实际产品可以扩展:
- 左侧会话列表,点击切换
sessionId - 支持停止生成(取消
fetch) - 消息气泡的markdown渲染
- 错误提示和重试按钮
如果你用Vue或React,核心原理不变,只是把fetch的解析逻辑封装成useChat之类的hook,后端接口完全可以直接复用。
9. 部署到服务器时要注意的几个点
9.1 API Key安全
一定不要把key写到application.yml里提交到Git。我见过太多人因为这一行配置泄露了密钥,然后被刷了几百块钱的API调用费。
推荐做法是启动时从环境变量读:
export DEEPSEEK_API_KEY=sk-xxxxxxxx java -jar deepseek-demo.jar或者用Docker部署时通过--env传:
docker run -d -p 8080:8080 \ -e DEEPSEEK_API_KEY=sk-xxxxxxxx \ deepseek-demo:latest9.2 CORS跨域配置
如果你前端是独立部署(比如Vue打包的静态资源在Nginx上),后端接口必须配CORS,不然浏览器报跨域错误:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("http://localhost:5173") .allowedMethods("GET", "POST", "DELETE", "OPTIONS") .allowedHeaders("*"); } }注意:如果走的是WebFlux,要用CorsWebFilter或者实现WebFluxConfigurer,别照抄WebMVC的写法。
9.3 内存占用与线程模型
WebFlux是非阻塞模型,理论上线程占用比传统WebMVC低很多。但DeepSeek的流式响应是IO密集型的,长时间占用的连接数是主要资源指标。RestClient默认的连接池足够支撑几百并发,小项目不用调优。
如果并发量上来了,关注两个指标:tomcat.threads.max(WebFlux默认用Netty,对应server.netty.max-connections)和数据库连接池上限。H2内存库没有连接池问题,换MySQL后注意spring.datasource.hikari.maximum-pool-size,默认10,并发高的时候要往上调。
10. 写在最后:这个demo的边界与进一步优化建议
跑完整个项目,最大的体会是"流式输出"这个看似简单的需求,牵扯到的技术点其实不少——HTTP分块传输、响应式编程、SSE协议解析、前端流式读取,每一层都有坑。当初如果只想快速交差,可以直接让后端把完整回复一次性返回,前端再做个打字机效果,但那样既浪费了DeepSeek的流式接口能力,还会让用户首字等待时间变长,体验差不少。
我个人的建议是:如果你的业务场景对实时性有要求(比如客服、助手、问答系统),一定要把流式输出作为核心链路来设计,从一开始就选好WebFlux和RestClient这套技术栈,后面扩展会顺畅很多。如果只是内部工具、不太在意响应速度,用同步接口加前端模拟打字也能凑合。
有两点我目前还在继续打磨:
一是遇到长回复时,前端的SSE解析偶尔还会出现断行问题,虽然不影响最终内容,但会让渲染过程有一点点闪烁。我打算改用官方推荐的text/event-stream解析库(比如@microsoft/fetch-event-source),而不是自己手写解析。
二是多会话管理目前只做到按sessionId区分历史,没有做用户权限隔离。如果你要放生产,需要在sessionId上绑定用户标识,并对接口鉴权。
最后分享一个调试小技巧:本地联调时,直接用curl看DeepSeek API的原始返回,比在代码里打日志快得多:
curl -N https://api.deepseek.com/chat/completions \ -H "Authorization: Bearer sk-xxxx" \ -H "Content-Type: application/json" \ -d '{ "model": "deepseek-chat", "messages": [{"role": "user", "content": "你好"}], "stream": true }'-N参数是关键,它告诉curl不要缓冲,实时打印流式数据。这样你能快速判断是API的问题还是自己代码解析的问题,省掉一大半排查时间。